Files
fusion/packages/cli/src/bin.ts
2026-04-26 17:56:18 -07:00

1350 lines
51 KiB
JavaScript

#!/usr/bin/env node
/**
* Bootstrap: pi-coding-agent reads package.json at module-init time (top-level
* `readFileSync` in its config module) and uses `piConfig.configDir` to decide
* where project-local resources live. Fusion wants those resources in `.fusion`
* rather than `.pi`, so we provide pi with a package.json config before any
* application imports can load pi.
*
* Node built-ins are safe to import statically — they have no side-effects
* that depend on package.json. All application imports MUST be dynamic
* (after the env is configured) so they resolve after PI_PACKAGE_DIR is set.
*/
import { existsSync, mkdtempSync, readFileSync, symlinkSync, writeFileSync } from "node:fs";
import { createRequire } from "node:module";
import { join, dirname } from "node:path";
import { tmpdir } from "node:os";
import { performance } from "node:perf_hooks";
// @ts-expect-error -- Bun-only global; undefined in Node
const isBunBinary = typeof Bun !== "undefined" && !!Bun.embeddedFiles;
function configurePiPackage(): void {
if (process.env.PI_PACKAGE_DIR) {
return;
}
const tmp = mkdtempSync(join(tmpdir(), "fn-pkg-"));
let packageJson: Record<string, unknown> = {
name: "pi",
version: "0.1.0",
type: "module",
};
try {
const require = createRequire(import.meta.url);
const piPackagePath = require.resolve("@mariozechner/pi-coding-agent/package.json");
const piPackageDir = dirname(piPackagePath);
packageJson = JSON.parse(readFileSync(piPackagePath, "utf-8")) as Record<string, unknown>;
for (const entry of ["dist", "docs", "examples", "README.md", "CHANGELOG.md"]) {
const source = join(piPackageDir, entry);
if (existsSync(source)) {
symlinkSync(source, join(tmp, entry));
}
}
} catch {
// A bundled binary may not expose pi's package.json. The config value is
// the only part required by Fusion's non-interactive agent sessions.
}
packageJson.piConfig = {
...((packageJson.piConfig as Record<string, unknown> | undefined) ?? {}),
configDir: ".fusion",
};
writeFileSync(join(tmp, "package.json"), JSON.stringify(packageJson, null, 2) + "\n");
process.env.PI_PACKAGE_DIR = tmp;
}
configurePiPackage();
// Drain Node's User Timing buffer. Ink (react-reconciler) in dev mode emits
// performance.mark()/measure() on every render; entries accumulate forever
// without an observer, retaining ~600MB after 20-30min of TUI rendering.
setInterval(() => {
performance.clearMeasures();
performance.clearMarks();
}, 30_000).unref();
/**
* Load `.env` (and `.env.local`) from the current working directory into
* process.env so that secrets like FUSION_DAEMON_TOKEN can live in a local
* gitignored file instead of being exported manually each session.
*
* Existing environment variables always win — this loader never clobbers
* values the user set explicitly in their shell. `.env.local` overrides
* `.env` when both are present.
*
* We deliberately hand-roll a minimal parser instead of pulling in dotenv:
* the CLI ships as a single bundled binary and we want to keep it lean.
*/
function loadEnvFile(path: string): void {
if (!existsSync(path)) return;
const contents = readFileSync(path, "utf-8");
for (const rawLine of contents.split(/\r?\n/)) {
const line = rawLine.trim();
if (!line || line.startsWith("#")) continue;
const eq = line.indexOf("=");
if (eq === -1) continue;
const key = line.slice(0, eq).trim();
if (!key || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) continue;
if (process.env[key] !== undefined) continue; // Shell wins.
let value = line.slice(eq + 1).trim();
if (
(value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))
) {
value = value.slice(1, -1);
}
process.env[key] = value;
}
}
function loadLocalEnv(): void {
const cwd = process.cwd();
loadEnvFile(join(cwd, ".env"));
loadEnvFile(join(cwd, ".env.local"));
}
loadLocalEnv();
// Command handlers are loaded lazily so --help can return immediately
// without importing the full command graph.
async function loadCommandHandlers() {
const { runDashboard } = await import("./commands/dashboard.js");
const { runServe } = await import("./commands/serve.js");
const { runDaemon } = await import("./commands/daemon.js");
const { runDesktop } = await import("./commands/desktop.js");
const { runTaskCreate, runTaskList, runTaskMove, runTaskMerge, runTaskUpdate, runTaskLog, runTaskLogs, runTaskShow, runTaskAttach, runTaskPause, runTaskUnpause, runTaskImportFromGitHub, runTaskDuplicate, runTaskArchive, runTaskUnarchive, runTaskRefine, runTaskPlan, runTaskDelete, runTaskRetry, runTaskComment, runTaskComments, runTaskSteer, runTaskPrCreate } = await import("./commands/task.js");
const { runSettingsShow, runSettingsSet } = await import("./commands/settings.js");
const { runSettingsExport } = await import("./commands/settings-export.js");
const { runSettingsImport } = await import("./commands/settings-import.js");
const { runGitStatus, runGitFetch, runGitPull, runGitPush } = await import("./commands/git.js");
const { runBackupCreate, runBackupList, runBackupRestore, runBackupCleanup } = await import("./commands/backup.js");
const { runMissionCreate, runMissionList, runMissionShow, runMissionDelete, runMissionActivateSlice } = await import("./commands/mission.js");
const { runProjectList, runProjectAdd, runProjectRemove, runProjectShow, runProjectInfo, runProjectSetDefault, runProjectDetect } = await import("./commands/project.js");
const { runNodeList, runNodeConnect, runNodeDisconnect, runNodeShow, runNodeHealth, runMeshStatus } = await import("./commands/node.js");
const { runInit } = await import("./commands/init.js");
const { runAgentStop, runAgentStart } = await import("./commands/agent.js");
const { runAgentImport } = await import("./commands/agent-import.js");
const { runAgentExport } = await import("./commands/agent-export.js");
const { runMessageInbox, runMessageOutbox, runMessageSend, runMessageRead, runMessageDelete, runAgentMailbox } = await import("./commands/message.js");
const { runPluginList, runPluginInstall, runPluginUninstall, runPluginEnable, runPluginDisable } = await import("./commands/plugin.js");
const { runPluginCreate } = await import("./commands/plugin-scaffold.js");
const { runSkillsSearch, runSkillsInstall } = await import("./commands/skills.js");
return {
runDashboard,
runServe,
runDaemon,
runDesktop,
runTaskCreate,
runTaskList,
runTaskMove,
runTaskMerge,
runTaskUpdate,
runTaskLog,
runTaskLogs,
runTaskShow,
runTaskAttach,
runTaskPause,
runTaskUnpause,
runTaskImportFromGitHub,
runTaskDuplicate,
runTaskArchive,
runTaskUnarchive,
runTaskRefine,
runTaskPlan,
runTaskDelete,
runTaskRetry,
runTaskComment,
runTaskComments,
runTaskSteer,
runTaskPrCreate,
runSettingsShow,
runSettingsSet,
runSettingsExport,
runSettingsImport,
runGitStatus,
runGitFetch,
runGitPull,
runGitPush,
runBackupCreate,
runBackupList,
runBackupRestore,
runBackupCleanup,
runMissionCreate,
runMissionList,
runMissionShow,
runMissionDelete,
runMissionActivateSlice,
runProjectList,
runProjectAdd,
runProjectRemove,
runProjectShow,
runProjectInfo,
runProjectSetDefault,
runProjectDetect,
runNodeList,
runNodeConnect,
runNodeDisconnect,
runNodeShow,
runNodeHealth,
runMeshStatus,
runInit,
runAgentStop,
runAgentStart,
runAgentImport,
runAgentExport,
runMessageInbox,
runMessageOutbox,
runMessageSend,
runMessageRead,
runMessageDelete,
runAgentMailbox,
runPluginList,
runPluginInstall,
runPluginUninstall,
runPluginEnable,
runPluginDisable,
runPluginCreate,
runSkillsSearch,
runSkillsInstall,
};
}
const HELP = `
fn — AI-orchestrated task board
Usage:
fn Launch the dashboard (same as fn dashboard)
fn init [opts] Initialize a new fn project (--name, --path, --git)
fn dashboard Start the board web UI
fn dashboard --paused Start with automation paused
fn dashboard --dev Start web UI only (no AI engine)
fn dashboard --interactive Start with interactive port selection
fn serve [--port <port>] [--host <host>] [--paused] [--daemon]
Start Fusion as a headless node (API + engine, no UI)
Use --daemon to enable bearer token authentication
fn daemon [--port <port>] [--host <host>] [--token <token>] [--paused] [--token-only]
Start Fusion daemon (API + engine, auth required)
fn desktop Launch the Fusion desktop app (Electron)
fn desktop --dev Launch with hot-reload (connects to Vite dev server)
fn desktop --paused Launch with automation paused
fn task create [desc] [opts] Create a new task (goes to triage)
fn task plan [description] [opts] Create task via AI-guided planning
fn task list List all tasks
fn task show <id> Show task details, steps, log
fn task logs <id> [--follow] [--limit <n>] [--type <type>]
Show task agent execution logs
fn task move <id> <col> Move a task to a column
fn task update <id> <step> <status> Update step status (pending|in-progress|done|skipped)
fn task log <id> <message> Add a log entry
fn task merge <id> Merge an in-review task and close it
fn task duplicate <id> Duplicate a task (creates copy in triage)
fn task refine <id> [opts] Create a refinement task from done/in-review
fn task archive <id> Archive a done task
fn task unarchive <id> Unarchive an archived task
fn task delete <id> [--force] Delete a task (use --force to skip confirmation)
fn task attach <id> <file> Attach a file to a task
fn task pause <id> Pause a task (stops all automation)
fn task unpause <id> Unpause a task (resumes automation)
fn task comment <id> [message] Add task comment (prompts if message omitted)
fn task comments <id> List task comments
fn task steer <id> [message] Add steering comment (prompts if message omitted)
fn task retry <id> Retry a failed task (clears error, moves to todo)
fn task pr-create <id> [--title <title>] [--base <branch>] [--body <body>]
Create a GitHub PR for an in-review task
fn task import <owner/repo> [opts] Import GitHub issues as tasks
fn mission create [title] [desc] Create a new mission
fn mission list | ls List missions
fn mission show | info <id> Show mission details
fn mission delete <id> [--force] Delete a mission
fn mission activate-slice <id> Mark a slice active
fn project list | ls [--json] List all registered projects
fn project add [name] [path] [opts] Register a new project
fn project remove | rm <name> [--force]
Unregister a project
fn project show <name> Show project details with health
fn project info [name] Show project details (alias for show)
fn project set-default | default <name>
Set default project
fn project detect Detect project from current directory
fn node list | ls [--json] List all nodes with status and type
fn node connect <name> --url <url> [--api-key <key>] [--max-concurrent <n>]
Connect to a remote node
fn node disconnect <name> [--force] Remove a node connection
fn node show | info [name] [--json] Show node details
fn node health <name> Health check a node
fn mesh status [--json] Show full mesh state
fn settings Show current Fusion configuration
fn settings set <key> <value> Update a configuration setting
fn settings export [opts] Export settings to a JSON file
fn settings import <file> [opts] Import settings from a JSON file
fn git status Show current branch, commit, dirty state, ahead/behind
fn git push Push current branch
fn git pull Pull current branch
fn git fetch [remote] Fetch from remote (default: origin)
fn agent stop <id> Stop a running agent (pause execution)
fn agent start <id> Start a stopped agent (resume execution)
fn agent import <path> [--dry-run] [--skip-existing]
Import agents from an Agent Companies package (directory, archive, or AGENTS.md file)
fn agent export <dir> [--company-name <name>] [--company-slug <slug>]
Export Fusion agents to an Agent Companies package directory
(agent skills assigned via metadata.skills affect execution-time tools)
fn agent mailbox <id> View an agent's mailbox
fn message inbox List inbox messages
fn message outbox List sent messages
fn message send <agent-id> <msg> Send a message to an agent
fn message read <id> Read a specific message
fn message delete <id> Delete a message
fn backup --create Create a database backup immediately
fn backup --list List all database backups
fn backup --restore <file> Restore database from a backup file
fn backup --cleanup Remove old backups exceeding retention limit
fn plugin list | ls List installed plugins
fn plugin install <path-or-package> Install a plugin from path or package
fn plugin add <path-or-package> Alias for plugin install
fn plugin uninstall <id> [--force] Uninstall a plugin
fn plugin enable <id> Enable a plugin
fn plugin disable <id> Disable a plugin
fn plugin create <name> Scaffold a new plugin project
fn skills search <query> Search skills.sh for agent skills
fn skills search <query> --limit 5 Limit results
fn skills install <owner/repo> Install skills from a source
fn skills install <owner/repo> --skill <name>
Install a specific skill
Options:
--project, -P <name> Target a specific project (bypasses CWD detection)
--port, -p <port> Dashboard/serve port (default: 4040)
--host <host> Serve host (default: 127.0.0.1 — localhost only; pass 0.0.0.0 to expose)
--token <token> Dashboard/daemon bearer token. Default: $FUSION_DASHBOARD_TOKEN, $FUSION_DAEMON_TOKEN, or auto-generated.
--no-auth Disable dashboard bearer-token auth (local-only; not recommended on 0.0.0.0)
--interactive Interactive mode (port selection for dashboard, issue selection for import)
--paused Start with engine paused (automation disabled)
--dev Start dashboard only (no AI engine)
--attach <file> Attach file(s) on task create (repeatable)
--depends <id> Declare dependency on task create (repeatable)
--feedback <text> Refinement feedback (non-interactive mode)
--yes Skip confirmation prompts (planning mode)
--limit, -l <n> Max issues to import (default: 30, max: 100)
--labels, -L <labels> Comma-separated label filter for import
--interactive, -i Interactive mode for issue selection
--help, -h Show this help
Columns: triage, todo, in-progress, in-review, done, archived
Supported file types: png, jpg, gif, webp, txt, log, json, yaml, yml, toml, csv, xml
The AI engine uses pi (github.com/badlogic/pi-mono) for agent sessions.
Requires configured API keys — run "pi" first to set up authentication.
`.trim();
function extractGlobalProjectFlag(argv: string[]): { cleanedArgs: string[]; projectName?: string } {
const cleanedArgs: string[] = [];
let projectName: string | undefined;
for (let i = 0; i < argv.length; i++) {
const arg = argv[i];
if (arg === "--project" || arg === "-P") {
if (projectName) {
throw new Error("Duplicate --project flag. Specify a project only once.");
}
const value = argv[i + 1];
if (!value || value.startsWith("-")) {
throw new Error("Usage: --project <name>");
}
projectName = value;
i++;
continue;
}
cleanedArgs.push(arg);
}
return { cleanedArgs, projectName };
}
function getFlagValue(args: string[], flag: string): string | undefined {
const index = args.indexOf(flag);
if (index === -1 || index + 1 >= args.length) {
return undefined;
}
const value = args[index + 1];
if (!value || value.startsWith("-")) {
return undefined;
}
return value;
}
function getFlagValueNumber(args: string[], flag: string): number | undefined {
const value = getFlagValue(args, flag);
if (value === undefined) {
return undefined;
}
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : undefined;
}
async function main() {
const { cleanedArgs: args, projectName } = extractGlobalProjectFlag(process.argv.slice(2));
if (args.includes("--help") || args.includes("-h")) {
console.log(HELP);
process.exit(0);
}
if (args.length === 0) {
// No subcommand — launch dashboard on the default port.
const { runDashboard } = await import("./commands/dashboard.js");
await runDashboard(4040);
return;
}
const command = args[0];
const {
runDashboard,
runServe,
runDaemon,
runDesktop,
runTaskCreate,
runTaskList,
runTaskMove,
runTaskMerge,
runTaskUpdate,
runTaskLog,
runTaskLogs,
runTaskShow,
runTaskAttach,
runTaskPause,
runTaskUnpause,
runTaskImportFromGitHub,
runTaskDuplicate,
runTaskArchive,
runTaskUnarchive,
runTaskRefine,
runTaskPlan,
runTaskDelete,
runTaskRetry,
runTaskComment,
runTaskComments,
runTaskSteer,
runTaskPrCreate,
runSettingsShow,
runSettingsSet,
runSettingsExport,
runSettingsImport,
runGitStatus,
runGitFetch,
runGitPull,
runGitPush,
runBackupCreate,
runBackupList,
runBackupRestore,
runBackupCleanup,
runMissionCreate,
runMissionList,
runMissionShow,
runMissionDelete,
runMissionActivateSlice,
runProjectList,
runProjectAdd,
runProjectRemove,
runProjectShow,
runProjectInfo,
runProjectSetDefault,
runProjectDetect,
runNodeList,
runNodeConnect,
runNodeDisconnect,
runNodeShow,
runNodeHealth,
runMeshStatus,
runInit,
runAgentStop,
runAgentStart,
runAgentImport,
runAgentExport,
runMessageInbox,
runMessageOutbox,
runMessageSend,
runMessageRead,
runMessageDelete,
runAgentMailbox,
runPluginList,
runPluginInstall,
runPluginUninstall,
runPluginEnable,
runPluginDisable,
runPluginCreate,
runSkillsSearch,
runSkillsInstall,
} = await loadCommandHandlers();
try {
switch (command) {
case "init": {
// Parse init options
const nameIdx = args.indexOf("--name");
const name = nameIdx !== -1 && nameIdx + 1 < args.length ? args[nameIdx + 1] : undefined;
const pathIdx = args.indexOf("--path");
const path = pathIdx !== -1 && pathIdx + 1 < args.length ? args[pathIdx + 1] : undefined;
const git = args.includes("--git");
await runInit({ name, path, git });
break;
}
case "dashboard": {
// Initialize native module resolution for Bun binary before starting dashboard
// This sets up the paths so node-pty can find its native assets
if (isBunBinary) {
const { initNativePatch } = await import("./runtime/native-patch.js");
initNativePatch();
}
const portIdx = args.indexOf("--port");
const portIdxShort = args.indexOf("-p");
const pi = portIdx !== -1 ? portIdx : portIdxShort;
const port = pi !== -1 ? parseInt(args[pi + 1], 10) : 4040;
const paused = args.includes("--paused");
const dev = args.includes("--dev");
const interactive = args.includes("--interactive");
const dashHostIdx = args.indexOf("--host");
const host = dashHostIdx !== -1 && dashHostIdx + 1 < args.length ? args[dashHostIdx + 1] : undefined;
const noAuth = args.includes("--no-auth");
const dashTokenIdx = args.indexOf("--token");
const token = dashTokenIdx !== -1 && dashTokenIdx + 1 < args.length ? args[dashTokenIdx + 1] : undefined;
await runDashboard(port, { paused, dev, interactive, host, noAuth, token });
break;
}
case "serve": {
const portIdx = args.indexOf("--port");
const portIdxShort = args.indexOf("-p");
const pi = portIdx !== -1 ? portIdx : portIdxShort;
const port = pi !== -1 ? parseInt(args[pi + 1], 10) : 4040;
const paused = args.includes("--paused");
const interactive = args.includes("--interactive");
const hostIdx = args.indexOf("--host");
const host = hostIdx !== -1 && hostIdx + 1 < args.length ? args[hostIdx + 1] : undefined;
const daemon = args.includes("--daemon");
await runServe(port, { paused, interactive, host, daemon });
break;
}
case "daemon": {
const portIdx = args.indexOf("--port");
const portIdxShort = args.indexOf("-p");
const pi = portIdx !== -1 ? portIdx : portIdxShort;
const port = pi !== -1 ? parseInt(args[pi + 1], 10) : 0;
const paused = args.includes("--paused");
const interactive = args.includes("--interactive");
const hostIdx = args.indexOf("--host");
const host = hostIdx !== -1 && hostIdx + 1 < args.length ? args[hostIdx + 1] : undefined;
const tokenIdx = args.indexOf("--token");
const token = tokenIdx !== -1 && tokenIdx + 1 < args.length ? args[tokenIdx + 1] : undefined;
const tokenOnly = args.includes("--token-only");
await runDaemon({ port, paused, interactive, host, token, tokenOnly });
break;
}
case "desktop": {
const paused = args.includes("--paused");
const dev = args.includes("--dev");
const interactive = args.includes("--interactive");
await runDesktop({ paused, dev, interactive });
break;
}
case "project": {
const subcommand = args[1];
switch (subcommand) {
case "list":
case "ls":
{
const json = args.includes("--json");
await runProjectList({ json });
}
break;
case "add": {
const name = args[2];
const path = args[3];
const isolationIdx = args.indexOf("--isolation");
const isolation = isolationIdx !== -1 && isolationIdx + 1 < args.length
? args[isolationIdx + 1] as "in-process" | "child-process"
: undefined;
const force = args.includes("--force");
const interactive = args.includes("--interactive");
await runProjectAdd(name, path, { isolation, force, interactive });
break;
}
case "info": {
const name = args[2];
await runProjectInfo(name);
break;
}
case "remove":
case "rm": {
const name = args[2];
const force = args.includes("--force");
await runProjectRemove(name, { force });
break;
}
case "show": {
const name = args[2];
await runProjectShow(name);
break;
}
case "set-default":
case "default": {
const name = args[2];
await runProjectSetDefault(name);
break;
}
case "detect":
await runProjectDetect();
break;
default:
console.error(`Unknown subcommand: project ${subcommand || ""}`);
console.log("Try: fn project list | add | remove | show | info | set-default | detect");
process.exit(1);
}
break;
}
case "node": {
const subcommand = args[1];
switch (subcommand) {
case "list":
case "ls": {
await runNodeList({ json: args.includes("--json") });
break;
}
case "connect": {
const name = args[2];
const url = getFlagValue(args, "--url");
if (!url) {
console.error("Usage: fn node connect <name> --url <url> [--api-key <key>] [--max-concurrent <n>]");
process.exit(1);
}
await runNodeConnect(name, {
url,
apiKey: getFlagValue(args, "--api-key"),
maxConcurrent: getFlagValueNumber(args, "--max-concurrent"),
});
break;
}
case "disconnect": {
const name = args[2];
await runNodeDisconnect(name, { force: args.includes("--force") });
break;
}
case "add": {
// Legacy alias for connect
const name = args[2];
const url = getFlagValue(args, "--url");
if (url) {
await runNodeConnect(name, {
url,
apiKey: getFlagValue(args, "--api-key"),
maxConcurrent: getFlagValueNumber(args, "--max-concurrent"),
});
} else {
console.error("Usage: fn node add <name> --url <url> [--api-key <key>] [--max-concurrent <n>]");
process.exit(1);
}
break;
}
case "remove":
case "rm": {
// Legacy alias for disconnect
const name = args[2];
await runNodeDisconnect(name, { force: args.includes("--force") });
break;
}
case "show":
case "info": {
await runNodeShow(args[2], { json: args.includes("--json") });
break;
}
case "health": {
await runNodeHealth(args[2]);
break;
}
default:
console.error(`Unknown subcommand: node ${subcommand || ""}`);
console.log("Try: fn node list | connect | disconnect | show | health");
process.exit(1);
}
break;
}
case "mesh": {
const subcommand = args[1];
switch (subcommand) {
case "status": {
await runMeshStatus({ json: args.includes("--json") });
break;
}
default:
console.error(`Unknown subcommand: mesh ${subcommand || ""}`);
console.log("Try: fn mesh status");
process.exit(1);
}
break;
}
case "task": {
const subcommand = args[1];
switch (subcommand) {
case "create": {
const createArgs = args.slice(2);
const attachFiles: string[] = [];
const dependsIds: string[] = [];
const descParts: string[] = [];
for (let i = 0; i < createArgs.length; i++) {
if (createArgs[i] === "--attach" && i + 1 < createArgs.length) {
attachFiles.push(createArgs[i + 1]);
i++; // skip the value
} else if (createArgs[i] === "--depends" && i + 1 < createArgs.length) {
dependsIds.push(createArgs[i + 1]);
i++; // skip the value
} else {
descParts.push(createArgs[i]);
}
}
const title = descParts.join(" ");
await runTaskCreate(title || undefined, attachFiles.length > 0 ? attachFiles : undefined, dependsIds.length > 0 ? dependsIds : undefined, projectName);
break;
}
case "plan": {
const planArgs = args.slice(2);
const yesFlag = planArgs.includes("--yes");
const descParts: string[] = [];
for (let i = 0; i < planArgs.length; i++) {
if (planArgs[i] === "--yes") {
continue; // skip flag
} else {
descParts.push(planArgs[i]);
}
}
const initialPlan = descParts.join(" ");
await runTaskPlan(initialPlan || undefined, yesFlag, projectName);
break;
}
case "list":
case "ls":
await runTaskList(projectName);
break;
case "move": {
const id = args[2];
const column = args[3];
if (!id || !column) {
console.error("Usage: fn task move <id> <column>");
process.exit(1);
}
await runTaskMove(id, column, projectName);
break;
}
case "show": {
const id = args[2];
if (!id) { console.error("Usage: fn task show <id>"); process.exit(1); }
await runTaskShow(id, projectName);
break;
}
case "update": {
const id = args[2], step = args[3], status = args[4];
if (!id || !step || !status) {
console.error("Usage: fn task update <id> <step> <status>");
console.error("Status: pending | in-progress | done | skipped");
process.exit(1);
}
await runTaskUpdate(id, step, status, projectName);
break;
}
case "log": {
const id = args[2], message = args.slice(3).join(" ");
if (!id || !message) { console.error("Usage: fn task log <id> <message>"); process.exit(1); }
await runTaskLog(id, message, undefined, projectName);
break;
}
case "logs": {
const id = args[2];
if (!id) { console.error("Usage: fn task logs <id> [--follow] [--limit <n>] [--type <type>]"); process.exit(1); }
// Parse flags
const follow = args.includes("--follow");
let limit: number | undefined;
const limitIdx = args.indexOf("--limit");
if (limitIdx !== -1 && limitIdx + 1 < args.length) {
const parsed = parseInt(args[limitIdx + 1], 10);
if (!isNaN(parsed)) {
limit = parsed;
}
}
let type: string | undefined;
const typeIdx = args.indexOf("--type");
if (typeIdx !== -1 && typeIdx + 1 < args.length) {
type = args[typeIdx + 1];
}
await runTaskLogs(id, { follow, limit, type: type as "text" | "thinking" | "tool" | "tool_result" | "tool_error" | undefined }, projectName);
break;
}
case "merge": {
const id = args[2];
if (!id) { console.error("Usage: fn task merge <id>"); process.exit(1); }
await runTaskMerge(id, projectName);
break;
}
case "duplicate": {
const id = args[2];
if (!id) { console.error("Usage: fn task duplicate <id>"); process.exit(1); }
await runTaskDuplicate(id, projectName);
break;
}
case "refine": {
const id = args[2];
if (!id) { console.error("Usage: fn task refine <id> [--feedback <text>]"); process.exit(1); }
// Parse optional --feedback flag
const feedbackIdx = args.indexOf("--feedback");
const feedback = feedbackIdx !== -1 && feedbackIdx + 1 < args.length
? args[feedbackIdx + 1]
: undefined;
await runTaskRefine(id, feedback, projectName);
break;
}
case "archive": {
const id = args[2];
if (!id) { console.error("Usage: fn task archive <id>"); process.exit(1); }
await runTaskArchive(id, projectName);
break;
}
case "unarchive": {
const id = args[2];
if (!id) { console.error("Usage: fn task unarchive <id>"); process.exit(1); }
await runTaskUnarchive(id, projectName);
break;
}
case "delete": {
const id = args[2];
if (!id) { console.error("Usage: fn task delete <id> [--force]"); process.exit(1); }
const force = args.includes("--force");
await runTaskDelete(id, force, projectName);
break;
}
case "attach": {
const id = args[2], file = args[3];
if (!id || !file) {
console.error("Usage: fn task attach <id> <file>");
process.exit(1);
}
await runTaskAttach(id, file, projectName);
break;
}
case "pause": {
const id = args[2];
if (!id) { console.error("Usage: fn task pause <id>"); process.exit(1); }
await runTaskPause(id, projectName);
break;
}
case "unpause": {
const id = args[2];
if (!id) { console.error("Usage: fn task unpause <id>"); process.exit(1); }
await runTaskUnpause(id, projectName);
break;
}
case "comment": {
const id = args[2];
if (!id) { console.error("Usage: fn task comment <id> [message] [--author <name>]"); process.exit(1); }
const authorIdx = args.indexOf("--author");
const author = authorIdx !== -1 && authorIdx + 1 < args.length ? args[authorIdx + 1] : undefined;
const messageParts = args.slice(3).filter((arg, index) => {
const absoluteIndex = index + 3;
return absoluteIndex !== authorIdx && absoluteIndex !== authorIdx + 1;
});
const message = messageParts.join(" ");
await runTaskComment(id, message || undefined, author || process.env.USER || "user", projectName);
break;
}
case "comments": {
const id = args[2];
if (!id) { console.error("Usage: fn task comments <id>"); process.exit(1); }
await runTaskComments(id, projectName);
break;
}
case "steer": {
const id = args[2];
const message = args.slice(3).join(" ");
if (!id) { console.error("Usage: fn task steer <id> [message]"); process.exit(1); }
await runTaskSteer(id, message || undefined, projectName);
break;
}
case "retry": {
const id = args[2];
if (!id) {
console.error("Usage: fn task retry <id>");
process.exit(1);
}
await runTaskRetry(id, projectName);
break;
}
case "pr-create": {
const id = args[2];
if (!id) {
console.error("Usage: fn task pr-create <id> [--title <title>] [--base <branch>] [--body <body>]");
process.exit(1);
}
// Parse optional flags
let title: string | undefined;
let base: string | undefined;
let body: string | undefined;
const titleIdx = args.indexOf("--title");
if (titleIdx !== -1 && titleIdx + 1 < args.length) {
title = args[titleIdx + 1];
}
const baseIdx = args.indexOf("--base");
if (baseIdx !== -1 && baseIdx + 1 < args.length) {
base = args[baseIdx + 1];
}
const bodyIdx = args.indexOf("--body");
if (bodyIdx !== -1 && bodyIdx + 1 < args.length) {
body = args[bodyIdx + 1];
}
await runTaskPrCreate(id, { title, base, body }, projectName);
break;
}
case "import": {
const ownerRepo = args[2];
if (!ownerRepo) {
console.error("Usage: fn task import <owner/repo> [options]");
console.error("Options: --limit <n>, -l <n> (default: 30, max: 100)");
console.error(" --labels <labels>, -L <labels> (comma-separated)");
console.error(" --interactive, -i (interactive mode)");
process.exit(1);
}
// Parse options
let limit = 30;
const limitIdx = args.indexOf("--limit");
const limitIdxShort = args.indexOf("-l");
const li = limitIdx !== -1 ? limitIdx : limitIdxShort;
if (li !== -1 && li + 1 < args.length) {
const parsed = parseInt(args[li + 1], 10);
if (!isNaN(parsed)) {
limit = Math.min(Math.max(parsed, 1), 100);
}
}
let labels: string[] | undefined;
const labelsIdx = args.indexOf("--labels");
const labelsIdxShort = args.indexOf("-L");
const labi = labelsIdx !== -1 ? labelsIdx : labelsIdxShort;
if (labi !== -1 && labi + 1 < args.length) {
labels = args[labi + 1].split(",").map(l => l.trim()).filter(Boolean);
}
// Check for interactive mode
const interactive = args.includes("--interactive") || args.includes("-i");
if (interactive) {
const { runTaskImportGitHubInteractive } = await import("./commands/task.js");
await runTaskImportGitHubInteractive(ownerRepo, { limit, labels }, projectName);
} else {
await runTaskImportFromGitHub(ownerRepo, { limit, labels }, projectName);
}
break;
}
default:
console.error(`Unknown subcommand: task ${subcommand || ""}`);
console.log("Try: fn task create | list | move");
process.exit(1);
}
break;
}
case "mission": {
const subcommand = args[1];
switch (subcommand) {
case "create": {
const title = args[2];
const description = args.length > 3 ? args.slice(3).join(" ") : undefined;
await runMissionCreate(title, description, projectName);
break;
}
case "list":
case "ls":
await runMissionList(projectName);
break;
case "show":
case "info": {
const id = args[2];
await runMissionShow(id, projectName);
break;
}
case "delete": {
const id = args[2];
const force = args.includes("--force");
await runMissionDelete(id, force, projectName);
break;
}
case "activate-slice": {
const id = args[2];
await runMissionActivateSlice(id, projectName);
break;
}
default:
console.error(`Unknown subcommand: mission ${subcommand || ""}`);
console.log("Try: fn mission create | list | show | delete | activate-slice");
process.exit(1);
}
break;
}
case "settings": {
const subcommand = args[1];
if (!subcommand || subcommand === "show") {
await runSettingsShow(projectName);
break;
}
if (subcommand === "set") {
const key = args[2];
const value = args.slice(3).join(" ");
if (!key || value === undefined) {
console.error("Usage: fn settings set <key> <value>");
console.error("Example: fn settings set maxConcurrent 4");
process.exit(1);
}
await runSettingsSet(key, value, projectName);
break;
}
if (subcommand === "export") {
// Parse export options
const scopeIdx = args.indexOf("--scope");
const scope = scopeIdx !== -1 && scopeIdx + 1 < args.length
? args[scopeIdx + 1] as "global" | "project" | "both"
: "both";
const outputIdx = args.indexOf("--output");
const output = outputIdx !== -1 && outputIdx + 1 < args.length
? args[outputIdx + 1]
: undefined;
await runSettingsExport({ scope, output, projectName });
break;
}
if (subcommand === "import") {
const file = args[2];
if (!file) {
console.error("Usage: fn settings import <file> [--scope global|project|both] [--merge] [--yes]");
console.error("Example: fn settings import fusion-settings-2026-03-31.json --yes");
process.exit(1);
}
// Parse import options
const scopeIdx = args.indexOf("--scope");
const scope = scopeIdx !== -1 && scopeIdx + 1 < args.length
? args[scopeIdx + 1] as "global" | "project" | "both"
: "both";
const merge = args.includes("--merge");
const yes = args.includes("--yes");
await runSettingsImport(file, { scope, merge, yes, projectName });
break;
}
console.error(`Unknown settings subcommand: ${subcommand}`);
console.error("Try: fn settings | fn settings set <key> <value> | fn settings export | fn settings import <file>");
process.exit(1);
break;
}
case "git": {
const subcommand = args[1];
switch (subcommand) {
case "status":
await runGitStatus(projectName);
break;
// fallthrough - git commands need consistent project resolution
case "fetch": {
const remote = args[2];
await runGitFetch(remote, projectName);
break;
}
case "pull": {
const skipConfirm = args.includes("--yes");
await runGitPull({ skipConfirm, projectName });
break;
}
case "push": {
const skipConfirm = args.includes("--yes");
await runGitPush({ skipConfirm, projectName });
break;
}
default:
console.error(`Unknown subcommand: git ${subcommand || ""}`);
console.log("Try: fn git status | fetch | pull | push");
process.exit(1);
}
break;
}
case "backup": {
const create = args.includes("--create");
const list = args.includes("--list");
const cleanup = args.includes("--cleanup");
const restoreIdx = args.indexOf("--restore");
const restoreFile = restoreIdx !== -1 && restoreIdx + 1 < args.length ? args[restoreIdx + 1] : undefined;
if (create) {
await runBackupCreate(projectName);
} else if (list) {
await runBackupList(projectName);
} else if (cleanup) {
await runBackupCleanup(projectName);
} else if (restoreFile) {
await runBackupRestore(restoreFile, projectName);
} else {
console.error("Usage: fn backup --create | --list | --cleanup | --restore <filename>");
process.exit(1);
}
break;
}
case "agent": {
const subcommand = args[1];
switch (subcommand) {
case "stop": {
const id = args[2];
if (!id) { console.error("Usage: fn agent stop <id>"); process.exit(1); }
await runAgentStop(id, projectName);
break;
}
case "start": {
const id = args[2];
if (!id) { console.error("Usage: fn agent start <id>"); process.exit(1); }
await runAgentStart(id, projectName);
break;
}
case "mailbox": {
const id = args[2];
if (!id) { console.error("Usage: fn agent mailbox <id>"); process.exit(1); }
await runAgentMailbox(id, projectName);
break;
}
case "import": {
const source = args[2];
if (!source) { console.error("Usage: fn agent import <path> [--dry-run] [--skip-existing]"); process.exit(1); }
const importArgs = args.slice(3);
const dryRun = importArgs.includes("--dry-run");
const skipExisting = importArgs.includes("--skip-existing");
await runAgentImport(source, { dryRun, skipExisting, project: projectName });
break;
}
case "export": {
const outputDir = args[2];
if (!outputDir) {
console.error("Usage: fn agent export <dir> [--company-name <name>] [--company-slug <slug>]");
process.exit(1);
}
const exportArgs = args.slice(3);
const companyName = getFlagValue(exportArgs, "--company-name");
const companySlug = getFlagValue(exportArgs, "--company-slug");
await runAgentExport(outputDir, { project: projectName, companyName, companySlug });
break;
}
default:
console.error(`Unknown subcommand: agent ${subcommand || ""}`);
console.log("Try: fn agent stop <id> | fn agent start <id> | fn agent mailbox <id> | fn agent import <path> | fn agent export <dir>");
process.exit(1);
}
break;
}
case "message": {
const subcommand = args[1];
switch (subcommand) {
case "inbox": {
await runMessageInbox(projectName);
break;
}
case "outbox": {
await runMessageOutbox(projectName);
break;
}
case "send": {
const toId = args[2];
const content = args.slice(3).join(" ").trim();
if (!toId || !content) {
console.error("Usage: fn message send <agent-id> <content>");
process.exit(1);
}
await runMessageSend(toId, content, projectName);
break;
}
case "read": {
const id = args[2];
if (!id) { console.error("Usage: fn message read <id>"); process.exit(1); }
await runMessageRead(id, projectName);
break;
}
case "delete": {
const id = args[2];
if (!id) { console.error("Usage: fn message delete <id>"); process.exit(1); }
await runMessageDelete(id, projectName);
break;
}
default:
console.error(`Unknown subcommand: message ${subcommand || ""}`);
console.log("Try: fn message inbox | fn message outbox | fn message send | fn message read | fn message delete");
process.exit(1);
}
break;
}
case "plugin": {
const sub = args[1];
switch (sub) {
case "list":
case "ls":
await runPluginList(projectName);
break;
case "install":
case "add": {
const source = args[2];
if (!source) {
console.error("Usage: fn plugin install <path-or-package> (alias: fn plugin add <path-or-package>)");
process.exit(1);
}
await runPluginInstall(source, { projectName });
break;
}
case "uninstall": {
const id = args[2];
if (!id) { console.error("Usage: fn plugin uninstall <id> [--force]"); process.exit(1); }
const force = args.includes("--force");
await runPluginUninstall(id, { force, projectName });
break;
}
case "enable": {
const id = args[2];
if (!id) { console.error("Usage: fn plugin enable <id>"); process.exit(1); }
await runPluginEnable(id, { projectName });
break;
}
case "disable": {
const id = args[2];
if (!id) { console.error("Usage: fn plugin disable <id>"); process.exit(1); }
await runPluginDisable(id, { projectName });
break;
}
case "create": {
const pluginName = args[2];
if (!pluginName) { console.error("Usage: fn plugin create <name>"); process.exit(1); }
await runPluginCreate(pluginName);
break;
}
default:
console.error(`Unknown subcommand: plugin ${sub || ""}`);
console.log("Try: fn plugin list | install | add (alias for install) | uninstall | enable | disable | create");
process.exit(1);
}
break;
}
case "skills": {
const subcommand = args[1];
if (!subcommand || subcommand === "--help" || subcommand === "-h") {
console.log("fn skills — Browse and install skills from skills.sh\n");
console.log("Usage:");
console.log(" fn skills search <query> Search skills.sh for agent skills");
console.log(" fn skills search <query> --limit 5 Limit results (default: 10, max: 50)");
console.log(" fn skills install <owner/repo> Install skills from a source");
console.log(" fn skills install <owner/repo> --skill <name>");
console.log(" Install a specific skill");
console.log("\nExamples:");
console.log(" fn skills search react");
console.log(" fn skills search firebase --limit 5");
console.log(" fn skills install firebase/agent-skills");
console.log(" fn skills install firebase/agent-skills --skill firebase-basics");
break;
}
if (subcommand === "search") {
// Collect all remaining args as the query
const queryArgs = args.slice(2);
// Parse --limit option
let limit = 10;
const filteredArgs: string[] = [];
for (let i = 0; i < queryArgs.length; i++) {
if (queryArgs[i] === "--limit" && i + 1 < queryArgs.length) {
const parsed = parseInt(queryArgs[i + 1], 10);
if (!isNaN(parsed)) {
limit = Math.min(Math.max(parsed, 1), 50);
}
i++; // skip the value
} else {
filteredArgs.push(queryArgs[i]!);
}
}
await runSkillsSearch(filteredArgs, { limit });
break;
}
if (subcommand === "install") {
// Collect all remaining args as the source and options
const installArgs = args.slice(2);
// Parse --skill option
let skill: string | undefined;
const filteredArgs: string[] = [];
for (let i = 0; i < installArgs.length; i++) {
if (installArgs[i] === "--skill" && i + 1 < installArgs.length) {
skill = installArgs[i + 1];
i++; // skip the value
} else {
filteredArgs.push(installArgs[i]!);
}
}
await runSkillsInstall(filteredArgs, { skill });
break;
}
console.error(`Unknown subcommand: skills ${subcommand}`);
console.log("Try: fn skills search | install");
process.exit(1);
break;
}
default:
console.error(`Unknown command: ${command}`);
console.log(HELP);
process.exit(1);
}
} catch (err) {
console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
process.exit(1);
}
}
await main();