feat(FN-3046): add fn binary panel and standalone CLI entrypoint

This merge introduces a standalone CLI binary mode ("droid") with a new dashboard panel for binary management, adds a todo planning entrypoint with peer exchange shutdown, and includes documentation updates for the CLI reference and standalone deployment. Key additions span the core database layer,

Fusion-Task-Id: FN-3046
This commit is contained in:
Fusion
2026-05-01 13:54:53 -07:00
committed by gsxdsm
parent dbf75e5d31
commit 1634ea39ad
14 changed files with 589 additions and 1 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": minor
---
Ship Droid CLI provider integration in the published Fusion CLI bundle by vendoring `@fusion/droid-cli` runtime extension files, so users can enable **Factory AI — via Droid CLI** from dashboard authentication once the `droid` binary is installed and authenticated locally.

View File

@@ -326,6 +326,7 @@ For Capacitor + PWA workflow, see [MOBILE.md](./MOBILE.md).
Fusion supports OAuth-based authentication for AI providers configured via **Settings → Authentication**. When the dashboard is accessed via a non-localhost host (remote node, LAN host/IP, or reverse proxy), provider login URLs are automatically rewritten to route OAuth callbacks through a bridge endpoint (`/api/auth/openai-codex/callback`), ensuring the redirect reaches the active browser session.
- **OpenAI Codex** — Authenticates via Settings OAuth flow with secure state validation
- **Factory AI — via Droid CLI** *(optional)* — requires local `droid` install + `droid auth login`, then enable the provider in **Settings → Authentication** and restart Fusion
- **Other providers** — Authenticate via API key entry in Settings
- **pi authentication** — Handled separately via the `pi` CLI (`/login`) or `ANTHROPIC_API_KEY` environment variable

View File

@@ -238,6 +238,15 @@ To revoke/reset access, choose the behavior you want:
- **Persistent reset:** clear `daemonToken` from `~/.fusion/settings.json` (or rotate it via `fn daemon --token-only`/token rotation workflow), then restart dashboard.
- **Client logout:** clear `fn.authToken` in browser localStorage so clients must re-authenticate with the current server token.
### Optional provider: Factory AI via Droid CLI
When the published CLI bundle includes the vendored `@fusion/droid-cli` extension, users can enable **Factory AI — via Droid CLI** in **Settings → Authentication**.
Requirements:
- `droid` binary installed and available on `PATH`
- successful local login (`droid auth login`)
- Fusion restart after toggling the provider on (to reload extensions)
---
## `fn serve`

View File

@@ -177,6 +177,19 @@ The pi extension exposes tools to create tasks, check progress, attach files, an
Fusion also works as a standalone CLI outside of pi. See [STANDALONE.md](./STANDALONE.md) for installation and usage without the pi extension.
## Optional provider: Factory AI via Droid CLI
`@runfusion/fusion` now ships a vendored `@fusion/droid-cli` extension in the published CLI bundle.
To use it:
1. Install the `droid` binary and ensure it is on your `PATH`
2. Authenticate with Droid CLI (`droid auth login`)
3. In Fusion dashboard, go to **Settings → Authentication** and enable **Factory AI — via Droid CLI**
4. Restart Fusion when prompted so the extension is loaded into the runtime
Once enabled, `droid-cli` models appear in Fusion model selection.
## Full documentation
Architecture details, development setup, and contributor info live in the [project README](https://github.com/Runfusion/Fusion#readme).

View File

@@ -16,6 +16,20 @@ If you don't have pi set up yet: `npm i -g @mariozechner/pi-coding-agent && pi`
## Usage
### Optional provider: Factory AI via Droid CLI
The published `@runfusion/fusion` package includes a vendored `@fusion/droid-cli` provider extension.
To enable it:
1. Install the `droid` CLI binary and confirm it is available on `PATH`
2. Authenticate with `droid auth login`
3. Open Fusion dashboard → **Settings → Authentication** and enable **Factory AI — via Droid CLI**
4. Restart Fusion when prompted to apply provider extension loading
After restart, `droid-cli` models are available in model pickers.
### Start the dashboard
Launch the web UI and AI engine:

View File

@@ -34,6 +34,7 @@
"dist/**/*.js.map",
"dist/client/**",
"dist/pi-claude-cli/**",
"dist/droid-cli/**",
"skill/**",
"README.md"
],

