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:
5
.changeset/fn-3046-droid-cli.md
Normal file
5
.changeset/fn-3046-droid-cli.md
Normal 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.
|
||||||
@@ -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.
|
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
|
- **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
|
- **Other providers** — Authenticate via API key entry in Settings
|
||||||
- **pi authentication** — Handled separately via the `pi` CLI (`/login`) or `ANTHROPIC_API_KEY` environment variable
|
- **pi authentication** — Handled separately via the `pi` CLI (`/login`) or `ANTHROPIC_API_KEY` environment variable
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
- **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.
|
- **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`
|
## `fn serve`
|
||||||
|
|||||||
@@ -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.
|
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
|
## Full documentation
|
||||||
|
|
||||||
Architecture details, development setup, and contributor info live in the [project README](https://github.com/Runfusion/Fusion#readme).
|
Architecture details, development setup, and contributor info live in the [project README](https://github.com/Runfusion/Fusion#readme).
|
||||||
|
|||||||
@@ -16,6 +16,20 @@ If you don't have pi set up yet: `npm i -g @mariozechner/pi-coding-agent && pi`
|
|||||||
|
|
||||||
## Usage
|
## 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
|
### Start the dashboard
|
||||||
|
|
||||||
Launch the web UI and AI engine:
|
Launch the web UI and AI engine:
|
||||||
|
|||||||
@@ -34,6 +34,7 @@
|
|||||||
"dist/**/*.js.map",
|
"dist/**/*.js.map",
|
||||||
"dist/client/**",
|
"dist/client/**",
|
||||||
"dist/pi-claude-cli/**",
|
"dist/pi-claude-cli/**",
|
||||||
|
"dist/droid-cli/**",
|
||||||
"skill/**",
|
"skill/**",
|
||||||
"README.md"
|
"README.md"
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
readClientIndexHtml,
|
readClientIndexHtml,
|
||||||
} from "./bundle-output-helpers";
|
} from "./bundle-output-helpers";
|
||||||
import { resolveClaudeCliExtensionFromModuleUrl } from "../commands/claude-cli-extension";
|
import { resolveClaudeCliExtensionFromModuleUrl } from "../commands/claude-cli-extension";
|
||||||
|
import { resolveDroidCliExtensionFromModuleUrl } from "../commands/droid-cli-extension";
|
||||||
|
|
||||||
const tsupConfigPath = join(cliRoot, "tsup.config.ts");
|
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);
|
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", () => {
|
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");
|
const processManagerSource = readFileSync(join(cliRoot, "dist", "pi-claude-cli", "src", "process-manager.ts"), "utf-8");
|
||||||
|
|
||||||
|
|||||||
37
packages/core/src/__tests__/fn-binary.test.ts
Normal file
37
packages/core/src/__tests__/fn-binary.test.ts
Normal 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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -12,6 +12,7 @@ import { BackendConnectionErrorPage } from "./components/BackendConnectionErrorP
|
|||||||
import { DashboardLoader, type DashboardLoaderStage } from "./components/DashboardLoader";
|
import { DashboardLoader, type DashboardLoaderStage } from "./components/DashboardLoader";
|
||||||
import { ExecutorStatusBar } from "./components/ExecutorStatusBar";
|
import { ExecutorStatusBar } from "./components/ExecutorStatusBar";
|
||||||
import { SessionNotificationBanner } from "./components/SessionNotificationBanner";
|
import { SessionNotificationBanner } from "./components/SessionNotificationBanner";
|
||||||
|
import { CliBinaryInstallBanner } from "./components/CliBinaryInstallBanner";
|
||||||
import { SetupWarningBanner } from "./components/SetupWarningBanner";
|
import { SetupWarningBanner } from "./components/SetupWarningBanner";
|
||||||
import { UpdateAvailableBanner } from "./components/UpdateAvailableBanner";
|
import { UpdateAvailableBanner } from "./components/UpdateAvailableBanner";
|
||||||
import { OnboardingResumeCard } from "./components/OnboardingResumeCard";
|
import { OnboardingResumeCard } from "./components/OnboardingResumeCard";
|
||||||
@@ -947,6 +948,11 @@ function AppInner() {
|
|||||||
onDismissAll={handleDismissAllNeedingInputSessions}
|
onDismissAll={handleDismissAllNeedingInputSessions}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{viewMode === "project" && currentProject && (
|
||||||
|
<CliBinaryInstallBanner
|
||||||
|
onOpenSettings={() => modalManager.openSettings("general" as SectionId)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{viewMode === "project" && currentProject && showOnboardingResumeCard && (
|
{viewMode === "project" && currentProject && showOnboardingResumeCard && (
|
||||||
<OnboardingResumeCard onResume={modalManager.openModelOnboarding} />
|
<OnboardingResumeCard onResume={modalManager.openModelOnboarding} />
|
||||||
)}
|
)}
|
||||||
|
|||||||
113
packages/dashboard/app/components/CliBinaryInstallBanner.css
Normal file
113
packages/dashboard/app/components/CliBinaryInstallBanner.css
Normal 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);
|
||||||
|
}
|
||||||
142
packages/dashboard/app/components/CliBinaryInstallBanner.tsx
Normal file
142
packages/dashboard/app/components/CliBinaryInstallBanner.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
226
packages/dashboard/app/components/CliBinaryPanel.css
Normal file
226
packages/dashboard/app/components/CliBinaryPanel.css
Normal 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;
|
||||||
|
}
|
||||||
@@ -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
|
* `npm install -g runfusion.ai` server-side), and two copy-to-clipboard
|
||||||
* commands so users with non-default npm setups can install themselves.
|
* 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 [status, setStatus] = useState<FnBinaryStatus | null>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [installing, setInstalling] = useState(false);
|
const [installing, setInstalling] = useState(false);
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import { useModalResizePersist } from "../hooks/useModalResizePersist";
|
|||||||
const PluginManager = lazy(() => import("./PluginManager").then((m) => ({ default: m.PluginManager })));
|
const PluginManager = lazy(() => import("./PluginManager").then((m) => ({ default: m.PluginManager })));
|
||||||
const PiExtensionsManager = lazy(() => import("./PiExtensionsManager").then((m) => ({ default: m.PiExtensionsManager })));
|
const PiExtensionsManager = lazy(() => import("./PiExtensionsManager").then((m) => ({ default: m.PiExtensionsManager })));
|
||||||
import { ClaudeCliProviderCard } from "./ClaudeCliProviderCard";
|
import { ClaudeCliProviderCard } from "./ClaudeCliProviderCard";
|
||||||
|
import { CliBinaryPanel } from "./CliBinaryPanel";
|
||||||
import { DroidCliProviderCard } from "./DroidCliProviderCard";
|
import { DroidCliProviderCard } from "./DroidCliProviderCard";
|
||||||
import { HermesRuntimeCard } from "./HermesRuntimeCard";
|
import { HermesRuntimeCard } from "./HermesRuntimeCard";
|
||||||
import { OpenClawRuntimeCard } from "./OpenClawRuntimeCard";
|
import { OpenClawRuntimeCard } from "./OpenClawRuntimeCard";
|
||||||
@@ -1834,6 +1835,7 @@ export function SettingsModal({
|
|||||||
it hidden even before clicking.
|
it hidden even before clicking.
|
||||||
</small>
|
</small>
|
||||||
</div>
|
</div>
|
||||||
|
<CliBinaryPanel />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
case "global-models": {
|
case "global-models": {
|
||||||
|
|||||||
Reference in New Issue
Block a user