diff --git a/install b/install index 92eeeef61c5e..833f826ce76b 100755 --- a/install +++ b/install @@ -975,7 +975,7 @@ install_bridge() { bridge_updated=true else # Fresh clone from public repo - if git clone --quiet https://github.com/FREELABEL/iris-bridge.git "$bridge_dir" 2>/dev/null; then + if git clone --quiet https://github.com/FREELABEL/iris-daemon.git "$bridge_dir" 2>/dev/null; then (cd "$bridge_dir" && npm install --production --silent 2>/dev/null) || { print_message warning "${ORANGE}npm install had issues — bridge may need manual setup${NC}" } @@ -984,7 +984,7 @@ install_bridge() { fi else print_message info "${MUTED}[5/7] Agent Bridge .......................... could not download${NC}" - print_message info "${MUTED} Try manually: ${NC}git clone https://github.com/FREELABEL/iris-bridge.git ~/.iris/bridge" + print_message info "${MUTED} Try manually: ${NC}git clone https://github.com/FREELABEL/iris-daemon.git ~/.iris/bridge" return 0 fi fi @@ -1157,6 +1157,34 @@ DCTL ln -sf "$bridge_dir/bridgectl" "$INSTALL_DIR/iris-bridge" ln -sf "$bridge_dir/daemonctl" "$INSTALL_DIR/iris-daemon" + # ── macOS LaunchAgent: auto-start daemon on login ── + if [ "$(uname)" = "Darwin" ]; then + local plist_name="io.heyiris.daemon.plist" + local plist_src="$bridge_dir/installers/macos/$plist_name" + local plist_dest="$HOME/Library/LaunchAgents/$plist_name" + local wrapper_src="$bridge_dir/installers/macos/iris-daemon-wrapper.sh" + local wrapper_dest="$HOME/.iris/iris-daemon-wrapper.sh" + + # Copy and configure wrapper script + if [ -f "$wrapper_src" ]; then + cp "$wrapper_src" "$wrapper_dest" + # Point wrapper to ~/.iris/bridge (where main installer puts daemon) + sed -i '' "s|DAEMON_DIR=\"\${IRIS_DIR}/daemon\"|DAEMON_DIR=\"\${IRIS_DIR}/bridge\"|g" "$wrapper_dest" 2>/dev/null || true + chmod 755 "$wrapper_dest" + fi + + # Install plist with HOME path substitution + if [ -f "$plist_src" ]; then + sed "s|__HOME__|$HOME|g" "$plist_src" > "$plist_dest" + # Also fix daemon path to bridge + sed -i '' "s|/.iris/daemon|/.iris/bridge|g" "$plist_dest" 2>/dev/null || true + + # Load (unload first if already loaded) + launchctl unload "$plist_dest" 2>/dev/null || true + launchctl load "$plist_dest" 2>/dev/null || true + fi + fi + if [ "$bridge_updated" = "true" ]; then print_message info "${GREEN}[5/7]${NC} Agent Bridge ${MUTED}.......................${NC} ${GREEN}updated${NC}" else @@ -1164,6 +1192,9 @@ DCTL fi print_message info "${MUTED} Bridge: ${NC}iris-bridge start|stop|status" print_message info "${MUTED} Daemon: ${NC}iris-daemon start|stop|status|register${MUTED} (Hive compute node)${NC}" + if [ "$(uname)" = "Darwin" ]; then + print_message info "${MUTED} Auto-start: ${NC}enabled${MUTED} (starts on login)${NC}" + fi } if [ "$skip_bridge" = "false" ]; then @@ -2189,6 +2220,8 @@ if [ "$AUTH_SUCCESS" = "true" ]; then RULES: - NEVER use curl or call APIs directly. Only CLI commands. +- NEVER guess or hallucinate URLs. Page URLs follow the format: https://heyiris.io/p/{slug} (NOT subdomains). Always read the URL from CLI output. +- When a CLI command produces output, READ it carefully and use the exact values shown. Do not make up IDs, URLs, or status values. - At the END of EVERY response, always include these links in a clean ASCII table like this: ┌──────────────────────────────────────────────────────────────┐ @@ -2241,7 +2274,12 @@ Wait for their answer. Based on what they pick, IMMEDIATELY run the correspondin - Agent: Ask what it should do in one sentence, then run: iris agents create - Multi-Agent Workflow: Ask what to automate in one sentence, then run: iris workflows list -- Web Page: Ask what the page is for, then guide to dashboard: $DASHBOARD (Pages tab) +- Web Page: Ask what the page is for, then run: iris pages create --slug --title "" --template landing + After creation, the public URL is shown in CLI output (main.heyiris.io/p/<slug>). Auto-publishes on create. + To add more components: iris pages pull <slug>, edit pages/<slug>.json, iris pages push <slug>. + Run: iris pages component-registry — to see ALL available component types. + VALID component types (use ONLY these exact names): Hero, SiteNavigation, SiteFooter, AnnouncementBanner, TestimonialsSection, TeamSection, ContactSection, LogoMarquee, FeatureShowcase, ComparisonMatrix, ClientGrid, CareersListing, PortfolioGallery, ProductGrid, ServiceMenu, EventGrid, FundingTiers, BeforeAfter, MapSection, NewsletterSignup, StepWizard, FileUpload, ShoppingCart, OrderConfirmation. + NEVER invent component types. If unsure, run: iris pages component-registry - Knowledge base / RAG: Run: iris bloqs create - List knowledge bases: Run: iris bloqs list - Add content to KB: Run: iris bloqs ingest <bloqId> <file> diff --git a/install.ps1 b/install.ps1 index dcb322155b73..e19c064bc1cd 100644 --- a/install.ps1 +++ b/install.ps1 @@ -186,13 +186,13 @@ if (-not $HasNode) { $BridgeUpdated = $true } else { try { - git clone --quiet https://github.com/FREELABEL/iris-bridge.git $BridgeDir 2>$null + git clone --quiet https://github.com/FREELABEL/iris-daemon.git $BridgeDir 2>$null Push-Location $BridgeDir npm install --production --silent 2>$null Pop-Location } catch { Write-StepSkipped "5/5" "Agent Bridge" "could not download" - Write-Muted "Try manually: git clone https://github.com/FREELABEL/iris-bridge.git ~\.iris\bridge" + Write-Muted "Try manually: git clone https://github.com/FREELABEL/iris-daemon.git ~\.iris\bridge" } } diff --git a/packages/opencode/package.json b/packages/opencode/package.json index 58f4cd8905a6..1ac10653e3ee 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.1.20", + "version": "1.1.22", "name": "opencode", "displayName": "iris-agent-cli", "type": "module", diff --git a/packages/opencode/src/cli/cmd/platform-boards.ts b/packages/opencode/src/cli/cmd/platform-boards.ts index 81d7d6d01491..c376234e346a 100644 --- a/packages/opencode/src/cli/cmd/platform-boards.ts +++ b/packages/opencode/src/cli/cmd/platform-boards.ts @@ -1,7 +1,7 @@ import { cmd } from "./cmd" import * as prompts from "@clack/prompts" import { UI } from "../ui" -import { irisFetch, requireAuth, handleApiError, printDivider, printKV, dim, bold, success, highlight } from "./iris-api" +import { irisFetch, requireAuth, handleApiError, printDivider, printKV, dim, bold, success, highlight, resolveUserId } from "./iris-api" import { existsSync, mkdirSync, writeFileSync, readFileSync } from "fs" import { join, basename } from "path" @@ -90,13 +90,21 @@ const BoardsListCommand = cmd({ spinner.start("Loading items…") try { + const userId = await resolveUserId() + if (!userId) { + spinner.stop("Failed", 1) + prompts.log.error("Could not resolve user ID. Set IRIS_USER_ID or run iris-login.") + prompts.outro("Done") + return + } const params = new URLSearchParams({ per_page: String(args.limit) }) - const res = await irisFetch(`/api/v1/bloqs/${args["bloq-id"]}/items?${params}`) + const res = await irisFetch(`/api/v1/user/${userId}/bloqs/${args["bloq-id"]}/items?${params}`) const ok = await handleApiError(res, "List items") if (!ok) { spinner.stop("Failed", 1); prompts.outro("Done"); return } - const data = (await res.json()) as { data?: any[] } - const items: any[] = data?.data ?? (Array.isArray(data) ? data : []) + const data = (await res.json()) as any + const rawItems = data?.data?.items ?? data?.data?.data ?? data?.data ?? [] + const items: any[] = Array.isArray(rawItems) ? rawItems : Object.values(rawItems) spinner.stop(`${items.length} item(s)`) if (items.length === 0) { diff --git a/packages/opencode/src/cli/cmd/platform-bug.ts b/packages/opencode/src/cli/cmd/platform-bug.ts index a39e397d4519..f59243bd5a6d 100644 --- a/packages/opencode/src/cli/cmd/platform-bug.ts +++ b/packages/opencode/src/cli/cmd/platform-bug.ts @@ -1,7 +1,7 @@ import { cmd } from "./cmd" import * as prompts from "@clack/prompts" import { UI } from "../ui" -import { irisFetch, dim, bold, success, IRIS_API } from "./iris-api" +import { irisFetch, requireAuth, handleApiError, printDivider, printKV, dim, bold, success, IRIS_API, resolveUserId } from "./iris-api" import { homedir, platform, release, arch, hostname, userInfo } from "os" import { join } from "path" import { existsSync, readFileSync } from "fs" @@ -214,16 +214,63 @@ const ReportCommand = cmd({ const ListCommand = cmd({ command: "list", aliases: ["ls"], - describe: "view bug reports (opens dashboard)", - async handler() { + describe: "list all bug reports", + builder: (yargs) => + yargs + .option("limit", { describe: "max results", type: "number", default: 20 }) + .option("json", { describe: "JSON output", type: "boolean", default: false }), + async handler(args) { + const token = await requireAuth() + if (!token) return + + const userId = await resolveUserId() + if (!userId) { + console.error("Could not resolve user ID. Set IRIS_USER_ID or run iris-login.") + return + } + + const params = new URLSearchParams({ per_page: String(args.limit) }) + const res = await irisFetch(`/api/v1/user/${userId}/bloqs/${BUG_BLOQ_ID}/items?${params}`) + const ok = await handleApiError(res, "List bug reports") + if (!ok) return + + const data = (await res.json()) as any + const rawItems = data?.data?.items ?? data?.data?.data ?? data?.data ?? [] + const items: any[] = Array.isArray(rawItems) ? rawItems : Object.values(rawItems) + + if (args.json) { + console.log(JSON.stringify(items, null, 2)) + return + } + console.log("") console.log(bold("📋 Bug Reports")) - console.log("") - console.log(` All reports go to ${dim("IRIS CLI Bug Reports")} (bloq #${BUG_BLOQ_ID})`) - console.log(` View at: ${success(`https://app.heyiris.io/iris?board=${BUG_BLOQ_ID}`)}`) - console.log("") - console.log(dim("To submit a new bug:")) - console.log(` iris bug report`) + console.log(` ${dim(`Bloq #${BUG_BLOQ_ID} — ${items.length} item(s)`)}`) + printDivider() + + if (items.length === 0) { + console.log(` ${dim("No bug reports found")}`) + } else { + for (const item of items) { + const contentStr = item.content ?? item.description ?? "" + const severity = contentStr.match(/Severity:\*?\*?\s*(\w+)/i)?.[1] ?? "" + const sevTag = severity ? ` [${severity.toUpperCase()}]` : "" + const status = item.status ? ` ${dim(item.status)}` : "" + console.log(` ${bold(String(item.title))} ${dim(`#${item.id}`)}${sevTag}${status}`) + if (contentStr) { + // Show first meaningful line (skip markdown headers) + const lines = String(contentStr).split("\n").filter((l: string) => l.trim() && !l.startsWith("**") && !l.startsWith("#")) + if (lines.length > 0) { + console.log(` ${dim(lines[0].slice(0, 100))}`) + } + } + console.log() + } + } + + printDivider() + console.log(dim(" iris bug report — submit a new bug")) + console.log(dim(" iris boards get <id> — view full details")) console.log("") }, }) diff --git a/packages/opencode/src/cli/cmd/platform-contracts.ts b/packages/opencode/src/cli/cmd/platform-contracts.ts new file mode 100644 index 000000000000..22113a28ad5d --- /dev/null +++ b/packages/opencode/src/cli/cmd/platform-contracts.ts @@ -0,0 +1,204 @@ +import { cmd } from "./cmd" +import * as prompts from "@clack/prompts" +import { irisFetch, requireAuth, handleApiError, printDivider, printKV, dim, bold, success, highlight } from "./iris-api" + +// ============================================================================ +// Contracts Send — send a contract to a lead for signing +// ============================================================================ + +const ContractsSendCommand = cmd({ + command: "send <lead-id>", + describe: "send a contract to a lead for signing", + builder: (yargs) => + yargs + .positional("lead-id", { describe: "lead ID", type: "number", demandOption: true }) + .option("template", { alias: "t", describe: "contract template name", type: "string" }) + .option("scope", { alias: "s", describe: "scope of work", type: "string" }) + .option("amount", { alias: "a", describe: "contract amount ($)", type: "number" }) + .option("json", { describe: "JSON output", type: "boolean" }), + async handler(args) { + if (!(await requireAuth())) return + + const leadId = args["lead-id"] + + // Fetch lead info + const leadRes = await irisFetch(`/api/v1/leads/${leadId}`) + if (!(await handleApiError(leadRes, "Fetch lead"))) return + const lead = await leadRes.json().catch(() => ({})) + const leadData = lead?.data ?? lead + + if (!leadData?.id) { + prompts.log.error(`Lead #${leadId} not found`) + return + } + + const name = leadData.name ?? leadData.first_name ?? `Lead #${leadId}` + console.log("") + console.log(bold(`Sending contract to: ${name}`)) + + // Prompt for missing fields + let scope = args.scope + if (!scope) { + const input = await prompts.text({ message: "Scope of work:" }) + if (prompts.isCancel(input)) return + scope = String(input) + } + + let amount = args.amount + if (!amount) { + const input = await prompts.text({ message: "Contract amount ($):", validate: (v) => isNaN(Number(v)) ? "Must be a number" : undefined }) + if (prompts.isCancel(input)) return + amount = Number(input) + } + + const body: Record<string, unknown> = { + scope, + amount, + send_contract: true, + } + if (args.template) body.template = args.template + + const spinner = prompts.spinner() + spinner.start("Creating contract...") + + // Use payment-gate endpoint which handles contract creation + const res = await irisFetch(`/api/v1/leads/${leadId}/payment-gate`, { + method: "POST", + body: JSON.stringify(body), + }) + + spinner.stop("Contract created") + + if (!(await handleApiError(res, "Send contract"))) return + const data = await res.json().catch(() => ({})) + + if (args.json) { console.log(JSON.stringify(data, null, 2)); return } + + if (!data.success) { + if (data.error === "duplicate") { + prompts.log.warn("A contract already exists for this lead") + const step = data.step?.data ?? {} + if (step.contract_signing_url) printKV("Existing Contract", step.contract_signing_url) + return + } + prompts.log.error(data.message || "Failed to create contract") + return + } + + console.log("") + console.log(success("Contract sent!")) + printDivider() + printKV("Lead", `${name} (#${leadId})`) + printKV("Amount", `$${Number(amount).toFixed(2)}`) + printKV("Scope", String(scope)) + printKV("Signing URL", data.contract_signing_url ?? dim("(not generated)")) + printKV("Proposal URL", data.proposal_url ?? dim("(not attached)")) + printDivider() + }, +}) + +// ============================================================================ +// Contracts Status — check contract signing status +// ============================================================================ + +const ContractsStatusCommand = cmd({ + command: "status <lead-id>", + aliases: ["check"], + describe: "check contract signing status for a lead", + builder: (yargs) => + yargs + .positional("lead-id", { describe: "lead ID", type: "number", demandOption: true }) + .option("json", { describe: "JSON output", type: "boolean" }), + async handler(args) { + if (!(await requireAuth())) return + + const leadId = args["lead-id"] + const res = await irisFetch(`/api/v1/leads/${leadId}/deal-status`) + if (!(await handleApiError(res, "Get contract status"))) return + + const result = await res.json().catch(() => ({})) + const status = result?.data ?? result + + if (args.json) { console.log(JSON.stringify(status, null, 2)); return } + + if (!status?.has_payment_gate) { + prompts.log.info(`No contract for lead #${leadId}`) + console.log(dim(`Send one: iris contracts send ${leadId}`)) + return + } + + console.log("") + console.log(bold(`Contract Status — Lead #${leadId}`)) + printDivider() + printKV("Contract", status.contract_signed ? success("SIGNED") : highlight("PENDING")) + printKV("Payment", status.payment_received ? success("RECEIVED") : highlight("PENDING")) + printKV("Amount", `$${Number(status.amount ?? 0).toFixed(2)}`) + printKV("Scope", status.scope ?? dim("—")) + + if (status.contract_signing_url) { + console.log("") + printKV("Signing URL", status.contract_signing_url) + } + printDivider() + }, +}) + +// ============================================================================ +// Contracts Templates — list available contract templates +// ============================================================================ + +const ContractsTemplatesCommand = cmd({ + command: "templates", + aliases: ["tpl"], + describe: "list available contract templates", + builder: (yargs) => + yargs + .option("json", { describe: "JSON output", type: "boolean" }), + async handler(args) { + if (!(await requireAuth())) return + + const res = await irisFetch(`/api/v1/bloq-packages?type=contract_template`) + if (!(await handleApiError(res, "List templates"))) return + + const result = await res.json().catch(() => ({})) + const templates = result?.data ?? result ?? [] + + if (args.json) { console.log(JSON.stringify(templates, null, 2)); return } + + if (!Array.isArray(templates) || templates.length === 0) { + prompts.log.info("No contract templates found") + console.log(dim("Templates are created as service packages with type=contract_template")) + return + } + + console.log("") + console.log(bold(`Contract Templates (${templates.length})`)) + printDivider() + + for (const tpl of templates) { + const name = tpl.name ?? "Untitled" + const price = tpl.price ? `$${Number(tpl.price).toFixed(2)}` : dim("—") + console.log(` ${highlight(`#${tpl.id}`)} ${name.padEnd(30)} ${price}`) + if (tpl.description) console.log(` ${dim(tpl.description.slice(0, 60))}`) + } + + printDivider() + }, +}) + +// ============================================================================ +// Root command +// ============================================================================ + +export const PlatformContractsCommand = cmd({ + command: "contracts", + aliases: ["contract"], + describe: "send contracts for signing, track status, manage templates", + builder: (yargs) => + yargs + .command(ContractsSendCommand) + .command(ContractsStatusCommand) + .command(ContractsTemplatesCommand) + .demandCommand(), + async handler() {}, +}) diff --git a/packages/opencode/src/cli/cmd/platform-daemon.ts b/packages/opencode/src/cli/cmd/platform-daemon.ts new file mode 100644 index 000000000000..49d917783ce9 --- /dev/null +++ b/packages/opencode/src/cli/cmd/platform-daemon.ts @@ -0,0 +1,249 @@ +import { cmd } from "./cmd" +import * as prompts from "@clack/prompts" +import { UI } from "../ui" +import { dim, bold, success } from "./iris-api" +import { join } from "path" +import { homedir } from "os" +import { existsSync } from "fs" +import { execSync, spawn } from "child_process" + +function printDivider() { + console.log(` ${dim("─".repeat(56))}`) +} + +function printKV(k: string, v: unknown) { + if (v === null || v === undefined || v === "") return + console.log(` ${dim(k + ":")} ${String(v)}`) +} + +function getDaemonCtl(): string | null { + const p = join(homedir(), ".iris", "bin", "iris-daemon") + return existsSync(p) ? p : null +} + +function getBridgeCtl(): string | null { + const p = join(homedir(), ".iris", "bin", "iris-bridge") + return existsSync(p) ? p : null +} + +function runCtl(ctl: string, action: string): string { + try { + return execSync(`${ctl} ${action} 2>&1`, { timeout: 10000 }).toString().trim() + } catch (e: any) { + return e.stdout?.toString?.() || e.stderr?.toString?.() || e.message + } +} + +const DaemonStartCommand = cmd({ + command: "start", + describe: "start the Hive daemon", + async handler() { + const ctl = getDaemonCtl() + if (!ctl) { + prompts.log.error("Daemon not installed. Run: curl -fsSL https://heyiris.io/install-code | bash") + return + } + prompts.log.info("Starting daemon...") + const out = runCtl(ctl, "start") + if (out) console.log(out) + }, +}) + +const DaemonStopCommand = cmd({ + command: "stop", + describe: "stop the Hive daemon", + async handler() { + const ctl = getDaemonCtl() + if (!ctl) { prompts.log.error("Daemon not installed"); return } + const out = runCtl(ctl, "stop") + if (out) console.log(out) + }, +}) + +const DaemonStatusCommand = cmd({ + command: "status", + describe: "show daemon and bridge status", + async handler() { + UI.empty() + prompts.intro("◈ IRIS Daemon") + + // Check daemon health + let daemonUp = false + try { + const res = await fetch("http://localhost:3200/health", { signal: AbortSignal.timeout(2000) }) + if (res.ok) { + daemonUp = true + const data = await res.json() as Record<string, any> + printDivider() + printKV("Status", success("online")) + printKV("Node", data.node_name ?? data.hostname) + printKV("Tasks", data.active_tasks ?? 0) + printKV("Schedules", data.schedules ?? 0) + printKV("CPU", data.cpu_percent ? `${data.cpu_percent}%` : null) + printKV("Memory", data.memory_free ? `${data.memory_free} free` : null) + printKV("API", data.api_url) + printDivider() + } + } catch {} + + if (!daemonUp) { + printDivider() + printKV("Status", "offline") + printDivider() + prompts.log.info(dim("Start with: iris daemon start")) + } + + prompts.outro("Done") + }, +}) + +const DaemonRestartCommand = cmd({ + command: "restart", + describe: "restart the Hive daemon", + async handler() { + const ctl = getDaemonCtl() + if (!ctl) { prompts.log.error("Daemon not installed"); return } + prompts.log.info("Restarting daemon...") + const out = runCtl(ctl, "restart") + if (out) console.log(out) + }, +}) + +const DaemonLogsCommand = cmd({ + command: "logs", + describe: "show daemon logs", + async handler() { + const ctl = getDaemonCtl() + if (!ctl) { prompts.log.error("Daemon not installed"); return } + const out = runCtl(ctl, "logs") + if (out) console.log(out) + }, +}) + +const DaemonRunsCommand = cmd({ + command: "runs", + aliases: ["schedules"], + describe: "show scheduled script runs, output, and source code", + builder: (yargs) => + yargs + .option("output", { alias: "o", describe: "show last run stdout", type: "boolean", default: false }) + .option("code", { alias: "c", describe: "show script source code", type: "boolean", default: false }) + .option("all", { alias: "a", describe: "show everything (output + code)", type: "boolean", default: false }), + async handler(args) { + UI.empty() + prompts.intro("◈ Bridge Schedules") + + const showOutput = args.all || args.output + const showCode = args.all || args.code + + try { + const res = await fetch("http://localhost:3200/daemon/schedules", { signal: AbortSignal.timeout(3000) }) + if (!res.ok) { prompts.log.error(`HTTP ${res.status}`); prompts.outro("Done"); return } + const data = await res.json() as { schedules?: any[] } + const schedules = data.schedules ?? [] + + if (schedules.length === 0) { + prompts.log.warn("No schedules. Create one with: iris hive schedule add <script> --cron \"...\"") + prompts.outro("Done") + return + } + + console.log(` ${dim("─".repeat(60))}`) + for (const s of schedules) { + const status = s.running + ? `${UI.Style.TEXT_HIGHLIGHT}● running${UI.Style.TEXT_NORMAL}` + : s.last_status === "completed" + ? `${UI.Style.TEXT_SUCCESS}● completed${UI.Style.TEXT_NORMAL}` + : s.last_status === "failed" + ? `${UI.Style.TEXT_DANGER}● failed${UI.Style.TEXT_NORMAL}` + : dim("○ pending") + + console.log(` ${bold(s.filename)} ${dim(s.cron)} ${status}`) + console.log(` ${dim("Runs:")} ${s.run_count} ${dim("Last:")} ${s.last_run ? new Date(s.last_run).toLocaleString() : "never"} ${dim("Duration:")} ${s.last_duration_ms ? `${s.last_duration_ms}ms` : "—"}`) + if (s.last_exit_code !== undefined && s.last_exit_code !== null) { + console.log(` ${dim("Exit:")} ${s.last_exit_code === 0 ? "0" : `${UI.Style.TEXT_DANGER}${s.last_exit_code}${UI.Style.TEXT_NORMAL}`}`) + } + console.log(` ${dim("ID:")} ${dim(s.id)}`) + + // Show last stdout + if (showOutput && s.last_stdout) { + console.log() + console.log(` ${dim("── Last Output ──────────────────────────────────")}`) + for (const line of s.last_stdout.trim().split("\n").slice(-20)) { + console.log(` ${line}`) + } + if (s.last_stderr) { + console.log(` ${dim("── Stderr ──")}`) + for (const line of s.last_stderr.trim().split("\n").slice(-5)) { + console.log(` ${UI.Style.TEXT_DANGER}${line}${UI.Style.TEXT_NORMAL}`) + } + } + } + + // Show script source code + if (showCode) { + const { existsSync, readFileSync } = await import("fs") + const { join: pathJoin } = await import("path") + const { homedir: osHome } = await import("os") + // Check multiple script locations (data/scripts from daemon, scripts/ from user) + const candidates = [ + pathJoin(osHome(), ".iris", "data", "scripts", s.filename), + pathJoin(osHome(), ".iris", "scripts", s.filename), + pathJoin(osHome(), ".iris", "bridge", "scripts", s.filename), + ] + const scriptPath = candidates.find(p => existsSync(p)) ?? candidates[0] + if (existsSync(scriptPath)) { + const code = readFileSync(scriptPath, "utf-8") + console.log() + console.log(` ${dim("── Source (" + s.filename + ") ──────────────────────")}`) + for (const line of code.split("\n")) { + console.log(` ${dim(line)}`) + } + } + } + + console.log() + } + console.log(` ${dim("─".repeat(60))}`) + if (!showOutput && !showCode) { + prompts.log.info(dim("iris bridge runs -o show last output")) + prompts.log.info(dim("iris bridge runs -c show script code")) + prompts.log.info(dim("iris bridge runs -a show everything")) + } + prompts.outro("Done") + } catch (err) { + prompts.log.error("Daemon not reachable on :3200. Is it running?") + prompts.log.info(dim("Start with: iris bridge start")) + prompts.outro("Done") + } + }, +}) + +const DaemonRegisterCommand = cmd({ + command: "register", + describe: "register this machine as a Hive compute node", + async handler() { + const ctl = getDaemonCtl() + if (!ctl) { prompts.log.error("Daemon not installed"); return } + prompts.log.info("Registering node...") + const out = runCtl(ctl, "register") + if (out) console.log(out) + }, +}) + +export const PlatformDaemonCommand = cmd({ + command: "bridge", + aliases: ["daemon"], + describe: "manage the IRIS bridge — start, stop, status, restart, logs, register", + builder: (yargs) => + yargs + .command(DaemonStartCommand) + .command(DaemonStopCommand) + .command(DaemonStatusCommand) + .command(DaemonRestartCommand) + .command(DaemonLogsCommand) + .command(DaemonRunsCommand) + .command(DaemonRegisterCommand) + .demandCommand(), + async handler() {}, +}) diff --git a/packages/opencode/src/cli/cmd/platform-onboard.ts b/packages/opencode/src/cli/cmd/platform-onboard.ts new file mode 100644 index 000000000000..52940a51d6ea --- /dev/null +++ b/packages/opencode/src/cli/cmd/platform-onboard.ts @@ -0,0 +1,273 @@ +import { cmd } from "./cmd" +import * as prompts from "@clack/prompts" +import { UI } from "../ui" +import { irisFetch, requireAuth, handleApiError, printDivider, printKV, dim, bold, success, highlight } from "./iris-api" + +// ============================================================================ +// iris onboard <url> — connect existing website → branded Genesis page +// ============================================================================ + +export const PlatformOnboardCommand = cmd({ + command: "onboard <url>", + aliases: ["connect-site"], + describe: "connect an existing website — extract brand identity and auto-generate a branded Genesis page", + builder: (y) => + y + .positional("url", { describe: "website URL to onboard", type: "string", demandOption: true }) + .option("slug", { describe: "page slug (auto-generated from brand name if omitted)", type: "string" }) + .option("owner-type", { describe: "page owner type", type: "string", default: "bloq" }) + .option("owner-id", { describe: "page owner ID", type: "number", default: 38 }) + .option("no-publish", { describe: "create as draft (don't auto-publish)", type: "boolean", default: false }) + .option("save-to-lead", { describe: "also save brand data to this lead ID", type: "number" }) + .option("extract-only", { describe: "only extract brand identity, don't create page", type: "boolean", default: false }), + async handler(args) { + UI.empty() + const url = args.url as string + prompts.intro(`◈ Onboard: ${url}`) + if (!(await requireAuth())) { prompts.outro("Done"); return } + + const sp = prompts.spinner() + + if (args["extract-only"]) { + // Brand extraction only mode + sp.start("Extracting brand identity…") + try { + const payload: Record<string, unknown> = { + tool: "websiteBrandExtractor", + params: { + url, + save_to_lead_id: args["save-to-lead"] ?? null, + }, + } + const res = await irisFetch("/api/v6/workspace/tools/execute", { method: "POST", body: JSON.stringify(payload) }) + if (!(await handleApiError(res, "Brand extraction"))) { sp.stop("Failed", 1); prompts.outro("Done"); return } + const data = await res.json() as any + const result = data?.result ? (typeof data.result === "string" ? JSON.parse(data.result) : data.result) : data + + if (!result?.success) { + sp.stop("Failed", 1) + prompts.log.error(result?.error ?? "Brand extraction failed") + prompts.outro("Done") + return + } + + sp.stop(success("Brand extracted")) + const brand = result.brand ?? {} + printDivider() + printKV("Brand", brand.brand_name ?? "Unknown") + printKV("Tagline", brand.tagline ?? "—") + printKV("Primary", brand.colors?.primary ?? "—") + printKV("Secondary", brand.colors?.secondary ?? "—") + printKV("Accent", brand.colors?.accent ?? "—") + printKV("Background", brand.colors?.background ?? "—") + printKV("Fonts", (brand.font_families ?? []).join(", ") || "—") + printKV("Style", brand.design_style ?? "—") + printKV("Theme", brand.theme_mode ?? "—") + + if (brand.logo_urls?.length) { + printKV("Logo", brand.logo_urls[0].src ?? "—") + } + + const socialLinks = Object.entries(brand.social_links ?? {}).filter(([, v]) => v) + if (socialLinks.length > 0) { + printKV("Social", socialLinks.map(([k]) => k).join(", ")) + } + + if (result.saved_to_lead_id) { + printKV("Saved to Lead", `#${result.saved_to_lead_id}`) + } + + printDivider() + prompts.outro(dim("Use iris onboard <url> (without --extract-only) to create a page")) + return + } catch (err) { + sp.stop("Error", 1) + prompts.log.error(err instanceof Error ? err.message : String(err)) + prompts.outro("Done") + return + } + } + + // Full onboarding: extract brand + create page + sp.start("Scraping brand identity…") + + try { + // Step 1: Extract brand + const extractPayload = { + tool: "websiteBrandExtractor", + params: { url }, + } + const extractRes = await irisFetch("/api/v6/workspace/tools/execute", { method: "POST", body: JSON.stringify(extractPayload) }) + if (!(await handleApiError(extractRes, "Brand extraction"))) { sp.stop("Failed", 1); prompts.outro("Done"); return } + const extractData = await extractRes.json() as any + const extractResult = extractData?.result ? (typeof extractData.result === "string" ? JSON.parse(extractData.result) : extractData.result) : extractData + + if (!extractResult?.success) { + sp.stop("Failed", 1) + prompts.log.error(extractResult?.error ?? "Brand extraction failed") + prompts.outro("Done") + return + } + + const brand = extractResult.brand ?? {} + const brandName = brand.brand_name ?? new URL(url).hostname + sp.stop(success(`Brand: ${brandName}`)) + + // Show brand summary + printDivider() + printKV("Brand", brandName) + printKV("Primary", brand.colors?.primary ?? "—") + printKV("Secondary", brand.colors?.secondary ?? "—") + printKV("Style", brand.design_style ?? "—") + printKV("Theme", brand.theme_mode ?? "—") + printDivider() + + // Step 2: Create page + const pageSlug = args.slug ?? brandName.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") + sp.start(`Creating page: ${pageSlug}…`) + + // Build page JSON from brand data + const colors = brand.colors ?? {} + const primary = colors.primary ?? "#3b82f6" + const secondary = colors.secondary ?? "#8b5cf6" + const themeMode = brand.theme_mode ?? "dark" + const background = colors.background ?? (themeMode === "dark" ? "#0a0a0a" : "#ffffff") + + const navLinks = (brand.navigation ?? []).slice(0, 5).map((n: any) => ({ + label: n.label ?? "", + url: n.href ?? "#", + })) + + const logoUrl = brand.logo_urls?.[0]?.src + const logo = logoUrl ? { text: null, url: "/", imageUrl: logoUrl } : { text: brandName, url: "/" } + + const ctaButton = brand.ctas?.[0] + ? { text: brand.ctas[0].text, url: brand.ctas[0].href ?? "#" } + : { text: "Get Started", url: "#contact" } + + const socialLinks = Object.entries(brand.social_links ?? {}) + .filter(([, v]) => v) + .map(([k, v]) => ({ label: k.charAt(0).toUpperCase() + k.slice(1), url: v as string })) + + const jsonContent = { + version: "1.0", + type: "landing", + theme: { + mode: themeMode, + backgroundColor: background, + branding: { + name: brandName, + primaryColor: primary, + secondaryColor: secondary, + }, + }, + components: [ + { + type: "SiteNavigation", + id: "nav-1", + props: { + logo, + links: navLinks.length > 0 ? navLinks : [{ label: "Home", url: "#" }, { label: "About", url: "#about" }], + ctaButton, + themeMode, + }, + }, + { + type: "Hero", + id: "hero-1", + props: { + title: brandName, + subtitle: brand.tagline ?? extractResult.page_description ?? "", + backgroundGradient: `linear-gradient(135deg, ${primary} 0%, ${secondary} 100%)`, + themeMode, + textAlign: "center", + minHeight: "500px", + }, + }, + { + type: "TextBlock", + id: "about-1", + props: { + title: `About ${brandName}`, + content: extractResult.page_description ?? brand.tagline ?? `Welcome to ${brandName}.`, + themeMode, + }, + }, + { + type: "ButtonCTA", + id: "cta-1", + props: { + text: ctaButton.text, + url: ctaButton.url, + themeMode, + }, + }, + { + type: "SiteFooter", + id: "footer-1", + props: { + logo, + tagline: brand.tagline ?? brandName, + columns: [ + { title: "Navigation", links: navLinks.length > 0 ? navLinks : [{ label: "Home", url: "#" }] }, + ...(socialLinks.length > 0 ? [{ title: "Social", links: socialLinks.slice(0, 5) }] : []), + ], + themeMode, + }, + }, + ], + } + + const pagePayload: Record<string, unknown> = { + slug: pageSlug, + title: brandName, + seo_title: extractResult.page_title ?? brandName, + seo_description: extractResult.page_description ?? brand.tagline ?? "", + owner_type: args["owner-type"], + owner_id: args["owner-id"], + status: "draft", + json_content: jsonContent, + auto_publish: !args["no-publish"], + ai_source_type: "site_onboarding", + ai_source_prompt: `Auto-generated from ${url}`, + } + + const pageRes = await irisFetch("/api/v1/pages", { method: "POST", body: JSON.stringify(pagePayload) }) + if (!(await handleApiError(pageRes, "Create page"))) { sp.stop("Failed", 1); prompts.outro("Done"); return } + const pageData = (await pageRes.json()) as { data?: any } + const page = pageData?.data ?? pageData + + sp.stop(success(`Created #${page.id}`)) + + // Save brand to lead if requested + if (args["save-to-lead"]) { + const leadPayload = { + tool: "websiteBrandExtractor", + params: { url, save_to_lead_id: args["save-to-lead"] }, + } + await irisFetch("/api/v6/workspace/tools/execute", { method: "POST", body: JSON.stringify(leadPayload) }).catch(() => {}) + } + + printDivider() + printKV("Page ID", page.id) + printKV("Slug", page.slug ?? pageSlug) + printKV("Status", args["no-publish"] ? "draft" : "published") + + const env = process.env.IRIS_ENV ?? "production" + const pageUrl = env === "local" + ? `http://local.iris.freelabel.net:9300/p/${pageSlug}` + : `https://heyiris.io/p/${pageSlug}` + printKV("URL", pageUrl) + printDivider() + + prompts.log.info(dim(`Edit: iris pages set ${pageSlug} "theme.branding.primaryColor" "${primary}"`)) + prompts.log.info(dim(`View: iris pages view ${pageSlug}`)) + prompts.outro(success("Site onboarded")) + + } catch (err) { + sp.stop("Error", 1) + prompts.log.error(err instanceof Error ? err.message : String(err)) + prompts.outro("Done") + } + }, +}) diff --git a/packages/opencode/src/cli/cmd/platform-pages.ts b/packages/opencode/src/cli/cmd/platform-pages.ts index dece4e2890aa..5dc2c7332f16 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/prompts" import { UI } from "../ui" -import { irisFetch, requireAuth, handleApiError, printDivider, printKV, dim, bold, success, highlight } from "./iris-api" +import { irisFetch, requireAuth, requireUserId, resolveUserId, handleApiError, printDivider, printKV, dim, bold, success, highlight, IRIS_API } from "./iris-api" import { existsSync, mkdirSync, writeFileSync, readFileSync } from "fs" import { join } from "path" @@ -9,11 +9,25 @@ import { join } from "path" // Helpers // ============================================================================ -function publicUrl(slug: string): string { +/** + * Get the public URL for a page. Prefers the API-provided public_url, + * falls back to constructing from slug. + */ +function publicUrl(slugOrPage: string | { public_url?: string; slug?: string }): string { + if (typeof slugOrPage === "object" && slugOrPage.public_url) { + return slugOrPage.public_url + } + const slug = typeof slugOrPage === "string" ? slugOrPage : (slugOrPage.slug ?? "") const env = process.env.IRIS_ENV ?? "production" return env === "local" ? `http://local.iris.freelabel.net:9300/p/${slug}` - : `https://heyiris.io/p/${slug}` + : `https://main.heyiris.io/p/${slug}` +} + +// Pages CRUD routes through iris-api (which proxies to fl-api with service token). +// The SDK key authenticates against iris-api; fl-api doesn't recognize it directly. +function pagesFetch(path: string, options?: RequestInit): Promise<Response> { + return irisFetch(path, options ?? {}, IRIS_API) } function formatStatus(status: string): string { @@ -28,7 +42,7 @@ async function getBySlug(slug: string, includeJson = false): Promise<any | null> include_json: includeJson ? "1" : "0", include_drafts: "1", }) - const res = await irisFetch(`/api/v1/pages/by-slug/${encodeURIComponent(slug)}?${params}`) + const res = await pagesFetch(`/api/v1/pages/by-slug/${encodeURIComponent(slug)}?${params}`) if (!res.ok) { await handleApiError(res, `Get page ${slug}`) return null @@ -97,7 +111,7 @@ const ListCmd = cmd({ const sp = prompts.spinner() sp.start("Loading pages…") try { - const res = await irisFetch("/api/v1/pages") + const res = await pagesFetch("/api/v1/pages") if (!(await handleApiError(res, "List pages"))) { sp.stop("Failed", 1); prompts.outro("Done"); return } const json = (await res.json()) as any // Handle both direct array and Laravel paginator ({ data: { data: [...] } }) @@ -128,7 +142,7 @@ const ListCmd = cmd({ const tpl = p?.json_content?.meta?.template ?? p?.json_content?.type ?? "-" console.log(` ${bold(p.slug)} ${dim(`#${p.id}`)} ${formatStatus(p.status)}`) console.log(` ${dim(p.title ?? "")} ${dim(`[${tpl}]`)}`) - console.log(` ${dim(publicUrl(p.slug))}`) + console.log(` ${dim(publicUrl(p))}`) console.log() } printDivider() @@ -170,7 +184,7 @@ const ViewCmd = cmd({ printKV("Title", page.title) printKV("Status", formatStatus(page.status)) printKV("Published", page.published_at ?? "Not published") - printKV("URL", publicUrl(page.slug)) + printKV("URL", publicUrl(page)) const compCount = page?.json_content?.components?.length ?? 0 printKV("Components", compCount) printDivider() @@ -229,7 +243,7 @@ const SetCmd = cmd({ const json = page.json_content ?? {} const parsed = parseValue(args.value) setNestedValue(json, args.path, parsed) - const res = await irisFetch(`/api/v1/pages/${page.id}`, { + const res = await pagesFetch(`/api/v1/pages/${page.id}`, { method: "PUT", body: JSON.stringify({ json_content: json }), }) @@ -322,13 +336,25 @@ const PushCmd = cmd({ return } + // Validate component types BEFORE pushing + const validation = validateComponents(jsonContent) + if (!validation.valid) { + sp.stop("Validation failed", 1) + for (const err of validation.errors) { + if (err === "") console.log() + else prompts.log.error(err) + } + prompts.outro("Done") + return + } + const updateData: Record<string, unknown> = { json_content: jsonContent } if (local.title) updateData.title = local.title if (local.seo_title) updateData.seo_title = local.seo_title if (local.seo_description) updateData.seo_description = local.seo_description if (local.og_image) updateData.og_image = local.og_image - const res = await irisFetch(`/api/v1/pages/${page.id}`, { + const res = await pagesFetch(`/api/v1/pages/${page.id}`, { method: "PUT", body: JSON.stringify(updateData), }) @@ -429,7 +455,7 @@ const PublishCmd = cmd({ try { const page = await getBySlug(args.slug, false) if (!page) { sp.stop("Failed", 1); prompts.outro("Done"); return } - const res = await irisFetch(`/api/v1/pages/${page.id}/publish`, { method: "POST" }) + const res = await pagesFetch(`/api/v1/pages/${page.id}/publish`, { method: "POST" }) if (!(await handleApiError(res, "Publish"))) { sp.stop("Failed", 1); prompts.outro("Done"); return } sp.stop(success("Published")) console.log(` ${highlight(publicUrl(args.slug))}`) @@ -455,7 +481,7 @@ const UnpublishCmd = cmd({ try { const page = await getBySlug(args.slug, false) if (!page) { sp.stop("Failed", 1); prompts.outro("Done"); return } - const res = await irisFetch(`/api/v1/pages/${page.id}/unpublish`, { method: "POST" }) + const res = await pagesFetch(`/api/v1/pages/${page.id}/unpublish`, { method: "POST" }) if (!(await handleApiError(res, "Unpublish"))) { sp.stop("Failed", 1); prompts.outro("Done"); return } sp.stop(success("Unpublished (draft)")) prompts.outro("Done") @@ -486,6 +512,37 @@ const CreateCmd = cmd({ const sp = prompts.spinner() sp.start("Creating…") try { + // Build initial json_content — the API requires it + const template = args.template ?? "landing" + const jsonContent = { + 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: [], + }, + }, + ], + } + const payload: Record<string, unknown> = { slug: args.slug, title: args.title, @@ -494,9 +551,10 @@ const CreateCmd = cmd({ owner_type: args["owner-type"], owner_id: args["owner-id"], status: "draft", + json_content: jsonContent, + auto_publish: true, } - if (args.template) payload.template = args.template - const res = await irisFetch("/api/v1/pages", { method: "POST", body: JSON.stringify(payload) }) + const res = await pagesFetch("/api/v1/pages", { method: "POST", body: JSON.stringify(payload) }) if (!(await handleApiError(res, "Create page"))) { sp.stop("Failed", 1); prompts.outro("Done"); return } const data = (await res.json()) as { data?: any } const p = data?.data ?? data @@ -506,7 +564,7 @@ const CreateCmd = cmd({ printKV("Slug", p.slug) printKV("Title", p.title) printKV("Status", p.status) - printKV("URL", publicUrl(p.slug)) + printKV("URL", publicUrl(p)) printDivider() prompts.outro(dim(`iris pages publish ${p.slug}`)) } catch (err) { @@ -562,7 +620,7 @@ const VersionsCmd = cmd({ try { const page = await getBySlug(args.slug, false) if (!page) { sp.stop("Failed", 1); prompts.outro("Done"); return } - const res = await irisFetch(`/api/v1/pages/${page.id}/versions`) + const res = await pagesFetch(`/api/v1/pages/${page.id}/versions`) if (!(await handleApiError(res, "Versions"))) { sp.stop("Failed", 1); prompts.outro("Done"); return } const data = (await res.json()) as { data?: any[] } const versions = data?.data ?? [] @@ -599,7 +657,7 @@ const RollbackCmd = cmd({ try { const page = await getBySlug(args.slug, false) if (!page) { sp.stop("Failed", 1); prompts.outro("Done"); return } - const res = await irisFetch(`/api/v1/pages/${page.id}/rollback/${args.version}`, { method: "POST" }) + const res = await pagesFetch(`/api/v1/pages/${page.id}/rollback/${args.version}`, { method: "POST" }) if (!(await handleApiError(res, "Rollback"))) { sp.stop("Failed", 1); prompts.outro("Done"); return } sp.stop(success(`Rolled back to v${args.version}`)) prompts.outro("Done") @@ -611,6 +669,192 @@ const RollbackCmd = cmd({ }, }) +// ============================================================================ +// Component Validation — reject invalid types before push/create +// ============================================================================ + +const VALID_COMPONENT_TYPES = new Set([ + "Hero", "SiteNavigation", "SiteFooter", "AnnouncementBanner", + "TestimonialsSection", "TeamSection", "ContactSection", "LogoMarquee", + "FeatureShowcase", "ComparisonMatrix", "ClientGrid", "CareersListing", + "PortfolioGallery", "ProductGrid", "ServiceMenu", "EventGrid", + "FundingTiers", "BeforeAfter", "MapSection", "NewsletterSignup", + "StepWizard", "FileUpload", "ShoppingCart", "OrderConfirmation", + "ProtectionPicker", "VehicleGrid", +]) + +function validateComponents(jsonContent: any): { valid: boolean; errors: string[] } { + const components = jsonContent?.components ?? [] + const errors: string[] = [] + + for (let i = 0; i < components.length; i++) { + const c = components[i] + if (!c?.type) { + errors.push(`components[${i}]: missing "type" field`) + continue + } + if (!VALID_COMPONENT_TYPES.has(c.type)) { + errors.push(`components[${i}]: "${c.type}" is not a valid component type`) + } + if (!c.id) { + errors.push(`components[${i}] (${c.type}): missing "id" field`) + } + } + + if (errors.length > 0) { + errors.push("") + errors.push(`Valid types: ${[...VALID_COMPONENT_TYPES].join(", ")}`) + errors.push(`Run: iris pages component-registry`) + } + + return { valid: errors.length === 0, errors } +} + +// ============================================================================ +// Component Registry — available component types for the page builder +// ============================================================================ + +const COMPONENT_REGISTRY: { type: string; description: string; requiredProps: string[] }[] = [ + { 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"] }, + { type: "SiteFooter", description: "Footer with brand name, links, copyright", requiredProps: ["brandName"] }, + { type: "AnnouncementBanner", description: "Dismissible banner strip at top of page", requiredProps: ["text"] }, + { type: "TestimonialsSection", description: "Customer testimonials with avatars and quotes", requiredProps: ["testimonials"] }, + { type: "TeamSection", description: "Team member grid with photos and roles", requiredProps: ["members"] }, + { type: "ContactSection", description: "Contact form with configurable fields", requiredProps: ["heading"] }, + { type: "LogoMarquee", description: "Auto-scrolling logo carousel (trusted by...)", requiredProps: ["logos"] }, + { type: "FeatureShowcase", description: "Feature highlights with icons and descriptions", requiredProps: ["features"] }, + { type: "ComparisonMatrix", description: "Pricing/feature comparison table", requiredProps: ["columns", "rows"] }, + { type: "ClientGrid", description: "Client/partner logo grid", requiredProps: ["clients"] }, + { type: "CareersListing", description: "Job listings with department filters", requiredProps: ["jobs"] }, + { type: "PortfolioGallery", description: "Image/project gallery grid with lightbox", requiredProps: ["items"] }, + { type: "ProductGrid", description: "E-commerce product cards with prices", requiredProps: ["products"] }, + { type: "ServiceMenu", description: "Service/menu items with prices and descriptions", requiredProps: ["categories"] }, + { type: "EventGrid", description: "Event cards with dates and venues", requiredProps: ["events"] }, + { type: "FundingTiers", description: "Pricing/funding tier cards", requiredProps: ["tiers"] }, + { type: "BeforeAfter", description: "Before/after image slider comparison", requiredProps: ["before", "after"] }, + { type: "MapSection", description: "Interactive map with location markers", requiredProps: ["locations"] }, + { type: "NewsletterSignup", description: "Email signup form", requiredProps: ["heading"] }, + { type: "StepWizard", description: "Multi-step form wizard", requiredProps: ["steps"] }, + { type: "FileUpload", description: "File upload dropzone", requiredProps: [] }, + { type: "ShoppingCart", description: "Shopping cart with line items", requiredProps: [] }, + { type: "OrderConfirmation", description: "Order confirmation/receipt page", requiredProps: [] }, +] + +const ComposeCmd = cmd({ + command: "compose <description..>", + describe: "AI-compose a page from a text description (uses Gemini)", + builder: (y) => + y + .positional("description", { describe: "what the page should be", type: "string", array: true }) + .option("slug", { describe: "page slug (auto-generated if omitted)", type: "string" }) + .option("title", { describe: "page title", type: "string" }) + .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("json", { type: "boolean" }), + async handler(args) { + UI.empty() + const desc = (args.description as string[]).join(" ") + prompts.intro(`◈ Compose Page`) + if (!(await requireAuth())) { prompts.outro("Done"); return } + + const userId = await resolveUserId() + if (!userId) { prompts.outro("Done"); return } + + const sp = prompts.spinner() + sp.start("Composing with AI (this may take 10-30s)…") + + try { + const payload: Record<string, unknown> = { + description: desc, + user_id: userId, + style: args.style, + theme_mode: args.theme, + } + if (args.slug) payload.slug = args.slug + if (args.title) payload.title = args.title + if (args.model) payload.model = args.model + + const res = await pagesFetch("/api/v1/pages/compose", { + method: "POST", + body: JSON.stringify(payload), + }) + + if (!res.ok) { + const body = await res.json().catch(() => ({})) as any + sp.stop("Failed", 1) + prompts.log.error(body.error ?? body.message ?? `HTTP ${res.status}`) + prompts.outro("Done") + return + } + + const data = (await res.json()) as any + if (!data.success) { + sp.stop("Failed", 1) + prompts.log.error(data.error ?? "Composition failed") + prompts.outro("Done") + return + } + + sp.stop(success(`Created "${data.slug}"`)) + printDivider() + printKV("Page ID", data.page_id) + printKV("Slug", data.slug) + printKV("URL", data.url) + printKV("Components", data.component_count ?? data.components?.length) + if (data.self_heal_attempts) printKV("Self-heal attempts", data.self_heal_attempts) + printDivider() + + if (args.json) { + console.log(JSON.stringify(data, null, 2)) + } + + prompts.log.info(`View: ${dim(`iris pages view ${data.slug}`)}`) + prompts.log.info(`Edit: ${dim(`iris pages pull ${data.slug}`)}`) + prompts.outro("Done") + } catch (err) { + sp.stop("Error", 1) + prompts.log.error(err instanceof Error ? err.message : String(err)) + prompts.outro("Done") + } + }, +}) + +const ComponentRegistryCmd = cmd({ + command: "component-registry", + aliases: ["registry", "available-components"], + describe: "list available component types for the page builder", + builder: (y) => y.option("json", { type: "boolean" }), + async handler(args) { + UI.empty() + prompts.intro("◈ Page Component Registry") + + if (args.json) { + console.log(JSON.stringify(COMPONENT_REGISTRY, null, 2)) + prompts.outro("Done") + return + } + + console.log() + console.log(` ${bold("Available components for Genesis pages:")}`) + console.log() + for (const c of COMPONENT_REGISTRY) { + console.log(` ${highlight(c.type)}`) + console.log(` ${dim(c.description)}`) + if (c.requiredProps.length) { + console.log(` ${dim("Required props: " + c.requiredProps.join(", "))}`) + } + console.log() + } + console.log(` ${dim(`${COMPONENT_REGISTRY.length} components available`)}`) + console.log() + prompts.log.info(`Example: ${dim('iris pages set my-page "components.1" \'{"type":"Hero","props":{"title":"Hello"}}\'')}`) + prompts.log.info(`Reference: ${dim("iris pages pull component-showcase")} — pull a working example`) + prompts.outro("Done") + }, +}) + // ============================================================================ // Root // ============================================================================ @@ -632,6 +876,8 @@ export const PlatformPagesCommand = cmd({ .command(UnpublishCmd) .command(CreateCmd) .command(ComponentsCmd) + .command(ComposeCmd) + .command(ComponentRegistryCmd) .command(VersionsCmd) .command(RollbackCmd) .demandCommand(), diff --git a/packages/opencode/src/cli/cmd/platform-profile.ts b/packages/opencode/src/cli/cmd/platform-profile.ts index ea242be05828..cc40f47a54b0 100644 --- a/packages/opencode/src/cli/cmd/platform-profile.ts +++ b/packages/opencode/src/cli/cmd/platform-profile.ts @@ -294,9 +294,150 @@ const ProfileMembershipsCommand = cmd({ }, }) +// ============================================================================ +// profile create --name <name> [--bio] [--category] [--website] +// ============================================================================ + +const ProfileCreateCommand = cmd({ + command: "create", + describe: "create a new profile", + builder: (yargs) => + yargs + .option("name", { describe: "profile name", type: "string", demandOption: true }) + .option("bio", { describe: "profile bio", type: "string" }) + .option("category", { describe: "profile category", type: "string" }) + .option("instagram", { describe: "Instagram handle", type: "string" }) + .option("twitter", { describe: "Twitter handle", type: "string" }) + .option("website", { describe: "website URL", type: "string" }), + async handler(args) { + await requireAuth() + const name = args.name as string + const body: Record<string, any> = { name } + if (args.bio) body.bio = args.bio + if (args.category) body.category = args.category + if (args.instagram) body.instagram = args.instagram + if (args.twitter) body.twitter = args.twitter + if (args.website) body.website_url = args.website + + const res = await irisFetch("/api/v1/profile/create", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }) + const ok = await handleApiError(res, "Create profile") + if (!ok) { prompts.outro("Failed"); return } + const data = (await res.json()) as any + const profile = data?.data ?? data + printDivider() + printKV("PK", profile.pk ?? profile.id ?? "?") + printKV("Slug", profile.id ?? profile.slug ?? "?") + printKV("Name", profile.name ?? name) + if (profile.bio) printKV("Bio", profile.bio) + printDivider() + prompts.outro(success("Profile created")) + }, +}) + +// ============================================================================ +// profile reassign-articles --from <pk> --to <pk> --match <keyword> +// ============================================================================ + +const ProfileReassignArticlesCommand = cmd({ + command: "reassign-articles", + describe: "move articles from one profile to another by keyword match", + builder: (yargs) => + yargs + .option("from", { describe: "source profile slug or PK", type: "string", demandOption: true }) + .option("to", { describe: "target profile slug or PK", type: "string", demandOption: true }) + .option("match", { describe: "keyword to match in article titles (case-insensitive)", type: "string", demandOption: true }) + .option("dry-run", { describe: "preview without making changes", type: "boolean", default: false }) + .option("yes", { alias: "y", describe: "skip confirmation", type: "boolean", default: false }), + async handler(args) { + await requireAuth() + const fromSlug = args.from as string + const toSlug = args.to as string + const keyword = (args.match as string).toLowerCase() + const dryRun = args["dry-run"] as boolean + + // Resolve source profile + const fromProfile = await fetchProfile(fromSlug) + if (!fromProfile) { prompts.outro(`Source profile "${fromSlug}" not found`); return } + console.log(` Source: ${bold(fromProfile.name)} (pk: ${fromProfile.pk})`) + + // Resolve target profile + const toProfile = await fetchProfile(toSlug) + if (!toProfile) { prompts.outro(`Target profile "${toSlug}" not found`); return } + console.log(` Target: ${bold(toProfile.name)} (pk: ${toProfile.pk})`) + + // Fetch articles from source profile + const articlesRes = await irisFetch(`/api/v1/articles?profile_id=${fromProfile.pk}&limit=100`) + const articlesOk = await handleApiError(articlesRes, "Fetch articles") + if (!articlesOk) return + const articlesData = (await articlesRes.json()) as any + // Handle nested pagination (data.data) or flat array (data) + const rawArticles = articlesData?.data?.data ?? articlesData?.data ?? articlesData ?? [] + const articles: any[] = Array.isArray(rawArticles) ? rawArticles : Object.values(rawArticles) + + // Filter by keyword + const matching = articles.filter((a: any) => + (a.title || "").toLowerCase().includes(keyword) + ) + + printDivider() + console.log(` Found ${matching.length} articles matching "${args.match}" out of ${articles.length} total`) + printDivider() + + if (matching.length === 0) { + prompts.outro("No matching articles found") + return + } + + for (const article of matching) { + console.log(` [${article.id}] ${article.title}`) + } + + if (dryRun) { + printDivider() + prompts.outro(dim("Dry run — no changes made")) + return + } + + const skipConfirm = args.yes as boolean + if (!skipConfirm) { + printDivider() + const confirmed = await prompts.confirm({ + message: `Move ${matching.length} articles from "${fromProfile.name}" to "${toProfile.name}"?`, + }) + if (!confirmed || prompts.isCancel(confirmed)) { + prompts.outro("Cancelled") + return + } + } + + // Reassign each article + let moved = 0 + for (const article of matching) { + const updateRes = await irisFetch(`/api/v1/articles/${article.id}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ profile_id: toProfile.pk }), + }) + if (updateRes.ok) { + moved++ + console.log(` ✓ Moved: ${article.title}`) + } else { + console.log(` ✗ Failed: ${article.title} (${updateRes.status})`) + } + } + + printDivider() + prompts.outro(success(`Moved ${moved}/${matching.length} articles`)) + }, +}) + export const PlatformProfileCommand = cmd({ command: "profile", - describe: "manage profiles (show, get, set, links, memberships)", + describe: "manage profiles (show, get, set, create, links, memberships, reassign-articles)", builder: (yargs) => yargs .command(ProfileShowCommand) @@ -304,6 +445,8 @@ export const PlatformProfileCommand = cmd({ .command(ProfileSetCommand) .command(ProfileLinksCommand) .command(ProfileMembershipsCommand) + .command(ProfileCreateCommand) + .command(ProfileReassignArticlesCommand) .demandCommand(), async handler() {}, }) diff --git a/packages/opencode/src/cli/cmd/platform-proposals.ts b/packages/opencode/src/cli/cmd/platform-proposals.ts new file mode 100644 index 000000000000..2153ea2fbbbc --- /dev/null +++ b/packages/opencode/src/cli/cmd/platform-proposals.ts @@ -0,0 +1,340 @@ +import { cmd } from "./cmd" +import * as prompts from "@clack/prompts" +import { irisFetch, requireAuth, handleApiError, printDivider, printKV, dim, bold, success, highlight } from "./iris-api" + +// ============================================================================ +// Proposals Create — generate proposal from lead data + send +// ============================================================================ + +const ProposalsCreateCommand = cmd({ + command: "create <lead-id>", + aliases: ["generate", "send"], + describe: "generate a proposal from lead notes/tasks and send for signing", + builder: (yargs) => + yargs + .positional("lead-id", { describe: "lead ID", type: "number", demandOption: true }) + .option("amount", { alias: "a", describe: "total amount ($)", type: "number" }) + .option("scope", { alias: "s", describe: "scope of work", type: "string" }) + .option("interval", { alias: "i", describe: "billing interval: month|quarter|year|one-time", type: "string" }) + .option("duration", { alias: "d", describe: "duration in months (default: 12)", type: "number" }) + .option("deposit", { describe: "deposit percentage 0-100", type: "number" }) + .option("rev-share", { describe: "revenue share percentage (e.g. 5)", type: "number" }) + .option("pass-fees", { describe: "pass processing fees to client (Stripe 2.9% + $0.30)", type: "boolean" }) + .option("brand-logo", { describe: "brand logo URL for proposal header", type: "string" }) + .option("package", { alias: "p", describe: "service package ID (auto-fills amount + scope)", type: "number" }) + .option("template", { alias: "t", describe: "proposal template name", type: "string" }) + .option("list-price", { describe: "list price before discount (shows strikethrough on proposal)", type: "number" }) + .option("discount", { describe: "discount percentage (auto-calculated from list-price vs amount if omitted)", type: "number" }) + .option("sender-name", { describe: "sender name shown on proposal (e.g. 'Alex Mayo')", type: "string" }) + .option("skip-contract", { describe: "skip contract attachment", type: "boolean" }) + .option("skip-send", { describe: "generate but don't send to client", type: "boolean" }) + .option("json", { describe: "JSON output", type: "boolean" }), + async handler(args) { + if (!(await requireAuth())) return + + const leadId = args["lead-id"] + + // Step 1: Fetch lead data (notes, tasks, deliverables) + const leadRes = await irisFetch(`/api/v1/leads/${leadId}?include=notes,tasks,deliverables`) + if (!(await handleApiError(leadRes, "Fetch lead"))) return + const lead = await leadRes.json().catch(() => ({})) + const leadData = lead?.data ?? lead + + if (!leadData?.id) { + prompts.log.error(`Lead #${leadId} not found`) + return + } + + console.log("") + console.log(bold(`Generating proposal for: ${leadData.name ?? leadData.first_name ?? `Lead #${leadId}`}`)) + + // Step 2: If no amount/scope, prompt or pull from package + let amount = args.amount + let scope = args.scope + + if (args.package) { + const pkgRes = await irisFetch(`/api/v1/bloq-packages/${args.package}`) + if (pkgRes.ok) { + const pkg = await pkgRes.json().catch(() => ({})) + const pkgData = pkg?.data ?? pkg + amount = amount ?? pkgData.price + scope = scope ?? pkgData.scope_template ?? pkgData.description + printKV("Package", pkgData.name ?? `#${args.package}`) + } + } + + if (!amount) { + const input = await prompts.text({ message: "Total amount ($):", validate: (v) => isNaN(Number(v)) ? "Must be a number" : undefined }) + if (prompts.isCancel(input)) return + amount = Number(input) + } + + if (!scope) { + const input = await prompts.text({ message: "Scope of work:" }) + if (prompts.isCancel(input)) return + scope = String(input) + } + + // Step 3: Create payment gate (which generates proposal + contract + Stripe) + const body: Record<string, unknown> = { + amount, + scope, + auto_send_reminders: true, + generate_proposal: true, + } + if (args.package) body.package_id = args.package + if (args["skip-contract"]) body.skip_contract = true + if (args.template) body.template = args.template + if (args.interval) body.interval = args.interval + if (args.duration) body.duration_months = args.duration + if (args.deposit !== undefined) body.deposit_percent = args.deposit + if (args["brand-logo"]) body.brand_logo_url = args["brand-logo"] + if (args["rev-share"] !== undefined) body.rev_share_percent = args["rev-share"] + if (args["pass-fees"]) body.processing_fee_mode = "pass_to_client" + if (args["list-price"] !== undefined) body.list_price = args["list-price"] + if (args["discount"] !== undefined) body.discount_percent = args["discount"] + if (args["sender-name"]) body.sender_name = args["sender-name"] + + const spinner = prompts.spinner() + spinner.start("Generating proposal...") + + const res = await irisFetch(`/api/v1/leads/${leadId}/payment-gate`, { + method: "POST", + body: JSON.stringify(body), + }) + + spinner.stop("Proposal generated") + + if (!(await handleApiError(res, "Create proposal"))) return + const data = await res.json().catch(() => ({})) + + if (args.json) { console.log(JSON.stringify(data, null, 2)); return } + + if (!data.success) { + if (data.error === "duplicate") { + prompts.log.warn(data.message || "A proposal already exists for this lead") + const step = data.step?.data ?? {} + if (step.proposal_url) { + console.log("") + printKV("Existing Proposal", step.proposal_url) + } + return + } + prompts.log.error(data.message || "Failed to create proposal") + return + } + + console.log("") + console.log(success("Proposal created!")) + printDivider() + printKV("Lead", `${leadData.name ?? leadData.first_name ?? ""} (#${leadId})`) + if (args["list-price"]) { + const discPct = args["discount"] ?? Math.round((1 - (Number(amount) / Number(args["list-price"]))) * 100) + printKV("List Price", `$${Number(args["list-price"]).toFixed(2)}${args.interval && args.interval !== "one-time" ? "/" + args.interval : ""}`) + printKV("Discount", `${discPct}% off`) + } + printKV("Amount", `$${Number(amount).toFixed(2)}${args.interval && args.interval !== "one-time" ? "/" + args.interval : ""}`) + if (args.interval && args.interval !== "one-time") { + const dur = args.duration ?? 12 + const total = Number(amount) * dur + printKV("Duration", `${dur} months`) + printKV("Total", `$${total.toFixed(2)}`) + if (args.deposit) { + const dep = total * (args.deposit / 100) + printKV("Deposit", `${args.deposit}% = $${dep.toFixed(2)} upfront`) + } + if (args["rev-share"]) { + printKV("Rev Share", `${args["rev-share"]}% of net platform revenue`) + } + } + printKV("Scope", String(scope)) + printDivider() + printKV("Proposal URL", data.proposal_url ?? dim("(not generated)")) + printKV("Contract URL", data.contract_signing_url ?? dim("(not attached)")) + printKV("Payment URL", data.stripe_checkout_url ?? dim("(not configured)")) + printDivider() + + if (!args["skip-send"] && data.proposal_url) { + console.log("") + console.log(dim("Proposal ready to send. Use outreach or share the URL above.")) + } + }, +}) + +// ============================================================================ +// Proposals Status — check proposal + deal status +// ============================================================================ + +const ProposalsStatusCommand = cmd({ + command: "status <lead-id>", + aliases: ["check"], + describe: "check proposal and deal status for a lead", + builder: (yargs) => + yargs + .positional("lead-id", { describe: "lead ID", type: "number", demandOption: true }) + .option("json", { describe: "JSON output", type: "boolean" }), + async handler(args) { + if (!(await requireAuth())) return + + const leadId = args["lead-id"] + const res = await irisFetch(`/api/v1/leads/${leadId}/deal-status`) + if (!(await handleApiError(res, "Get proposal status"))) return + + const result = await res.json().catch(() => ({})) + const status = result?.data ?? result + + if (args.json) { console.log(JSON.stringify(status, null, 2)); return } + + if (!status?.has_payment_gate) { + prompts.log.info(`No proposal for lead #${leadId}`) + console.log(dim(`Create one: iris proposals create ${leadId}`)) + return + } + + const statusLabels: Record<string, string> = { + deal_closed: success("CLOSED"), + awaiting_payment: highlight("AWAITING PAYMENT"), + awaiting_contract: highlight("AWAITING CONTRACT"), + awaiting_both: dim("PENDING"), + } + + console.log("") + console.log(bold(`Proposal Status — Lead #${leadId}`)) + printDivider() + printKV("Status", statusLabels[status.status] ?? status.status) + printKV("Amount", `$${Number(status.amount ?? 0).toFixed(2)}`) + printKV("Scope", status.scope ?? dim("—")) + printKV("Contract", status.contract_signed ? success("Signed") : highlight("Pending")) + printKV("Payment", status.payment_received ? success("Received") : highlight("Pending")) + printKV("Reminders", `${status.reminders_sent ?? 0}/${status.reminders_total ?? 0} sent`) + printDivider() + + if (status.proposal_url) printKV("Proposal", status.proposal_url) + if (status.contract_signing_url) printKV("Contract", status.contract_signing_url) + if (status.stripe_checkout_url) printKV("Payment", status.stripe_checkout_url) + + if (status.proposal_url || status.contract_signing_url) { + printDivider() + } + }, +}) + +// ============================================================================ +// Proposals List — list all leads with active proposals +// ============================================================================ + +const ProposalsListCommand = cmd({ + command: "list", + aliases: ["ls"], + describe: "list leads with active proposals/payment gates", + builder: (yargs) => + yargs + .option("status", { alias: "s", describe: "filter by status", type: "string", choices: ["pending", "awaiting_payment", "awaiting_contract", "deal_closed"] }) + .option("json", { describe: "JSON output", type: "boolean" }), + async handler(args) { + if (!(await requireAuth())) return + + const params = new URLSearchParams({ has_payment_gate: "1" }) + if (args.status) params.set("deal_status", args.status) + + const res = await irisFetch(`/api/v1/leads?${params}`) + if (!(await handleApiError(res, "List proposals"))) return + + const result = await res.json().catch(() => ({})) + const leads = result?.data ?? result ?? [] + + if (args.json) { console.log(JSON.stringify(leads, null, 2)); return } + + if (!Array.isArray(leads) || leads.length === 0) { + prompts.log.info("No active proposals found") + return + } + + console.log("") + console.log(bold(`Active Proposals (${leads.length})`)) + printDivider() + + for (const lead of leads) { + const name = lead.name ?? lead.first_name ?? `Lead #${lead.id}` + const amount = lead.deal_amount ? `$${Number(lead.deal_amount).toFixed(2)}` : dim("—") + const st = lead.deal_status ?? dim("unknown") + console.log(` ${highlight(`#${lead.id}`)} ${name.padEnd(25)} ${amount.padEnd(12)} ${st}`) + } + + printDivider() + }, +}) + +// ============================================================================ +// Proposals Cancel — cancel/clear an active proposal +// ============================================================================ + +const ProposalsCancelCommand = cmd({ + command: "cancel <lead-id>", + aliases: ["clear", "delete"], + describe: "cancel the active proposal/payment gate for a lead", + builder: (yargs) => + yargs + .positional("lead-id", { describe: "lead ID", type: "number", demandOption: true }) + .option("json", { describe: "JSON output", type: "boolean" }), + async handler(args) { + if (!(await requireAuth())) return + + const leadId = args["lead-id"] + + // Find the active payment gate step + const stepsRes = await irisFetch(`/api/v1/leads/${leadId}/outreach-steps`) + if (!(await handleApiError(stepsRes, "Fetch outreach steps"))) return + + const stepsData = await stepsRes.json().catch(() => ({})) + const steps = stepsData?.data ?? stepsData?.steps ?? stepsData ?? [] + + if (!Array.isArray(steps)) { + prompts.log.error("Could not read outreach steps") + return + } + + const activeGates = steps.filter((s: any) => s.type === "payment_gate" && !s.is_completed) + + if (activeGates.length === 0) { + prompts.log.info(`No active proposal for lead #${leadId}`) + return + } + + // Complete each active payment gate + its reminder steps + let cancelled = 0 + for (const gate of activeGates) { + const res = await irisFetch(`/api/v1/leads/${leadId}/outreach-steps/${gate.id}/complete`, { + method: "POST", + }) + if (res.ok) cancelled++ + } + + if (args.json) { + console.log(JSON.stringify({ cancelled, lead_id: leadId })) + return + } + + console.log("") + console.log(success(`Cancelled ${cancelled} active proposal(s) for lead #${leadId}`)) + console.log(dim(`Create a new one: iris proposals create ${leadId}`)) + }, +}) + +// ============================================================================ +// Root command +// ============================================================================ + +export const PlatformProposalsCommand = cmd({ + command: "proposals", + aliases: ["proposal"], + describe: "create, send, and track client proposals with contracts + payment", + builder: (yargs) => + yargs + .command(ProposalsCreateCommand) + .command(ProposalsStatusCommand) + .command(ProposalsListCommand) + .command(ProposalsCancelCommand) + .demandCommand(), + async handler() {}, +}) diff --git a/packages/opencode/src/cli/cmd/platform-schedules.ts b/packages/opencode/src/cli/cmd/platform-schedules.ts index 626b22e5fea1..38e5c29a812c 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/prompts" import { UI } from "../ui" -import { irisFetch, requireAuth, requireUserId, handleApiError, printDivider, printKV, dim, bold, success } from "./iris-api" +import { irisFetch, requireAuth, requireUserId, handleApiError, printDivider, printKV, dim, bold, success, IRIS_API } from "./iris-api" // ============================================================================ // Display helpers @@ -21,15 +21,81 @@ function statusColor(status: string): string { return `${c}${status}${UI.Style.TEXT_NORMAL}` } -function printSchedule(s: Record<string, unknown>): void { - const name = bold(String(s.name ?? s.title ?? `Schedule #${s.id}`)) +function timeUntil(dateStr: string | null | undefined): string { + if (!dateStr) return "" + const now = Date.now() + const target = new Date(String(dateStr)).getTime() + const diff = target - now + if (isNaN(target)) return "" + if (diff < 0) return "overdue" + if (diff < 60_000) return `${Math.round(diff / 1000)}s` + if (diff < 3600_000) return `${Math.round(diff / 60_000)}m` + if (diff < 86400_000) return `${Math.round(diff / 3600_000)}h` + return `${Math.round(diff / 86400_000)}d` +} + +function taskLabel(s: Record<string, any>): string { + const data = s.data ?? {} + return data.task_type ?? data.type ?? s.task_name ?? "" +} + +function executionEnv(s: Record<string, any>): { label: string; icon: string } { + const data = s.data ?? {} + const taskName = String(s.task_name ?? "") + const dataType = String(data.type ?? "") + const taskType = String(data.task_type ?? "") + + // Hive = dispatched to local daemon on user's machine + if (dataType === "hive_task_dispatch" || taskName === "hive_task_dispatch" + || ["discover", "som_batch", "som", "social_stats_sync", "leadgen"].includes(taskType)) { + return { label: "hive", icon: "⬡" } + } + + // Heartbeat = runs on iris-api + if (dataType === "heartbeat" || taskName === "heartbeat") { + return { label: "iris", icon: "◉" } + } + + // Agent task = spawned by heartbeat or scheduler, runs on fl-api + if (dataType === "agent_task") { + const source = String(data.source ?? data.created_from ?? "") + if (source.includes("heartbeat")) return { label: "auto", icon: "⟳" } + return { label: "cloud", icon: "☁" } + } + + // Listener = event-driven + if (dataType === "listener") { + return { label: "hook", icon: "⚡" } + } + + // Unknown / legacy (no type set) + return { label: "cloud", icon: "☁" } +} + +function printSchedule(s: Record<string, any>, showCountdown = true): void { + const name = bold(String(s.name ?? s.title ?? s.task_name ?? `Schedule #${s.id}`)) const id = dim(`#${s.id}`) const status = s.status ? ` ${statusColor(String(s.status))}` : "" const freq = s.frequency ?? s.cron_expression ?? s.interval const freqStr = freq ? ` ${dim(String(freq))}` : "" - console.log(` ${name} ${id}${status}${freqStr}`) - if (s.description) { - console.log(` ${dim(String(s.description).slice(0, 100))}`) + + // Task type label + const tl = taskLabel(s) + const typeStr = tl ? ` ${dim(`[${tl}]`)}` : "" + + // Countdown to next run + let countdown = "" + if (showCountdown && s.next_run_at && s.status === "scheduled") { + const until = timeUntil(String(s.next_run_at)) + countdown = until ? ` ${UI.Style.TEXT_HIGHLIGHT}⏱ ${until}${UI.Style.TEXT_NORMAL}` : "" + } + + console.log(` ${name} ${id}${status}${freqStr}${typeStr}${countdown}`) + + // Show prompt/description on second line + const prompt = s.data?.prompt ?? s.prompt ?? s.description ?? "" + if (prompt) { + console.log(` ${dim(String(prompt).slice(0, 100))}`) } } @@ -43,8 +109,11 @@ const SchedulesListCommand = cmd({ describe: "list scheduled jobs", builder: (yargs) => yargs - .option("limit", { describe: "max results", type: "number", default: 20 }) + .option("limit", { describe: "max results", type: "number", default: 50 }) + .option("active", { describe: "show only active/scheduled/running jobs (hide completed one-offs)", type: "boolean", default: false }) + .option("latest", { describe: "include latest execution result for each job", type: "boolean", default: false }) .option("agent-id", { describe: "filter by agent ID", type: "number" }) + .option("json", { describe: "JSON output", type: "boolean" }) .option("user-id", { describe: "user ID (or IRIS_USER_ID env)", type: "number" }), async handler(args) { UI.empty() @@ -68,24 +137,186 @@ const SchedulesListCommand = cmd({ if (!ok) { spinner.stop("Failed", 1); prompts.outro("Done"); return } const data = (await res.json()) as { data?: any[]; total?: number } - const schedules: any[] = data?.data ?? [] - spinner.stop(`${schedules.length} schedule(s)`) + let schedules: any[] = data?.data ?? [] + + // --active: filter to scheduled/running/paused only (skip completed/cancelled one-offs) + if (args.active) { + schedules = schedules.filter((s: any) => { + const status = String(s.status ?? "").toLowerCase() + return ["scheduled", "running", "paused", "active", "enabled"].includes(status) + }) + } + + // Resolve bloq names in one batch + const bloqIds = [...new Set(schedules.map((s: any) => s.bloq_id).filter(Boolean))] + const bloqNames: Record<number, string> = {} + if (bloqIds.length > 0) { + try { + const bloqRes = await irisFetch(`/api/v1/users/${userId}/bloqs?ids=${bloqIds.join(",")}`) + if (bloqRes.ok) { + const bloqData = (await bloqRes.json()) as any + for (const b of (bloqData?.data ?? bloqData ?? [])) { + if (b?.id && b?.name) bloqNames[b.id] = b.name + } + } + } catch {} + } + + // --latest: fetch last execution for each job (parallel) + const latestExecs: Record<number, any> = {} + if (args.latest && schedules.length > 0) { + const execPromises = schedules.map(async (s: any) => { + try { + const execRes = await irisFetch( + `/api/v1/users/${userId}/bloqs/scheduled-jobs/${s.id}/executions?per_page=1` + ) + if (execRes.ok) { + const execData = (await execRes.json()) as any + const execs = execData?.data ?? [] + if (execs.length > 0) latestExecs[s.id] = execs[0] + } + } catch {} + }) + await Promise.all(execPromises) + } + + spinner.stop(`${schedules.length} schedule(s)${args.active ? " (active)" : ""}`) + + if (args.json) { + console.log(JSON.stringify(schedules.map((s: any) => ({ + id: s.id, + name: s.name ?? s.title ?? s.task_name, + status: s.status, + env: executionEnv(s).label, + bloq_id: s.bloq_id ?? null, + bloq_name: s.bloq_id ? (bloqNames[s.bloq_id] ?? null) : null, + frequency: s.frequency, + task_type: taskLabel(s), + prompt: s.data?.prompt ?? s.prompt, + next_run_at: s.next_run_at, + time_until: timeUntil(s.next_run_at), + last_run_at: s.last_run_at, + })), null, 2)) + prompts.outro("Done") + return + } if (schedules.length === 0) { - prompts.log.warn("No schedules found") + prompts.log.warn(args.active ? "No active schedules. Use without --active to see all." : "No schedules found") prompts.outro("Done") return } - printDivider() + // Group by execution environment + const groups: Record<string, { icon: string; label: string; description: string; items: any[] }> = { + hive: { icon: "⬡", label: "HIVE", description: "local machine", items: [] }, + iris: { icon: "◉", label: "IRIS", description: "cloud heartbeats", items: [] }, + auto: { icon: "⟳", label: "AUTO", description: "spawned by heartbeat", items: [] }, + cloud: { icon: "☁", label: "CLOUD", description: "agent tasks", items: [] }, + hook: { icon: "⚡", label: "HOOKS", description: "event listeners", items: [] }, + } + for (const s of schedules) { - printSchedule(s) + const env = executionEnv(s) + const group = groups[env.label] ?? groups["cloud"] + group.items.push(s) + } + + // Render each group + for (const [, group] of Object.entries(groups)) { + if (group.items.length === 0) continue + console.log() + console.log(` ${group.icon} ${bold(group.label)} ${dim(`(${group.description})`)}`) + printDivider() + + for (const s of group.items) { + const id = dim(`#${s.id}`) + const status = String(s.status ?? "").toLowerCase() + + // Status badge — detect stuck jobs (running + once + old) + let badge = "" + if (status === "running") { + const freq = String(s.frequency ?? "").toLowerCase() + const createdAt = s.created_at ? new Date(String(s.created_at)).getTime() : 0 + const age = Date.now() - createdAt + const isStuck = freq === "once" && age > 3600_000 // once + older than 1 hour + if (isStuck) { + badge = `${UI.Style.TEXT_DANGER}⚠ stuck${UI.Style.TEXT_NORMAL}` + } else { + badge = `${UI.Style.TEXT_HIGHLIGHT}running${UI.Style.TEXT_NORMAL}` + } + } else if (status === "paused") { + badge = `${UI.Style.TEXT_WARNING}paused${UI.Style.TEXT_NORMAL}` + } else if (status === "scheduled") { + const until = timeUntil(s.next_run_at) + badge = until === "overdue" + ? `${UI.Style.TEXT_DANGER}⚠ overdue${UI.Style.TEXT_NORMAL}` + : `${UI.Style.TEXT_HIGHLIGHT}⏱ ${until}${UI.Style.TEXT_NORMAL}` + } else { + badge = statusColor(status) + } + + // Frequency — clean up ugly underscores + const freq = String(s.frequency ?? "").replace(/_/g, " ") + + // Name — prefer agent name from data, avoid repeating task_name as prompt + const agentName = s.data?.agent_name ?? "" + const name = agentName || String(s.name ?? s.title ?? s.task_name ?? "").slice(0, 50) + + // Origin tag — show where this task came from + const createdFrom = String(s.data?.created_from ?? s.data?.source ?? "") + const originTag = createdFrom.includes("heartbeat") ? dim(" (via heartbeat)") : "" + + // Description — short, one line, no repeats + const tl = taskLabel(s) + const prompt = s.data?.prompt ?? s.prompt ?? "" + let desc = "" + if (tl && tl !== name && tl !== s.task_name) desc = tl + if (prompt && prompt !== name && prompt !== s.task_name && !prompt.startsWith(name)) { + // Deduplicate — if prompt just repeats task_name multiple times, skip it + const unique = [...new Set(prompt.split(/[.!?\n]+/).map((s: string) => s.trim()).filter(Boolean))] + const cleaned = unique.slice(0, 2).join(". ").slice(0, 60) + if (cleaned && cleaned !== name) { + desc = desc ? `${desc}: ${cleaned}` : cleaned + } + } + + // Bloq context — try eager-loaded relationship first, then batch lookup + const bloqId = s.bloq_id as number | null + const bloqName = s.bloq?.name ?? (bloqId ? bloqNames[bloqId] : null) + const bloqTag = bloqName ? dim(` → ${bloqName}`) : bloqId ? dim(` → bloq #${bloqId}`) : "" + + console.log(` ${id} ${badge.padEnd(12)} ${dim(freq.padEnd(12))} ${bold(name)}${originTag}`) + if (bloqTag || desc) { + const parts = [bloqTag, desc ? dim(desc) : ""].filter(Boolean) + console.log(` ${parts.join(" ")}`) + } + + // Latest execution result + const exec = latestExecs[s.id] + if (exec) { + const execStatus = exec.status === "completed" + ? `${UI.Style.TEXT_SUCCESS}✓${UI.Style.TEXT_NORMAL}` + : exec.status === "failed" + ? `${UI.Style.TEXT_DANGER}✗${UI.Style.TEXT_NORMAL}` + : dim(exec.status ?? "?") + const when = exec.completed_at + ? timeUntil(exec.completed_at) === "overdue" ? dim("just now") : dim(`${timeUntil(exec.completed_at)} ago`) + : "" + const model = exec.model_used ? dim(`[${exec.model_used}]`) : "" + const tokens = exec.tokens_used ? dim(`${Number(exec.tokens_used).toLocaleString()} tok`) : "" + const preview = String(exec.response_preview ?? exec.response ?? "").replace(/\n/g, " ").slice(0, 70) + + console.log(` ${execStatus} ${when} ${model} ${tokens}`) + if (preview) console.log(` ${dim(`"${preview}${preview.length >= 70 ? "…" : ""}"`)}`) + } + } } - printDivider() + console.log() prompts.outro( - `${dim("iris schedules get <id>")} · ${dim("iris schedules run <id>")}`, + `${dim("iris schedules get <id>")} · ${dim("iris schedules history <id>")}`, ) } catch (err) { spinner.stop("Error", 1) @@ -200,6 +431,8 @@ const SchedulesHistoryCommand = cmd({ yargs .positional("id", { describe: "schedule ID", type: "number", demandOption: true }) .option("limit", { describe: "max results", type: "number", default: 10 }) + .option("full", { describe: "show full response (not just preview)", type: "boolean", default: false }) + .option("json", { describe: "JSON output", type: "boolean" }) .option("user-id", { describe: "user ID (or IRIS_USER_ID env)", type: "number" }), async handler(args) { UI.empty() @@ -230,18 +463,217 @@ const SchedulesHistoryCommand = cmd({ return } - printDivider() + if (args.json) { + console.log(JSON.stringify(runs, null, 2)) + prompts.outro("Done") + return + } + for (const r of runs) { - const status = statusColor(String(r.status ?? "unknown")) - const created = r.created_at ? dim(String(r.created_at)) : "" - console.log(` ${bold(String(r.id))} ${status} ${created}`) - if (r.summary ?? r.response) { - console.log(` ${dim(String(r.summary ?? r.response).slice(0, 120))}`) - } + const statusBadge = r.status === "completed" + ? `${UI.Style.TEXT_SUCCESS}✓ completed${UI.Style.TEXT_NORMAL}` + : r.status === "failed" + ? `${UI.Style.TEXT_DANGER}✗ failed${UI.Style.TEXT_NORMAL}` + : statusColor(String(r.status ?? "?")) + + const when = r.completed_at ?? r.started_at ?? r.created_at + const ago = when ? timeUntil(String(when)) : "" + const agoStr = ago === "overdue" ? dim("just now") : ago ? dim(`${ago} ago`) : "" + console.log() + printDivider() + console.log(` ${bold(`Run #${r.id}`)} ${statusBadge} ${agoStr}`) + + // Metadata line + const meta: string[] = [] + if (r.model_used) meta.push(`model: ${r.model_used}`) + if (r.tokens_used) meta.push(`${Number(r.tokens_used).toLocaleString()} tokens`) + if (r.started_at && r.completed_at) { + const dur = Math.round((new Date(r.completed_at).getTime() - new Date(r.started_at).getTime()) / 1000) + meta.push(`${dur}s`) + } + if (r.execution_source) meta.push(r.execution_source) + if (meta.length) console.log(` ${dim(meta.join(" · "))}`) + + // Tools used + if (r.functions_executed) { + try { + const tools = JSON.parse(r.functions_executed) + if (Array.isArray(tools) && tools.length > 0) { + console.log(` ${dim("tools: " + tools.join(", "))}`) + } + } catch {} + } + + // Error + if (r.error_message) { + console.log(` ${UI.Style.TEXT_DANGER}error: ${String(r.error_message).slice(0, 200)}${UI.Style.TEXT_NORMAL}`) + } + + // Response + const response = String(r.response ?? r.response_preview ?? r.summary ?? "") + if (response) { + console.log() + if (args.full) { + // Full response with word wrap + const lines = response.split("\n") + for (const line of lines) { + console.log(` ${dim(line)}`) + } + } else { + // Preview (first 3 lines or 200 chars) + const preview = response.replace(/\n/g, " ").slice(0, 200) + console.log(` ${dim(`"${preview}${response.length > 200 ? "…" : ""}"`)}`) + } + } } + console.log() printDivider() + if (!args.full) { + prompts.log.info(dim(`Tip: iris schedules history ${args.id} --full — show complete responses`)) + } + prompts.outro("Done") + } catch (err) { + spinner.stop("Error", 1) + prompts.log.error(err instanceof Error ? err.message : String(err)) + prompts.outro("Done") + } + }, +}) + +// ============================================================================ +// Inspect — show agent config, system prompt, model, tools for a schedule +// ============================================================================ + +const SchedulesInspectCommand = cmd({ + command: "inspect <id>", + describe: "show the agent config, system prompt, and tools for a scheduled job", + builder: (yargs) => + yargs + .positional("id", { describe: "schedule ID", type: "number", demandOption: true }) + .option("json", { describe: "JSON output", type: "boolean" }) + .option("user-id", { describe: "user ID (or IRIS_USER_ID env)", type: "number" }), + async handler(args) { + UI.empty() + prompts.intro(`◈ Inspect Schedule #${args.id}`) + + const token = await requireAuth() + if (!token) { prompts.outro("Done"); return } + + const userId = await requireUserId(args["user-id"]) + if (!userId) { prompts.outro("Done"); return } + + const spinner = prompts.spinner() + spinner.start("Loading…") + + try { + // Fetch all schedules and find the one we want (the individual GET returns only data column) + const res = await irisFetch(`/api/v1/users/${userId}/bloqs/scheduled-jobs?per_page=200`) + const ok = await handleApiError(res, "Get schedules") + if (!ok) { spinner.stop("Failed", 1); prompts.outro("Done"); return } + + const allData = (await res.json()) as { data?: any[] } + const schedule = (allData?.data ?? []).find((s: any) => s.id === args.id) + if (!schedule) { + spinner.stop("Not found", 1) + prompts.log.error(`Schedule #${args.id} not found`) + prompts.outro("Done") + return + } + + // Fetch agent details if agent_id exists + let agent: any = null + if (schedule.agent_id) { + try { + const agentRes = await irisFetch(`/api/v1/users/${userId}/bloqs/agents/${schedule.agent_id}`) + if (agentRes.ok) { + const agentData = (await agentRes.json()) as any + agent = agentData?.data ?? agentData + } + } catch {} + } + + spinner.stop(bold(schedule.task_name ?? `Schedule #${args.id}`)) + + if (args.json) { + console.log(JSON.stringify({ schedule, agent }, null, 2)) + prompts.outro("Done") + return + } + + // Schedule info + printDivider() + printKV("ID", schedule.id) + printKV("Status", schedule.status) + printKV("Frequency", schedule.frequency) + printKV("Next run", schedule.next_run_at) + printKV("Bloq", schedule.bloq?.name ?? (schedule.bloq_id ? `#${schedule.bloq_id}` : null)) + printKV("Created", schedule.created_at) + printDivider() + + // Agent config + if (agent) { + console.log() + console.log(` ${bold("Agent Config")}`) + printDivider() + printKV("Agent ID", agent.id) + printKV("Name", agent.name) + printKV("Model", agent.model ?? agent.settings?.model) + printKV("Heartbeat mode", agent.heartbeat_mode) + printKV("Heartbeat freq", agent.settings?.heartbeat_frequency ?? agent.settings?.frequency) + + // System prompt + const systemPrompt = agent.system_prompt ?? agent.instructions ?? agent.settings?.system_prompt + if (systemPrompt) { + console.log() + console.log(` ${bold("System Prompt")}`) + printDivider() + const lines = String(systemPrompt).split("\n").slice(0, 20) + for (const line of lines) { + console.log(` ${dim(line)}`) + } + if (String(systemPrompt).split("\n").length > 20) { + console.log(` ${dim(`... (${String(systemPrompt).split("\\n").length} total lines)`)}`) + } + } + + // Tools / capabilities + const tools = agent.settings?.tools ?? agent.capabilities ?? agent.settings?.capabilities + if (tools && (Array.isArray(tools) ? tools.length : Object.keys(tools).length)) { + console.log() + console.log(` ${bold("Tools / Capabilities")}`) + printDivider() + const toolList = Array.isArray(tools) ? tools : Object.keys(tools) + console.log(` ${dim(toolList.join(", "))}`) + } + + // Integrations + const integrations = agent.settings?.integrations ?? [] + if (Array.isArray(integrations) && integrations.length) { + console.log() + console.log(` ${bold("Integrations")}`) + printDivider() + console.log(` ${dim(integrations.join(", "))}`) + } + + printDivider() + } + + // Task prompt + const prompt = schedule.prompt ?? schedule.data?.prompt + if (prompt) { + console.log() + console.log(` ${bold("Task Prompt")}`) + printDivider() + for (const line of String(prompt).split("\n").slice(0, 10)) { + console.log(` ${dim(line)}`) + } + } + + console.log() + prompts.log.info(dim(`Edit agent: iris agents update ${schedule.agent_id}`)) + prompts.log.info(dim(`Run history: iris schedules history ${args.id} --full`)) prompts.outro("Done") } catch (err) { spinner.stop("Error", 1) @@ -291,20 +723,373 @@ const SchedulesToggleCommand = cmd({ }, }) +const SchedulesCreateCommand = cmd({ + command: "create", + describe: "create a scheduled job (any type: agent, heartbeat, competitor crawl, SEO check, hive)", + builder: (yargs) => + yargs + .option("type", { + describe: "job type", + type: "string", + choices: ["agent_task", "heartbeat", "competitor_intelligence", "seo_rank_check", "hive_task_dispatch", "custom_script", "code_workflow", "browser_workflow", "agentic_browser"], + demandOption: true, + }) + .option("frequency", { + describe: "run frequency", + type: "string", + choices: ["once", "hourly", "every_2_hours", "every_4_hours", "every_6_hours", "every_8_hours", "every_12_hours", "daily", "weekdays", "weekly", "monthly"], + default: "daily", + }) + .option("agent", { describe: "agent ID (required — used for scheduling)", type: "number", demandOption: true }) + .option("name", { describe: "job name/task_name", type: "string" }) + .option("prompt", { describe: "task prompt (for agent_task type)", type: "string" }) + .option("time", { describe: "time of day to run (HH:MM, 24h)", type: "string", default: "09:00" }) + .option("timezone", { describe: "timezone", type: "string", default: "America/New_York" }) + .option("max-runs", { describe: "max number of executions (null = unlimited)", type: "number" }) + .option("params", { describe: "JSON params for tool jobs (e.g., sources, keywords, domain)", type: "string" }) + .option("user-id", { describe: "user ID (or IRIS_USER_ID env)", type: "number" }), + async handler(args) { + UI.empty() + prompts.intro(`◈ Create Schedule: ${args.type}`) + + const token = await requireAuth() + if (!token) { prompts.outro("Done"); return } + + const userId = await requireUserId(args["user-id"]) + if (!userId) { prompts.outro("Done"); return } + + // Parse params JSON + let params: Record<string, unknown> = {} + if (args.params) { + try { + params = JSON.parse(args.params) + } catch { + prompts.log.error("Invalid JSON in --params") + prompts.outro("Done") + return + } + } + + // Build default names based on type + const typeNames: Record<string, string> = { + agent_task: "Scheduled Agent Task", + heartbeat: "Heartbeat", + competitor_intelligence: "Competitor Intelligence Crawl", + seo_rank_check: "SEO Rank Check", + hive_task_dispatch: "Hive Task Dispatch", + custom_script: "Custom Script", + code_workflow: "Code Workflow", + browser_workflow: "Browser Workflow", + agentic_browser: "AI Browser Agent", + } + const taskName = args.name ?? typeNames[args.type] ?? args.type + + // Build default prompts based on type + const typePrompts: Record<string, string> = { + agent_task: args.prompt ?? "Execute scheduled task", + heartbeat: "Run heartbeat check", + competitor_intelligence: "Crawl competitor sources and ingest into knowledge base", + seo_rank_check: "Check keyword rankings vs competitors", + hive_task_dispatch: "Dispatch Hive campaign task", + custom_script: "Execute custom script on Hive node", + code_workflow: "Execute code workflow on Hive node", + browser_workflow: "Execute Playwright browser automation on Hive node", + agentic_browser: "AI browser agent — give it a goal, it navigates autonomously", + } + const prompt = args.prompt ?? typePrompts[args.type] ?? taskName + + const payload: Record<string, unknown> = { + agent_id: args.agent, + task_name: taskName, + prompt, + time: args.time, + frequency: args.frequency, + timezone: args.timezone, + data: { + type: args.type, + params, + ...params, + }, + } + + if (args["max-runs"]) { + payload.max_runs = args["max-runs"] + } + + const spinner = prompts.spinner() + spinner.start("Creating schedule…") + + try { + const res = await irisFetch(`/api/v1/users/${userId}/bloqs/scheduled-jobs`, { + method: "POST", + body: JSON.stringify(payload), + }) + const ok = await handleApiError(res, "Create schedule") + if (!ok) { spinner.stop("Failed", 1); prompts.outro("Done"); return } + + const data = (await res.json()) as { data?: any } + const job = data?.data ?? data + spinner.stop(success(`Created #${job.id ?? "?"}`)) + + printDivider() + printKV("ID", job.id) + printKV("Type", args.type) + printKV("Name", taskName) + printKV("Frequency", args.frequency) + printKV("Time", args.time) + printKV("Agent", args.agent) + printKV("Status", job.status ?? "scheduled") + printKV("Next Run", job.next_run_at ?? "pending") + if (Object.keys(params).length > 0) { + printKV("Params", JSON.stringify(params).slice(0, 100)) + } + printDivider() + + prompts.log.info(dim(`iris schedule list — view all schedules`)) + prompts.log.info(dim(`iris schedule run ${job.id ?? "<id>"} — trigger now`)) + prompts.outro(success("Schedule created")) + } catch (err) { + spinner.stop("Error", 1) + prompts.log.error(err instanceof Error ? err.message : String(err)) + prompts.outro("Done") + } + }, +}) + +const SchedulesDeleteCommand = cmd({ + command: "delete <id>", + aliases: ["rm"], + describe: "delete a scheduled job", + builder: (yargs) => + yargs + .positional("id", { describe: "schedule ID", type: "number", demandOption: true }) + .option("user-id", { describe: "user ID (or IRIS_USER_ID env)", type: "number" }), + async handler(args) { + UI.empty() + prompts.intro(`◈ Delete Schedule #${args.id}`) + + const token = await requireAuth() + if (!token) { prompts.outro("Done"); return } + + const userId = await requireUserId(args["user-id"]) + if (!userId) { prompts.outro("Done"); return } + + const spinner = prompts.spinner() + spinner.start("Deleting…") + + try { + const res = await irisFetch(`/api/v1/users/${userId}/bloqs/scheduled-jobs/${args.id}`, { + method: "DELETE", + }) + const ok = await handleApiError(res, "Delete schedule") + if (!ok) { spinner.stop("Failed", 1); prompts.outro("Done"); return } + + spinner.stop(success("Deleted")) + prompts.outro("Done") + } catch (err) { + spinner.stop("Error", 1) + prompts.log.error(err instanceof Error ? err.message : String(err)) + prompts.outro("Done") + } + }, +}) + // ============================================================================ // Root command // ============================================================================ +// ============================================================================ +// Diagnose — test the full execution chain for a scheduled job +// ============================================================================ + +const SchedulesDiagnoseCommand = cmd({ + command: "diagnose [id]", + describe: "test the full execution chain — scheduler, dispatch, worker, daemon", + builder: (yargs) => + yargs + .positional("id", { describe: "schedule ID to diagnose (or omit for full system check)", type: "number" }) + .option("user-id", { describe: "user ID", type: "number" }), + async handler(args) { + UI.empty() + prompts.intro("◈ Schedule Diagnostics") + + const token = await requireAuth() + if (!token) { prompts.outro("Done"); return } + + const userId = await requireUserId(args["user-id"]) + if (!userId) { prompts.outro("Done"); return } + + const checks: { name: string; status: "pass" | "fail" | "warn"; detail: string }[] = [] + + const check = (name: string, status: "pass" | "fail" | "warn", detail: string) => { + checks.push({ name, status, detail }) + const icon = status === "pass" ? `${UI.Style.TEXT_SUCCESS}✓${UI.Style.TEXT_NORMAL}` : + status === "fail" ? `${UI.Style.TEXT_DANGER}✗${UI.Style.TEXT_NORMAL}` : + `${UI.Style.TEXT_WARNING}⚠${UI.Style.TEXT_NORMAL}` + console.log(` ${icon} ${bold(name)}: ${dim(detail)}`) + } + + console.log() + + // 1. fl-api health (scheduler runs here) + try { + const res = await irisFetch("/api/v1/pages?per_page=1") + check("fl-api", res.ok ? "pass" : "fail", res.ok ? "reachable" : `HTTP ${res.status}`) + } catch (e) { + check("fl-api", "fail", `unreachable: ${e instanceof Error ? e.message : String(e)}`) + } + + // 2. iris-api health (heartbeat executor runs here) + try { + const res = await irisFetch("/api/health", {}, IRIS_API) + if (res.ok) { + const data = await res.json() as any + check("iris-api", "pass", `DB: ${data.database ?? "?"}, AI: ${Object.keys(data).filter(k => k.startsWith("ai_")).map(k => `${k.replace("ai_","")}=${(data[k] as any)?.status ?? "?"}`).join(", ") || "not checked"}`) + } else { + check("iris-api", "fail", `HTTP ${res.status}`) + } + } catch (e) { + check("iris-api", "fail", `unreachable: ${e instanceof Error ? e.message : String(e)}`) + } + + // 3. Local daemon (hive tasks execute here) + try { + const res = await fetch("http://localhost:3200/health", { signal: AbortSignal.timeout(3000) }) + if (res.ok) { + const data = await res.json() as any + const daemon = data.daemon ?? {} + check("daemon", "pass", `node: ${daemon.node_id?.slice(0, 12) ?? "?"}, status: ${daemon.status ?? "?"}`) + } else { + check("daemon", "fail", `HTTP ${res.status}`) + } + } catch { + check("daemon", "fail", "not running on localhost:3200 — run: iris-daemon start") + } + + // 4. Daemon Pusher connection + try { + const res = await fetch("http://localhost:3200/health", { signal: AbortSignal.timeout(3000) }) + if (res.ok) { + const data = await res.json() as any + const configRes = await fetch("http://localhost:3200/daemon/queue", { signal: AbortSignal.timeout(3000) }) + if (configRes.ok) { + const q = await configRes.json() as any + check("daemon-queue", q.paused ? "warn" : "pass", `active: ${q.active_tasks ?? 0}, paused: ${q.paused ? "YES" : "no"}, capacity: ${q.capacity ?? "?"}`) + } + } + } catch {} + + // 5. Redis (queue backend) + try { + const res = await irisFetch("/api/health", {}, IRIS_API) + if (res.ok) { + check("redis-queue", "pass", "iris-api is up (Redis is the queue backend)") + } + } catch { + check("redis-queue", "warn", "could not verify") + } + + // 6. If specific job ID given, check its state + if (args.id) { + console.log() + console.log(` ${bold("Job #" + args.id)}`) + printDivider() + + try { + const res = await irisFetch(`/api/v1/users/${userId}/bloqs/scheduled-jobs?per_page=200`) + if (res.ok) { + const all = ((await res.json()) as any)?.data ?? [] + const job = all.find((s: any) => s.id === args.id) + if (job) { + const env = executionEnv(job) + check("job-exists", "pass", `${job.task_name ?? "?"} | ${job.frequency ?? "?"} | ${env.icon} ${env.label}`) + check("job-status", job.status === "scheduled" ? "pass" : job.status === "running" ? "warn" : "fail", + `${job.status}${job.status === "running" ? " (may be stuck — check run_count)" : ""}`) + + const nextRun = job.next_run_at ? new Date(job.next_run_at) : null + if (nextRun) { + const until = timeUntil(job.next_run_at) + check("next-run", until === "overdue" ? "warn" : "pass", `${job.next_run_at} (${until})`) + } + + // Agent check + if (job.agent_id) { + const agent = job.agent + if (agent) { + check("agent", "pass", `#${agent.id} ${agent.name} | mode: ${agent.heartbeat_mode} | model: ${agent.config?.model ?? agent.settings?.model ?? "default"}`) + if (agent.initial_prompt) check("agent-mission", "pass", `${String(agent.initial_prompt).slice(0, 80)}...`) + else check("agent-mission", "warn", "no initial_prompt set — using generic heartbeat prompt") + if (agent.settings?.system_prompt) check("agent-identity", "pass", "custom system_prompt set") + if (agent.settings?.heartbeat_tools) check("agent-tools", "pass", `filtered: ${JSON.stringify(agent.settings.heartbeat_tools)}`) + } + } + + // Bloq check + if (job.bloq) { + check("bloq", "pass", `#${job.bloq.id} ${job.bloq.name}`) + } else if (job.bloq_id) { + check("bloq", "warn", `#${job.bloq_id} (name not loaded)`) + } + + // Execution check + try { + const execRes = await irisFetch(`/api/v1/users/${userId}/bloqs/scheduled-jobs/${args.id}/executions?per_page=1`) + if (execRes.ok) { + const execs = ((await execRes.json()) as any)?.data ?? [] + if (execs.length > 0) { + const e = execs[0] + check("last-execution", e.status === "completed" ? "pass" : "fail", + `#${e.id} ${e.status} | ${e.model_used ?? "?"} | ${e.tokens_used ? Number(e.tokens_used).toLocaleString() + " tok" : "?"}`) + if (e.error_message) check("last-error", "fail", String(e.error_message).slice(0, 120)) + } else { + check("last-execution", "warn", "no executions yet") + } + } + } catch {} + } else { + check("job-exists", "fail", `job #${args.id} not found`) + } + } + } catch (e) { + check("job-lookup", "fail", `${e instanceof Error ? e.message : String(e)}`) + } + } + + // Summary + console.log() + printDivider() + const passed = checks.filter(c => c.status === "pass").length + const failed = checks.filter(c => c.status === "fail").length + const warned = checks.filter(c => c.status === "warn").length + console.log(` ${passed} passed, ${warned} warnings, ${failed} failed`) + + if (failed > 0) { + console.log() + console.log(` ${bold("Fix these:")}`) + for (const c of checks.filter(c => c.status === "fail")) { + console.log(` ${UI.Style.TEXT_DANGER}✗${UI.Style.TEXT_NORMAL} ${c.name}: ${c.detail}`) + } + } + + prompts.outro("Done") + }, +}) + export const PlatformSchedulesCommand = cmd({ command: "schedules", - describe: "manage agent scheduled jobs", + aliases: ["schedule"], + describe: "manage scheduled jobs — create, list, run, toggle, delete (all job types)", builder: (yargs) => yargs + .command(SchedulesCreateCommand) .command(SchedulesListCommand) .command(SchedulesGetCommand) .command(SchedulesRunCommand) .command(SchedulesHistoryCommand) + .command(SchedulesInspectCommand) .command(SchedulesToggleCommand) + .command(SchedulesDeleteCommand) + .command(SchedulesDiagnoseCommand) .demandCommand(), async handler() {}, }) diff --git a/packages/opencode/src/cli/cmd/platform-workflows.ts b/packages/opencode/src/cli/cmd/platform-workflows.ts index 55ab383c28a1..e1ed41e04983 100644 --- a/packages/opencode/src/cli/cmd/platform-workflows.ts +++ b/packages/opencode/src/cli/cmd/platform-workflows.ts @@ -390,13 +390,15 @@ const WorkflowsGetCommand = cmd({ const WorkflowsCreateCommand = cmd({ command: "create", - describe: "create a new workflow", + describe: "create a new workflow (visual, agentic, or code)", builder: (yargs) => yargs .option("name", { describe: "workflow name", type: "string" }) .option("description", { describe: "workflow description", type: "string" }) .option("bloq-id", { describe: "bloq ID", type: "number" }) - .option("type", { describe: "workflow type", type: "string" }) + .option("type", { describe: "workflow type (standard, code)", type: "string" }) + .option("script", { describe: "path to script file (for code workflows)", type: "string" }) + .option("runtime", { describe: "script runtime: javascript, bash, python (default: javascript)", type: "string", default: "javascript" }) .option("user-id", { describe: "user ID (or IRIS_USER_ID env)", type: "number" }), async handler(args) { UI.empty() @@ -408,10 +410,26 @@ const WorkflowsCreateCommand = cmd({ const userId = await requireUserId(args["user-id"]) if (!userId) { prompts.outro("Done"); return } + // If --script is provided, auto-set type to 'code' + const isCode = args.script || args.type === "code" + let scriptContent = "" + + if (args.script) { + const { existsSync, readFileSync } = await import("fs") + if (!existsSync(args.script)) { + prompts.log.error(`Script file not found: ${args.script}`) + prompts.outro("Done") + return + } + scriptContent = readFileSync(args.script, "utf-8") + } + let name = args.name if (!name) { + const defaultName = args.script ? args.script.split("/").pop()?.replace(/\.[^.]+$/, "") ?? "code-workflow" : "" name = (await prompts.text({ message: "Workflow name", + initialValue: defaultName, validate: (x) => (x && x.length > 0 ? undefined : "Required"), })) as string if (prompts.isCancel(name)) { prompts.outro("Cancelled"); return } @@ -424,7 +442,17 @@ const WorkflowsCreateCommand = cmd({ const payload: Record<string, unknown> = { name } if (args.description) payload.description = args.description if (args["bloq-id"]) payload.bloq_id = args["bloq-id"] - if (args.type) payload.type = args.type + + if (isCode) { + payload.type = "code" + payload.execution_mode = "code" + payload.settings = { + script_content: scriptContent, + runtime: args.runtime, + } + } else if (args.type) { + payload.type = args.type + } const res = await irisFetch(`/api/v1/users/${userId}/bloqs/workflows`, { method: "POST", @@ -440,8 +468,16 @@ const WorkflowsCreateCommand = cmd({ printDivider() printKV("ID", w.id) printKV("Name", w.name) + printKV("Type", isCode ? "code" : (w.type ?? "standard")) + if (isCode) { + printKV("Runtime", args.runtime) + printKV("Script", `${scriptContent.length} chars`) + } printDivider() + if (isCode) { + prompts.log.info(dim(`iris workflows run ${w.id} — execute on Hive node`)) + } prompts.outro(dim(`iris workflows get ${w.id}`)) } catch (err) { spinner.stop("Error", 1) diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts index c997ba64a1d5..adc25f44aacd 100644 --- a/packages/opencode/src/index.ts +++ b/packages/opencode/src/index.ts @@ -57,7 +57,7 @@ import { PlatformOutreachStrategyCommand } from "./cli/cmd/platform-outreach-str import { PlatformOutreachCampaignCommand } from "./cli/cmd/platform-outreach-campaign" import { PlatformOutreachSendCommand } from "./cli/cmd/platform-outreach-send" import { PlatformSomCommand } from "./cli/cmd/platform-som" -import { PlatformMonitorCommand } from "./cli/cmd/platform-monitor" +import { PlatformMonitorCommand, BriefingCommand } from "./cli/cmd/platform-monitor" import { PlatformInvoicesCommand } from "./cli/cmd/platform-invoices" import { PlatformPaymentsCommand } from "./cli/cmd/platform-payments" import { PlatformDeliverCommand } from "./cli/cmd/platform-deliver" @@ -67,6 +67,10 @@ import { PlatformBugCommand } from "./cli/cmd/platform-bug" import { PlatformAtlasMeetingsCommand } from "./cli/cmd/platform-atlas-meetings" import { PlatformAtlasBrandKitCommand } from "./cli/cmd/platform-atlas-brand-kit" import { PlatformLeadsMeetingCommand } from "./cli/cmd/platform-leads-meeting" +import { PlatformDaemonCommand } from "./cli/cmd/platform-daemon" +import { PlatformOnboardCommand } from "./cli/cmd/platform-onboard" +import { PlatformProposalsCommand } from "./cli/cmd/platform-proposals" +import { PlatformContractsCommand } from "./cli/cmd/platform-contracts" import { PlatformPagesCommand } from "./cli/cmd/platform-pages" import { PlatformPagesBatchCommand } from "./cli/cmd/platform-pages-batch" import { PlatformPartialsCommand } from "./cli/cmd/platform-partials" @@ -118,7 +122,7 @@ const rawArgs = hideBin(process.argv) const cli = yargs(rawArgs) .parserConfiguration({ "populate--": true }) - .scriptName("opencode") + .scriptName("iris") .wrap(100) .help("help", "show help") .alias("help", "h") @@ -209,6 +213,7 @@ const cli = yargs(rawArgs) .command(reg(PlatformOutreachSendCommand)) .command(reg(PlatformSomCommand)) .command(reg(PlatformMonitorCommand)) + .command(reg(BriefingCommand)) .command(reg(PlatformInvoicesCommand)) .command(reg(PlatformPaymentsCommand)) .command(reg(PlatformDeliverCommand)) @@ -221,6 +226,10 @@ const cli = yargs(rawArgs) .command(reg(PlatformAtlasMeetingsCommand)) .command(reg(PlatformAtlasBrandKitCommand)) .command(reg(PlatformLeadsMeetingCommand)) + .command(reg(PlatformDaemonCommand)) + .command(reg(PlatformOnboardCommand)) + .command(reg(PlatformProposalsCommand)) + .command(reg(PlatformContractsCommand)) .command(reg(PlatformPagesCommand)) .command(reg(PlatformPagesBatchCommand)) .command(reg(PlatformPartialsCommand)) @@ -268,6 +277,21 @@ if (hasHelp && hasNoCommand) { process.exit(0) } +// Auto-start daemon if not running (silent, non-blocking) +try { + const { join: pathJoin } = await import("path") + const { homedir: osHome } = await import("os") + const { existsSync } = await import("fs") + const daemonCtl = pathJoin(osHome(), ".iris", "bin", "iris-daemon") + if (existsSync(daemonCtl)) { + const health = await fetch("http://localhost:3200/health", { signal: AbortSignal.timeout(500) }).catch(() => null) + if (!health?.ok) { + const { spawn } = await import("child_process") + spawn(daemonCtl, ["start"], { detached: true, stdio: "ignore" }).unref() + } + } +} catch {} + try { await cli.parse() } catch (e) { diff --git a/scaffold/AGENTS.md b/scaffold/AGENTS.md index b983dc9eb0fd..77b02393aaac 100644 --- a/scaffold/AGENTS.md +++ b/scaffold/AGENTS.md @@ -16,15 +16,19 @@ You are running inside the **IRIS CLI** — an AI coding assistant from the IRIS | `iris-login` | Interactive auth — writes `~/.iris/sdk/.env`. Run after install. | | `iris-daemon start \| stop \| status` | Local Hive daemon (port 3200) for distributed compute | | `iris hive` | Distributed compute / agent mesh commands | -| `iris platform-leads` | Lead capture, enrichment, outreach | -| `iris platform-bloqs` | Manage bloqs (the core unit of IRIS knowledge/work) | -| `iris platform-pages` | Genesis composable page builder | -| `iris platform-workflows` | Workflow execution and history | -| `iris platform-agents` | Agent CRUD, scheduling, heartbeat config | -| `iris platform-chat` | Chat with agents from the terminal | +| `iris leads` | Lead capture, enrichment, outreach (alias: `crm`) | +| `iris bloqs` | Manage bloqs — knowledge bases (aliases: `kb`, `memory`) | +| `iris pages` | Genesis composable page builder (alias: `genesis`) | +| `iris workflows` | Workflow execution and history | +| `iris agents` | Agent CRUD, scheduling, heartbeat config | +| `iris chat` | Chat with agents from the terminal (alias: `c`) | +| `iris integrations` | Execute integration functions, OAuth connect (alias: `int`) | +| `iris connect <type>` | Connect an integration via OAuth | +| `iris list-connected` | Show connected integrations | | `iris mcp serve` | Expose IRIS as an MCP server for other agents | -| `iris auth` / `iris models` / `iris run` / `iris generate` | Standard CLI ops | +| `iris auth` / `iris models` / `iris run` | Standard CLI ops | | `iris github` | GitHub integration | +| `iris bug report` | Report bugs to the IRIS team | | `iris --help` | Full command tree | When the user asks "how do I X" and X maps to an IRIS command, **suggest the command first** before writing code from scratch. @@ -46,6 +50,69 @@ Recipes available out of the box: When the user asks something that might match a recipe, **read the recipe file first** instead of guessing. The recipes have exact commands, expected output, and known gotchas. +## Critical Rules + +- **NEVER use curl or call APIs directly.** Use `iris` CLI commands. +- **NEVER guess or hallucinate URLs.** Always read URLs from CLI output. Page URLs follow: `main.heyiris.io/p/{slug}` +- **NEVER invent component type names.** Run `iris pages component-registry` first. Invalid types render blank. +- **READ CLI output carefully.** Use exact values shown — don't make up IDs, URLs, or status values. + +## Genesis Page Builder — Component Rules + +When building or editing pages with `iris pages`, follow these rules: + +1. **Run `iris pages component-registry`** before adding components to see all valid types +2. **Use `iris pages pull component-showcase`** as a reference for working component JSON +3. **Page URLs** are shown in CLI output — format: `main.heyiris.io/p/{slug}` + +**Valid component types (use ONLY these exact names):** +Hero, SiteNavigation, SiteFooter, AnnouncementBanner, TestimonialsSection, TeamSection, ContactSection, LogoMarquee, FeatureShowcase, ComparisonMatrix, ClientGrid, CareersListing, PortfolioGallery, ProductGrid, ServiceMenu, EventGrid, FundingTiers, BeforeAfter, MapSection, NewsletterSignup, StepWizard, FileUpload, ShoppingCart, OrderConfirmation + +**Every component needs:** `type` (exact name from above), `id` (unique string), `props` (object) + +**Workflow: pull → edit → push** +```bash +iris pages pull <slug> # download to pages/<slug>.json +# edit the JSON file +iris pages push <slug> # upload back +``` + +## Autonomous Agent Scheduling + +Manage scheduled heartbeat agents, hive tasks, and workflows: + +```bash +iris schedules list --active # Grouped: ⬡ hive / ◉ iris / ☁ cloud +iris schedules list --active --latest # + last execution result +iris schedules inspect <id> # Agent config, system prompt, tools +iris schedules history <id> --full # Full execution output +iris schedules run <id> # Trigger manually +iris schedules toggle <id> # Pause/resume +iris schedules delete <id> # Remove +``` + +### Creating Specialized Agents (Agent-First Architecture) +Agents define their own mission and tools via database fields: +- `initial_prompt` → agent's mission (injected as `<agent_mission>` in heartbeat) +- `settings.system_prompt` → agent's identity (overrides generic prompt) +- `settings.heartbeat_tools` → tool filter (e.g. `["manageLeads", "agent_memory"]`) + +Debug with: `iris schedules inspect <id>` to see the resolved config. + +## Integration Functions + +When running `iris integrations exec <type>` without a function, the CLI shows available functions. + +| Integration | Functions | +|-------------|-----------| +| gmail | `read_emails`, `search_emails`, `send_email` | +| google-drive | `search_files`, `export_file`, `read_doc` | +| google-calendar | `get_events`, `create_event` | +| slack | `send_message`, `list_channels` | +| canva | `list_designs`, `export_design` | + +Run `iris integrations exec <type>` (no function) to discover functions for any integration. + ## What you should NOT assume - You are NOT working on the IRIS source code unless `cwd` is the `iris-code` repo. By default, assume the user is in their OWN project and behave like a general-purpose coding agent there. diff --git a/scaffold/how-to/README.md b/scaffold/how-to/README.md index 8926ed8ca278..1934251ddc56 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` | +| "build a page", "create a landing page", "genesis", "add components", "page builder" | `pages.md` | ## How to use these files diff --git a/scaffold/how-to/hive-dispatch.md b/scaffold/how-to/hive-dispatch.md index 945d64f0aacf..50e686400465 100644 --- a/scaffold/how-to/hive-dispatch.md +++ b/scaffold/how-to/hive-dispatch.md @@ -15,7 +15,7 @@ If the daemon directory doesn't exist: ```bash $ ls ~/.iris/bridge/daemon.js # If missing, re-run the IRIS installer with Node present, OR clone manually: -$ git clone https://github.com/FREELABEL/iris-bridge.git ~/.iris/bridge && cd ~/.iris/bridge && npm install --production +$ git clone https://github.com/FREELABEL/iris-daemon.git ~/.iris/bridge && cd ~/.iris/bridge && npm install --production ``` ## Step 1: Start the daemon diff --git a/scaffold/how-to/pages.md b/scaffold/how-to/pages.md new file mode 100644 index 000000000000..1e0f04297286 --- /dev/null +++ b/scaffold/how-to/pages.md @@ -0,0 +1,105 @@ +# Genesis Pages — How-To + +Build and manage composable landing pages from the CLI. + +## Quick Reference + +```bash +iris pages list # list all pages +iris pages view <slug> # view page details + public URL +iris pages create --slug <slug> --title "<title>" # create + auto-publish +iris pages pull <slug> # download JSON to pages/<slug>.json +iris pages push <slug> # upload local JSON back to API +iris pages publish <slug> # publish a draft page +iris pages unpublish <slug> # take a page offline +iris pages components <slug> # list components on a page +iris pages component-registry # list ALL valid component types +iris pages versions <slug> # show version history +iris pages rollback <slug> --version <n> # rollback to previous version +``` + +## Create a Page + +```bash +iris pages create --slug my-page --title "My Page" --seo-description "Page description" +``` + +This creates a page with a Hero + SiteFooter and auto-publishes it. +The public URL is shown in the output: `main.heyiris.io/p/my-page` + +## Add Components + +The recommended workflow is pull → edit → push: + +```bash +iris pages pull my-page # creates pages/my-page.json +# edit pages/my-page.json — add components to the "components" array +iris pages push my-page # uploads changes, creates new version +``` + +## Valid Component Types + +**ONLY use these exact type names.** Invalid types render as blank: + +| Type | Description | +|------|-------------| +| Hero | Full-width hero banner with title, subtitle, CTA buttons | +| SiteNavigation | Top navigation bar with logo, links, CTA button | +| SiteFooter | Footer with brand name, links, copyright | +| AnnouncementBanner | Dismissible banner strip at top of page | +| TestimonialsSection | Customer testimonials with avatars and quotes | +| TeamSection | Team member grid with photos and roles | +| ContactSection | Contact form with configurable fields | +| LogoMarquee | Auto-scrolling logo carousel | +| FeatureShowcase | Feature highlights with icons and descriptions | +| ComparisonMatrix | Pricing/feature comparison table | +| ClientGrid | Client/partner logo grid | +| CareersListing | Job listings with department filters | +| PortfolioGallery | Image/project gallery grid with lightbox | +| ProductGrid | E-commerce product cards with prices | +| ServiceMenu | Service/menu items with prices and descriptions | +| EventGrid | Event cards with dates and venues | +| FundingTiers | Pricing/funding tier cards | +| BeforeAfter | Before/after image slider comparison | +| MapSection | Interactive map with location markers | +| NewsletterSignup | Email signup form | +| StepWizard | Multi-step form wizard | +| FileUpload | File upload dropzone | +| ShoppingCart | Shopping cart with line items | +| OrderConfirmation | Order confirmation/receipt page | + +## Component JSON Structure + +Every component needs `type`, `id`, and `props`: + +```json +{ + "type": "Hero", + "id": "my-hero", + "props": { + "themeMode": "dark", + "title": "Welcome", + "subtitle": "This is my page", + "labelText": "NEW", + "labelColor": "#34d399", + "primaryButtonText": "Get Started", + "primaryButtonUrl": "#contact", + "textAlign": "center" + } +} +``` + +## Reference Page + +Pull the component showcase for working examples of every component: + +```bash +iris pages pull component-showcase +cat pages/component-showcase.json # 28 components with full props +``` + +## Common Gotchas + +- **Blank page?** You used an invalid component type. Run `iris pages component-registry` to check. +- **Auth error on pages list?** The CLI routes pages through iris-api. If auth fails, the service token may need refreshing. +- **Page URL format:** `main.heyiris.io/p/{slug}` — NOT `heyiris.io/p/{slug}` (that domain doesn't route /p/). diff --git a/scaffold/manifest.json b/scaffold/manifest.json index 6f2c5ba94c81..5e8556fdc444 100644 --- a/scaffold/manifest.json +++ b/scaffold/manifest.json @@ -49,6 +49,12 @@ "dest": "how-to/debug-install-failures.md", "managed": true, "purpose": "Diagnose 5 common install failure modes: baseline asset mismatch, macOS version floor, missing deps, scaffold fetch fail, branding. Includes quick diagnostic command to send users." + }, + { + "src": "how-to/pages.md", + "dest": "how-to/pages.md", + "managed": true, + "purpose": "Genesis page builder: create pages, valid component types, pull/edit/push workflow, component-showcase reference." } ] }