View File

@@ -11,6 +11,7 @@ import {
readClientIndexHtml,
} from "./bundle-output-helpers";
import { resolveClaudeCliExtensionFromModuleUrl } from "../commands/claude-cli-extension";
import { resolveDroidCliExtensionFromModuleUrl } from "../commands/droid-cli-extension";
const tsupConfigPath = join(cliRoot, "tsup.config.ts");
@@ -119,6 +120,24 @@ describe("CLI bundle output", () => {
expect(existsSync(join(stagedRoot, "src", "process-manager.ts"))).toBe(true);
});
it("resolveDroidCliExtension succeeds against the staged dist/ layout", () => {
const result = resolveDroidCliExtensionFromModuleUrl(pathToFileURL(bundlePath).href);
expect(result.status).toBe("ok");
if (result.status === "ok") {
expect(result.path).toBe(join(cliRoot, "dist", "droid-cli", "index.ts"));
expect(result.packageVersion).toMatch(/\d+\.\d+\.\d+/);
}
});
it("dist/droid-cli/ is staged with correct files", () => {
const stagedRoot = join(cliRoot, "dist", "droid-cli");
expect(existsSync(join(stagedRoot, "package.json"))).toBe(true);
expect(existsSync(join(stagedRoot, "index.ts"))).toBe(true);
expect(existsSync(join(stagedRoot, "src", "process-manager.ts"))).toBe(true);
});
it("pi-claude-cli source imports child process helpers from node:child_process", () => {
const processManagerSource = readFileSync(join(cliRoot, "dist", "pi-claude-cli", "src", "process-manager.ts"), "utf-8");

View File

@@ -0,0 +1,37 @@
import { describe, it, expect } from "vitest";
import {
detectFnBinary,
FN_INSTALL_CURL,
FN_INSTALL_NPM,
FN_NPM_PACKAGE,
FN_NPX_INVOCATION,
} from "../fn-binary.js";
describe("fn-binary constants", () => {
it("uses runfusion.ai as the canonical npm package", () => {
expect(FN_NPM_PACKAGE).toBe("runfusion.ai");
expect(FN_INSTALL_NPM).toBe("npm install -g runfusion.ai");
expect(FN_NPX_INVOCATION).toBe("npx -y runfusion.ai");
});
it("exposes the curl one-line installer", () => {
expect(FN_INSTALL_CURL).toBe("curl -fsSL https://runfusion.ai/install.sh | sh");
});
});
describe("detectFnBinary", () => {
it("never throws and always returns a usable invocation", async () => {
// We don't assert installed/missing — the host running CI may or may not
// have `fn` on PATH. The contract is: result is well-formed and
// `invocation` is a string the caller can prepend to a command.
const result = await detectFnBinary();
expect(typeof result.installed).toBe("boolean");
expect(typeof result.invocation).toBe("string");
expect(result.invocation.length).toBeGreaterThan(0);
if (!result.installed) {
expect(result.invocation).toBe(FN_NPX_INVOCATION);
} else {
expect(["fn", "fusion"]).toContain(result.binary);
}
});
});

View File

@@ -12,6 +12,7 @@ import { BackendConnectionErrorPage } from "./components/BackendConnectionErrorP
import { DashboardLoader, type DashboardLoaderStage } from "./components/DashboardLoader";
import { ExecutorStatusBar } from "./components/ExecutorStatusBar";
import { SessionNotificationBanner } from "./components/SessionNotificationBanner";
import { CliBinaryInstallBanner } from "./components/CliBinaryInstallBanner";
import { SetupWarningBanner } from "./components/SetupWarningBanner";
import { UpdateAvailableBanner } from "./components/UpdateAvailableBanner";
import { OnboardingResumeCard } from "./components/OnboardingResumeCard";
@@ -947,6 +948,11 @@ function AppInner() {
onDismissAll={handleDismissAllNeedingInputSessions}
/>
)}
{viewMode === "project" && currentProject && (
<CliBinaryInstallBanner
onOpenSettings={() => modalManager.openSettings("general" as SectionId)}
/>
)}
{viewMode === "project" && currentProject && showOnboardingResumeCard && (
<OnboardingResumeCard onResume={modalManager.openModelOnboarding} />
)}

View File

@@ -0,0 +1,113 @@
.cli-binary-banner {
display: flex;
align-items: flex-start;
gap: 12px;
margin: 12px 16px 0;
padding: 12px 16px;
background: linear-gradient(
90deg,
rgba(59, 130, 246, 0.12),
rgba(59, 130, 246, 0.04)
);
border: 1px solid rgba(59, 130, 246, 0.35);
border-radius: 8px;
}
.cli-binary-banner__body {
flex: 1;
display: flex;
flex-direction: column;
gap: 6px;
min-width: 0;
}
.cli-binary-banner__title {
font-weight: 600;
font-size: 14px;
color: var(--text-primary, #e6edf3);
}
.cli-binary-banner__text {
font-size: 13px;
line-height: 1.5;
color: var(--text-secondary, #98a3b1);
}
.cli-binary-banner__text code {
background: rgba(255, 255, 255, 0.06);
padding: 1px 6px;
border-radius: 4px;
font-size: 0.92em;
}
.cli-binary-banner__actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 4px;
}
.cli-binary-banner__primary,
.cli-binary-banner__secondary {
cursor: pointer;
border-radius: 6px;
padding: 6px 12px;
font-size: 13px;
border: 1px solid transparent;
transition: background 0.15s ease;
}
.cli-binary-banner__primary {
background: var(--accent, #3b82f6);
color: #fff;
}
.cli-binary-banner__primary:hover:not(:disabled) {
background: var(--accent-hover, #2563eb);
}
.cli-binary-banner__primary:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.cli-binary-banner__secondary {
background: rgba(255, 255, 255, 0.04);
color: var(--text-primary, #e6edf3);
border-color: rgba(255, 255, 255, 0.12);
}
.cli-binary-banner__secondary:hover {
background: rgba(255, 255, 255, 0.08);
}
.cli-binary-banner__error {
margin-top: 4px;
padding: 6px 10px;
background: rgba(218, 54, 51, 0.1);
border-left: 3px solid rgba(218, 54, 51, 0.55);
border-radius: 0 4px 4px 0;
color: #f7c4c2;
font-size: 12.5px;
line-height: 1.5;
}
.cli-binary-banner__dismiss {
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
padding: 0;
background: transparent;
color: var(--text-secondary, #98a3b1);
border: none;
border-radius: 4px;
cursor: pointer;
flex-shrink: 0;
}
.cli-binary-banner__dismiss:hover {
background: rgba(255, 255, 255, 0.06);
color: var(--text-primary, #e6edf3);
}

View File

@@ -0,0 +1,142 @@
import { useCallback, useEffect, useState } from "react";
import { X } from "lucide-react";
import {
fetchFnBinaryStatus,
installFnBinary,
type FnBinaryStatus,
} from "../api/legacy";
import "./CliBinaryInstallBanner.css";
interface Props {
/** Open Settings → General so the user can manage manually. */
onOpenSettings: () => void;
}
/** localStorage key for permanent dismissal. */
const DISMISS_KEY = "fusion:cli-binary-banner-dismissed";
function isDismissed(): boolean {
if (typeof window === "undefined") return false;
try {
return window.localStorage.getItem(DISMISS_KEY) === "1";
} catch {
return false;
}
}
function persistDismissal(): void {
if (typeof window === "undefined") return;
try {
window.localStorage.setItem(DISMISS_KEY, "1");
} catch {
// Ignore quota / private-mode errors — dismissal lasts the session only.
}
}
/**
* One-time banner that nudges users to install the global `fn`/`fusion`
* CLI binary. Renders only when:
*
* - Status probe completes successfully
* - The binary is not on PATH
* - User has not previously dismissed the banner
*
* Dismissal is permanent (localStorage). The Settings → General → CLI
* Binary panel always lets the user reinstall later.
*/
export function CliBinaryInstallBanner({ onOpenSettings }: Props) {
const [status, setStatus] = useState<FnBinaryStatus | null>(null);
const [dismissed, setDismissed] = useState<boolean>(() => isDismissed());
const [installing, setInstalling] = useState(false);
const [installError, setInstallError] = useState<string | null>(null);
useEffect(() => {
if (dismissed) return;
let cancelled = false;
void fetchFnBinaryStatus()
.then((next) => {
if (!cancelled) setStatus(next);
})
.catch(() => {
// Treat probe failure as "don't show banner" — better silent than
// bothering the user with infrastructure errors on first load.
});
return () => {
cancelled = true;
};
}, [dismissed]);
const handleInstall = useCallback(async () => {
setInstalling(true);
setInstallError(null);
try {
const response = await installFnBinary();
setStatus({
binary: response.binary,
expectedVersion: response.expectedVersion,
state: response.state,
install: response.install,
});
if (!response.installResult.success) {
setInstallError(
response.installResult.permissionsHint ||
response.installResult.stderr ||
`Install failed (exit ${response.installResult.exitCode ?? "n/a"})`,
);
}
} catch (err) {
setInstallError(err instanceof Error ? err.message : String(err));
} finally {
setInstalling(false);
}
}, []);
const handleDismiss = useCallback(() => {
persistDismissal();
setDismissed(true);
}, []);
if (dismissed) return null;
if (!status) return null;
if (status.state === "installed") return null;
return (
<div className="cli-binary-banner" role="status">
<div className="cli-binary-banner__body">
<div className="cli-binary-banner__title">Install the Fusion CLI</div>
<div className="cli-binary-banner__text">
Get the <code>fn</code> and <code>fusion</code> commands on your terminal so you
can drive Fusion from anywhere. One click below or copy the command into your shell.
</div>
<div className="cli-binary-banner__actions">
<button
type="button"
className="cli-binary-banner__primary"
onClick={() => void handleInstall()}
disabled={installing}
>
{installing ? "Installing…" : "Install with npm"}
</button>
<button
type="button"
className="cli-binary-banner__secondary"
onClick={onOpenSettings}
>
Open Settings
</button>
</div>
{installError && (
<div className="cli-binary-banner__error">{installError}</div>
)}
</div>
<button
type="button"
className="cli-binary-banner__dismiss"
aria-label="Dismiss"
onClick={handleDismiss}
>
<X size={16} />
</button>
</div>
);
}

View File

@@ -0,0 +1,226 @@
.cli-binary-panel {
display: flex;
flex-direction: column;
gap: 12px;
padding: 16px;
border: 1px solid var(--border-color, rgba(255, 255, 255, 0.08));
border-radius: 8px;
background: var(--surface-2, rgba(255, 255, 255, 0.02));
}
.cli-binary-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.cli-binary-header .settings-section-heading {
margin: 0;
}
.cli-binary-pill {
display: inline-flex;
align-items: center;
padding: 2px 10px;
border-radius: 999px;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.02em;
text-transform: uppercase;
}
.cli-binary-pill--ok {
background: rgba(46, 160, 67, 0.18);
color: #57c97a;
border: 1px solid rgba(46, 160, 67, 0.4);
}
.cli-binary-pill--warn {
background: rgba(210, 153, 34, 0.18);
color: #e3b341;
border: 1px solid rgba(210, 153, 34, 0.4);
}
.cli-binary-pill--err {
background: rgba(218, 54, 51, 0.18);
color: #f48581;
border: 1px solid rgba(218, 54, 51, 0.4);
}
.cli-binary-help {
color: var(--text-secondary, #98a3b1);
line-height: 1.4;
}
.cli-binary-help code {
background: rgba(255, 255, 255, 0.06);
padding: 1px 6px;
border-radius: 4px;
font-size: 0.92em;
}
.cli-binary-status-line {
margin: 0;
color: var(--text-secondary, #98a3b1);
}
.cli-binary-status-line code {
background: rgba(255, 255, 255, 0.06);
padding: 1px 6px;
border-radius: 4px;
}
.cli-binary-detail {
display: flex;
flex-direction: column;
gap: 12px;
}
.cli-binary-info-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 4px;
font-size: 13px;
}
.cli-binary-info-list li {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.cli-binary-info-list span {
color: var(--text-secondary, #98a3b1);
min-width: 64px;
}
.cli-binary-info-list code {
background: rgba(255, 255, 255, 0.06);
padding: 1px 6px;
border-radius: 4px;
word-break: break-all;
}
.cli-binary-expected {
color: var(--text-secondary, #98a3b1);
font-size: 12px;
}
.cli-binary-actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.cli-binary-install-btn,
.cli-binary-refresh-btn,
.cli-binary-copy-btn {
cursor: pointer;
border-radius: 6px;
padding: 6px 12px;
font-size: 13px;
border: 1px solid transparent;
transition: background 0.15s ease, border-color 0.15s ease;
}
.cli-binary-install-btn {
background: var(--accent, #3b82f6);
color: #fff;
}
.cli-binary-install-btn:hover:not(:disabled) {
background: var(--accent-hover, #2563eb);
}
.cli-binary-install-btn:disabled,
.cli-binary-refresh-btn:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.cli-binary-refresh-btn,
.cli-binary-copy-btn {
background: rgba(255, 255, 255, 0.04);
color: var(--text-primary, #e6edf3);
border-color: rgba(255, 255, 255, 0.12);
}
.cli-binary-refresh-btn:hover:not(:disabled),
.cli-binary-copy-btn:hover {
background: rgba(255, 255, 255, 0.08);
}
.cli-binary-commands {
display: flex;
flex-direction: column;
gap: 6px;
}
.cli-binary-commands label {
font-size: 12px;
color: var(--text-secondary, #98a3b1);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.cli-binary-command-row {
display: flex;
align-items: center;
gap: 8px;
background: rgba(0, 0, 0, 0.25);
border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: 6px;
padding: 6px 8px 6px 12px;
}
.cli-binary-command-row code {
flex: 1;
font-family: var(--font-mono, ui-monospace, "SFMono-Regular", monospace);
font-size: 12.5px;
color: var(--text-primary, #e6edf3);
white-space: nowrap;
overflow-x: auto;
}
.cli-binary-install-log {
border-top: 1px solid rgba(255, 255, 255, 0.06);
padding-top: 10px;
font-size: 12.5px;
}
.cli-binary-install-log summary {
cursor: pointer;
color: var(--text-secondary, #98a3b1);
}
.cli-binary-install-output {
margin: 6px 0 0;
padding: 8px 10px;
background: rgba(0, 0, 0, 0.35);
border-radius: 4px;
max-height: 220px;
overflow: auto;
white-space: pre-wrap;
font-size: 12px;
line-height: 1.4;
}
.cli-binary-install-output--err {
color: #f48581;
}
.cli-binary-permissions-hint {
margin: 6px 0;
padding: 8px 10px;
background: rgba(218, 54, 51, 0.1);
border-left: 3px solid rgba(218, 54, 51, 0.55);
border-radius: 0 4px 4px 0;
color: #f7c4c2;
font-size: 12.5px;
line-height: 1.5;
}

View File

@@ -29,7 +29,7 @@ const STATE_LABELS: Record<FnBinaryStatus["state"], { text: string; tone: "ok" |
* `npm install -g runfusion.ai` server-side), and two copy-to-clipboard
* commands so users with non-default npm setups can install themselves.
*/
export function CliBinaryPanel({ defer = false }: Props): JSX.Element {
export function CliBinaryPanel({ defer = false }: Props) {
const [status, setStatus] = useState<FnBinaryStatus | null>(null);
const [loading, setLoading] = useState(false);
const [installing, setInstalling] = useState(false);

View File

@@ -26,6 +26,7 @@ import { useModalResizePersist } from "../hooks/useModalResizePersist";
const PluginManager = lazy(() => import("./PluginManager").then((m) => ({ default: m.PluginManager })));
const PiExtensionsManager = lazy(() => import("./PiExtensionsManager").then((m) => ({ default: m.PiExtensionsManager })));
import { ClaudeCliProviderCard } from "./ClaudeCliProviderCard";
import { CliBinaryPanel } from "./CliBinaryPanel";
import { DroidCliProviderCard } from "./DroidCliProviderCard";
import { HermesRuntimeCard } from "./HermesRuntimeCard";
import { OpenClawRuntimeCard } from "./OpenClawRuntimeCard";
@@ -1834,6 +1835,7 @@ export function SettingsModal({
it hidden even before clicking.
</small>
</div>
<CliBinaryPanel />
</>
);
case "global-models": {