diff --git a/.opencode/skill/iris-platform/SKILL.md b/.opencode/skill/iris-platform/SKILL.md index bde93ea2a5cf..5b4325184585 100644 --- a/.opencode/skill/iris-platform/SKILL.md +++ b/.opencode/skill/iris-platform/SKILL.md @@ -139,7 +139,7 @@ iris schedules list --agent-id |----------|---------| | `IRIS_API_KEY` | Authentication token (or use `iris auth login`) | | `IRIS_USER_ID` | Your numeric user ID (needed for agents, bloqs, workflows) | -| `IRIS_FL_API_URL` | Override API base (default: `https://apiv2.heyiris.io`) | +| `IRIS_FL_API_URL` | Override API base (default: `https://raichu.heyiris.io`) | | `IRIS_API_URL` | Override IRIS API URL (default: `https://iris-api.heyiris.io`) | --- diff --git a/install b/install index 11f4177a6d20..ab7707e3e555 100755 --- a/install +++ b/install @@ -1049,7 +1049,7 @@ case "${1:-status}" in -H "Authorization: Bearer $_TOKEN" \ -H "Content-Type: application/json" \ -d "{\"name\": \"$_HLABEL\", \"capabilities\": {\"docker\": $_HDOCK}}" \ - "https://apiv2.heyiris.io/api/v1/hive/register-node" 2>/dev/null || echo '{}') + "https://raichu.heyiris.io/api/v1/hive/register-node" 2>/dev/null || echo '{}') _NKEY=$(echo "$_RESP" | jq -r '.node_key // empty' 2>/dev/null || \ echo "$_RESP" | python3 -c "import sys,json; print(json.load(sys.stdin).get('node_key',''))" 2>/dev/null || \ echo "$_RESP" | sed -n 's/.*"node_key"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p') @@ -1126,7 +1126,7 @@ case "${1:-status}" in -H "Authorization: Bearer $SDK_TOKEN" \ -H "Content-Type: application/json" \ -d "{\"name\": \"$HOSTNAME_LABEL\", \"capabilities\": {\"docker\": $HAS_DOCKER}}" \ - "https://apiv2.heyiris.io/api/v1/hive/register-node" 2>/dev/null || echo "000") + "https://raichu.heyiris.io/api/v1/hive/register-node" 2>/dev/null || echo "000") RESP=$(cat "$REG_TMP" 2>/dev/null || echo '{}'); rm -f "$REG_TMP" NODE_KEY=$(echo "$RESP" | jq -r '.node_key // empty' 2>/dev/null || \ echo "$RESP" | python3 -c "import sys,json; print(json.load(sys.stdin).get('node_key',''))" 2>/dev/null || \ @@ -1211,7 +1211,7 @@ CYAN='\033[0;36m'; GREEN='\033[0;32m'; RED='\033[0;31m'; MUTED='\033[0;2m'; NC=' IRIS_DIR="$HOME/.iris" SDK_ENV="$IRIS_DIR/sdk/.env" CONFIG_JSON="$IRIS_DIR/config.json" -API_BASE="https://apiv2.heyiris.io" +API_BASE="https://raichu.heyiris.io" # Parse flags PRE_TOKEN="" ; PRE_USER_ID="" @@ -2071,7 +2071,7 @@ ENVFILE -H "Authorization: Bearer $SDK_TOKEN" \ -H "Content-Type: application/json" \ --data-raw "{\"name\": \"$(echo "$HOSTNAME_LABEL" | sed 's/[\"\\]/\\&/g')\", \"capabilities\": {\"docker\": $HAS_DOCKER}}" \ - "https://apiv2.heyiris.io/api/v1/hive/register-node" 2>/dev/null || echo '{}') + "https://raichu.heyiris.io/api/v1/hive/register-node" 2>/dev/null || echo '{}') NODE_KEY=$(json_field "$NODE_RESPONSE" "node_key") if [ -n "$NODE_KEY" ] && [ "$NODE_KEY" != "null" ]; then mkdir -p "$HOME/.iris" @@ -2106,7 +2106,7 @@ if [ "$tty_available" = "true" ] && [ -z "$pre_token" ]; then echo -e "" echo -e "${MUTED} Sending verification code to ${NC}${USER_EMAIL}${MUTED}...${NC}" - SEND_RESPONSE=$(curl -s --max-time 15 -X POST https://apiv2.heyiris.io/api/v1/auth/send-login-code \ + SEND_RESPONSE=$(curl -s --max-time 15 -X POST https://raichu.heyiris.io/api/v1/auth/send-login-code \ -H "Content-Type: application/json" \ -H "Accept: application/json" \ --data-raw "{\"email\": \"$(echo "$USER_EMAIL" | sed 's/[\"\\]/\\&/g')\", \"method\": \"with_login_code\", \"expiration_minutes\": 30, \"auto_create\": true}" 2>/dev/null || echo '{"success":false,"error":"Network error"}') @@ -2134,7 +2134,7 @@ if [ "$tty_available" = "true" ] && [ -z "$pre_token" ]; then echo -e "" echo -e "${MUTED} Verifying...${NC}" - LOGIN_RESPONSE=$(curl -s --max-time 15 -X POST https://apiv2.heyiris.io/api/v1/auth/login-with-code \ + LOGIN_RESPONSE=$(curl -s --max-time 15 -X POST https://raichu.heyiris.io/api/v1/auth/login-with-code \ -H "Content-Type: application/json" \ -H "Accept: application/json" \ --data-raw "{ @@ -2180,7 +2180,7 @@ ENVFILE -H "Authorization: Bearer $SDK_TOKEN" \ -H "Content-Type: application/json" \ --data-raw "{\"name\": \"$(echo "$HOSTNAME_LABEL" | sed 's/[\"\\]/\\&/g')\", \"capabilities\": {\"docker\": $HAS_DOCKER}}" \ - "https://apiv2.heyiris.io/api/v1/hive/register-node" 2>/dev/null || echo '{}') + "https://raichu.heyiris.io/api/v1/hive/register-node" 2>/dev/null || echo '{}') NODE_KEY=$(json_field "$NODE_RESPONSE" "node_key") if [ -n "$NODE_KEY" ] && [ "$NODE_KEY" != "null" ]; then mkdir -p "$HOME/.iris" diff --git a/install.ps1 b/install.ps1 index e19c064bc1cd..24201ec88517 100644 --- a/install.ps1 +++ b/install.ps1 @@ -290,7 +290,7 @@ $ErrorActionPreference = "Stop" $IRIS_DIR = "$env:USERPROFILE\.iris" $SDK_ENV = "$IRIS_DIR\sdk\.env" $CONFIG_JSON = "$IRIS_DIR\config.json" -$API_BASE = "https://apiv2.heyiris.io" +$API_BASE = "https://raichu.heyiris.io" function Get-JsonField { param([string]$Json, [string]$Field) diff --git a/packages/opencode/src/cli/cmd/auth.ts b/packages/opencode/src/cli/cmd/auth.ts index 89c65a42b280..0b8e2967e2af 100644 --- a/packages/opencode/src/cli/cmd/auth.ts +++ b/packages/opencode/src/cli/cmd/auth.ts @@ -222,7 +222,7 @@ export const AuthListCommand = cmd({ const IRIS_DIR = path.join(os.homedir(), ".iris") const SDK_ENV_PATH = path.join(IRIS_DIR, "sdk", ".env") -const IRIS_AUTH_API = process.env.IRIS_AUTH_API_URL ?? "https://apiv2.heyiris.io" +const IRIS_AUTH_API = process.env.IRIS_AUTH_API_URL ?? "https://raichu.heyiris.io" async function irisLoginStatus(): Promise<{ authenticated: boolean; token?: string; userId?: string }> { try { diff --git a/packages/opencode/src/cli/cmd/platform-campaign.ts b/packages/opencode/src/cli/cmd/platform-campaign.ts new file mode 100644 index 000000000000..7dc406e6e3b8 --- /dev/null +++ b/packages/opencode/src/cli/cmd/platform-campaign.ts @@ -0,0 +1,387 @@ +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 { existsSync, readFileSync, writeFileSync } from "fs" +import { join } from "path" +import { homedir } from "os" +import { execSync } from "child_process" + +// ============================================================================ +// iris campaign create — interactive wizard for new outreach campaigns +// ============================================================================ + +const CampaignCreateCommand = cmd({ + command: "create", + describe: "create a new outreach campaign (interactive wizard)", + builder: (yargs) => + yargs + .option("vertical", { describe: "campaign vertical", type: "string" }) + .option("ig-account", { describe: "Instagram account handle", type: "string" }) + .option("location", { describe: "target location (e.g., Austin, TX)", type: "string" }) + .option("keywords", { describe: "target keywords (comma-separated)", type: "string" }) + .option("frequency", { describe: "run frequency", type: "string" }) + .option("user-id", { describe: "user ID", type: "number" }), + async handler(args) { + UI.empty() + prompts.intro("◈ Create Campaign") + + const token = await requireAuth() + if (!token) { prompts.outro("Done"); return } + + const userId = await requireUserId(args["user-id"]) + if (!userId) { prompts.outro("Done"); return } + + // Step 1: Vertical + let vertical = args.vertical + if (!vertical) { + vertical = (await prompts.select({ + message: "What vertical is this campaign for?", + options: [ + { value: "restaurants", label: "Restaurants & Food" }, + { value: "venues", label: "Venues & Event Spaces" }, + { value: "dentists", label: "Dentists & Medical" }, + { value: "creators", label: "Content Creators" }, + { value: "realtors", label: "Real Estate Agents" }, + { value: "fitness", label: "Fitness & Wellness" }, + { value: "beauty", label: "Beauty & Salons" }, + { value: "lawyers", label: "Law Firms" }, + { value: "custom", label: "Custom (enter your own)" }, + ], + })) as string + if (prompts.isCancel(vertical)) { prompts.outro("Cancelled"); return } + } + + if (vertical === "custom") { + vertical = (await prompts.text({ + message: "Enter your vertical name", + placeholder: "e.g., pet grooming", + validate: (x) => (x && x.length > 0 ? undefined : "Required"), + })) as string + if (prompts.isCancel(vertical)) { prompts.outro("Cancelled"); return } + } + + // Step 2: IG Account + let igAccount = args["ig-account"] + if (!igAccount) { + igAccount = (await prompts.text({ + message: "Your Instagram handle (for sending DMs)", + placeholder: "e.g., myrestaurant", + validate: (x) => (x && x.length > 0 ? undefined : "Required"), + })) as string + if (prompts.isCancel(igAccount)) { prompts.outro("Cancelled"); return } + } + igAccount = igAccount.replace("@", "") + + // Step 3: Location + let location = args.location + if (!location) { + location = (await prompts.text({ + message: "Target location", + placeholder: "e.g., Austin, TX", + })) as string + if (prompts.isCancel(location)) { prompts.outro("Cancelled"); return } + } + + // Step 4: Keywords + let keywords = args.keywords + if (!keywords) { + keywords = (await prompts.text({ + message: "Target keywords (comma-separated)", + placeholder: `e.g., ${vertical}, local businesses`, + initialValue: vertical, + })) as string + if (prompts.isCancel(keywords)) { prompts.outro("Cancelled"); return } + } + + // Step 5: Channel + const channel = (await prompts.select({ + message: "Outreach channel", + options: [ + { value: "instagram", label: "Instagram DM (Recommended)" }, + { value: "email", label: "Email" }, + { value: "both", label: "Instagram DM + Email" }, + { value: "linkedin", label: "LinkedIn" }, + ], + })) as string + if (prompts.isCancel(channel)) { prompts.outro("Cancelled"); return } + + // Step 6: Frequency + let frequency = args.frequency + if (!frequency) { + frequency = (await prompts.select({ + message: "How often should outreach run?", + options: [ + { value: "hourly", label: "Every hour" }, + { value: "every_4_hours", label: "Every 4 hours (Recommended)" }, + { value: "daily", label: "Daily" }, + { value: "weekly", label: "Weekly" }, + ], + })) as string + if (prompts.isCancel(frequency)) { prompts.outro("Cancelled"); return } + } + + // Step 7: Message style + const messageStyle = (await prompts.select({ + message: "Message style", + options: [ + { value: "friendly", label: "Friendly & casual" }, + { value: "professional", label: "Professional & direct" }, + { value: "custom", label: "Custom (write your own)" }, + ], + })) as string + if (prompts.isCancel(messageStyle)) { prompts.outro("Cancelled"); return } + + let messageTemplate = "" + if (messageStyle === "custom") { + messageTemplate = (await prompts.text({ + message: "Enter your DM template (use {name}, {company} for personalization)", + placeholder: "Hey {name}! I saw your page and loved...", + })) as string + if (prompts.isCancel(messageTemplate)) { prompts.outro("Cancelled"); return } + } else { + const templates: Record = { + friendly: `Hey {name}! Love what you're doing with {company}. We're building something that could help you reach more people in ${location || "your area"} — would love to show you. What are you working on?`, + professional: `Hi {name}, I came across {company} and think there's a strong fit with what we're building. We help businesses like yours automate outreach and grow their audience. Would you be open to a quick chat?`, + } + messageTemplate = templates[messageStyle] || templates.friendly + } + + // ──── BUILD CAMPAIGN ──────────────────────────────── + + const spinner = prompts.spinner() + const slug = vertical.toLowerCase().replace(/[^a-z0-9]+/g, "-") + const campaignName = `${vertical.charAt(0).toUpperCase() + vertical.slice(1)} Outreach${location ? ` - ${location}` : ""}` + + // 1. Create Board (Bloq) + spinner.start("Creating board...") + try { + const bloqRes = await irisFetch(`/api/v1/users/${userId}/bloqs`, { + method: "POST", + body: JSON.stringify({ + name: campaignName, + type: "user", + }), + }) + if (!(await handleApiError(bloqRes, "Create board"))) { spinner.stop("Failed", 1); prompts.outro("Done"); return } + const bloq = ((await bloqRes.json()) as any)?.data ?? (await bloqRes.json()) + const boardId = bloq.id + spinner.stop(success(`Board #${boardId}: ${campaignName}`)) + + // 2. Create Outreach Strategy + spinner.start("Creating outreach strategy...") + const strategyName = `${vertical.charAt(0).toUpperCase() + vertical.slice(1)} Outreach | V1` + const steps = [] + + if (channel === "instagram" || channel === "both") { + steps.push({ + title: "Instagram DM", + type: "instagram", + instructions: messageTemplate, + delay_hours: 0, + }) + steps.push({ + title: "Follow-up DM", + type: "instagram", + instructions: `Hey {name}, just following up on my last message! Would love to connect about {company}. Let me know if you're interested.`, + delay_hours: 48, + }) + } + + if (channel === "email" || channel === "both") { + steps.push({ + title: "Email Outreach", + type: "email", + instructions: `Subject: Quick question about {company}\n\n${messageTemplate}`, + delay_hours: channel === "both" ? 72 : 0, + }) + } + + if (channel === "linkedin") { + steps.push({ + title: "LinkedIn Connection", + type: "linkedin", + instructions: messageTemplate, + delay_hours: 0, + }) + } + + const strategyRes = await irisFetch(`/api/v1/users/${userId}/bloqs/${boardId}/outreach-strategies`, { + method: "POST", + body: JSON.stringify({ + name: strategyName, + category: "cold_outreach", + icon: "fab fa-instagram", + steps, + }), + }) + const strategyOk = await handleApiError(strategyRes, "Create strategy") + if (strategyOk) { + const strategy = ((await strategyRes.json()) as any)?.data + spinner.stop(success(`Strategy: ${strategyName} (${steps.length} steps)`)) + } else { + spinner.stop(dim("Strategy creation skipped (API may not support it)")) + } + + // 3. Create Schedule + spinner.start("Creating schedule...") + const cronMap: Record = { + hourly: "0 * * * *", + every_4_hours: "0 */4 * * *", + daily: "0 9 * * *", + weekly: "0 9 * * 1", + } + + // Create cloud schedule + const scheduleRes = await irisFetch(`/api/v1/users/${userId}/bloqs/scheduled-jobs`, { + method: "POST", + body: JSON.stringify({ + agent_id: 11, + task_name: campaignName, + prompt: `${slug} limit=15 warmup=1`, + time: "09:00", + frequency, + timezone: "America/Chicago", + data: { + type: "hive_task_dispatch", + campaign_id: slug, + params: { + boardId: String(boardId), + strategy: strategyName, + igAccount, + location, + keywords, + channel, + }, + }, + }), + }) + if (await handleApiError(scheduleRes, "Create schedule")) { + const sched = ((await scheduleRes.json()) as any)?.data + spinner.stop(success(`Schedule: ${frequency} (${cronMap[frequency] || frequency})`)) + } else { + spinner.stop(dim("Schedule creation skipped")) + } + + // 4. Summary + printDivider() + printKV("Campaign", campaignName) + printKV("Board ID", boardId) + printKV("Vertical", vertical) + printKV("IG Account", `@${igAccount}`) + printKV("Location", location || "—") + printKV("Keywords", keywords || "—") + printKV("Channel", channel) + printKV("Frequency", frequency) + printKV("Strategy", strategyName) + printKV("Message", messageTemplate.slice(0, 80) + "...") + printDivider() + + // 5. Offer to save IG session + if (channel === "instagram" || channel === "both") { + const saveSession = await prompts.confirm({ + message: "Save Instagram session now? (opens browser for login)", + }) + if (saveSession) { + prompts.log.info("Opening browser for Instagram login...") + try { + execSync( + `IG_ACCOUNT=${igAccount} IG_USERNAME=${igAccount} npx playwright test tests/e2e/save-instagram-session.spec.ts --headed --timeout 300000`, + { cwd: join(homedir(), "Sites", "freelabel"), stdio: "inherit" } + ) + prompts.log.info(success("IG session saved")) + } catch { + prompts.log.warn("Session save failed or was cancelled. Run later:") + prompts.log.info(dim(`IG_ACCOUNT=${igAccount} npm run som:save-session`)) + } + } else { + prompts.log.info(dim(`Save later: IG_ACCOUNT=${igAccount} npm run som:save-session`)) + } + } + + // 6. Offer to add to som-config.js + prompts.log.info("") + prompts.log.info(dim(`To add to campaign registry:`)) + prompts.log.info(dim(` Edit tests/e2e/som-config.js and add:`)) + prompts.log.info(dim(` ${slug}: { boardId: '${boardId}', strategy: '${strategyName}', igAccount: '${igAccount}', active: true, label: '${campaignName}', color: '\\x1b[36m' }`)) + + prompts.log.info("") + prompts.log.info(dim("Monitor: iris bridge runs")) + prompts.log.info(dim(`Leads: iris leads list --bloq-id ${boardId}`)) + prompts.outro(success("Campaign created")) + + } catch (err) { + spinner.stop("Error", 1) + prompts.log.error(err instanceof Error ? err.message : String(err)) + prompts.outro("Done") + } + }, +}) + +// ============================================================================ +// iris campaign list — show all campaigns from som-config.js +// ============================================================================ + +const CampaignListCommand = cmd({ + command: "list", + aliases: ["ls"], + describe: "list all outreach campaigns", + async handler() { + UI.empty() + prompts.intro("◈ Campaigns") + + // Try to read som-config.js + const configPaths = [ + join(homedir(), "Sites", "freelabel", "tests", "e2e", "som-config.js"), + join(process.cwd(), "tests", "e2e", "som-config.js"), + ] + + let config: any = null + for (const p of configPaths) { + if (existsSync(p)) { + try { + delete require.cache[require.resolve(p)] + config = require(p) + break + } catch {} + } + } + + if (!config?.campaigns) { + prompts.log.warn("som-config.js not found. Run from the freelabel project root.") + prompts.outro("Done") + return + } + + printDivider() + for (const [id, c] of Object.entries(config.campaigns) as [string, any][]) { + const status = c.active + ? `${UI.Style.TEXT_SUCCESS}● active${UI.Style.TEXT_NORMAL}` + : dim("○ inactive") + const ig = c.igAccount ? `@${c.igAccount}` : "—" + console.log(` ${bold(c.label || id)} ${status} ${dim(ig)} ${dim(`board:${c.boardId}`)}`) + console.log(` ${dim(`Strategy: ${c.strategy}`)}`) + console.log() + } + printDivider() + + prompts.log.info(dim("iris campaign create — create a new campaign")) + prompts.outro("Done") + }, +}) + +// ============================================================================ +// Root command +// ============================================================================ + +export const PlatformCampaignCommand = cmd({ + command: "campaign", + aliases: ["campaigns"], + describe: "manage outreach campaigns — create, list, monitor", + builder: (yargs) => + yargs + .command(CampaignCreateCommand) + .command(CampaignListCommand) + .demandCommand(), + async handler() {}, +}) diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts index 8ef14b570f0b..51d3a024d080 100644 --- a/packages/opencode/src/index.ts +++ b/packages/opencode/src/index.ts @@ -67,6 +67,7 @@ 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 { PlatformCampaignCommand } from "./cli/cmd/platform-campaign" import { PlatformDaemonCommand } from "./cli/cmd/platform-daemon" import { PlatformOnboardCommand } from "./cli/cmd/platform-onboard" import { PlatformProposalsCommand } from "./cli/cmd/platform-proposals" @@ -93,6 +94,7 @@ import { PlatformVoiceCommand } from "./cli/cmd/platform-voice" // TODO: platform-mail and platform-imessage commands (not yet implemented) // import { PlatformMailCommand } from "./cli/cmd/platform-mail" // import { PlatformImessageCommand } from "./cli/cmd/platform-imessage" +import { PlatformCalendarCommand } from "./cli/cmd/platform-calendar" import { PlatformWalletCommand } from "./cli/cmd/platform-wallet" import { PlatformConfigCommand } from "./cli/cmd/platform-config" import { PlatformAppCommand } from "./cli/cmd/platform-app" @@ -229,6 +231,7 @@ const cli = yargs(rawArgs) .command(reg(PlatformAtlasMeetingsCommand)) .command(reg(PlatformAtlasBrandKitCommand)) .command(reg(PlatformLeadsMeetingCommand)) + .command(reg(PlatformCampaignCommand)) .command(reg(PlatformDaemonCommand)) .command(reg(PlatformOnboardCommand)) .command(reg(PlatformProposalsCommand)) @@ -254,6 +257,7 @@ const cli = yargs(rawArgs) .command(reg(PlatformVoiceCommand)) // .command(reg(PlatformMailCommand)) // TODO: not yet implemented // .command(reg(PlatformImessageCommand)) // TODO: not yet implemented + .command(reg(PlatformCalendarCommand)) .command(reg(PlatformWalletCommand)) // PHP-port commands — config, app, automation, automation:test // (integrations is owned by PlatformRunCommand in platform-run.ts)