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.
|
||||
|
||||
- **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
|
||||
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -34,6 +34,7 @@
|
||||
"dist/**/*.js.map",
|
||||
"dist/client/**",
|
||||
"dist/pi-claude-cli/**",
|
||||
"dist/droid-cli/**",
|
||||
"skill/**",
|
||||
"README.md"
|
||||
],
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
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 { 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} />
|
||||
)}
|
||||
|
||||
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
|
||||
* 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);
|
||||
|
||||
@@ -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": {
|
||||
|
||||
Reference in New Issue
Block a user