Compare commits

...

1 Commits

Author SHA1 Message Date
Zeyi (Rice) Fan 1e65139610 feat(electron): customizable path to the omni CLI
## Related issue

N/A

## Summary

Let users see and change which `omni` CLI binary the desktop shell uses,
resolved at startup and surfaced on both the setup page and the in-app
Settings.

- **Probe both names** (`omnigent_cli.js`): the CLI ships as `omnigent`
  (canonical) and `omni` (alias) of the same entry point. `candidatePaths()`
  and `whichOmnigent()` now try both, so a machine with only `omni` on PATH
  resolves.
- **Resolve at startup** (`main.js`): warm `resolvedCliPath()` in
  `app.whenReady()` so the first status/control call is instant and the
  fields can pre-fill. The user override stays in `settings.omnigent_path`;
  auto-resolution stays dynamic (re-probed each launch) so a moved binary
  self-heals.
- **Setup page** (`setup/index.html`): the CLI setting is hidden by default
  behind a **gear icon** (top-right) that opens a small modal. The resolved /
  auto-detected path shows as the field's **placeholder** (the value stays
  empty until the user types an override); free-text + Browse set it, and the
  install one-liner + an accent dot on the gear appear when the CLI is missing.
- **In-app Settings → Local CLI** (`SettingsPage.tsx`, `settingsNav.tsx`):
  a desktop-only section showing install state/version/resolved path, a
  Change… (native picker) button, and Reset to auto-detected.
- **Bridge** (`preload.js`, `nativeBridge.ts`, `main.js`): new
  pinned-origin IPC `cli-get-status` / `cli-pick-path` / `cli-reset-path`
  exposed on `omnigentDesktop`. Deliberately NO free-text setter on the SPA
  bridge — a connected server must not be able to silently repoint the CLI
  at an arbitrary binary that host-control would spawn; changing it requires
  a user-driven native dialog. Free-text stays on the trusted setup page.

## Test Plan

- `cd ap-web/electron && npm test` — 54 pass (new `candidatePaths` /
  `resolveCliPath` omni-alias coverage).
- `cd ap-web && npx tsc -b` exit 0; `vitest run settingsNav` — 6 pass
  (incl. new desktop-gating test); NewChatDialog suite still green.
- `node --check` all electron modules; `prettier` + `oxlint` clean.

## Type of change

- [ ] Bug fix
- [x] Feature
- [ ] Refactor / chore
- [ ] Docs
- [ ] Test / CI
- [ ] Breaking change

## Test coverage

- [x] Unit tests added / updated
- [ ] Integration tests added / updated
- [ ] E2E tests added / updated
- [x] Manual verification completed
- [ ] Existing tests cover this change
- [ ] Not applicable

## Coverage notes

