feat: add Hermes gateway controls

This commit is contained in:
Simos Mikelatos
2026-07-01 15:29:53 +00:00
parent dcd7044258
commit c3a4ab8a45
11 changed files with 780 additions and 2 deletions

45
docs/hermes-gateway.md Normal file
View File

@@ -0,0 +1,45 @@
# Hermes Gateway Controls
CloudCLI can manage the Hermes Gateway process from **Settings -> Agents -> Hermes -> Gateway**.
The gateway is optional. Normal CloudCLI chat and automation should continue to use `POST /api/agent` with `provider: "hermes"`. The gateway is for long-running Hermes integrations such as Telegram, Discord, WhatsApp, and other messaging surfaces configured by Hermes.
## What The Gateway Tab Does
- Shows whether Hermes is installed and whether the gateway is running.
- Starts Hermes with `hermes gateway run` in the current CloudCLI server environment.
- Stops or restarts a gateway process started by CloudCLI.
- Opens `hermes gateway setup` in the terminal for platform configuration.
- Shows detected Hermes profiles for visibility.
- Shows recent logs from the gateway process managed by CloudCLI.
## What It Does Not Do
- It does not expose the Hermes HTTP gateway as a raw public API.
- It does not add a new authentication model.
- It does not replace the CloudCLI Agent API.
- It does not create Docker containers or require Docker.
## API Boundaries
CloudCLI has two separate surfaces:
- **CloudCLI Agent API**: `POST /api/agent`
Use this for programmatic CloudCLI tasks, including Hermes tasks. It supports `projectPath`, `githubUrl`, sessions, branches, and other CloudCLI workflow options.
- **Hermes Gateway controls**: `/api/providers/hermes/gateway/*`
These are browser-authenticated UI control endpoints used by the settings page.
The gateway controls intentionally stay inside the authenticated provider API. They are not a customer-facing replacement for `POST /api/agent`.
## Hosted Environments
In hosted or containerized environments, CloudCLI runs the gateway in foreground mode because system service managers such as systemd or launchd may not be available. This matches Hermes' recommended foreground mode for containers and similar runtimes.
If Docker is not available, the Gateway tab still works for the local Hermes process. Docker is only relevant for advanced deployments where a user chooses to run Hermes separately.
## References
- [Hermes Agent releases](https://github.com/NousResearch/hermes-agent/releases) for current gateway and messaging platform capabilities.
- [Hermes hooks documentation](https://hermes-agent.nousresearch.com/docs/user-guide/features/hooks) for non-interactive gateway runs and hook approval behavior.
- [Hermes environment variables](https://hermes-agent.nousresearch.com/docs/reference/environment-variables) for Docker-image-specific gateway supervision details.

View File

@@ -2,6 +2,7 @@ import express, { type Request, type Response } from 'express';
import { providerAuthService } from '@/modules/providers/services/provider-auth.service.js'; import { providerAuthService } from '@/modules/providers/services/provider-auth.service.js';
import { providerCapabilitiesService } from '@/modules/providers/services/provider-capabilities.service.js'; import { providerCapabilitiesService } from '@/modules/providers/services/provider-capabilities.service.js';
import { hermesGatewayService } from '@/modules/providers/services/hermes-gateway.service.js';
import { providerMcpService } from '@/modules/providers/services/mcp.service.js'; import { providerMcpService } from '@/modules/providers/services/mcp.service.js';
import { providerModelsService } from '@/modules/providers/services/provider-models.service.js'; import { providerModelsService } from '@/modules/providers/services/provider-models.service.js';
import { providerSkillsService } from '@/modules/providers/services/skills.service.js'; import { providerSkillsService } from '@/modules/providers/services/skills.service.js';
@@ -340,6 +341,18 @@ const parseProvider = (value: unknown): LLMProvider => {
}); });
}; };
const parseHermesProvider = (value: unknown): 'hermes' => {
const provider = parseProvider(value);
if (provider !== 'hermes') {
throw new AppError('Gateway controls are only available for Hermes.', {
code: 'HERMES_GATEWAY_UNSUPPORTED_PROVIDER',
statusCode: 404,
});
}
return provider;
};
const parseSessionRenameSummary = (payload: unknown): string => { const parseSessionRenameSummary = (payload: unknown): string => {
if (!payload || typeof payload !== 'object') { if (!payload || typeof payload !== 'object') {
throw new AppError('Request body must be an object.', { throw new AppError('Request body must be an object.', {
@@ -637,6 +650,51 @@ router.get(
}), }),
); );
// ----------------- Hermes gateway routes -----------------
router.get(
'/:provider/gateway/status',
asyncHandler(async (req: Request, res: Response) => {
parseHermesProvider(req.params.provider);
const status = await hermesGatewayService.getStatus();
res.json(createApiSuccessResponse(status));
}),
);
router.get(
'/:provider/gateway/logs',
asyncHandler(async (req: Request, res: Response) => {
parseHermesProvider(req.params.provider);
res.json(createApiSuccessResponse({ logs: hermesGatewayService.getLogs() }));
}),
);
router.post(
'/:provider/gateway/start',
asyncHandler(async (req: Request, res: Response) => {
parseHermesProvider(req.params.provider);
const status = await hermesGatewayService.start();
res.json(createApiSuccessResponse(status));
}),
);
router.post(
'/:provider/gateway/stop',
asyncHandler(async (req: Request, res: Response) => {
parseHermesProvider(req.params.provider);
const status = await hermesGatewayService.stop();
res.json(createApiSuccessResponse(status));
}),
);
router.post(
'/:provider/gateway/restart',
asyncHandler(async (req: Request, res: Response) => {
parseHermesProvider(req.params.provider);
const status = await hermesGatewayService.restart();
res.json(createApiSuccessResponse(status));
}),
);
// ----------------- Session routes ----------------- // ----------------- Session routes -----------------
/** /**
* Session gateway entry point: allocates the stable app-facing session id for * Session gateway entry point: allocates the stable app-facing session id for

View File

@@ -0,0 +1,317 @@
import { spawn, execFile, type ChildProcess } from 'node:child_process';
import { promisify } from 'node:util';
import { AppError } from '@/shared/utils.js';
const execFileAsync = promisify(execFile);
const gatewayCommandParts = (process.env.HERMES_GATEWAY_COMMAND || '').trim().split(/\s+/).filter(Boolean);
const fallbackHermesCommand = (
process.env.HERMES_COMMAND_PATH
|| process.env.HERMES_CLI_PATH
|| 'hermes'
).trim().split(/\s+/)[0] || 'hermes';
const HERMES_COMMAND = gatewayCommandParts[0] || fallbackHermesCommand;
const HERMES_BASE_ARGS = gatewayCommandParts.slice(1);
const MAX_LOG_LINES = 300;
const COMMAND_TIMEOUT_MS = 10_000;
type CommandResult = {
stdout: string;
stderr: string;
exitCode: number | null;
error?: string;
};
export type HermesGatewayProfile = {
name: string;
current: boolean;
model: string | null;
gateway: string | null;
alias: string | null;
distribution: string | null;
};
export type HermesGatewayStatus = {
installed: boolean;
command: string;
version: string | null;
running: boolean;
managedByCloudCLI: boolean;
state: 'running' | 'stopped' | 'unknown';
statusOutput: string;
profiles: HermesGatewayProfile[];
logs: string[];
lastExit: {
code: number | null;
signal: NodeJS.Signals | null;
at: string;
} | null;
commands: {
setup: string;
run: string;
};
};
const removeAnsi = (value: string): string => value.replace(/\x1B\[[0-?]*[ -/]*[@-~]/g, '');
const compactOutput = (result: CommandResult): string => (
[result.stdout, result.stderr].filter(Boolean).join('\n').trim()
);
const parseGatewayRunning = (output: string, managedByCloudCLI: boolean): boolean => {
if (managedByCloudCLI) {
return true;
}
const normalized = output.toLowerCase();
if (/\b(not running|stopped|inactive|failed)\b/.test(normalized)) {
return false;
}
return /\b(running|active)\b/.test(normalized);
};
const parseProfiles = (output: string): HermesGatewayProfile[] => {
const profiles: HermesGatewayProfile[] = [];
for (const rawLine of removeAnsi(output).split(/\r?\n/)) {
const line = rawLine.trim();
if (!line || line.startsWith('Profile ') || /^[-─\s]+$/.test(line)) {
continue;
}
const current = line.startsWith('◆');
const cleaned = line.replace(/^[◆*]\s*/, '').trim();
const columns = cleaned.split(/\s{2,}/).map((column) => column.trim());
const [name, model, gateway, alias, distribution] = columns;
if (!name || name.toLowerCase() === 'profile') {
continue;
}
profiles.push({
name,
current,
model: model && model !== '—' ? model : null,
gateway: gateway && gateway !== '—' ? gateway : null,
alias: alias && alias !== '—' ? alias : null,
distribution: distribution && distribution !== '—' ? distribution : null,
});
}
return profiles;
};
class HermesGatewayService {
private gatewayProcess: ChildProcess | null = null;
private readonly logs: string[] = [];
private lastExit: HermesGatewayStatus['lastExit'] = null;
async getStatus(): Promise<HermesGatewayStatus> {
const versionResult = await this.runHermes(['--version'], { timeout: 5000 });
const installed = versionResult.exitCode === 0;
const version = installed ? compactOutput(versionResult).split(/\r?\n/)[0] || null : null;
const statusResult = installed
? await this.runHermes(['gateway', 'status'], { timeout: COMMAND_TIMEOUT_MS })
: { stdout: '', stderr: versionResult.error || 'Hermes is not installed.', exitCode: 1 };
const profilesResult = installed
? await this.runHermes(['profile', 'list'], { timeout: COMMAND_TIMEOUT_MS })
: { stdout: '', stderr: '', exitCode: 1 };
const statusOutput = compactOutput(statusResult);
const managedByCloudCLI = this.isManagedProcessRunning();
const running = parseGatewayRunning(statusOutput, managedByCloudCLI);
return {
installed,
command: this.commandPrefix().join(' '),
version,
running,
managedByCloudCLI,
state: running ? 'running' : statusOutput ? 'stopped' : 'unknown',
statusOutput,
profiles: parseProfiles(profilesResult.stdout),
logs: this.getLogs(),
lastExit: this.lastExit,
commands: {
setup: [...this.commandPrefix(), 'gateway', 'setup'].join(' '),
run: [...this.commandPrefix(), 'gateway', 'run'].join(' '),
},
};
}
async start(): Promise<HermesGatewayStatus> {
await this.assertInstalled();
if (this.isManagedProcessRunning()) {
return this.getStatus();
}
const currentStatus = await this.getStatus();
if (currentStatus.running) {
return currentStatus;
}
const args = [...HERMES_BASE_ARGS, 'gateway', 'run', '--accept-hooks'];
this.appendLog(`[cloudcli] starting Hermes gateway: ${HERMES_COMMAND} ${args.join(' ')}`);
this.lastExit = null;
const child = spawn(HERMES_COMMAND, args, {
env: {
...process.env,
HERMES_ACCEPT_HOOKS: '1',
},
stdio: ['ignore', 'pipe', 'pipe'],
});
this.gatewayProcess = child;
child.stdout?.on('data', (chunk) => this.appendLog(String(chunk)));
child.stderr?.on('data', (chunk) => this.appendLog(String(chunk)));
child.on('error', (error) => {
this.appendLog(`[cloudcli] gateway process error: ${error.message}`);
});
child.on('exit', (code, signal) => {
this.lastExit = {
code,
signal,
at: new Date().toISOString(),
};
this.appendLog(`[cloudcli] gateway exited with code ${code ?? 'null'}${signal ? ` signal ${signal}` : ''}`);
this.gatewayProcess = null;
});
await new Promise((resolve) => {
setTimeout(resolve, 500);
});
if (!this.isManagedProcessRunning()) {
throw new AppError('Hermes gateway exited before it could start.', {
code: 'HERMES_GATEWAY_START_FAILED',
statusCode: 500,
details: {
logs: this.getLogs().slice(-20),
lastExit: this.lastExit,
},
});
}
return this.getStatus();
}
async stop(): Promise<HermesGatewayStatus> {
if (this.isManagedProcessRunning() && this.gatewayProcess) {
this.appendLog('[cloudcli] stopping managed Hermes gateway');
await this.stopManagedProcess();
return this.getStatus();
}
await this.runHermes(['gateway', 'stop'], { timeout: COMMAND_TIMEOUT_MS });
return this.getStatus();
}
async restart(): Promise<HermesGatewayStatus> {
if (this.isManagedProcessRunning()) {
await this.stopManagedProcess();
} else {
await this.runHermes(['gateway', 'stop'], { timeout: COMMAND_TIMEOUT_MS });
}
return this.start();
}
getLogs(): string[] {
return [...this.logs];
}
private async assertInstalled(): Promise<void> {
const result = await this.runHermes(['--version'], { timeout: 5000 });
if (result.exitCode !== 0) {
throw new AppError('Hermes is not installed or is not available on PATH.', {
code: 'HERMES_NOT_INSTALLED',
statusCode: 400,
details: compactOutput(result),
});
}
}
private isManagedProcessRunning(): boolean {
return Boolean(this.gatewayProcess && !this.gatewayProcess.killed && this.gatewayProcess.exitCode === null);
}
private async stopManagedProcess(): Promise<void> {
const child = this.gatewayProcess;
if (!child) {
return;
}
const exited = new Promise<void>((resolve) => {
child.once('exit', () => resolve());
});
child.kill('SIGTERM');
await Promise.race([
exited,
new Promise<void>((resolve) => {
setTimeout(() => {
if (this.gatewayProcess === child && this.isManagedProcessRunning()) {
this.appendLog('[cloudcli] gateway did not stop after SIGTERM; sending SIGKILL');
child.kill('SIGKILL');
}
resolve();
}, 5000);
}),
]);
}
private async runHermes(args: string[], options: { timeout: number }): Promise<CommandResult> {
try {
const result = await execFileAsync(HERMES_COMMAND, [...HERMES_BASE_ARGS, ...args], {
timeout: options.timeout,
maxBuffer: 1024 * 1024,
env: {
...process.env,
HERMES_ACCEPT_HOOKS: '1',
},
});
return {
stdout: result.stdout ?? '',
stderr: result.stderr ?? '',
exitCode: 0,
};
} catch (error) {
const execError = error as NodeJS.ErrnoException & {
stdout?: string;
stderr?: string;
code?: number | string | null;
};
return {
stdout: execError.stdout ?? '',
stderr: execError.stderr ?? '',
exitCode: typeof execError.code === 'number' ? execError.code : 1,
error: execError.message,
};
}
}
private appendLog(chunk: string): void {
const lines = removeAnsi(chunk)
.split(/\r?\n/)
.map((line) => line.trimEnd())
.filter(Boolean);
if (lines.length === 0) {
return;
}
this.logs.push(...lines.map((line) => `${new Date().toISOString()} ${line}`));
if (this.logs.length > MAX_LOG_LINES) {
this.logs.splice(0, this.logs.length - MAX_LOG_LINES);
}
}
private commandPrefix(): string[] {
return [HERMES_COMMAND, ...HERMES_BASE_ARGS];
}
}
export const hermesGatewayService = new HermesGatewayService();

