#!/usr/bin/env bun // --- Polaris CLI --- // Usage: // polaris — install + login (default onboarding) // polaris install — install local components (no auth) // polaris login — authenticate via Google SSO (production) // polaris login --local — authenticate against localhost // polaris login --url — authenticate against a custom URL // polaris login --profile — explicit profile key (default: email) // polaris use — switch active account (triggers login if not found) // polaris profiles — list accounts // polaris daemon — start the local daemon // polaris status — show connection status // polaris recover — re-POST locally logged events missing upstream // polaris feedback — report a bug or request a feature // polaris logout — remove credentials import { mkdir, writeFile, readFile, rm, copyFile, chmod, readdir } from "node:fs/promises"; import { homedir, hostname } from "node:os"; import { join } from "node:path"; const POLARIS_DIR = join(homedir(), ".polaris"); const CONFIG_FILE = join(POLARIS_DIR, "config.json"); const LEGACY_CREDENTIALS_FILE = join(POLARIS_DIR, "credentials.json"); const CLAUDE_DIR = join(homedir(), ".claude"); const DEFAULT_APP_URL = "https://app.withpolaris.ai"; const LOCAL_APP_URL = "http://localhost:3000"; // --- Config --- interface Profile { api: string; app: string; token: string; email: string; name: string; org_id: string; participant_id: string; } interface Config { active: string; profiles: Record; daemonSecret?: string; } async function loadConfig(): Promise { try { return JSON.parse(await readFile(CONFIG_FILE, "utf-8")); } catch { // Migrate from legacy credentials.json if it exists try { const legacy = JSON.parse(await readFile(LEGACY_CREDENTIALS_FILE, "utf-8")); if (legacy.token) { const appUrl = legacy.service_url ?? DEFAULT_APP_URL; const profileName = deriveProfileName(appUrl); const config: Config = { active: profileName, profiles: { [profileName]: { api: appToApi(appUrl), app: appUrl, token: legacy.token, email: legacy.email ?? "", name: legacy.name ?? "", org_id: legacy.org_id ?? "", participant_id: legacy.participant_id ?? "", }, }, }; await saveConfig(config); return config; } } catch { /* no legacy file either */ } return { active: "", profiles: {} }; } } async function saveConfig(config: Config): Promise { await mkdir(POLARIS_DIR, { recursive: true }); await writeFile(CONFIG_FILE, JSON.stringify(config, null, 2)); } function getActiveProfile(config: Config): Profile | null { if (!config.active || !config.profiles[config.active]) return null; return config.profiles[config.active]; } function deriveProfileName(appUrl: string): string { if (appUrl.includes("localhost") || appUrl.includes("127.0.0.1")) return "local"; try { const host = new URL(appUrl).hostname; // app.withpolaris.ai → prod if (host.includes("withpolaris.ai")) return "prod"; // strip common prefixes return host.replace(/^app\./, "").replace(/\./g, "-"); } catch { return "default"; } } function appToApi(appUrl: string): string { if (appUrl.includes("localhost") || appUrl.includes("127.0.0.1")) { return appUrl.replace(":3000", ":4321"); } return appUrl.replace("app.", "api."); } // --- Daemon auto-restart (macOS: launchd, Linux: systemd user service) --- // macOS: LaunchAgent const LAUNCH_AGENT_LABEL = "ai.withpolaris.daemon"; const LAUNCH_AGENT_PLIST = join(homedir(), "Library", "LaunchAgents", `${LAUNCH_AGENT_LABEL}.plist`); async function installLaunchAgent(daemonSecret?: string): Promise { const binDir = join(POLARIS_DIR, "bin"); const runnerPath = join(binDir, "run-daemon.sh"); const daemonTsPath = join( POLARIS_DIR, "mcp", "node_modules", "@lightupai", "polaris", "src", "daemon", "daemon.ts" ); await mkdir(binDir, { recursive: true }); // Runner searches common bun locations so launchd's minimal PATH is not an issue const runner = [ "#!/bin/sh", `# Polaris daemon — managed by launchd (${LAUNCH_AGENT_LABEL})`, `for BUNPATH in "$HOME/.bun/bin/bun" /opt/homebrew/bin/bun /usr/local/bin/bun; do`, ` [ -x "$BUNPATH" ] && break`, `done`, `exec "$BUNPATH" run "${daemonTsPath}"`, ].join("\n") + "\n"; await writeFile(runnerPath, runner); await chmod(runnerPath, 0o755); const secretEnv = daemonSecret ? `\n EnvironmentVariables\n \n POLARIS_DAEMON_SECRET\n ${daemonSecret}\n ` : ""; const plist = ` Label ${LAUNCH_AGENT_LABEL} ProgramArguments ${runnerPath} ${secretEnv} KeepAlive RunAtLoad ThrottleInterval 5 StandardOutPath /tmp/polaris-daemon.log StandardErrorPath /tmp/polaris-daemon.log `; await mkdir(join(homedir(), "Library", "LaunchAgents"), { recursive: true }); await writeFile(LAUNCH_AGENT_PLIST, plist); // Unload any existing instance, then load fresh (ignore errors if not loaded yet) Bun.spawnSync(["launchctl", "unload", "-w", LAUNCH_AGENT_PLIST], { stdout: "ignore", stderr: "ignore" }); const load = Bun.spawnSync(["launchctl", "load", "-w", LAUNCH_AGENT_PLIST], { stdout: "pipe", stderr: "pipe" }); if (load.exitCode !== 0) { console.error(` Warning: could not register daemon with launchd: ${load.stderr.toString().trim()}`); console.error(` Try manually: launchctl load -w ${LAUNCH_AGENT_PLIST}`); } } async function removeLaunchAgent(): Promise { Bun.spawnSync(["launchctl", "unload", "-w", LAUNCH_AGENT_PLIST], { stdout: "ignore", stderr: "ignore" }); try { await rm(LAUNCH_AGENT_PLIST); } catch { /* already gone */ } } // Linux: systemd user service const SYSTEMD_SERVICE_NAME = "polaris-daemon"; const SYSTEMD_USER_DIR = join(homedir(), ".config", "systemd", "user"); const SYSTEMD_SERVICE_FILE = join(SYSTEMD_USER_DIR, `${SYSTEMD_SERVICE_NAME}.service`); async function installSystemdService(daemonSecret?: string): Promise { // Resolve bun's absolute path at install time so systemd's minimal PATH // doesn't matter. Bun.which() searches PATH; process.execPath is the // interpreter we're already running under. const resolvedBun = Bun.which("bun") ?? process.execPath; // Warn when bun resolved to an npx cache directory — that path is volatile // and will break the service if the cache is pruned or the hash changes. const isVolatile = resolvedBun.includes("/_npx/") || resolvedBun.includes("/npx_cache/"); if (isVolatile) { console.error(` Warning: bun was found at a volatile npm cache path:`); console.error(` ${resolvedBun}`); console.error(` The daemon service may break if npm cleans its cache.`); console.error(` Install bun permanently for reliability: curl -fsSL https://bun.sh/install | bash`); } const binDir = join(POLARIS_DIR, "bin"); const runnerPath = join(binDir, "run-daemon.sh"); const daemonTsPath = join( POLARIS_DIR, "mcp", "node_modules", "@lightupai", "polaris", "src", "daemon", "daemon.ts" ); await mkdir(binDir, { recursive: true }); // Runner tries the baked-in path first (fast path), then falls back to // common stable install locations, then npx as a last resort — so the // service survives a cache prune or bun reinstall without needing // `polaris install` to be rerun. const runner = [ "#!/bin/sh", `# Polaris daemon — managed by systemd (${SYSTEMD_SERVICE_NAME}.service)`, `DAEMONTS="${daemonTsPath}"`, `for BUNPATH in "${resolvedBun}" "$HOME/.bun/bin/bun" /usr/local/bin/bun /usr/bin/bun; do`, ` if [ -x "$BUNPATH" ]; then exec "$BUNPATH" run "$DAEMONTS"; fi`, `done`, `# npx fallback — slow but avoids exit-127 restart loops`, `exec npx --yes bun run "$DAEMONTS"`, ].join("\n") + "\n"; await writeFile(runnerPath, runner); await chmod(runnerPath, 0o755); const secretLine = daemonSecret ? `Environment=POLARIS_DAEMON_SECRET=${daemonSecret}\n` : ""; const unit = `[Unit] Description=Polaris daemon After=network.target [Service] Type=simple ExecStart=${runnerPath} Restart=always RestartSec=5 ${secretLine}Environment=PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin StandardOutput=append:/tmp/polaris-daemon.log StandardError=append:/tmp/polaris-daemon.log [Install] WantedBy=default.target `; await mkdir(SYSTEMD_USER_DIR, { recursive: true }); await writeFile(SYSTEMD_SERVICE_FILE, unit); // Stop any existing manually-started daemon so it releases port 4322 // before systemd takes over await killExistingDaemon(); // Reload unit files, enable (start-at-login) and start/restart the service Bun.spawnSync(["systemctl", "--user", "daemon-reload"], { stdout: "ignore", stderr: "ignore" }); Bun.spawnSync(["systemctl", "--user", "enable", SYSTEMD_SERVICE_NAME], { stdout: "ignore", stderr: "ignore" }); Bun.spawnSync(["systemctl", "--user", "restart", SYSTEMD_SERVICE_NAME], { stdout: "ignore", stderr: "ignore" }); // Verify the service came up const check = Bun.spawnSync(["systemctl", "--user", "is-active", SYSTEMD_SERVICE_NAME], { stdout: "pipe", stderr: "ignore" }); if (check.stdout.toString().trim() !== "active") { console.error(` Warning: daemon service did not start. Check logs:`); const journal = Bun.spawnSync( ["journalctl", "--user", "-u", SYSTEMD_SERVICE_NAME, "-n", "10", "--no-pager"], { stdout: "pipe", stderr: "ignore" } ); console.error(journal.stdout.toString().trim()); console.error(` Try manually: systemctl --user restart ${SYSTEMD_SERVICE_NAME}`); } // Enable lingering so the user service survives logout and starts at boot // (best-effort — requires sudo-less loginctl or appropriate privileges) Bun.spawnSync(["loginctl", "enable-linger"], { stdout: "ignore", stderr: "ignore" }); } async function removeSystemdService(): Promise { Bun.spawnSync(["systemctl", "--user", "disable", "--now", SYSTEMD_SERVICE_NAME], { stdout: "ignore", stderr: "ignore" }); try { await rm(SYSTEMD_SERVICE_FILE); } catch { /* already gone */ } Bun.spawnSync(["systemctl", "--user", "daemon-reload"], { stdout: "ignore", stderr: "ignore" }); } // Platform dispatcher // --- Session state migration --- // // When upgrading from a pre-0.0.109 daemon (which never wrote daemon-state.json), // the new daemon would start with an empty session map and drop every connected // session. This function fetches the running daemon's session list via GET /status // and writes daemon-state.json before the daemon is stopped, so the new daemon // can restore the map and reconnect cloud WebSockets without any user action. // Best-effort: failures are silent (daemon may not be running, or already migrated). async function migrateSessionState(): Promise { const stateFile = join(POLARIS_DIR, "sessions", "daemon-state.json"); // If a 0.0.109+ daemon already wrote the file, its state is more complete // (includes learned hook aliases) — don't overwrite it. try { await readFile(stateFile); return; } catch { /* doesn't exist — proceed */ } try { const config = await loadConfig(); const secret = resolveDaemonSecret(config); const headers: Record = {}; if (secret) headers["x-polaris-daemon-secret"] = secret; const res = await fetch("http://127.0.0.1:4322/status", { headers, signal: AbortSignal.timeout(2000), }); if (!res.ok) return; const data = await res.json() as { sessions?: Array<{ ccSessionId: string; project: string; session: string; user: string }> }; if (!data.sessions || data.sessions.length === 0) return; const activeProfile = config?.active || ""; const sessions = data.sessions.map((s) => ({ ccSessionId: s.ccSessionId, project: s.project, session: s.session, user: s.user, agent: "agent:claude", profile: activeProfile, })); await mkdir(join(POLARIS_DIR, "sessions"), { recursive: true }); await writeFile(stateFile, JSON.stringify({ sessions })); console.log(` ✓ Migrated ${sessions.length} active session(s) to persistent state`); } catch { /* best-effort — daemon not running or unreachable */ } } async function installDaemonService(daemonSecret?: string): Promise { // Migrate session state from older daemon before it is stopped await migrateSessionState(); if (process.platform === "darwin") return installLaunchAgent(daemonSecret); if (process.platform === "linux") return installSystemdService(daemonSecret); // Windows and others: no managed service; daemon is started manually } async function removeDaemonService(): Promise { if (process.platform === "darwin") return removeLaunchAgent(); if (process.platform === "linux") return removeSystemdService(); } function daemonServiceLabel(): string { if (process.platform === "darwin") return "launchd (auto-restarts on crash, starts at login)"; if (process.platform === "linux") return "systemd user service (auto-restarts on crash, starts at login)"; return "background process"; } // --- Daemon secret --- // Shared local secret for daemon HTTP auth. Stored in ~/.polaris/config.json // (`daemonSecret`); the daemon requires it as `x-polaris-daemon-secret` on all // of its endpoints once a secret is resolved (env POLARIS_DAEMON_SECRET takes // precedence over the config file). Reused across installs so previously // registered MCP servers and hooks stay valid. async function ensureDaemonSecret(): Promise { const config = await loadConfig(); const envSecret = process.env.POLARIS_DAEMON_SECRET; if (envSecret) { if (config.daemonSecret !== envSecret) { config.daemonSecret = envSecret; await saveConfig(config); } return envSecret; } if (config.daemonSecret) return config.daemonSecret; const secret = crypto.randomUUID(); config.daemonSecret = secret; await saveConfig(config); return secret; } function resolveDaemonSecret(config: Config): string | undefined { return process.env.POLARIS_DAEMON_SECRET || config.daemonSecret || undefined; } // --- Install --- // Wire Claude Code hooks + status line into ~/.claude/settings.json. // UserPromptSubmit/Stop run bun scripts (prompt-time inject delivery and full // Stop transcript capture); PreToolUse/PostToolUse use the plain curl relay. // Hooks are copied to ~/.polaris/hooks/ — a stable path that survives package // upgrades/relocations — and re-copied on every install so they stay fresh. async function wireHooks(daemonSecret?: string): Promise { const srcHooksDir = join(import.meta.dir, "..", "..", "hooks"); const hooksDir = join(POLARIS_DIR, "hooks"); await mkdir(hooksDir, { recursive: true }); for (const file of ["capture.sh", "capture-prompt.ts", "capture-stop.ts", "statusline.sh"]) { await copyFile(join(srcHooksDir, file), join(hooksDir, file)); if (file.endsWith(".sh")) await chmod(join(hooksDir, file), 0o755); } const captureShPath = join(hooksDir, "capture.sh"); const capturePromptPath = join(hooksDir, "capture-prompt.ts"); const captureStopPath = join(hooksDir, "capture-stop.ts"); const statusLinePath = join(hooksDir, "statusline.sh"); // Pass the shared daemon secret to hook processes via the command env so // they can authenticate with the daemon (x-polaris-daemon-secret header). const env = daemonSecret ? `POLARIS_DAEMON_SECRET=${daemonSecret} ` : ""; const hooksConfig = { UserPromptSubmit: [{ hooks: [{ type: "command", command: `${env}npx bun "${capturePromptPath}"` }] }], Stop: [{ hooks: [{ type: "command", command: `${env}npx bun "${captureStopPath}"` }] }], PreToolUse: [{ hooks: [{ type: "command", command: `${env}"${captureShPath}"` }] }], PostToolUse: [{ hooks: [{ type: "command", command: `${env}"${captureShPath}"` }] }], }; const settingsPath = join(CLAUDE_DIR, "settings.json"); let existingSettings: Record = {}; try { existingSettings = JSON.parse(await readFile(settingsPath, "utf-8")); } catch { /* doesn't exist yet */ } const mergedSettings = { ...existingSettings, hooks: { ...(existingSettings as { hooks?: Record }).hooks, ...hooksConfig, }, statusLine: { type: "command", command: `${env}"${statusLinePath}"`, }, }; await writeFile(settingsPath, JSON.stringify(mergedSettings, null, 2)); } async function install(participantId?: string) { await mkdir(CLAUDE_DIR, { recursive: true }); // Shared local secret for daemon auth (generated once, reused thereafter) const daemonSecret = await ensureDaemonSecret(); // Install package to ~/.polaris/mcp/ (user-local, no sudo needed, persistent) const mcpDir = join(POLARIS_DIR, "mcp"); await mkdir(mcpDir, { recursive: true }); console.log(" Installing MCP server to ~/.polaris/mcp/..."); const npmInstall = Bun.spawnSync( ["npm", "install", "--prefix", mcpDir, "@lightupai/polaris@latest"], { stdout: "ignore", stderr: "pipe" } ); if (npmInstall.exitCode !== 0) { console.error(" Warning: MCP server install failed."); console.error(" " + npmInstall.stderr.toString().trim()); } // The binary is at ~/.polaris/mcp/node_modules/.bin/polaris-mcp const mcpBin = join(mcpDir, "node_modules", ".bin", "polaris-mcp"); // Register MCP server with Claude Code via `claude mcp add` // This writes to the correct config location that Claude Code reads // Remove first to avoid duplicates, then add with user scope Bun.spawnSync(["claude", "mcp", "remove", "polaris", "-s", "user"], { stdout: "ignore", stderr: "ignore", }); const mcpAdd = Bun.spawnSync( [ "claude", "mcp", "add", "polaris", "-s", "user", "-e", "POLARIS_DAEMON_URL=http://127.0.0.1:4322", "-e", `POLARIS_DAEMON_SECRET=${daemonSecret}`, "--", mcpBin, ], { stdout: "pipe", stderr: "pipe" } ); if (mcpAdd.exitCode === 0) { console.log(" ✓ MCP server registered with Claude Code"); } else { console.error(" Warning: could not register MCP server with Claude Code."); console.error(" " + mcpAdd.stderr.toString().trim()); console.error(` Run manually: claude mcp add -s user -e POLARIS_DAEMON_URL=http://127.0.0.1:4322 -e POLARIS_DAEMON_SECRET=${daemonSecret} polaris -- ${mcpBin}`); } // Hooks (copied to ~/.polaris/hooks/ with the daemon secret in their env) await wireHooks(daemonSecret); console.log(" ✓ Hooks + status line config written"); // /polaris skill const skillDir = join(CLAUDE_DIR, "skills", "polaris"); await mkdir(skillDir, { recursive: true }); const identity = participantId ? `\`${participantId}\`` : "the user's participant ID (ask them if unknown)"; const skillContent = `--- name: polaris description: Connect to a Polaris multiplayer collaboration session allowed-tools: polaris_connect polaris_disconnect polaris_status polaris_reply polaris_context polaris_rename polaris_rename_session polaris_update argument-hint: [join #channel [session-name] | rename | rename-session | disconnect | update | (no args for status)] --- ## Polaris — Multiplayer Collaboration Manage your connection to a Polaris collaboration session. ### Commands Based on the arguments provided, do ONE of the following: **\`/polaris join #channel-name [session-name]\`** — Connect to a channel: 1. Call \`polaris_connect\` with the given project, user identity ${identity}, and optional \`session\` name (e.g. \`auth-feature\`, \`bugfix-154\`) 2. A session name is auto-generated if not provided 3. Report the connection status including the session name **\`/polaris rename \`** — Rename the current **project** (and its Slack channel): 1. Call \`polaris_rename\` with the new name 2. Report the result **\`/polaris rename-session \`** — Rename this **session thread** only (not the project): 1. Call \`polaris_rename_session\` with the new name 2. Report the result **\`/polaris update\`** — Update Polaris to the latest version: 1. Call \`polaris_update\` 2. Report what changed and remind the user to restart Claude Code if the MCP server was updated **\`/polaris disconnect\`** — Disconnect: 1. Call \`polaris_disconnect\` 2. Confirm disconnection **\`/polaris\`** (no arguments) — Show status: 1. Call \`polaris_status\` 2. Display the current connection state ### Daemon The Polaris daemon runs locally and is managed automatically — do NOT manually configure launchd or systemd. If the user reports the daemon is down or not starting at login, tell them to run \`polaris install\` which sets up auto-restart (launchd on macOS, systemd user service on Linux). ### Arguments: $ARGUMENTS `; await writeFile(join(skillDir, "SKILL.md"), skillContent); console.log(" ✓ /polaris skill written"); } // --- Login --- async function login(appUrl: string, profileName?: string, loginHint?: string) { // Profile key is determined after auth (we use the email). profileName is an // explicit override (e.g. --profile flag); otherwise we'll use userInfo.email. // Browser OAuth let resolveToken: (token: string) => void; const tokenPromise = new Promise((resolve) => { resolveToken = resolve; }); const callbackServer = Bun.serve({ port: 0, hostname: "127.0.0.1", fetch(req) { const url = new URL(req.url); if (url.pathname === "/callback") { const token = url.searchParams.get("token"); if (token) { resolveToken!(token); return new Response( `Polaris

