feat: add Hermes provider

This commit is contained in:
Simos Mikelatos
2026-06-30 09:51:18 +00:00
parent 2ebe64f218
commit 048c671b13
49 changed files with 2816 additions and 76 deletions

View File

@@ -39,6 +39,7 @@ interface UseChatComposerStateArgs {
codexModel: string;
geminiModel: string;
opencodeModel: string;
hermesModel: string;
isLoading: boolean;
canAbortSession: boolean;
tokenBudget: Record<string, unknown> | null;
@@ -173,6 +174,7 @@ export function useChatComposerState({
codexModel,
geminiModel,
opencodeModel,
hermesModel,
isLoading,
canAbortSession,
tokenBudget,
@@ -336,6 +338,8 @@ export function useChatComposerState({
? geminiModel
: provider === 'opencode'
? opencodeModel
: provider === 'hermes'
? (hermesModel === '__hermes_configured_model__' ? undefined : hermesModel)
: claudeModel,
tokenUsage: tokenBudget,
};
@@ -391,6 +395,7 @@ export function useChatComposerState({
cursorModel,
geminiModel,
opencodeModel,
hermesModel,
handleBuiltInCommand,
handleCustomCommand,
input,
@@ -703,6 +708,8 @@ export function useChatComposerState({
? 'gemini-settings'
: provider === 'opencode'
? 'opencode-settings'
: provider === 'hermes'
? 'hermes-settings'
: 'claude-settings';
const savedSettings = safeLocalStorage.getItem(settingsKey);
if (savedSettings) {
@@ -729,6 +736,8 @@ export function useChatComposerState({
? geminiModel
: provider === 'opencode'
? opencodeModel
: provider === 'hermes'
? (hermesModel === '__hermes_configured_model__' ? undefined : hermesModel)
: claudeModel;
// One message shape for every provider. The backend resolves the
@@ -774,6 +783,7 @@ export function useChatComposerState({
executeCommand,
geminiModel,
opencodeModel,
hermesModel,
isLoading,
onSessionProcessing,
onSessionEstablished,

View File

@@ -15,6 +15,7 @@ const FALLBACK_DEFAULT_MODEL: Record<LLMProvider, string> = {
codex: 'gpt-5.4',
gemini: 'gemini-3.1-pro-preview',
opencode: 'anthropic/claude-sonnet-4-5',
hermes: '__hermes_configured_model__',
};
/**
@@ -29,6 +30,7 @@ const FALLBACK_PERMISSION_MODES: Record<LLMProvider, PermissionMode[]> = {
codex: ['default', 'acceptEdits', 'bypassPermissions'],
gemini: ['default', 'acceptEdits', 'bypassPermissions', 'plan'],
opencode: ['default'],
hermes: ['default'],
};
type ProviderCapabilities = {
@@ -93,6 +95,9 @@ export function useChatProviderState({ selectedSession, selectedProject }: UseCh
const [opencodeModel, setOpenCodeModel] = useState<string>(() => {
return localStorage.getItem('opencode-model') || FALLBACK_DEFAULT_MODEL.opencode;
});
const [hermesModel, setHermesModel] = useState<string>(() => {
return localStorage.getItem('hermes-model') || FALLBACK_DEFAULT_MODEL.hermes;
});
/**
* Backend-owned capability matrix keyed by provider. Drives the permission
@@ -141,12 +146,20 @@ export function useChatProviderState({ selectedSession, selectedProject }: UseCh
return;
}
setOpenCodeModel(model);
localStorage.setItem('opencode-model', model);
if (targetProvider === 'opencode') {
setOpenCodeModel(model);
localStorage.setItem('opencode-model', model);
return;
}
if (targetProvider === 'hermes') {
setHermesModel(model);
localStorage.setItem('hermes-model', model);
}
}, []);
const loadProviderModels = useCallback(async (options: { bypassCache?: boolean } = {}) => {
const providers: LLMProvider[] = ['claude', 'cursor', 'codex', 'gemini', 'opencode'];
const providers: LLMProvider[] = ['claude', 'cursor', 'codex', 'gemini', 'opencode', 'hermes'];
const requestId = providerModelsRequestIdRef.current + 1;
providerModelsRequestIdRef.current = requestId;
const isHardRefresh = options.bypassCache === true;
@@ -324,6 +337,19 @@ export function useChatProviderState({ selectedSession, selectedProject }: UseCh
}
}, [providerModelCatalog.opencode, opencodeModel]);
useEffect(() => {
const hermes = providerModelCatalog.hermes;
if (hermes) {
const next = pickStoredOrCurrent('hermes-model', hermesModel, hermes);
if (next !== hermesModel) {
setHermesModel(next);
}
if (localStorage.getItem('hermes-model') !== next) {
localStorage.setItem('hermes-model', next);
}
}
}, [providerModelCatalog.hermes, hermesModel]);
useEffect(() => {
if (!selectedSession?.id) {
return;
@@ -434,6 +460,8 @@ export function useChatProviderState({ selectedSession, selectedProject }: UseCh
setGeminiModel,
opencodeModel,
setOpenCodeModel,
hermesModel,
setHermesModel,
permissionMode,
setPermissionMode,
pendingPermissionRequests,

View File

@@ -75,6 +75,8 @@ function ChatInterface({
setGeminiModel,
opencodeModel,
setOpenCodeModel,
hermesModel,
setHermesModel,
permissionMode,
pendingPermissionRequests,
setPendingPermissionRequests,
@@ -201,6 +203,7 @@ function ChatInterface({
codexModel,
geminiModel,
opencodeModel,
hermesModel,
isLoading: isProcessing,
canAbortSession,
tokenBudget,
@@ -293,7 +296,9 @@ function ChatInterface({
? t('messageTypes.gemini')
: provider === 'opencode'
? t('messageTypes.opencode', { defaultValue: 'OpenCode' })
: t('messageTypes.claude');
: provider === 'hermes'
? t('messageTypes.hermes', { defaultValue: 'Hermes' })
: t('messageTypes.claude');
return (
<div className="flex h-full items-center justify-center">
@@ -334,6 +339,8 @@ function ChatInterface({
setGeminiModel={setGeminiModel}
opencodeModel={opencodeModel}
setOpenCodeModel={setOpenCodeModel}
hermesModel={hermesModel}
setHermesModel={setHermesModel}
providerModelCatalog={providerModelCatalog}
providerModelsLoading={providerModelsLoading}
tasksEnabled={tasksEnabled}
@@ -425,7 +432,9 @@ function ChatInterface({
? t('messageTypes.gemini')
: provider === 'opencode'
? t('messageTypes.opencode', { defaultValue: 'OpenCode' })
: t('messageTypes.claude'),
: provider === 'hermes'
? t('messageTypes.hermes', { defaultValue: 'Hermes' })
: t('messageTypes.claude'),
})}
isTextareaExpanded={isTextareaExpanded}
sendByCtrlEnter={sendByCtrlEnter}

View File

@@ -39,6 +39,8 @@ interface ChatMessagesPaneProps {
setGeminiModel: (model: string) => void;
opencodeModel: string;
setOpenCodeModel: (model: string) => void;
hermesModel: string;
setHermesModel: (model: string) => void;
providerModelCatalog: Partial<Record<LLMProvider, ProviderModelsDefinition>>;
providerModelsLoading: boolean;
tasksEnabled: boolean;
@@ -89,6 +91,8 @@ function ChatMessagesPane({
setGeminiModel,
opencodeModel,
setOpenCodeModel,
hermesModel,
setHermesModel,
providerModelCatalog,
providerModelsLoading,
tasksEnabled,
@@ -177,6 +181,8 @@ function ChatMessagesPane({
setGeminiModel={setGeminiModel}
opencodeModel={opencodeModel}
setOpenCodeModel={setOpenCodeModel}
hermesModel={hermesModel}
setHermesModel={setHermesModel}
providerModelCatalog={providerModelCatalog}
providerModelsLoading={providerModelsLoading}
tasksEnabled={tasksEnabled}

View File

@@ -63,6 +63,7 @@ const PROVIDER_LABELS: Record<string, string> = {
codex: 'Codex',
gemini: 'Gemini',
opencode: 'OpenCode',
hermes: 'Hermes',
};
const FALLBACK_COMMANDS: CommandEntry[] = [

View File

@@ -183,6 +183,8 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, a
? t('messageTypes.gemini')
: provider === 'opencode'
? t('messageTypes.opencode', { defaultValue: 'OpenCode' })
: provider === 'hermes'
? t('messageTypes.hermes', { defaultValue: 'Hermes' })
: t('messageTypes.claude'))}
</div>
</div>
@@ -430,4 +432,3 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, a
});
export default MessageComponent;

View File

@@ -29,6 +29,7 @@ const PROVIDER_META: { id: LLMProvider; name: string }[] = [
{ id: "gemini", name: "Google" },
{ id: "cursor", name: "Cursor" },
{ id: "opencode", name: "OpenCode" },
{ id: "hermes", name: "Hermes" },
];
const MOD_KEY =
@@ -50,6 +51,8 @@ type ProviderSelectionEmptyStateProps = {
setGeminiModel: (model: string) => void;
opencodeModel: string;
setOpenCodeModel: (model: string) => void;
hermesModel: string;
setHermesModel: (model: string) => void;
providerModelCatalog: Partial<Record<LLMProvider, ProviderModelsDefinition>>;
providerModelsLoading: boolean;
tasksEnabled: boolean;
@@ -79,11 +82,13 @@ function getCurrentModel(
co: string,
g: string,
o: string,
h: string,
) {
if (p === "claude") return c;
if (p === "codex") return co;
if (p === "gemini") return g;
if (p === "opencode") return o;
if (p === "hermes") return h;
return cu;
}
@@ -92,6 +97,7 @@ function getProviderDisplayName(p: LLMProvider) {
if (p === "cursor") return "Cursor";
if (p === "codex") return "Codex";
if (p === "opencode") return "OpenCode";
if (p === "hermes") return "Hermes";
return "Gemini";
}
@@ -111,6 +117,8 @@ export default function ProviderSelectionEmptyState({
setGeminiModel,
opencodeModel,
setOpenCodeModel,
hermesModel,
setHermesModel,
providerModelCatalog,
providerModelsLoading,
tasksEnabled,
@@ -140,6 +148,7 @@ export default function ProviderSelectionEmptyState({
codexModel,
geminiModel,
opencodeModel,
hermesModel,
);
const currentModelLabel = useMemo(() => {
@@ -164,12 +173,15 @@ export default function ProviderSelectionEmptyState({
} else if (providerId === "opencode") {
setOpenCodeModel(modelValue);
localStorage.setItem("opencode-model", modelValue);
} else if (providerId === "hermes") {
setHermesModel(modelValue);
localStorage.setItem("hermes-model", modelValue);
} else {
setCursorModel(modelValue);
localStorage.setItem("cursor-model", modelValue);
}
},
[setClaudeModel, setCursorModel, setCodexModel, setGeminiModel, setOpenCodeModel],
[setClaudeModel, setCursorModel, setCodexModel, setGeminiModel, setOpenCodeModel, setHermesModel],
);
const handleModelSelect = useCallback(
@@ -319,6 +331,10 @@ export default function ProviderSelectionEmptyState({
model: opencodeModel,
defaultValue: "Ready with OpenCode {{model}}",
}),
hermes: t("providerSelection.readyPrompt.hermes", {
model: hermesModel,
defaultValue: "Ready with Hermes {{model}}",
}),
}[provider]
}
</p>

View File

@@ -0,0 +1,16 @@
type HermesLogoProps = {
className?: string;
};
export default function HermesLogo({ className = 'w-5 h-5' }: HermesLogoProps) {
return (
<svg className={className} viewBox="0 0 24 24" role="img" aria-label="Hermes">
<rect width="24" height="24" rx="6" fill="#047857" />
<path
d="M6.2 6.5h2.4v4.3h6.8V6.5h2.4v11h-2.4v-4.6H8.6v4.6H6.2v-11Z"
fill="white"
/>
<path d="M9.3 4.7h5.4l-1.2 1.2h-3L9.3 4.7Z" fill="#A7F3D0" />
</svg>
);
}

View File

@@ -3,6 +3,7 @@ import ClaudeLogo from './ClaudeLogo';
import CodexLogo from './CodexLogo';
import CursorLogo from './CursorLogo';
import GeminiLogo from './GeminiLogo';
import HermesLogo from './HermesLogo';
import OpenCodeLogo from './OpenCodeLogo';
type SessionProviderLogoProps = {
@@ -30,5 +31,9 @@ export default function SessionProviderLogo({
return <OpenCodeLogo className={className} />;
}
if (provider === 'hermes') {
return <HermesLogo className={className} />;
}
return <ClaudeLogo className={className} />;
}

View File

@@ -6,6 +6,7 @@ export const MCP_PROVIDER_NAMES: Record<McpProvider, string> = {
codex: 'Codex',
gemini: 'Gemini',
opencode: 'OpenCode',
hermes: 'Hermes',
};
export const MCP_SUPPORTED_SCOPES: Record<McpProvider, McpScope[]> = {
@@ -14,6 +15,7 @@ export const MCP_SUPPORTED_SCOPES: Record<McpProvider, McpScope[]> = {
codex: ['user', 'project'],
gemini: ['user', 'project'],
opencode: ['user', 'project'],
hermes: ['user', 'project'],
};
export const MCP_SUPPORTED_TRANSPORTS: Record<McpProvider, McpTransport[]> = {
@@ -22,6 +24,7 @@ export const MCP_SUPPORTED_TRANSPORTS: Record<McpProvider, McpTransport[]> = {
codex: ['stdio', 'http'],
gemini: ['stdio', 'http', 'sse'],
opencode: ['stdio', 'http'],
hermes: ['stdio', 'http'],
};
export const MCP_GLOBAL_SUPPORTED_SCOPES: McpScope[] = ['user', 'project'];
@@ -34,6 +37,7 @@ export const MCP_PROVIDER_BUTTON_CLASSES: Record<McpProvider, string> = {
codex: 'bg-gray-800 text-white hover:bg-gray-900 dark:bg-gray-700 dark:hover:bg-gray-600',
gemini: 'bg-blue-600 text-white hover:bg-blue-700',
opencode: 'bg-zinc-900 text-white hover:bg-zinc-800 dark:bg-zinc-700 dark:hover:bg-zinc-600',
hermes: 'bg-emerald-700 text-white hover:bg-emerald-800 dark:bg-emerald-600 dark:hover:bg-emerald-700',
};
export const MCP_SUPPORTS_WORKING_DIRECTORY: Record<McpProvider, boolean> = {
@@ -42,6 +46,7 @@ export const MCP_SUPPORTS_WORKING_DIRECTORY: Record<McpProvider, boolean> = {
codex: true,
gemini: true,
opencode: false,
hermes: false,
};
export const DEFAULT_MCP_FORM: McpFormState = {

View File

@@ -10,7 +10,7 @@ export type ProviderAuthStatus = {
export type ProviderAuthStatusMap = Record<LLMProvider, ProviderAuthStatus>;
export const CLI_PROVIDERS: LLMProvider[] = ['claude', 'cursor', 'codex', 'gemini', 'opencode'];
export const CLI_PROVIDERS: LLMProvider[] = ['claude', 'cursor', 'codex', 'gemini', 'opencode', 'hermes'];
export const PROVIDER_AUTH_STATUS_ENDPOINTS: Record<LLMProvider, string> = {
claude: '/api/providers/claude/auth/status',
@@ -18,6 +18,7 @@ export const PROVIDER_AUTH_STATUS_ENDPOINTS: Record<LLMProvider, string> = {
codex: '/api/providers/codex/auth/status',
gemini: '/api/providers/gemini/auth/status',
opencode: '/api/providers/opencode/auth/status',
hermes: '/api/providers/hermes/auth/status',
};
export const createInitialProviderAuthStatusMap = (loading = true): ProviderAuthStatusMap => ({
@@ -26,4 +27,5 @@ export const createInitialProviderAuthStatusMap = (loading = true): ProviderAuth
codex: { authenticated: false, email: null, method: null, error: null, loading },
gemini: { authenticated: false, email: null, method: null, error: null, loading },
opencode: { authenticated: false, email: null, method: null, error: null, loading },
hermes: { authenticated: false, email: null, method: null, error: null, loading },
});

View File

@@ -9,6 +9,7 @@ type ProviderLoginModalProps = {
provider?: LLMProvider;
onComplete?: (exitCode: number) => void;
customCommand?: string;
customTitle?: string;
isAuthenticated?: boolean;
};
@@ -41,6 +42,10 @@ const getProviderCommand = ({
return 'opencode auth login';
}
if (provider === 'hermes') {
return 'hermes model';
}
return 'gemini status';
};
@@ -49,6 +54,7 @@ const getProviderTitle = (provider: LLMProvider) => {
if (provider === 'cursor') return 'Cursor CLI Login';
if (provider === 'codex') return 'Codex CLI Login';
if (provider === 'opencode') return 'OpenCode CLI Login';
if (provider === 'hermes') return 'Hermes Agent Setup';
return 'Gemini CLI Configuration';
};
@@ -58,6 +64,7 @@ export default function ProviderLoginModal({
provider = 'claude',
onComplete,
customCommand,
customTitle,
isAuthenticated = false,
}: ProviderLoginModalProps) {
if (!isOpen) {
@@ -65,7 +72,7 @@ export default function ProviderLoginModal({
}
const command = getProviderCommand({ provider, customCommand, isAuthenticated });
const title = getProviderTitle(provider);
const title = customTitle || getProviderTitle(provider);
const handleComplete = (exitCode: number) => {
onComplete?.(exitCode);

View File

@@ -39,7 +39,7 @@ export const SETTINGS_MAIN_TABS: SettingsMainTabMeta[] = [
{ id: 'about', label: 'About', keywords: 'about version info', icon: Info },
];
export const AGENT_PROVIDERS: AgentProvider[] = ['claude', 'cursor', 'codex', 'gemini', 'opencode'];
export const AGENT_PROVIDERS: AgentProvider[] = ['claude', 'cursor', 'codex', 'gemini', 'opencode', 'hermes'];
export const AGENT_CATEGORIES: AgentCategory[] = ['account', 'permissions', 'mcp'];
export const DEFAULT_PROJECT_SORT_ORDER: ProjectSortOrder = 'name';

View File

@@ -164,6 +164,8 @@ export function useSettingsController({ isOpen, initialTab }: UseSettingsControl
const [showLoginModal, setShowLoginModal] = useState(false);
const [loginProvider, setLoginProvider] = useState<ActiveLoginProvider>('');
const [loginCommand, setLoginCommand] = useState<string | undefined>(undefined);
const [loginTitle, setLoginTitle] = useState<string | undefined>(undefined);
const {
providerAuthStatus,
checkProviderAuthStatus,
@@ -231,8 +233,10 @@ export function useSettingsController({ isOpen, initialTab }: UseSettingsControl
}
}, []);
const openLoginForProvider = useCallback((provider: AgentProvider) => {
const openLoginForProvider = useCallback((provider: AgentProvider, customCommand?: string, customTitle?: string) => {
setLoginProvider(provider);
setLoginCommand(customCommand);
setLoginTitle(customTitle);
setShowLoginModal(true);
}, []);
@@ -417,6 +421,8 @@ export function useSettingsController({ isOpen, initialTab }: UseSettingsControl
showLoginModal,
setShowLoginModal,
loginProvider,
loginCommand,
loginTitle,
handleLoginComplete,
};
}

View File

@@ -58,6 +58,8 @@ function Settings({ isOpen, onClose, projects = [], initialTab = 'agents' }: Set
showLoginModal,
setShowLoginModal,
loginProvider,
loginCommand,
loginTitle,
handleLoginComplete,
} = useSettingsController({
isOpen,
@@ -232,6 +234,8 @@ function Settings({ isOpen, onClose, projects = [], initialTab = 'agents' }: Set
onClose={() => setShowLoginModal(false)}
provider={loginProvider || 'claude'}
onComplete={handleLoginComplete}
customCommand={loginCommand}
customTitle={loginTitle}
isAuthenticated={isAuthenticated}
/>

View File

@@ -12,7 +12,7 @@ type AgentListItemProps = {
type AgentConfig = {
name: string;
color: 'blue' | 'purple' | 'gray' | 'indigo' | 'zinc';
color: 'blue' | 'purple' | 'gray' | 'indigo' | 'zinc' | 'emerald';
};
const agentConfig: Record<AgentProvider, AgentConfig> = {
@@ -36,6 +36,10 @@ const agentConfig: Record<AgentProvider, AgentConfig> = {
name: 'OpenCode',
color: 'zinc',
},
hermes: {
name: 'Hermes',
color: 'emerald',
},
};
const colorClasses = {
@@ -54,6 +58,9 @@ const colorClasses = {
zinc: {
dot: 'bg-zinc-500',
},
emerald: {
dot: 'bg-emerald-600',
},
} as const;
export default function AgentListItem({

View File

@@ -29,29 +29,33 @@ export default function AgentsSettingsTab({
), [selectedAgent]);
const visibleAgents = useMemo<AgentProvider[]>(() => {
return ['claude', 'cursor', 'codex', 'gemini', 'opencode'];
return ['claude', 'cursor', 'codex', 'gemini', 'opencode', 'hermes'];
}, []);
const agentContextById = useMemo<Record<AgentProvider, AgentContext>>(() => ({
claude: {
authStatus: providerAuthStatus.claude,
onLogin: () => onProviderLogin('claude'),
onLogin: (customCommand, customTitle) => onProviderLogin('claude', customCommand, customTitle),
},
cursor: {
authStatus: providerAuthStatus.cursor,
onLogin: () => onProviderLogin('cursor'),
onLogin: (customCommand, customTitle) => onProviderLogin('cursor', customCommand, customTitle),
},
codex: {
authStatus: providerAuthStatus.codex,
onLogin: () => onProviderLogin('codex'),
onLogin: (customCommand, customTitle) => onProviderLogin('codex', customCommand, customTitle),
},
gemini: {
authStatus: providerAuthStatus.gemini,
onLogin: () => onProviderLogin('gemini'),
onLogin: (customCommand, customTitle) => onProviderLogin('gemini', customCommand, customTitle),
},
opencode: {
authStatus: providerAuthStatus.opencode,
onLogin: () => onProviderLogin('opencode'),
onLogin: (customCommand, customTitle) => onProviderLogin('opencode', customCommand, customTitle),
},
hermes: {
authStatus: providerAuthStatus.hermes,
onLogin: (customCommand, customTitle) => onProviderLogin('hermes', customCommand, customTitle),
},
}), [
onProviderLogin,
@@ -60,6 +64,7 @@ export default function AgentsSettingsTab({
providerAuthStatus.cursor,
providerAuthStatus.gemini,
providerAuthStatus.opencode,
providerAuthStatus.hermes,
]);
useEffect(() => {

View File

@@ -9,6 +9,7 @@ const AGENT_NAMES: Record<AgentProvider, string> = {
codex: 'Codex',
gemini: 'Gemini',
opencode: 'OpenCode',
hermes: 'Hermes',
};
export default function AgentSelectorSection({
@@ -25,7 +26,8 @@ export default function AgentSelectorSection({
agent === 'claude' ? 'bg-blue-500' :
agent === 'cursor' ? 'bg-purple-500' :
agent === 'gemini' ? 'bg-indigo-500' :
agent === 'opencode' ? 'bg-zinc-500' : 'bg-foreground/60';
agent === 'opencode' ? 'bg-zinc-500' :
agent === 'hermes' ? 'bg-emerald-600' : 'bg-foreground/60';
return (
<Pill

View File

@@ -1,5 +1,11 @@
import { LogIn } from 'lucide-react';
import {
CheckCircle2,
KeyRound,
Layers3,
LogIn,
} from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { Badge, Button } from '../../../../../../../shared/view/ui';
import SessionProviderLogo from '../../../../../../llm-logo-provider/SessionProviderLogo';
import type { AgentProvider, AuthStatus } from '../../../../../types/types';
@@ -7,7 +13,7 @@ import type { AgentProvider, AuthStatus } from '../../../../../types/types';
type AccountContentProps = {
agent: AgentProvider;
authStatus: AuthStatus;
onLogin: () => void;
onLogin: (customCommand?: string, customTitle?: string) => void;
};
type AgentVisualConfig = {
@@ -63,8 +69,59 @@ const agentConfig: Record<AgentProvider, AgentVisualConfig> = {
subtextClass: 'text-zinc-700 dark:text-zinc-300',
buttonClass: 'bg-zinc-900 hover:bg-zinc-800 active:bg-zinc-950 dark:bg-zinc-700 dark:hover:bg-zinc-600',
},
hermes: {
name: 'Hermes',
description: 'Nous Research Hermes Agent',
bgClass: 'bg-emerald-50 dark:bg-emerald-900/20',
borderClass: 'border-emerald-200 dark:border-emerald-800',
textClass: 'text-emerald-950 dark:text-emerald-100',
subtextClass: 'text-emerald-700 dark:text-emerald-300',
buttonClass: 'bg-emerald-700 hover:bg-emerald-800 active:bg-emerald-900',
},
};
type HermesAction = {
label: string;
description: string;
command: string;
title: string;
icon: typeof Layers3;
};
type HermesActionGroup = {
title: string;
actions: HermesAction[];
};
const hermesActionGroups: HermesActionGroup[] = [
{
title: 'Setup',
actions: [
{
label: 'Provider setup',
description: 'Configure provider credentials and the active model.',
command: 'hermes model',
title: 'Hermes Provider Setup',
icon: Layers3,
},
{
label: 'Credential pools',
description: 'Manage API keys and OAuth credentials.',
command: 'hermes auth',
title: 'Hermes Credential Pools',
icon: KeyRound,
},
{
label: 'ACP check',
description: 'Validate the Hermes ACP adapter.',
command: 'hermes acp --check',
title: 'Hermes ACP Check',
icon: CheckCircle2,
},
],
},
];
export default function AccountContent({ agent, authStatus, onLogin }: AccountContentProps) {
const { t } = useTranslation('settings');
const config = agentConfig[agent];
@@ -133,7 +190,7 @@ export default function AccountContent({ agent, authStatus, onLogin }: AccountCo
</div>
</div>
<Button
onClick={onLogin}
onClick={() => onLogin()}
className={`${config.buttonClass} text-white`}
size="sm"
>
@@ -144,6 +201,43 @@ export default function AccountContent({ agent, authStatus, onLogin }: AccountCo
</div>
)}
{agent === 'hermes' && (
<div className="border-t border-border/50 pt-4">
<div className={`mb-3 font-medium ${config.textClass}`}>
{t('agents.hermes.actions.title', { defaultValue: 'Hermes tools' })}
</div>
<div className="space-y-4">
{hermesActionGroups.map((group) => (
<div key={group.title}>
<div className={`mb-2 text-xs font-semibold uppercase ${config.subtextClass}`}>
{group.title}
</div>
<div className="grid gap-2 md:grid-cols-2">
{group.actions.map((action) => {
const Icon = action.icon;
return (
<Button
key={action.command}
type="button"
variant="outline"
className="h-auto justify-start gap-3 border-border/70 bg-background/70 px-3 py-2 text-left"
onClick={() => onLogin(action.command, action.title)}
>
<Icon className="h-4 w-4 flex-shrink-0" />
<span className="min-w-0">
<span className="block text-sm font-medium text-foreground">{action.label}</span>
<span className="block text-xs text-muted-foreground">{action.description}</span>
</span>
</Button>
);
})}
</div>
</div>
))}
</div>
</div>
)}
{authStatus.error && (
<div className="border-t border-border/50 pt-4">
<div className="text-sm text-red-600 dark:text-red-400">

View File

@@ -11,7 +11,7 @@ import type {
export type AgentContext = {
authStatus: AuthStatus;
onLogin: () => void;
onLogin: (customCommand?: string, customTitle?: string) => void;
};
export type AgentContextByProvider = Record<AgentProvider, AgentContext>;
@@ -19,7 +19,7 @@ export type ProviderAuthStatusByProvider = Record<AgentProvider, AuthStatus>;
export type AgentsSettingsTabProps = {
providerAuthStatus: ProviderAuthStatusByProvider;
onProviderLogin: (provider: AgentProvider) => void;
onProviderLogin: (provider: AgentProvider, customCommand?: string, customTitle?: string) => void;
claudePermissions: ClaudePermissionsState;
onClaudePermissionsChange: (value: ClaudePermissionsState) => void;
cursorPermissions: CursorPermissionsState;

View File

@@ -5,6 +5,9 @@ import type {
ApiResponse,
ProviderSkill,
ProviderSkillCreatePayload,
ProviderSkillRegistryActionResponse,
ProviderSkillRegistryResult,
ProviderSkillRegistrySearchResponse,
ProviderSkillsResponse,
SkillsProject,
SkillsProvider,
@@ -197,6 +200,50 @@ const saveProviderSkills = async (
return (data.data.skills || []).map((skill) => normalizeSkill(provider, skill));
};
const searchProviderSkillRegistry = async (
provider: SkillsProvider,
query: string,
limit = 10,
): Promise<ProviderSkillRegistryResult[]> => {
const params = new URLSearchParams({ query, limit: String(limit) });
const response = await authenticatedFetch(`/api/providers/${provider}/skills/registry/search?${params.toString()}`);
const data = await toResponseJson<ApiResponse<ProviderSkillRegistrySearchResponse>>(response);
if (!response.ok || !data.success) {
throw new Error(getApiErrorMessage(data, 'Failed to search skill registry'));
}
return data.data.results || [];
};
const runProviderSkillRegistryAction = async (
provider: SkillsProvider,
action: 'install' | 'check' | 'update' | 'audit',
payload?: Record<string, unknown>,
): Promise<ProviderSkillRegistryActionResponse['result']> => {
const response = await authenticatedFetch(`/api/providers/${provider}/skills/registry/${action}`, {
method: 'POST',
body: payload ? JSON.stringify(payload) : undefined,
});
const data = await toResponseJson<ApiResponse<ProviderSkillRegistryActionResponse>>(response);
if (!response.ok || !data.success) {
throw new Error(getApiErrorMessage(data, `Failed to run ${action}`));
}
return data.data.result;
};
const uninstallProviderSkillRegistrySkill = async (
provider: SkillsProvider,
name: string,
): Promise<ProviderSkillRegistryActionResponse['result']> => {
const response = await authenticatedFetch(`/api/providers/${provider}/skills/registry/${encodeURIComponent(name)}`, {
method: 'DELETE',
});
const data = await toResponseJson<ApiResponse<ProviderSkillRegistryActionResponse>>(response);
if (!response.ok || !data.success) {
throw new Error(getApiErrorMessage(data, 'Failed to uninstall skill'));
}
return data.data.result;
};
const getCacheKey = (provider: SkillsProvider, projects: ProjectTarget[]): string => {
const projectKey = projects.map((project) => project.path).sort().join('|');
return `${provider}:${projectKey}`;
@@ -221,6 +268,10 @@ export function useProviderSkills({ selectedProvider, currentProjects }: UseProv
const [isLoadingProjectScopes, setIsLoadingProjectScopes] = useState(false);
const [loadError, setLoadError] = useState<string | null>(null);
const [saveStatus, setSaveStatus] = useState<'success' | 'error' | null>(null);
const [registryResults, setRegistryResults] = useState<ProviderSkillRegistryResult[]>([]);
const [registryError, setRegistryError] = useState<string | null>(null);
const [registryStatus, setRegistryStatus] = useState<string | null>(null);
const [registryBusyKey, setRegistryBusyKey] = useState<string | null>(null);
const activeLoadIdRef = useRef(0);
const projectTargets = useMemo(() => createProjectTargets(currentProjects), [currentProjects]);
@@ -250,7 +301,10 @@ export function useProviderSkills({ selectedProvider, currentProjects }: UseProv
setIsLoadingProjectScopes(false);
setLoadError(null);
let nextSkills = cachedEntry && !options.force ? cachedEntry.skills : [];
// Build the authoritative list from the fresh fetches only. The cache still
// feeds instant display above, but seeding the merge from it would let
// skills deleted out-of-band survive the union and never get pruned.
let nextSkills: ProviderSkill[] = [];
let firstError: string | null = null;
try {
@@ -319,12 +373,86 @@ export function useProviderSkills({ selectedProvider, currentProjects }: UseProv
}
}, [refreshSkills, selectedProvider]);
const searchRegistry = useCallback(async (query: string) => {
const normalizedQuery = query.trim();
if (!normalizedQuery) {
setRegistryResults([]);
setRegistryError(null);
return;
}
setRegistryBusyKey('search');
setRegistryError(null);
setRegistryStatus(null);
try {
setRegistryResults(await searchProviderSkillRegistry(selectedProvider, normalizedQuery, 12));
} catch (error) {
setRegistryError(error instanceof Error ? error.message : 'Failed to search skill registry');
} finally {
setRegistryBusyKey((current) => (current === 'search' ? null : current));
}
}, [selectedProvider]);
const installRegistrySkill = useCallback(async (identifier: string) => {
setRegistryBusyKey(`install:${identifier}`);
setRegistryError(null);
setRegistryStatus(null);
try {
await runProviderSkillRegistryAction(selectedProvider, 'install', { identifier });
clearProviderSkillCache(selectedProvider);
await refreshSkills({ force: true });
setRegistryStatus('Skill installed.');
} catch (error) {
setRegistryError(error instanceof Error ? error.message : 'Failed to install skill');
} finally {
setRegistryBusyKey((current) => (current === `install:${identifier}` ? null : current));
}
}, [refreshSkills, selectedProvider]);
const uninstallRegistrySkill = useCallback(async (name: string) => {
setRegistryBusyKey(`uninstall:${name}`);
setRegistryError(null);
setRegistryStatus(null);
try {
await uninstallProviderSkillRegistrySkill(selectedProvider, name);
clearProviderSkillCache(selectedProvider);
await refreshSkills({ force: true });
setRegistryStatus('Skill uninstalled.');
} catch (error) {
setRegistryError(error instanceof Error ? error.message : 'Failed to uninstall skill');
} finally {
setRegistryBusyKey((current) => (current === `uninstall:${name}` ? null : current));
}
}, [refreshSkills, selectedProvider]);
const runRegistryMaintenance = useCallback(async (action: 'check' | 'update' | 'audit') => {
setRegistryBusyKey(action);
setRegistryError(null);
setRegistryStatus(null);
try {
const result = await runProviderSkillRegistryAction(selectedProvider, action);
if (action === 'update' || action === 'audit') {
clearProviderSkillCache(selectedProvider);
await refreshSkills({ force: true });
}
setRegistryStatus((result.stdout || result.stderr || `${action} completed.`).trim());
} catch (error) {
setRegistryError(error instanceof Error ? error.message : `Failed to run ${action}`);
} finally {
setRegistryBusyKey((current) => (current === action ? null : current));
}
}, [refreshSkills, selectedProvider]);
useEffect(() => {
void refreshSkills();
}, [refreshSkills]);
useEffect(() => {
setSaveStatus(null);
setRegistryResults([]);
setRegistryError(null);
setRegistryStatus(null);
setRegistryBusyKey(null);
}, [selectedProvider]);
useEffect(() => {
@@ -342,7 +470,15 @@ export function useProviderSkills({ selectedProvider, currentProjects }: UseProv
isLoadingProjectScopes,
loadError,
saveStatus,
registryResults,
registryError,
registryStatus,
registryBusyKey,
addSkills,
refreshSkills,
searchRegistry,
installRegistrySkill,
uninstallRegistrySkill,
runRegistryMaintenance,
};
}

View File

@@ -43,6 +43,28 @@ export type ProviderSkillsResponse = {
skills: Array<Partial<ProviderSkill>>;
};
export type ProviderSkillRegistryResult = {
name: string;
identifier: string;
source?: string;
trustLevel?: string;
description?: string;
};
export type ProviderSkillRegistrySearchResponse = {
provider: SkillsProvider;
results: ProviderSkillRegistryResult[];
};
export type ProviderSkillRegistryActionResponse = {
provider: SkillsProvider;
result: {
ok: boolean;
stdout: string;
stderr: string;
};
};
export type ApiSuccessResponse<T> = {
success: true;
data: T;

View File

@@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useDropzone } from 'react-dropzone';
import {
CheckCircle2,
Compass,
FileCode2,
FileText,
FileUp,
@@ -62,6 +63,7 @@ const PROVIDER_NAMES: Record<SkillsProvider, string> = {
cursor: 'Cursor',
gemini: 'Gemini',
opencode: 'OpenCode',
hermes: 'Hermes',
};
const PROVIDER_SKILL_PATHS: Record<Exclude<SkillsProvider, 'opencode'>, string> = {
@@ -69,8 +71,30 @@ const PROVIDER_SKILL_PATHS: Record<Exclude<SkillsProvider, 'opencode'>, string>
codex: '~/.agents/skills/<skill-name>/SKILL.md',
cursor: '~/.cursor/skills/<skill-name>/SKILL.md',
gemini: '~/.gemini/skills/<skill-name>/SKILL.md',
hermes: '~/.hermes/skills/<skill-name>/SKILL.md',
};
const HERMES_SKILL_ACTIONS = [
{
label: 'Check Updates',
description: 'Check installed hub skills.',
action: 'check' as const,
icon: RefreshCw,
},
{
label: 'Update Hub Skills',
description: 'Apply available hub updates.',
action: 'update' as const,
icon: Upload,
},
{
label: 'Audit Installed',
description: 'Re-scan installed hub skills.',
action: 'audit' as const,
icon: CheckCircle2,
},
];
const SCOPE_LABELS: Record<SkillsScope, string> = {
user: 'User',
plugin: 'Plugin',
@@ -209,13 +233,21 @@ export default function ProviderSkills({ selectedProvider, currentProjects }: Pr
isLoadingProjectScopes,
loadError,
saveStatus,
registryResults,
registryError,
registryStatus,
registryBusyKey,
addSkills,
refreshSkills,
searchRegistry,
installRegistrySkill,
runRegistryMaintenance,
} = useProviderSkills({ selectedProvider, currentProjects });
const [queuedFiles, setQueuedFiles] = useState<QueuedSkillFile[]>([]);
const [submitError, setSubmitError] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const [registryQuery, setRegistryQuery] = useState('');
const fileInputRef = useRef<HTMLInputElement>(null);
const folderInputRef = useRef<HTMLInputElement>(null);
@@ -388,6 +420,125 @@ export default function ProviderSkills({ selectedProvider, currentProjects }: Pr
</Button>
</div>
{selectedProvider === 'hermes' && (
<div className="rounded-lg border border-border/70 bg-muted/15 p-3">
<div className="mb-3 flex min-w-0 flex-col gap-1">
<div className="min-w-0">
<div className="flex items-center gap-2 text-sm font-medium text-foreground">
<Compass className="h-4 w-4" />
Hermes Skills Hub
</div>
<div className="text-xs text-muted-foreground">
Search the Hermes registry, install skills, and keep installed hub skills current.
</div>
</div>
</div>
<div className="flex flex-col gap-2 sm:flex-row">
<div className="relative min-w-0 flex-1">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
type="text"
value={registryQuery}
onChange={(event) => setRegistryQuery(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter') {
void searchRegistry(registryQuery);
}
}}
placeholder="Search Hermes skills..."
aria-label="Search Hermes skills registry"
className="h-9 w-full pl-9"
/>
</div>
<Button
type="button"
variant="outline"
size="sm"
className="w-full sm:w-auto"
disabled={!registryQuery.trim() || registryBusyKey === 'search'}
onClick={() => void searchRegistry(registryQuery)}
>
{registryBusyKey === 'search' ? <Loader2 className="h-4 w-4 animate-spin" /> : <Search className="h-4 w-4" />}
Search
</Button>
</div>
<div className="mt-3 grid gap-2 md:grid-cols-3">
{HERMES_SKILL_ACTIONS.map((action) => {
const Icon = action.icon;
return (
<Button
key={action.action}
type="button"
variant="outline"
className="h-auto justify-start gap-3 border-border/70 bg-background/70 px-3 py-2 text-left"
disabled={registryBusyKey === action.action}
onClick={() => void runRegistryMaintenance(action.action)}
>
{registryBusyKey === action.action
? <Loader2 className="h-4 w-4 flex-shrink-0 animate-spin" />
: <Icon className="h-4 w-4 flex-shrink-0" />}
<span className="min-w-0">
<span className="block text-sm font-medium text-foreground">{action.label}</span>
<span className="block text-xs text-muted-foreground">{action.description}</span>
</span>
</Button>
);
})}
</div>
{(registryError || registryStatus) && (
<div className={cn(
'mt-3 rounded-lg border px-3 py-2 text-xs',
registryError
? 'border-red-200 bg-red-50 text-red-700 dark:border-red-800/60 dark:bg-red-900/20 dark:text-red-200'
: 'border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300',
)}>
{registryError || registryStatus}
</div>
)}
{registryResults.length > 0 && (
<div className="mt-3 grid gap-2">
{registryResults.map((result) => (
<div
key={result.identifier}
className="flex flex-col gap-3 rounded-lg border border-border/70 bg-background/70 p-3 sm:flex-row sm:items-start sm:justify-between"
>
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm font-medium text-foreground">{result.name}</span>
{result.source && (
<Badge variant="outline" className="rounded-full text-[10px]">{result.source}</Badge>
)}
{result.trustLevel && (
<Badge variant="secondary" className="rounded-full text-[10px]">{result.trustLevel}</Badge>
)}
</div>
<div className="mt-1 break-all font-mono text-xs text-muted-foreground">{result.identifier}</div>
{result.description && (
<div className="mt-1 line-clamp-2 text-sm text-muted-foreground">{result.description}</div>
)}
</div>
<Button
type="button"
size="sm"
className="w-full sm:w-auto"
disabled={registryBusyKey === `install:${result.identifier}`}
onClick={() => void installRegistrySkill(result.identifier)}
>
{registryBusyKey === `install:${result.identifier}`
? <Loader2 className="h-4 w-4 animate-spin" />
: <Upload className="h-4 w-4" />}
Install
</Button>
</div>
))}
</div>
)}
</div>
)}
<Card className="min-w-0 overflow-hidden border-border/70 bg-background shadow-sm">
<CardHeader className="space-y-3 border-b border-border/60 bg-muted/20">
<div className="flex flex-col gap-2 sm:flex-row sm:flex-wrap sm:items-center">