View File

@@ -5,7 +5,7 @@ import type { ProviderAuthStatus } from '../../provider-auth/types';
export type SettingsMainTab = 'agents' | 'appearance' | 'git' | 'api' | 'voice' | 'tasks' | 'browser' | 'notifications' | 'plugins' | 'about'; export type SettingsMainTab = 'agents' | 'appearance' | 'git' | 'api' | 'voice' | 'tasks' | 'browser' | 'notifications' | 'plugins' | 'about';
export type AgentProvider = LLMProvider; export type AgentProvider = LLMProvider;
export type AgentCategory = 'account' | 'permissions' | 'mcp' | 'skills'; export type AgentCategory = 'account' | 'permissions' | 'gateway' | 'mcp' | 'skills';
export type ProjectSortOrder = 'name' | 'date'; export type ProjectSortOrder = 'name' | 'date';
export type SaveStatus = 'success' | 'error' | null; export type SaveStatus = 'success' | 'error' | null;
export type CodexPermissionMode = 'default' | 'acceptEdits' | 'bypassPermissions'; export type CodexPermissionMode = 'default' | 'acceptEdits' | 'bypassPermissions';

View File

@@ -25,6 +25,8 @@ export default function AgentsSettingsTab({
const visibleCategories = useMemo<AgentCategory[]>(() => ( const visibleCategories = useMemo<AgentCategory[]>(() => (
selectedAgent === 'opencode' selectedAgent === 'opencode'
? ['account', 'permissions', 'mcp'] ? ['account', 'permissions', 'mcp']
: selectedAgent === 'hermes'
? ['account', 'gateway', 'mcp', 'skills']
: ['account', 'permissions', 'mcp', 'skills'] : ['account', 'permissions', 'mcp', 'skills']
), [selectedAgent]); ), [selectedAgent]);

View File

@@ -5,6 +5,7 @@ import type { SkillsProject } from '../../../../../skills/types';
import { ProviderSkills } from '../../../../../skills'; import { ProviderSkills } from '../../../../../skills';
import AccountContent from './content/AccountContent'; import AccountContent from './content/AccountContent';
import GatewayContent from './content/GatewayContent';
import PermissionsContent from './content/PermissionsContent'; import PermissionsContent from './content/PermissionsContent';
export default function AgentCategoryContentSection({ export default function AgentCategoryContentSection({
@@ -29,6 +30,12 @@ export default function AgentCategoryContentSection({
/> />
)} )}
{selectedCategory === 'gateway' && selectedAgent === 'hermes' && (
<GatewayContent
onOpenSetup={agentContextById.hermes.onLogin}
/>
)}
{selectedCategory === 'permissions' && selectedAgent === 'claude' && ( {selectedCategory === 'permissions' && selectedAgent === 'claude' && (
<PermissionsContent <PermissionsContent
agent="claude" agent="claude"

View File

@@ -29,6 +29,7 @@ export default function AgentCategoryTabsSection({
> >
{category === 'account' && t('tabs.account')} {category === 'account' && t('tabs.account')}
{category === 'permissions' && t('tabs.permissions')} {category === 'permissions' && t('tabs.permissions')}
{category === 'gateway' && t('tabs.gateway', { defaultValue: 'Gateway' })}
{category === 'mcp' && t('tabs.mcpServers')} {category === 'mcp' && t('tabs.mcpServers')}
{category === 'skills' && t('tabs.skills', { {category === 'skills' && t('tabs.skills', {
defaultValue: selectedAgent === 'opencode' ? 'Shared Skills' : 'Skills', defaultValue: selectedAgent === 'opencode' ? 'Shared Skills' : 'Skills',

View File

@@ -0,0 +1,314 @@
import { useCallback, useEffect, useState } from 'react';
import {
Activity,
Layers3,
Play,
RefreshCw,
RotateCcw,
Square,
Terminal,
} from 'lucide-react';
import { Badge, Button, HelpTooltip } from '../../../../../../../shared/view/ui';
import { authenticatedFetch } from '../../../../../../../utils/api';
import SettingsCard from '../../../../SettingsCard';
import SettingsSection from '../../../../SettingsSection';
type GatewayProfile = {
name: string;
current: boolean;
model: string | null;
gateway: string | null;
alias: string | null;
distribution: string | null;
};
type GatewayStatus = {
installed: boolean;
command: string;
version: string | null;
running: boolean;
managedByCloudCLI: boolean;
state: 'running' | 'stopped' | 'unknown';
statusOutput: string;
profiles: GatewayProfile[];
logs: string[];
lastExit: {
code: number | null;
signal: string | null;
at: string;
} | null;
commands: {
setup: string;
run: string;
};
};
type ApiSuccess<T> = {
success: boolean;
data: T;
};
type GatewayContentProps = {
onOpenSetup: (customCommand?: string, customTitle?: string) => void;
};
const gatewayTooltip = 'Use this when tools outside CloudCLI need to reach Hermes through messaging integrations. Normal CloudCLI chat uses the built-in agent API.';
const setupTooltip = 'Opens Hermes setup in the terminal so you can connect Telegram, Discord, WhatsApp, or another supported platform.';
const profilesTooltip = 'Profiles are isolated Hermes configurations. This page shows them for visibility and controls the active gateway process.';
const logsTooltip = 'These logs come from the gateway process started by CloudCLI in this server session.';
async function readGatewayResponse<T>(response: Response): Promise<T> {
const payload = await response.json().catch(() => null) as ApiSuccess<T> | { error?: string } | null;
if (!response.ok || !payload || !('success' in payload) || !payload.success) {
throw new Error((payload && 'error' in payload && payload.error) || 'Gateway request failed');
}
return payload.data;
}
export default function GatewayContent({ onOpenSetup }: GatewayContentProps) {
const [status, setStatus] = useState<GatewayStatus | null>(null);
const [loading, setLoading] = useState(true);
const [action, setAction] = useState<'start' | 'stop' | 'restart' | null>(null);
const [error, setError] = useState<string | null>(null);
const refreshStatus = useCallback(async () => {
setError(null);
try {
const response = await authenticatedFetch('/api/providers/hermes/gateway/status');
const nextStatus = await readGatewayResponse<GatewayStatus>(response);
setStatus(nextStatus);
} catch (refreshError) {
setError(refreshError instanceof Error ? refreshError.message : 'Could not load gateway status');
} finally {
setLoading(false);
}
}, []);
const runAction = useCallback(async (nextAction: 'start' | 'stop' | 'restart') => {
setAction(nextAction);
setError(null);
try {
const response = await authenticatedFetch(`/api/providers/hermes/gateway/${nextAction}`, {
method: 'POST',
});
const nextStatus = await readGatewayResponse<GatewayStatus>(response);
setStatus(nextStatus);
} catch (actionError) {
setError(actionError instanceof Error ? actionError.message : `Could not ${nextAction} gateway`);
void refreshStatus();
} finally {
setAction(null);
}
}, [refreshStatus]);
useEffect(() => {
void refreshStatus();
}, [refreshStatus]);
const busy = Boolean(action);
const running = Boolean(status?.running);
return (
<div className="space-y-6">
<div className="mb-4 flex items-center gap-3">
<div className="flex h-9 w-9 items-center justify-center rounded-md bg-emerald-100 text-emerald-800 dark:bg-emerald-900/30 dark:text-emerald-200">
<Activity className="h-5 w-5" />
</div>
<div className="min-w-0">
<div className="flex items-center gap-2">
<h3 className="text-lg font-medium text-foreground">Hermes Gateway</h3>
<HelpTooltip content={gatewayTooltip} position="right" />
</div>
<p className="text-sm text-muted-foreground">
Manage Hermes messaging gateway runtime for the active environment.
</p>
</div>
</div>
<SettingsSection
title="Runtime"
description="Start the gateway in the current CloudCLI server environment."
>
<SettingsCard>
<div className="flex flex-col gap-4 p-4 lg:flex-row lg:items-start lg:justify-between">
<div className="min-w-0 space-y-3">
<div className="flex flex-wrap items-center gap-2">
<Badge
variant="secondary"
className={running
? 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300'
: 'bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-300'}
>
{loading ? 'Checking' : running ? 'Running' : 'Stopped'}
</Badge>
{status?.managedByCloudCLI && (
<Badge variant="outline">Managed by CloudCLI</Badge>
)}
{status && !status.installed && (
<Badge variant="destructive">Hermes not installed</Badge>
)}
</div>
<div className="space-y-1 text-sm">
<div className="text-foreground">
Command: <span className="font-mono text-muted-foreground">{status?.command ?? 'hermes'}</span>
</div>
<div className="text-muted-foreground">
{status?.version ?? 'Hermes status will appear after refresh.'}
</div>
{status?.lastExit && (
<div className="text-muted-foreground">
Last exit: code {status.lastExit.code ?? 'null'}
{status.lastExit.signal ? `, signal ${status.lastExit.signal}` : ''}
</div>
)}
</div>
</div>
<div className="flex flex-wrap gap-2 lg:justify-end">
<Button
type="button"
variant="outline"
size="sm"
onClick={() => void refreshStatus()}
disabled={loading || busy}
>
<RefreshCw className="h-4 w-4" />
Refresh
</Button>
<Button
type="button"
size="sm"
onClick={() => void runAction('start')}
disabled={!status?.installed || running || busy}
>
<Play className="h-4 w-4" />
{action === 'start' ? 'Starting' : 'Start'}
</Button>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => void runAction('restart')}
disabled={!status?.installed || busy}
>
<RotateCcw className="h-4 w-4" />
{action === 'restart' ? 'Restarting' : 'Restart'}
</Button>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => void runAction('stop')}
disabled={!status?.installed || !running || busy}
>
<Square className="h-4 w-4" />
{action === 'stop' ? 'Stopping' : 'Stop'}
</Button>
</div>
</div>
{status?.statusOutput && (
<div className="border-t border-border px-4 py-3">
<pre className="max-h-36 overflow-auto whitespace-pre-wrap text-xs leading-relaxed text-muted-foreground">
{status.statusOutput}
</pre>
</div>
)}
{error && (
<div className="border-t border-border px-4 py-3 text-sm text-red-600 dark:text-red-400">
{error}
</div>
)}
</SettingsCard>
</SettingsSection>
<SettingsSection
title="Setup"
description="Configure messaging platforms through the Hermes CLI."
>
<SettingsCard>
<div className="flex flex-col gap-3 p-4 sm:flex-row sm:items-center sm:justify-between">
<div className="min-w-0">
<div className="flex items-center gap-2 text-sm font-medium text-foreground">
Platform setup
<HelpTooltip content={setupTooltip} position="right" />
</div>
<div className="mt-1 text-sm text-muted-foreground">
Opens <span className="font-mono">hermes gateway setup</span> in the shell.
</div>
</div>
<Button
type="button"
variant="outline"
size="sm"
className="w-full sm:w-auto"
onClick={() => onOpenSetup('hermes gateway setup', 'Hermes Gateway Setup')}
>
<Terminal className="h-4 w-4" />
Open setup
</Button>
</div>
</SettingsCard>
</SettingsSection>
<SettingsSection
title="Profiles"
description="View Hermes profiles detected in this environment."
>
<SettingsCard>
<div className="flex items-center gap-2 border-b border-border px-4 py-3 text-sm font-medium text-foreground">
<Layers3 className="h-4 w-4 text-muted-foreground" />
Hermes profiles
<HelpTooltip content={profilesTooltip} position="right" />
</div>
{status?.profiles.length ? (
<div className="divide-y divide-border">
{status.profiles.map((profile) => (
<div key={profile.name} className="grid gap-2 px-4 py-3 text-sm sm:grid-cols-[1fr_auto_auto] sm:items-center">
<div className="min-w-0">
<div className="flex items-center gap-2 font-medium text-foreground">
{profile.name}
{profile.current && <Badge variant="outline">Current</Badge>}
</div>
<div className="mt-0.5 truncate text-muted-foreground">
{profile.model ?? 'No model configured'}
</div>
</div>
<div className="text-muted-foreground">
Gateway: {profile.gateway ?? 'unknown'}
</div>
<div className="text-muted-foreground">
Alias: {profile.alias ?? 'none'}
</div>
</div>
))}
</div>
) : (
<div className="px-4 py-4 text-sm text-muted-foreground">
{loading ? 'Loading profiles...' : 'No Hermes profiles were found.'}
</div>
)}
</SettingsCard>
</SettingsSection>
<SettingsSection
title="Logs"
description="Recent output from the gateway process managed by CloudCLI."
>
<SettingsCard>
<div className="flex items-center gap-2 border-b border-border px-4 py-3 text-sm font-medium text-foreground">
Gateway output
<HelpTooltip content={logsTooltip} position="right" />
</div>
<pre className="max-h-64 overflow-auto whitespace-pre-wrap px-4 py-3 text-xs leading-relaxed text-muted-foreground">
{status?.logs.length ? status.logs.join('\n') : 'No CloudCLI-managed gateway logs yet.'}
</pre>
</SettingsCard>
</SettingsSection>
</div>
);
}

View File

@@ -3,6 +3,7 @@
"tabs": { "tabs": {
"account": "Account", "account": "Account",
"permissions": "Permissions", "permissions": "Permissions",
"gateway": "Gateway",
"mcpServers": "MCP Servers", "mcpServers": "MCP Servers",
"skills": "Skills", "skills": "Skills",
"appearance": "Appearance" "appearance": "Appearance"

View File

@@ -0,0 +1,32 @@
import { CircleHelp } from 'lucide-react';
import Tooltip from './Tooltip';
type HelpTooltipProps = {
content: string;
label?: string;
position?: 'top' | 'bottom' | 'left' | 'right';
};
export default function HelpTooltip({
content,
label = 'Help',
position = 'top',
}: HelpTooltipProps) {
return (
<Tooltip
content={content}
position={position}
className="max-w-[260px] whitespace-normal px-3 py-2 text-left text-xs leading-relaxed"
>
<span
aria-label={label}
className="inline-flex h-5 w-5 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
role="img"
title={content}
>
<CircleHelp className="h-4 w-4" />
</span>
</Tooltip>
);
}

View File

@@ -9,6 +9,7 @@ export { Collapsible, CollapsibleTrigger, CollapsibleContent } from './Collapsib
export { Command, CommandInput, CommandList, CommandEmpty, CommandGroup, CommandItem, CommandSeparator } from './Command'; export { Command, CommandInput, CommandList, CommandEmpty, CommandGroup, CommandItem, CommandSeparator } from './Command';
export { default as DarkModeToggle } from './DarkModeToggle'; export { default as DarkModeToggle } from './DarkModeToggle';
export { Dialog, DialogTrigger, DialogContent, DialogTitle } from './Dialog'; export { Dialog, DialogTrigger, DialogContent, DialogTitle } from './Dialog';
export { default as HelpTooltip } from './HelpTooltip';
export { Input } from './Input'; export { Input } from './Input';
export { ScrollArea } from './ScrollArea'; export { ScrollArea } from './ScrollArea';
export { Reasoning, ReasoningTrigger, ReasoningContent, useReasoning } from './Reasoning'; export { Reasoning, ReasoningTrigger, ReasoningContent, useReasoning } from './Reasoning';