Authenticated!

You can close this tab and return to the terminal.

`, { headers: { "Content-Type": "text/html" } } ); } } return new Response("Not found", { status: 404 }); }, }); const callbackPort = callbackServer.port; let authUrl = `${appUrl}/auth/cli?port=${callbackPort}`; if (loginHint) authUrl += `&login_hint=${encodeURIComponent(loginHint)}`; console.log(` Opening browser for Google sign-in...`); console.log(` If the browser doesn't open, visit: ${authUrl}\n`); const proc = Bun.spawn( process.platform === "darwin" ? ["open", authUrl] : process.platform === "win32" ? ["cmd", "/c", "start", authUrl] : ["xdg-open", authUrl], { stdout: "ignore", stderr: "ignore" } ); await proc.exited; console.log(" Waiting for authentication..."); const token = await tokenPromise; setTimeout(() => callbackServer.stop(true), 3000); // Validate token const res = await fetch(`${appUrl}/auth/token?token=${token}`); if (!res.ok) { console.error(" ✗ Failed to validate token. Please try again."); process.exit(1); } const userInfo = (await res.json()) as { sub: string; email: string; name: string; org_id: string; participant_id: string; }; let orgName = userInfo.email.split("@")[1].split(".")[0]; orgName = orgName.charAt(0).toUpperCase() + orgName.slice(1); console.log(`\n Authenticated as ${userInfo.name} (${userInfo.email})`); console.log(` Organization: ${orgName}`); console.log(` Participant ID: ${userInfo.participant_id}`); // Save to profile — keyed by email unless an explicit --profile name was given const config = await loadConfig(); const profileKey = profileName ?? userInfo.email; config.profiles[profileKey] = { api: appToApi(appUrl), app: appUrl, token, email: userInfo.email, name: userInfo.name, org_id: userInfo.org_id, participant_id: userInfo.participant_id, }; config.active = profileKey; await saveConfig(config); // Also write legacy credentials.json for backward compat (daemon reads it) await writeFile(LEGACY_CREDENTIALS_FILE, JSON.stringify({ token, ...userInfo, service_url: appUrl, }, null, 2)); console.log(` ✓ Logged in as ${userInfo.email}`); // Re-install skill with personalized participant ID await mkdir(join(CLAUDE_DIR, "skills", "polaris"), { recursive: true }); const identity = `\`${userInfo.participant_id}\``; const skillDir = join(CLAUDE_DIR, "skills", "polaris"); const skillContent = `--- name: polaris description: Connect to a Polaris multiplayer collaboration session allowed-tools: polaris_connect polaris_disconnect polaris_status polaris_reply polaris_context polaris_rename polaris_rename_session polaris_update argument-hint: [join #channel [session-name] | rename | rename-session | disconnect | update | (no args for status)] --- ## Polaris — Multiplayer Collaboration Manage your connection to a Polaris collaboration session. ### Commands Based on the arguments provided, do ONE of the following: **\`/polaris join #channel-name [session-name]\`** — Connect to a channel: 1. Call \`polaris_connect\` with the given project, user identity ${identity}, and optional \`session\` name (e.g. \`auth-feature\`, \`bugfix-154\`) 2. A session name is auto-generated if not provided 3. Report the connection status including the session name **\`/polaris rename \`** — Rename the current **project** (and its Slack channel): 1. Call \`polaris_rename\` with the new name 2. Report the result **\`/polaris rename-session \`** — Rename this **session thread** only (not the project): 1. Call \`polaris_rename_session\` with the new name 2. Report the result **\`/polaris update\`** — Update Polaris to the latest version: 1. Call \`polaris_update\` 2. Report what changed and remind the user to restart Claude Code if the MCP server was updated **\`/polaris disconnect\`** — Disconnect: 1. Call \`polaris_disconnect\` 2. Confirm disconnection **\`/polaris\`** (no arguments) — Show status: 1. Call \`polaris_status\` 2. Display the current connection state ### Arguments: $ARGUMENTS `; await writeFile(join(skillDir, "SKILL.md"), skillContent); // Hooks (same wiring as install — keeps hook commands up to date) await wireHooks(await ensureDaemonSecret()); console.log(" ✓ Hooks + status line config written"); // Post system event (device connected) const apiUrl = appToApi(appUrl); try { await fetch(`${apiUrl}/projects/_system/sessions/_system/events`, { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}`, }, body: JSON.stringify({ sender: userInfo.participant_id, payload: { hook_event_name: "Stop", session_id: "_system", stop_response: `Device connected: ${hostname()} (${process.platform})`, }, }), }); } catch { /* non-fatal */ } } // --- Use --- // Accepts an email address or a legacy profile key. If the account isn't // found locally, starts the login flow (with login_hint pre-filled). async function use(arg: string) { const config = await loadConfig(); // 1. Direct key match (backward compat with old "prod" / "local" style names) let matchedKey: string | undefined; if (config.profiles[arg]) { matchedKey = arg; } else { // 2. Search by email field const entry = Object.entries(config.profiles).find(([, p]) => p.email === arg); if (entry) matchedKey = entry[0]; } if (matchedKey) { config.active = matchedKey; await saveConfig(config); const profile = config.profiles[matchedKey]; // Update legacy credentials.json for daemon await writeFile(LEGACY_CREDENTIALS_FILE, JSON.stringify({ token: profile.token, email: profile.email, name: profile.name, org_id: profile.org_id, participant_id: profile.participant_id, service_url: profile.app, }, null, 2)); console.log(`Active account: ${profile.email}`); console.log("Restart the daemon for this to take effect."); return; } // 3. Not found locally — start auth (pre-fill email hint when it looks like // an email address so Google can skip account selection) console.log(`Account "${arg}" not found locally. Starting authentication...\n`); const isEmail = arg.includes("@"); await login(DEFAULT_APP_URL, undefined, isEmail ? arg : undefined); console.log("\n✓ Login complete!"); // Replace daemon so new credentials are picked up immediately await killExistingDaemon(); const daemonPath = join(import.meta.dir, "..", "daemon", "daemon.ts"); Bun.spawn(["bun", "run", daemonPath], { stdout: "ignore", stderr: "ignore", env: { ...process.env }, }).unref?.(); console.log(" ✓ Daemon started in background"); } // --- Profiles --- async function profiles() { const config = await loadConfig(); const keys = Object.keys(config.profiles); if (keys.length === 0) { console.log("No accounts configured. Run: polaris login"); return; } console.log("Accounts:\n"); for (const key of keys) { const p = config.profiles[key]; const active = key === config.active ? " (active)" : ""; // Use email as the primary label; show key only when it differs (legacy style) const label = p.email || key; const keyHint = p.email && key !== p.email ? ` [key: ${key}]` : ""; console.log(` ${label}${active}${keyHint}`); if (p.name) console.log(` Name: ${p.name}`); console.log(` API: ${p.api}`); console.log(""); } console.log(`Use \`polaris use \` to switch accounts.`); } // --- Daemon --- async function killExistingDaemon(): Promise { // Ask the running daemon to shut down gracefully via the HTTP API, then // wait briefly for the port to free up. Falls back silently if no daemon // is running or the port is already free. try { const secret = await ensureDaemonSecret(); const headers: Record = { "Content-Type": "application/json" }; if (secret) headers["x-polaris-daemon-secret"] = secret; await fetch("http://127.0.0.1:4322/shutdown", { method: "POST", headers }).catch(() => {}); } catch { /* no daemon running */ } // Give it a moment to exit, then kill any straggler by port await new Promise((r) => setTimeout(r, 500)); try { Bun.spawnSync(["sh", "-c", "lsof -ti :4322 | xargs kill -9 2>/dev/null || true"]); } catch { /* best-effort */ } } async function daemon() { const daemonPath = join(import.meta.dir, "..", "daemon", "daemon.ts"); console.log("Starting Polaris daemon..."); const proc = Bun.spawn(["bun", "run", daemonPath], { stdout: "inherit", stderr: "inherit", env: { ...process.env, }, }); await proc.exited; } // --- Status --- async function status() { // Active profile const config = await loadConfig(); const profile = getActiveProfile(config); if (profile) { console.log(`Profile: ${config.active} (${profile.api})`); console.log(`User: ${profile.name} (${profile.email})`); } else { console.log("Not logged in. Run: polaris login"); } // Daemon (send the shared local secret when configured — the daemon // requires it on all endpoints once one is resolved) try { const secret = resolveDaemonSecret(config); const res = await fetch("http://127.0.0.1:4322/status", { headers: secret ? { "x-polaris-daemon-secret": secret } : undefined, }); const data = (await res.json()) as { ok: boolean; sessions?: Array<{ ccSessionId: string; project: string; session: string; user: string }> }; if (data.ok) { console.log("\nDaemon: running"); if (data.sessions && data.sessions.length > 0) { console.log(`Active sessions (${data.sessions.length}):`); for (const s of data.sessions) { console.log(` ${s.project}/${s.session} as ${s.user}`); } } else { console.log("No active sessions"); } } } catch { console.log("\nDaemon: not running"); } } // --- Recover --- // Replay locally logged daemon events (~/.polaris/logs/*.jsonl) that never // made it upstream. Presence is determined by fetching each session's // messages (GET /projects/:p/sessions/:s/messages) and diffing: by event id // when the logged payload carries one, otherwise by the payload's identifying // fields (hook ids are assigned server-side at ingest, so hook log entries // have no id of their own). Missing events are re-POSTed via the normal // events ingest path. Best-effort: failures are skipped, never throws. interface RecoverRoute { project: string; session: string; user: string; agent: string } interface RecoverCandidate { project: string; session: string; sender: string; payload: Record } function isPresentUpstream( payload: Record, upstream: Array<{ id?: string; payload?: Record }> ): boolean { const id = payload.id; if (typeof id === "string" && upstream.some((e) => e.id === id)) return true; const kind = payload.hook_event_name; return upstream.some((e) => { const p = e.payload; if (!p || p.hook_event_name !== kind || p.session_id !== payload.session_id) return false; switch (kind) { case "UserPromptSubmit": return p.prompt === payload.prompt; case "Stop": return (p.stop_response ?? p.last_assistant_message) === (payload.stop_response ?? payload.last_assistant_message); case "PreToolUse": case "PostToolUse": return p.tool_name === payload.tool_name && JSON.stringify(p.tool_input) === JSON.stringify(payload.tool_input); default: return JSON.stringify(p) === JSON.stringify(payload); } }); } async function recover() { try { const config = await loadConfig(); const profile = getActiveProfile(config); if (!profile) { console.log("Not logged in. Run: polaris login"); return; } const headers = { "Content-Type": "application/json", Authorization: `Bearer ${profile.token}`, }; const logDir = join(POLARIS_DIR, "logs"); let files: string[] = []; try { files = (await readdir(logDir)).filter((f) => f.endsWith(".jsonl")).sort(); } catch { /* no log dir */ } if (files.length === 0) { console.log("No local logs found in ~/.polaris/logs — nothing to recover."); return; } // Pass 1: walk logs chronologically (files are daemon-YYYY-MM-DD.jsonl), // learning session routing from /connect entries and collecting candidate // events. Failed relays are logged twice (with/without a response field), // so dedupe identical payloads. const routes = new Map(); // keyed by cc session id const candidates: RecoverCandidate[] = []; const seen = new Set(); let scanned = 0; let unroutable = 0; const routeFor = (sessionId: string): RecoverRoute | undefined => { // Exact match first; like the daemon, fall back to the single known // session when only one mapping exists (MCP and hook session ids differ) const route = routes.get(sessionId); if (route) return route; if (routes.size === 1) return routes.values().next().value; return undefined; }; for (const file of files) { let text: string; try { text = await readFile(join(logDir, file), "utf-8"); } catch { continue; } for (const line of text.split("\n")) { if (!line.trim()) continue; let entry: { endpoint?: string; payload?: Record }; try { entry = JSON.parse(line); } catch { continue; } const payload = entry.payload; if (!payload || typeof payload !== "object") continue; if (entry.endpoint === "/connect") { // Routing is only learnable for explicit session names; generated // names are assigned by the daemon after logging const c = payload as { ccSessionId?: string; project?: string; session?: string; user?: string; agent?: string }; if (c.ccSessionId && c.project && c.session && c.user) { routes.set(c.ccSessionId, { project: c.project, session: c.session, user: c.user, agent: c.agent || "agent:claude" }); } continue; } if (entry.endpoint === "/events") { const hookEvent = payload.hook_event_name; const sessionId = payload.session_id; if (typeof hookEvent !== "string" || typeof sessionId !== "string") continue; const key = `events:${JSON.stringify(payload)}`; if (seen.has(key)) continue; seen.add(key); scanned++; const route = routeFor(sessionId); if (!route) { unroutable++; continue; } const sender = hookEvent === "UserPromptSubmit" ? route.user : route.agent; candidates.push({ project: route.project, session: route.session, sender, payload }); continue; } if (entry.endpoint === "/reply") { const r = payload as { ccSessionId?: string; message?: string }; if (!r.ccSessionId || !r.message) continue; const key = `reply:${JSON.stringify(payload)}`; if (seen.has(key)) continue; seen.add(key); scanned++; const route = routeFor(r.ccSessionId); if (!route) { unroutable++; continue; } // Reconstruct the same Stop payload the daemon relays for replies candidates.push({ project: route.project, session: route.session, sender: route.agent, payload: { hook_event_name: "Stop", session_id: r.ccSessionId, stop_response: r.message }, }); } } } // Pass 2: per session, fetch upstream messages and re-POST what's missing const groups = new Map(); for (const c of candidates) { const key = `${c.project}${c.session}`; let group = groups.get(key); if (!group) { group = []; groups.set(key, group); } group.push(c); } let missing = 0; let restored = 0; for (const group of groups.values()) { const { project, session } = group[0]; let upstream: Array<{ id?: string; payload?: Record }> = []; try { const res = await fetch(`${profile.api}/projects/${project}/sessions/${session}/messages`, { headers }); if (res.ok) upstream = (await res.json()) as typeof upstream; } catch { /* unreachable — treat as empty */ } for (const c of group) { if (isPresentUpstream(c.payload, upstream)) continue; missing++; try { const res = await fetch(`${profile.api}/projects/${project}/sessions/${session}/events`, { method: "POST", headers, body: JSON.stringify({ sender: c.sender, payload: c.payload }), }); if (res.ok) restored++; } catch { /* leave as missing */ } } } console.log(`Recover: ${scanned} logged events scanned, ${missing} missing upstream, ${restored} restored.`); if (unroutable > 0) { console.log(`Skipped ${unroutable} event(s) with no recoverable project/session mapping.`); } if (missing > restored) { console.log(`${missing - restored} event(s) could not be restored (API unreachable or rejected).`); } } catch (err) { // Best-effort: report and exit cleanly, never throw console.error(`Recover failed: ${err instanceof Error ? err.message : String(err)}`); } } // --- Logout --- async function logout(all = false) { if (all) { await removeDaemonService(); try { await rm(POLARIS_DIR, { recursive: true }); console.log("All profiles and credentials removed."); } catch { /* already gone */ } } else { const config = await loadConfig(); if (!config.active || !config.profiles[config.active]) { console.log("No active profile to remove."); return; } const name = config.active; delete config.profiles[name]; // Set active to first remaining profile, or empty const remaining = Object.keys(config.profiles); config.active = remaining.length > 0 ? remaining[0] : ""; await saveConfig(config); console.log(`Profile "${name}" removed.`); if (config.active) { console.log(`Active profile switched to: ${config.active}`); } } console.log("MCP config and hooks are still installed — run `polaris install` to reset them."); } // --- Feedback --- const GITHUB_ISSUES_URL = "https://github.com/lightup-data/polaris/issues/new"; function openUrl(url: string): void { Bun.spawn( process.platform === "darwin" ? ["open", url] : process.platform === "win32" ? ["cmd", "/c", "start", url] : ["xdg-open", url], { stdout: "ignore", stderr: "ignore" } ).unref?.(); } async function feedback(text?: string, label?: "bug" | "enhancement") { let version = ""; try { const pkgPath = join(import.meta.dir, "..", "..", "package.json"); ({ version } = JSON.parse(await readFile(pkgPath, "utf-8")) as { version: string }); } catch { /* non-fatal */ } const params: Record = {}; if (text) { params.title = text; const versionLine = version ? `**polaris version:** ${version}\n\n` : ""; params.body = `${versionLine}${text}`; } if (label) params.labels = label; const qs = new URLSearchParams(params).toString(); const url = qs ? `${GITHUB_ISSUES_URL}?${qs}` : GITHUB_ISSUES_URL; openUrl(url); if (text) { console.log(`Opening GitHub with your feedback: "${text}"`); } else { console.log("Opening GitHub issues page..."); } console.log(`\nIf the browser didn't open: ${url}`); } // --- Update check --- // Warn if a newer version is available on npm. Best-effort: never blocks startup. async function checkForUpdate(): Promise { try { const pkgPath = join(import.meta.dir, "..", "..", "package.json"); const { version: current } = JSON.parse(await readFile(pkgPath, "utf-8")) as { version: string }; const res = await fetch("https://registry.npmjs.org/@lightupai/polaris/latest", { signal: AbortSignal.timeout(3000) }); if (!res.ok) return; const { version: latest } = await res.json() as { version: string }; if (latest !== current) { console.error(`\n⚠ A newer version of Polaris is available: ${latest} (you have ${current})`); console.error(" Run: npm install -g @lightupai/polaris@latest\n"); } } catch { /* best-effort — network may be unavailable */ } } // --- Main --- const args = process.argv.slice(2); const command = args[0]; function getFlag(name: string): string | undefined { const idx = args.indexOf(`--${name}`); if (idx >= 0 && idx + 1 < args.length) return args[idx + 1]; return undefined; } function hasFlag(name: string): boolean { return args.includes(`--${name}`); } void checkForUpdate(); switch (command) { case "install": console.log("Polaris — installing local components\n"); await install(); await installDaemonService(await ensureDaemonSecret()); console.log(` ✓ Daemon registered with ${daemonServiceLabel()}`); console.log("\nInstall complete."); break; case "login": { const appUrl = hasFlag("local") ? LOCAL_APP_URL : (getFlag("url") ?? DEFAULT_APP_URL); const profileName = getFlag("profile"); console.log("Polaris — authenticating\n"); await login(appUrl, profileName); console.log("\n✓ Login complete!"); await installDaemonService(await ensureDaemonSecret()); console.log(` ✓ Daemon registered with ${daemonServiceLabel()}`); console.log("\nNext: restart Claude Code, then run `/polaris join #channel-name` in your AI agent."); break; } case "use": if (!args[1]) { console.error("Usage: polaris use "); process.exit(1); } await use(args[1]); break; case "profiles": await profiles(); break; case "daemon": await killExistingDaemon(); await daemon(); break; case "status": await status(); break; case "recover": await recover(); break; case "feedback": { const isBug = hasFlag("bug"); const isFeature = hasFlag("feature"); const label = isBug ? "bug" : isFeature ? "enhancement" : undefined; const text = args.slice(1).find(a => !a.startsWith("--")); await feedback(text, label); break; } case "logout": await logout(hasFlag("all")); break; case undefined: // Default: install + login console.log("Polaris — setting up your machine\n"); console.log("[1/2] Installing local components...\n"); await install(); console.log("\n Install complete. Run `polaris install` to repeat this step independently.\n"); console.log("[2/2] Authenticating...\n"); await login(DEFAULT_APP_URL); console.log("\n✓ Polaris is set up on this machine!"); await installDaemonService(await ensureDaemonSecret()); console.log(` ✓ Daemon registered with ${daemonServiceLabel()}`); console.log("\nNext: restart Claude Code, then run `/polaris join #channel-name` in your AI agent."); break; default: console.log("Polaris CLI\n"); console.log("Usage:"); console.log(" polaris — install + login (default setup)"); console.log(" polaris install — install local components (no auth)"); console.log(" polaris login — authenticate (production)"); console.log(" polaris login --local — authenticate (local dev)"); console.log(" polaris use — switch active account (triggers login if needed)"); console.log(" polaris profiles — list all accounts"); console.log(" polaris daemon — start the local daemon"); console.log(" polaris status — show connection status"); console.log(" polaris recover — re-POST locally logged events missing upstream"); console.log(" polaris feedback — report a bug or request a feature"); console.log(" polaris logout — remove active profile"); console.log(" polaris logout --all — remove all credentials"); }