Unit tests cover the `omni`-alias probing (`candidatePaths`,
`resolveCliPath`) and the desktop-only nav gating (`settingsNavGroups`).
The setup-page gear/modal, the fs/dialog-backed IPC handlers, and the
native picker are exercised in the manual verification flow, as the other
shell IO is. Live GUI verification of the full pick/reset flow is pending
(the test machine's out-of-date local DB schema blocks launching), but the
resolution, bridge, and SPA rendering paths are covered by the suites above.
2026-06-26 16:17:33 -07:00
10 changed files with 597 additions and 150 deletions
+25 -13
View File
@@ -298,23 +298,35 @@ deliberately separate:
confirmation** the page can't forge or auto-dismiss (persisted per server
origin, so a trusted server is asked only once).
### Detecting the CLI (setup page)
### Detecting the CLI and customizing its path
On the setup page the shell probes for the `omnigent` binary —
`settings.omnigent_path` first, then `PATH`, then the well-known install
locations (`~/.local/bin`, `~/.cargo/bin`, Homebrew, `/usr/local/bin`). A
GUI-launched app inherits a minimal `PATH`, which is why the install locations
are probed directly. When the CLI isn't found, the page shows the install
one-liner
The CLI ships under two names that resolve to the same entry point — `omnigent`
(canonical) and `omni` (short alias) — and the shell probes **both**:
`settings.omnigent_path` first, then `PATH` (`omnigent` then `omni`), then the
well-known install locations (`~/.local/bin`, `~/.cargo/bin`, Homebrew,
`/usr/local/bin`, each tried under both names). A GUI-launched app inherits a
minimal `PATH`, which is why the install locations are probed directly. The path
is resolved once at startup and cached in-memory for the session.
```bash
curl -fsSL https://raw.githubusercontent.com/omnigent-ai/omnigent/main/scripts/install_oss.sh | sh
```
You can see and change which binary is used in two places:
- **Setup page** — hidden by default behind a **gear icon** (top-right) that
opens a small modal. The resolved/auto-detected path shows as the field's
**placeholder** (the value stays empty until you type an override); set it via
free-text or a native file picker. When nothing is found the gear gets an
accent dot and the modal shows the install one-liner
```bash
curl -fsSL https://raw.githubusercontent.com/omnigent-ai/omnigent/main/scripts/install_oss.sh | sh
```
- **In-app** — **Settings → Local CLI** (desktop only): shows the resolved path
and version, a **Change…** button (native file picker) and **Reset to
auto-detected**. For safety the in-app surface exposes **no free-text setter**
— a connected server must not be able to silently repoint the CLI at an
arbitrary binary, so changing it requires a user-driven OS dialog.
and a field to point the app at the binary (typed or via a native file picker).
A configured path is saved to `settings.json` (`omnigent_path`) only once it
validates as a runnable `omnigent`. Connecting to a **remote** server never
needs the CLI — only "Start locally" and hosting do.
validates as a runnable CLI; clearing it reverts to auto-detection. Connecting
to a **remote** server never needs the CLI — only "Start locally" and hosting do.
### Start locally
+230 -98
View File
@@ -46,6 +46,10 @@
background: var(--background);
color: var(--foreground);
padding: 0 16px;
/* App chrome, not a document — suppress text selection everywhere
except the input fields (re-enabled below). */
-webkit-user-select: none;
user-select: none;
}
.card {
width: 100%;
@@ -79,6 +83,9 @@
background: transparent;
color: var(--foreground);
outline: none;
/* Re-enable selection in the editable fields (body suppresses it). */
-webkit-user-select: text;
user-select: text;
}
input::placeholder {
color: var(--muted-foreground);
@@ -112,7 +119,7 @@
background: color-mix(in srgb, var(--foreground) 5%, transparent);
}
.recents {
margin-top: 24px;
margin-top: 12px;
}
.recents-title {
margin: 0 0 8px;
@@ -171,39 +178,97 @@
#start-local:hover:not(:disabled) {
background: color-mix(in srgb, var(--primary) 90%, transparent);
}
.cli-panel {
margin-top: 16px;
padding: 12px;
/* Settings gear (top-right) — opens the Omnigent CLI modal. no-drag so it's
clickable over the drag strip. */
.gear-btn {
position: fixed;
top: 8px;
right: 12px;
z-index: 10;
-webkit-app-region: no-drag;
display: inline-flex;
align-items: center;
justify-content: center;
width: 30px;
height: 30px;
padding: 0;
border: 1px solid var(--border);
border-radius: 8px;
background: transparent;
color: var(--muted-foreground);
}
.gear-btn:hover {
color: var(--foreground);
background: color-mix(in srgb, var(--foreground) 5%, transparent);
}
/* A small accent dot draws the eye to the gear when the CLI is missing. */
.gear-btn.attention::after {
content: "";
position: absolute;
top: 3px;
right: 3px;
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--destructive);
}
.modal-overlay {
position: fixed;
inset: 0;
z-index: 100;
display: flex;
align-items: center;
justify-content: center;
padding: 16px;
background: color-mix(in srgb, #000 45%, transparent);
}
/* The author `display: flex` above outranks the UA `[hidden]` rule, so
hiding needs an explicit, higher-specificity rule — without this the
modal shows on load and won't close. */
.modal-overlay[hidden] {
display: none;
}
.modal {
width: 100%;
max-width: 26rem;
padding: 16px;
border: 1px solid var(--border);
border-radius: var(--radius-lg);
background: var(--background);
color: var(--foreground);
font-size: 13px;
line-height: 1.45;
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.35);
}
.cli-panel .title {
font-weight: 500;
margin: 0 0 6px;
}
.cli-panel p {
.modal p {
margin: 6px 0;
}
.cli-cmd {
display: flex;
gap: 8px;
align-items: stretch;
margin: 8px 0;
.modal a {
color: var(--foreground);
text-decoration: underline;
}
.cli-cmd code {
flex: 1;
padding: 6px 8px;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 12px;
background: color-mix(in srgb, var(--foreground) 5%, transparent);
border-radius: 6px;
overflow-x: auto;
white-space: nowrap;
.modal-title {
font-weight: 600;
font-size: 15px;
margin: 0 0 4px;
}
.mini-btn,
.path-row button {
.modal-close {
margin-top: 14px;
padding: 9px 12px;
font-weight: 500;
border: none;
background: var(--primary);
color: var(--primary-foreground);
}
.modal-close:hover {
background: color-mix(in srgb, var(--primary) 90%, transparent);
}
#cli-path-label {
font-weight: 500;
margin: 10px 0 2px;
}
.path-row button,
#cli-redetect {
width: auto;
flex: none;
padding: 6px 12px;
@@ -212,6 +277,12 @@
background: transparent;
color: var(--foreground);
}
#cli-redetect {
margin-top: 8px;
}
#cli-redetect:hover {
background: color-mix(in srgb, var(--foreground) 5%, transparent);
}
.path-row {
display: flex;
gap: 8px;
@@ -244,6 +315,32 @@
</head>
<body>
<div class="drag-strip"></div>
<button
type="button"
id="cli-gear"
class="gear-btn"
aria-label="Configure the Omnigent CLI"
title="Configure the Omnigent CLI"
hidden
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<path
d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"
/>
<circle cx="12" cy="12" r="3" />
</svg>
</button>
<div class="card">
<picture>
<source
@@ -254,31 +351,9 @@
</picture>
<p class="sub">Run an Omnigents server on this machine, or connect to an existing one.</p>
<div id="local-section">
<button id="start-local" disabled>Start locally</button>
<div class="cli-panel" id="cli-panel" hidden>
<p class="title" id="cli-title"></p>
<div id="cli-install" hidden>
<p>Install the Omnigent CLI to run a server locally:</p>
<div class="cli-cmd">
<code id="cli-cmd-text"></code>
<button type="button" class="mini-btn" id="cli-copy">Copy</button>
</div>
<p>Already installed? Point the app at the binary:</p>
<div class="path-row">
<input
id="cli-path"
type="text"
placeholder="/path/to/omnigent"
autocomplete="off"
spellcheck="false"
/>
<button type="button" id="cli-browse">Browse…</button>
</div>
<p class="cli-note" id="cli-path-note"></p>
</div>
</div>
<!-- Always enabled: when the CLI is missing this opens the settings
modal (install / set path) instead of failing to start. -->
<button id="start-local">Start locally</button>
<div class="divider">or connect to a server</div>
</div>
@@ -298,6 +373,39 @@
<div id="recents-list"></div>
</div>
</div>
<!-- Omnigent CLI settings, opened from the gear. Hidden by default. -->
<div class="modal-overlay" id="cli-modal" hidden>
<div class="modal" role="dialog" aria-modal="true" aria-label="Omnigent CLI settings">
<p class="modal-title">Omnigent CLI</p>
<p class="cli-note" id="cli-status"></p>
<div id="cli-install" hidden>
<p>
<a
href="https://omnigent.ai/quickstart/install#install-omnigent"
target="_blank"
rel="noreferrer"
>Install the Omnigent CLI ↗</a
>
</p>
<p>Already installed it? Re-detect, or set the path below.</p>
<button type="button" id="cli-redetect">Re-detect</button>
</div>
<p id="cli-path-label">Path to the Omnigent CLI</p>
<div class="path-row">
<input
id="cli-path"
type="text"
placeholder="/path/to/omni"
autocomplete="off"
spellcheck="false"
/>
<button type="button" id="cli-browse">Browse…</button>
</div>
<p class="cli-note" id="cli-path-note"></p>
<button type="button" id="cli-modal-close" class="modal-close">Done</button>
</div>
</div>
<script src="../src/url.js"></script>
<script>
// Shared URL helpers (electron/src/url.js), exposed as window.omnigentUrl
@@ -413,33 +521,18 @@
// binary. Remote Connect never depends on the CLI.
const startLocalBtn = document.getElementById("start-local");
const localSection = document.getElementById("local-section");
const cliPanel = document.getElementById("cli-panel");
const cliTitle = document.getElementById("cli-title");
const cliGear = document.getElementById("cli-gear");
const cliModal = document.getElementById("cli-modal");
const cliModalClose = document.getElementById("cli-modal-close");
const cliStatus = document.getElementById("cli-status");
const cliInstall = document.getElementById("cli-install");
const cliCmdText = document.getElementById("cli-cmd-text");
const cliCopy = document.getElementById("cli-copy");
const cliRedetect = document.getElementById("cli-redetect");
const cliPathInput = document.getElementById("cli-path");
const cliBrowse = document.getElementById("cli-browse");
const cliPathNote = document.getElementById("cli-path-note");
// Copy without a clipboard permission: a transient off-screen textarea +
// execCommand works on a user gesture in the file:// setup page.
function copyText(text) {
try {
const ta = document.createElement("textarea");
ta.value = text;
ta.setAttribute("readonly", "");
ta.style.position = "fixed";
ta.style.opacity = "0";
document.body.appendChild(ta);
ta.select();
document.execCommand("copy");
document.body.removeChild(ta);
return true;
} catch {
return false;
}
}
// Tracks the last resolved install state so the Start-locally click can
// open settings (instead of failing) when the CLI isn't available.
let cliInstalled = false;
async function refreshCliStatus() {
let status;
@@ -448,24 +541,30 @@
} catch {
return;
}
// textContent, never innerHTML: the command and version come from the
// main process and are rendered as inert text.
cliCmdText.textContent = status.installCommand || "";
const installed = Boolean(status.installed);
// Starting a local server needs the CLI; gate the button on it.
startLocalBtn.disabled = !installed;
if (installed) {
cliPanel.hidden = true;
} else {
cliPanel.hidden = false;
cliInstall.hidden = false;
cliTitle.textContent = "Omnigent CLI not found";
}
cliInstalled = installed;
// Modal contents: install one-liner only when missing. The resolved /
// auto-detected path is shown as the field's PLACEHOLDER (the value
// stays empty until the user types an override), so the field reads as
// "auto" by default.
cliInstall.hidden = installed;
cliStatus.textContent = installed
? status.version
? `Found: ${status.version}`
: "Omnigent CLI found."
: "Omnigent CLI not found.";
cliPathInput.placeholder = status.path || "/path/to/omni";
// Draw the eye to the (otherwise quiet) gear when the CLI is missing.
cliGear.classList.toggle("attention", !installed);
}
// Validate + persist the typed path. Returns true when there's nothing to
// do (empty) or the path was accepted — i.e. it's safe to close the modal;
// false when a non-empty path was rejected (keep the modal open with the
// error showing).
async function applyCliPath(value) {
const p = (value || "").trim();
if (p === "") return;
if (p === "") return true;
cliPathNote.className = "cli-note";
cliPathNote.textContent = "Checking…";
let result;
@@ -475,22 +574,43 @@
result = { accepted: false };
}
if (result && result.accepted) {
cliPathNote.textContent = result.version ? `Found: ${result.version}` : "Found omnigent.";
cliPathNote.textContent = result.version
? `Found: ${result.version}`
: "Found the Omnigent CLI.";
// Clear the value so the field reverts to showing the (now-updated)
// resolved path as its placeholder.
cliPathInput.value = "";
await refreshCliStatus();
} else {
cliPathNote.className = "cli-note bad";
cliPathNote.textContent = "That path is not a runnable omnigent binary.";
return true;
}
cliPathNote.className = "cli-note bad";
cliPathNote.textContent = "That path is not a runnable Omnigent CLI.";
return false;
}
function openCliModal() {
cliModal.hidden = false;
void refreshCliStatus();
}
function closeCliModal() {
cliModal.hidden = true;
}
if (setup.getCliStatus) {
cliCopy.addEventListener("click", () => {
if (copyText(cliCmdText.textContent)) {
cliCopy.textContent = "Copied";
setTimeout(() => {
cliCopy.textContent = "Copy";
}, 1500);
}
// The gear is the only entry point to the CLI settings — hidden by
// default, revealed once we know the bridge exists.
cliGear.hidden = false;
cliGear.addEventListener("click", openCliModal);
// "Done" commits the typed path (it isn't applied on every keystroke).
// On an invalid path, keep the modal open so the error stays visible.
cliModalClose.addEventListener("click", async () => {
if (await applyCliPath(cliPathInput.value)) closeCliModal();
});
cliModal.addEventListener("click", (e) => {
if (e.target === cliModal) closeCliModal(); // backdrop click
});
document.addEventListener("keydown", (e) => {
if (e.key === "Escape" && !cliModal.hidden) closeCliModal();
});
cliBrowse.addEventListener("click", async () => {
const picked = await setup.browseCliPath();
@@ -502,7 +622,18 @@
cliPathInput.addEventListener("keydown", (e) => {
if (e.key === "Enter") applyCliPath(cliPathInput.value);
});
// Re-run auto-detection (getCliStatus re-resolves every call) — the
// post-install path: install via the link, then click to pick it up.
cliRedetect.addEventListener("click", async () => {
cliStatus.textContent = "Checking…";
await refreshCliStatus();
});
startLocalBtn.addEventListener("click", async () => {
// No CLI yet → open settings (install / set path) rather than fail.
if (!cliInstalled) {
openCliModal();
return;
}
err.textContent = "";
const prev = startLocalBtn.textContent;
startLocalBtn.disabled = true;
@@ -524,7 +655,8 @@
startLocalBtn.textContent = prev;
startLocalBtn.disabled = false;
});
// Resolve CLI status (enables/disables Start-locally).
// Resolve CLI status so the Start-locally click knows whether to start
// or open settings, and so the gear's attention dot reflects reality.
refreshCliStatus();
} else {
// Older shell without the CLI bridge: hide the whole local section.
+71 -16
View File
@@ -653,6 +653,45 @@ function resolvedCliPath() {
return cachedCli.path;
}
/**
* Validate `configuredPath` as a runnable CLI and persist it as the override
* when it checks out; an empty string clears the override (revert to PATH /
* candidates). A typo is NOT saved (so it can't mask a working PATH lookup).
* Returns the resulting CLI status plus whether the path was accepted. Shared
* by the setup page (free-text) and the in-app picker.
*
* @param {string} configuredPath
* @returns {Promise<Record<string, unknown> & { accepted: boolean }>}
*/
async function applyCliPath(configuredPath) {
const trimmed = String(configuredPath ?? "").trim();
const status = await omnigentCli.getCliStatus(trimmed || null);
const accepted = status.installed && status.source === "configured";
if (accepted) {
const settings = loadSettings();
settings.omnigent_path = trimmed;
saveSettings(settings);
} else if (trimmed === "") {
const settings = loadSettings();
delete settings.omnigent_path;
saveSettings(settings);
}
return { ...status, accepted };
}
/**
* Clear any saved CLI-path override so resolution falls back to PATH and the
* well-known install locations, then report the freshly-resolved status.
*
* @returns {Promise<Record<string, unknown>>}
*/
async function clearCliPath() {
const settings = loadSettings();
delete settings.omnigent_path;
saveSettings(settings);
return omnigentCli.getCliStatus(null);
}
/** Maximum number of entries kept in the persisted recent-servers list. */
const MAX_RECENT_SERVERS = 5;
@@ -809,7 +848,9 @@ function createWindow(targetUrl, opts = {}) {
// Without saved coordinates Electron centers the window.
...(savedBounds ? { x: savedBounds.x, y: savedBounds.y } : {}),
minWidth: 720,
minHeight: 480,
// Tall enough that the bundled setup page (logo, Start-locally, divider,
// URL field, Connect, and a few recents) fits without overflowing.
minHeight: 600,
title: "Omnigent",
backgroundColor: "#0b0b0c",
// macOS: hide the native title bar but keep the traffic lights, inset
@@ -1776,20 +1817,7 @@ function registerIpc() {
if (!isSetupPageSender(event)) {
throw new Error("set-cli-path is only available to the setup page");
}
const trimmed = String(configuredPath ?? "").trim();
const status = await omnigentCli.getCliStatus(trimmed || null);
const accepted = status.installed && status.source === "configured";
if (accepted) {
const settings = loadSettings();
settings.omnigent_path = trimmed;
saveSettings(settings);
} else if (trimmed === "") {
// Empty input clears any saved override (revert to PATH/candidates).
const settings = loadSettings();
delete settings.omnigent_path;
saveSettings(settings);
}
return { ...status, accepted };
return applyCliPath(configuredPath);
});
// Setup page → native file picker for the omnigent binary. Returns the chosen
@@ -1800,7 +1828,7 @@ function registerIpc() {
}
const win = BrowserWindow.fromWebContents(event.sender) ?? activeWindow();
const result = await dialog.showOpenDialog(win ?? undefined, {
title: "Locate the omnigent binary",
title: "Locate the Omnigent CLI binary",
properties: ["openFile"],
});
if (result.canceled || result.filePaths.length === 0) return null;
@@ -1832,6 +1860,29 @@ function registerIpc() {
return { cliInstalled: Boolean(resolvedCliPath()), hostId: omnigentCli.localHostId() };
});
// SPA (in-app Settings → Local CLI) → is the CLI installed and runnable,
// plus the resolved path / version / source. Read-only; pinned-origin gated.
ipcMain.handle("omnigent:cli-get-status", async (event) => {
if (!isPinnedOriginSender(event)) {
console.warn("[omnigent] cli-get-status from untrusted sender dropped");
return null;
}
return omnigentCli.getCliStatus(loadSettings().omnigent_path);
});
// SPA → reset to auto-detected (clear the override). Chooses no path itself,
// so it's safe to expose to the SPA. SETTING a path is deliberately NOT
// exposed here: a connected (remote, semi-trusted) server could otherwise
// point the CLI at an arbitrary binary that host-control would later spawn
// (and validation runs `<path> --version`). Choosing a path stays on the
// bundled file:// setup page.
ipcMain.handle("omnigent:cli-reset-path", async (event) => {
if (!isPinnedOriginSender(event)) {
throw new Error("cli-reset-path is only available to a connected server page");
}
return clearCliPath();
});
// SPA → start / stop / restart this machine's host daemon for the window's
// own server (the host selection menu's "connect this machine" action).
ipcMain.handle("omnigent:host-control", async (event, action) => {
@@ -1908,6 +1959,10 @@ if (!gotLock) {
registerWebAuthn();
registerIpc();
buildMenu();
// Resolve the CLI path once at startup so the first status/control call is
// instant (primes the in-memory cache in resolvedCliPath); also lets the
// setup page / Local CLI settings pre-fill the resolved path immediately.
resolvedCliPath();
createWindow();
app.on("activate", () => {
+44 -14
View File
@@ -252,7 +252,16 @@ async function localServerHealthy(timeoutMs = 1500) {
}
/**
* Well-known install locations for the `omnigent` binary, in priority order.
* The CLI binary's two console-script names — both resolve to the same entry
* point (`omnigent.cli:main`); `omni` is the short alias. We probe `omnigent`
* first (canonical) but accept `omni` so a machine that only installed the
* alias still resolves. See pyproject.toml `[project.scripts]`.
*/
const CLI_NAMES = ["omnigent", "omni"];
/**
* Well-known install locations for the CLI binary, in priority order. For each
* directory we list the `omnigent` name then the `omni` alias.
* `uv tool install` (the documented installer) drops it in ~/.local/bin;
* the rest cover Homebrew and source/cargo installs. Probing these matters
* because a GUI-launched Electron app inherits a minimal PATH that usually
@@ -262,12 +271,13 @@ async function localServerHealthy(timeoutMs = 1500) {
*/
function candidatePaths() {
const home = os.homedir();
return [
path.join(home, ".local", "bin", "omnigent"),
path.join(home, ".cargo", "bin", "omnigent"),
"/opt/homebrew/bin/omnigent",
"/usr/local/bin/omnigent",
const dirs = [
path.join(home, ".local", "bin"),
path.join(home, ".cargo", "bin"),
"/opt/homebrew/bin",
"/usr/local/bin",
];
return dirs.flatMap((dir) => CLI_NAMES.map((name) => path.join(dir, name)));
}
/**
@@ -287,19 +297,20 @@ function isExecutableFile(p) {
}
/**
* Resolve `omnigent` on PATH (or the user's login shell PATH). Returns null
* when not found. On POSIX we go through `command -v` so shell-managed PATHs
* (uv shims) resolve; on Windows we use `where`.
* Resolve the CLI on PATH (or the user's login shell PATH) by name. Returns
* null when not found. On POSIX we go through `command -v` so shell-managed
* PATHs (uv shims) resolve; on Windows we use `where`.
*
* @param {string} name e.g. "omnigent" or "omni"
* @returns {string | null}
*/
function whichOmnigent() {
function whichName(name) {
try {
if (process.platform === "win32") {
const out = execFileSync("where", ["omnigent"], { encoding: "utf8" });
const out = execFileSync("where", [name], { encoding: "utf8" });
return out.trim().split(/\r?\n/)[0] || null;
}
const out = execFileSync("/bin/sh", ["-c", "command -v omnigent"], {
const out = execFileSync("/bin/sh", ["-c", `command -v ${name}`], {
encoding: "utf8",
});
return out.trim() || null;
@@ -308,6 +319,20 @@ function whichOmnigent() {
}
}
/**
* Resolve the CLI on PATH, trying `omnigent` then the `omni` alias. Returns the
* first hit, or null when neither is on PATH.
*
* @returns {string | null}
*/
function whichOmnigent() {
for (const name of CLI_NAMES) {
const found = whichName(name);
if (found) return found;
}
return null;
}
/**
* Locate the `omnigent` binary. Resolution order: a user-configured path, then
* PATH, then the well-known candidate locations. Returns the resolved path and
@@ -467,11 +492,16 @@ async function getCliStatus(configuredPath) {
};
}
const res = await runCli(resolved.path, ["--version"], { timeoutMs: 5000 });
const ok = res.code === 0;
const version = res.stdout.trim() || res.stderr.trim() || "";
// Must exit cleanly AND identify itself as omni — `omnigent --version` prints
// e.g. "omnigent 0.3.0.dev0 (…)". The exit-code alone isn't enough: an
// unrelated binary (e.g. /bin/echo) also exits 0 on `--version`, and we must
// not accept it as the CLI (it would later fail to run a server / host).
const ok = res.code === 0 && /\bomni/i.test(version);
return {
installed: ok,
path: ok ? resolved.path : null,
version: ok ? res.stdout.trim() || res.stderr.trim() || null : null,
version: ok ? version || null : null,
source: ok ? resolved.source : null,
installCommand: INSTALL_COMMAND,
};
+12
View File
@@ -92,6 +92,18 @@ contextBridge.exposeInMainWorld("omnigentDesktop", {
ipcRenderer.on("omnigent:host-status-changed", listener);
return () => ipcRenderer.removeListener("omnigent:host-status-changed", listener);
},
/**
* The local `omni` CLI status — `{ installed, path, version, source,
* installCommand }`. Read-only; lets the in-app Local CLI settings show which
* binary is in use.
*/
getCliStatus: () => ipcRenderer.invoke("omnigent:cli-get-status"),
/**
* Clear the saved CLI-path override (revert to auto-detection). The SPA can
* reset but cannot SET a path: choosing a binary is restricted to the trusted
* setup page, so a connected server can't repoint the CLI at an arbitrary one.
*/
resetCliPath: () => ipcRenderer.invoke("omnigent:cli-reset-path"),
});
// Setup-page bridge: persist + navigate to a server URL, and read the saved
+29
View File
@@ -13,6 +13,7 @@ const {
isLoopbackServer,
sameLoopbackServer,
parseLocalServerPidfile,
candidatePaths,
resolveCliPath,
parseJsonLoose,
matchesServer,
@@ -78,7 +79,35 @@ describe("parseLocalServerPidfile", () => {
});
});
describe("candidatePaths", () => {
it("probes both the omnigent name and the omni alias in each location", () => {
const paths = candidatePaths();
// Every well-known dir contributes an `omnigent` and an `omni` entry.
assert.ok(paths.some((p) => p.endsWith("/.local/bin/omnigent")));
assert.ok(paths.some((p) => p.endsWith("/.local/bin/omni")));
assert.ok(paths.includes("/opt/homebrew/bin/omnigent"));
assert.ok(paths.includes("/opt/homebrew/bin/omni"));
assert.ok(paths.includes("/usr/local/bin/omni"));
});
it("lists the canonical omnigent name before the omni alias within a dir", () => {
const paths = candidatePaths();
const og = paths.indexOf("/opt/homebrew/bin/omnigent");
const omni = paths.indexOf("/opt/homebrew/bin/omni");
assert.ok(og !== -1 && omni !== -1 && og < omni);
});
});
describe("resolveCliPath", () => {
it("resolves the omni alias when only it is executable", () => {
const got = resolveCliPath(null, {
isExecutableFile: (p) => p === "/home/me/.local/bin/omni",
whichOmnigent: () => null,
candidatePaths: () => ["/home/me/.local/bin/omnigent", "/home/me/.local/bin/omni"],
});
assert.deepEqual(got, { path: "/home/me/.local/bin/omni", source: "candidate" });
});
it("prefers a usable configured path", () => {
const got = resolveCliPath("/custom/omnigent", {
isExecutableFile: (p) => p === "/custom/omnigent",
+51
View File
@@ -126,11 +126,31 @@ interface ElectronDesktopApi extends NativeShellApi {
controlHost?: (action: HostControlAction) => Promise<HostActionResult>;
/** Subscribe to host status-change pings (re-read on fire); returns an unsubscribe. */
onHostStatusChanged?: (callback: () => void) => () => void;
/** The local `omni` CLI status (installed, resolved path, version, source). */
getCliStatus?: () => Promise<CliStatus | null>;
/** Clear the CLI-path override (revert to auto-detection); resolves status. */
resetCliPath?: () => Promise<CliStatus | null>;
}
/** A lifecycle action for the host daemon. */
export type HostControlAction = "start" | "stop" | "restart";
/** Status of the local `omni` CLI, from the desktop shell. */
export interface CliStatus {
/** Whether the CLI was found and is runnable. */
installed: boolean;
/** The resolved binary path (configured override or auto-detected), or null. */
path: string | null;
/** The CLI's reported version, or null. */
version: string | null;
/** How the path was resolved: an explicit override, PATH, or a known location. */
source: "configured" | "path" | "candidate" | null;
/** The install one-liner to show when the CLI is missing. */
installCommand: string;
/** Whether a just-submitted path was accepted (present on pick/set results). */
accepted?: boolean;
}
/** This machine's identity, read from local config (fast — no subprocess). */
export interface HostIdentity {
/** Whether the `omnigent` CLI was found and is runnable. */
@@ -491,3 +511,34 @@ export function onHostStatusChanged(callback: () => void): () => void {
return () => {};
}
}
/**
* Fetch the local `omni` CLI status from the desktop shell (installed, resolved
* path, version, source). Resolves `null` outside the Electron shell or under a
* shell too old to expose the CLI bridge.
*/
export async function getCliStatus(): Promise<CliStatus | null> {
const electron = electronApi();
if (!electron?.getCliStatus) return null;
try {
return await electron.getCliStatus();
} catch (err) {
console.warn("[nativeBridge] electron getCliStatus failed:", err);
return null;
}
}
/**
* Clear the saved CLI-path override so the shell reverts to auto-detection,
* then resolve the freshly-detected status. Resolves `null` outside the shell.
*/
export async function resetCliPath(): Promise<CliStatus | null> {
const electron = electronApi();
if (!electron?.resetCliPath) return null;
try {
return await electron.resetCliPath();
} catch (err) {
console.warn("[nativeBridge] electron resetCliPath failed:", err);
return null;
}
}
+98
View File
@@ -57,6 +57,7 @@ import { absoluteTime } from "@/lib/relativeTime";
import { useSettingsRoute } from "@/shell/settingsNav";
import { type ThemeMode, normalizeThemeMode } from "@/components/theme/themeMode";
import { useIsEmbedded } from "@/lib/embedded";
import { type CliStatus, getCliStatus, isElectronShell, resetCliPath } from "@/lib/nativeBridge";
import { cn } from "@/lib/utils";
/**
@@ -77,6 +78,7 @@ export function SettingsPage() {
{section === "shortcuts" && <ShortcutsSection />}
{section === "account" && accountsEnabled && <AccountSection />}
{section === "archived" && <ArchivedSection />}
{section === "cli" && isElectronShell() && <LocalCliSection />}
</PageScroll>
);
}
@@ -155,6 +157,102 @@ function ShortcutsSection() {
);
}
/**
* Desktop-only: shows which Omnigent CLI binary the shell resolved
* (auto-detected or a custom override). Read-only — setting a custom path is
* done on the connect/setup screen (the trusted surface that allows free-text
* entry); the SPA exposes no path setter. A safe "reset to auto-detected" stays
* here since it chooses no path.
*/
function LocalCliSection() {
const [status, setStatus] = useState<CliStatus | null | "loading">("loading");
const [busy, setBusy] = useState(false);
useEffect(() => {
void getCliStatus().then(setStatus);
}, []);
const onReset = useCallback(async () => {
setBusy(true);
const next = await resetCliPath();
setBusy(false);
if (next) setStatus(next); // null only when the bridge is missing (old shell)
}, []);
if (status === "loading") {
return (
<Section title="Local CLI">
<p className="text-sm text-muted-foreground">Checking</p>
</Section>
);
}
return (
<Section
title="Local CLI"
description="The Omnigent command-line tool this app uses to run a local server and connect this machine as a runner."
>
{status === null ? (
<p className="text-sm text-muted-foreground">CLI status is unavailable.</p>
) : (
<div className="flex flex-col gap-4">
<div className="flex items-center gap-2 text-sm">
<span
aria-hidden
className={cn(
"size-2 rounded-full",
status.installed ? "bg-success" : "bg-muted-foreground/40",
)}
/>
<span>
{status.installed
? `Found${status.version ? ` · ${status.version}` : ""}`
: "Not found"}
</span>
</div>
{status.path ? (
<div className="flex flex-col gap-1">
<span className="text-xs text-muted-foreground">
{status.source === "configured" ? "Path (custom)" : "Path (auto-detected)"}
</span>
<code className="block overflow-x-auto rounded-md border border-border bg-muted/40 px-3 py-2 text-xs">
{status.path}
</code>
</div>
) : (
<div className="flex flex-col gap-2">
<p className="text-sm text-muted-foreground">
The Omnigent CLI wasn't found. Install it, then set its path from the connect
screen:
</p>
{status.installCommand && (
<code className="block overflow-x-auto rounded-md border border-border bg-muted/40 px-3 py-2 text-xs">
{status.installCommand}
</code>
)}
</div>
)}
<p className="text-xs text-muted-foreground">
For security, a custom path can only be set from the connect screen — this prevents a
connected server from pointing the app at a different binary. Open it from the Server
menu (Change Server…) and use the settings gear.
</p>
{status.source === "configured" && (
<div>
<Button variant="ghost" size="sm" disabled={busy} onClick={() => void onReset()}>
Reset to auto-detected
</Button>
</div>
)}
</div>
)}
</Section>
);
}
function AccountSection() {
const [me, setMe] = useState<CurrentAccount | null | "unknown">("unknown");
+12 -3
View File
@@ -38,7 +38,7 @@ afterEach(cleanup);
describe("settingsNavGroups", () => {
it("flags Keyboard shortcuts as hidden on mobile, but not the other items", () => {
const items = settingsNavGroups(false).flatMap((g) => g.items);
const items = settingsNavGroups(false, false).flatMap((g) => g.items);
const shortcuts = items.find((i) => i.id === "shortcuts");
expect(shortcuts?.hideOnMobile).toBe(true);
for (const item of items) {
@@ -48,17 +48,26 @@ describe("settingsNavGroups", () => {
it("includes Account (leading) only when accounts auth is enabled", () => {
expect(
settingsNavGroups(false)
settingsNavGroups(false, false)
.flatMap((g) => g.items)
.map((i) => i.id),
).not.toContain("account");
const withAccounts = settingsNavGroups(true)
const withAccounts = settingsNavGroups(true, false)
.flatMap((g) => g.items)
.map((i) => i.id);
expect(withAccounts).toContain("account");
// Account leads its group — it's the most-visited section on accounts deploys.
expect(withAccounts[0]).toBe("account");
});
it("includes the Local CLI section only in the desktop shell", () => {
const ids = (isDesktop: boolean) =>
settingsNavGroups(false, isDesktop)
.flatMap((g) => g.items)
.map((i) => i.id);
expect(ids(false)).not.toContain("cli");
expect(ids(true)).toContain("cli");
});
});
describe("SettingsSidebarBody", () => {
+25 -6
View File
@@ -12,21 +12,24 @@ import {
KeyboardIcon,
PaletteIcon,
PanelRightOpenIcon,
TerminalIcon,
UserCogIcon,
} from "lucide-react";
import { Link, useLocation } from "@/lib/routing";
import { Button } from "@/components/ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { useServerInfo } from "@/lib/CapabilitiesContext";
import { isElectronShell } from "@/lib/nativeBridge";
import { cn } from "@/lib/utils";
export type SettingsSectionId = "appearance" | "shortcuts" | "account" | "archived";
export type SettingsSectionId = "appearance" | "shortcuts" | "account" | "archived" | "cli";
const SECTION_IDS: readonly SettingsSectionId[] = [
"appearance",
"shortcuts",
"account",
"archived",
"cli",
];
interface SettingsNavItem {
@@ -42,8 +45,14 @@ interface SettingsNavGroup {
items: SettingsNavItem[];
}
/** Nav groups for the current deploy — the Account section is auth-gated. */
export function settingsNavGroups(accountsEnabled: boolean): SettingsNavGroup[] {
/**
* Nav groups for the current deploy. The Account section is auth-gated; the
* Desktop group (Local CLI) appears only in the Electron shell.
*/
export function settingsNavGroups(
accountsEnabled: boolean,
isDesktop: boolean,
): SettingsNavGroup[] {
const general: SettingsNavItem[] = [
{ id: "appearance", label: "Appearance", icon: PaletteIcon },
{ id: "shortcuts", label: "Keyboard shortcuts", icon: KeyboardIcon, hideOnMobile: true },
@@ -53,13 +62,23 @@ export function settingsNavGroups(accountsEnabled: boolean): SettingsNavGroup[]
// on accounts deploys.
general.unshift({ id: "account", label: "Account", icon: UserCogIcon });
}
return [
const groups: SettingsNavGroup[] = [];
// Desktop (Local CLI) leads when present — it's the shell-specific section a
// desktop user is most likely here to change.
if (isDesktop) {
groups.push({
title: "Desktop",
items: [{ id: "cli", label: "Local CLI", icon: TerminalIcon }],
});
}
groups.push(
{ title: "General", items: general },
{
title: "Archived",
items: [{ id: "archived", label: "Archived sessions", icon: ArchiveIcon }],
},
];
);
return groups;
}
/**
@@ -100,7 +119,7 @@ export function SettingsSidebarBody({
const info = useServerInfo();
const accountsEnabled = info !== "loading" && info.accounts_enabled;
const { section } = useSettingsRoute();
const groups = settingsNavGroups(accountsEnabled);
const groups = settingsNavGroups(accountsEnabled, isElectronShell());
return (
<>