(() => {
+ 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,
diff --git a/src/components/chat/view/ChatInterface.tsx b/src/components/chat/view/ChatInterface.tsx
index b466c6ba..b01e056a 100644
--- a/src/components/chat/view/ChatInterface.tsx
+++ b/src/components/chat/view/ChatInterface.tsx
@@ -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 (
@@ -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}
diff --git a/src/components/chat/view/subcomponents/ChatMessagesPane.tsx b/src/components/chat/view/subcomponents/ChatMessagesPane.tsx
index bb61096a..341970cd 100644
--- a/src/components/chat/view/subcomponents/ChatMessagesPane.tsx
+++ b/src/components/chat/view/subcomponents/ChatMessagesPane.tsx
@@ -39,6 +39,8 @@ interface ChatMessagesPaneProps {
setGeminiModel: (model: string) => void;
opencodeModel: string;
setOpenCodeModel: (model: string) => void;
+ hermesModel: string;
+ setHermesModel: (model: string) => void;
providerModelCatalog: Partial>;
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}
diff --git a/src/components/chat/view/subcomponents/CommandResultModal.tsx b/src/components/chat/view/subcomponents/CommandResultModal.tsx
index d80a97d6..4a629d79 100644
--- a/src/components/chat/view/subcomponents/CommandResultModal.tsx
+++ b/src/components/chat/view/subcomponents/CommandResultModal.tsx
@@ -63,6 +63,7 @@ const PROVIDER_LABELS: Record = {
codex: 'Codex',
gemini: 'Gemini',
opencode: 'OpenCode',
+ hermes: 'Hermes',
};
const FALLBACK_COMMANDS: CommandEntry[] = [
diff --git a/src/components/chat/view/subcomponents/MessageComponent.tsx b/src/components/chat/view/subcomponents/MessageComponent.tsx
index e9615a85..aadb992f 100644
--- a/src/components/chat/view/subcomponents/MessageComponent.tsx
+++ b/src/components/chat/view/subcomponents/MessageComponent.tsx
@@ -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'))}
@@ -430,4 +432,3 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, a
});
export default MessageComponent;
-
diff --git a/src/components/chat/view/subcomponents/ProviderSelectionEmptyState.tsx b/src/components/chat/view/subcomponents/ProviderSelectionEmptyState.tsx
index 6d97ca88..3325b0cf 100644
--- a/src/components/chat/view/subcomponents/ProviderSelectionEmptyState.tsx
+++ b/src/components/chat/view/subcomponents/ProviderSelectionEmptyState.tsx
@@ -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>;
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]
}
diff --git a/src/components/llm-logo-provider/HermesLogo.tsx b/src/components/llm-logo-provider/HermesLogo.tsx
new file mode 100644
index 00000000..49c7598f
--- /dev/null
+++ b/src/components/llm-logo-provider/HermesLogo.tsx
@@ -0,0 +1,16 @@
+type HermesLogoProps = {
+ className?: string;
+};
+
+export default function HermesLogo({ className = 'w-5 h-5' }: HermesLogoProps) {
+ return (
+
+
+
+
+
+ );
+}
diff --git a/src/components/llm-logo-provider/SessionProviderLogo.tsx b/src/components/llm-logo-provider/SessionProviderLogo.tsx
index e29ecd6d..348eaca4 100644
--- a/src/components/llm-logo-provider/SessionProviderLogo.tsx
+++ b/src/components/llm-logo-provider/SessionProviderLogo.tsx
@@ -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 ;
}
+ if (provider === 'hermes') {
+ return ;
+ }
+
return ;
}
diff --git a/src/components/mcp/constants.ts b/src/components/mcp/constants.ts
index ab6396ff..c2ff79c0 100644
--- a/src/components/mcp/constants.ts
+++ b/src/components/mcp/constants.ts
@@ -6,6 +6,7 @@ export const MCP_PROVIDER_NAMES: Record = {
codex: 'Codex',
gemini: 'Gemini',
opencode: 'OpenCode',
+ hermes: 'Hermes',
};
export const MCP_SUPPORTED_SCOPES: Record = {
@@ -14,6 +15,7 @@ export const MCP_SUPPORTED_SCOPES: Record = {
codex: ['user', 'project'],
gemini: ['user', 'project'],
opencode: ['user', 'project'],
+ hermes: ['user', 'project'],
};
export const MCP_SUPPORTED_TRANSPORTS: Record = {
@@ -22,6 +24,7 @@ export const MCP_SUPPORTED_TRANSPORTS: Record = {
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 = {
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 = {
@@ -42,6 +46,7 @@ export const MCP_SUPPORTS_WORKING_DIRECTORY: Record = {
codex: true,
gemini: true,
opencode: false,
+ hermes: false,
};
export const DEFAULT_MCP_FORM: McpFormState = {
diff --git a/src/components/provider-auth/types.ts b/src/components/provider-auth/types.ts
index afa08094..178efdcf 100644
--- a/src/components/provider-auth/types.ts
+++ b/src/components/provider-auth/types.ts
@@ -10,7 +10,7 @@ export type ProviderAuthStatus = {
export type ProviderAuthStatusMap = Record;
-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 = {
claude: '/api/providers/claude/auth/status',
@@ -18,6 +18,7 @@ export const PROVIDER_AUTH_STATUS_ENDPOINTS: Record = {
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 },
});
diff --git a/src/components/provider-auth/view/ProviderLoginModal.tsx b/src/components/provider-auth/view/ProviderLoginModal.tsx
index 9de1d227..4127f188 100644
--- a/src/components/provider-auth/view/ProviderLoginModal.tsx
+++ b/src/components/provider-auth/view/ProviderLoginModal.tsx
@@ -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);
diff --git a/src/components/settings/constants/constants.ts b/src/components/settings/constants/constants.ts
index 8e5bfebe..a073374f 100644
--- a/src/components/settings/constants/constants.ts
+++ b/src/components/settings/constants/constants.ts
@@ -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';
diff --git a/src/components/settings/hooks/useSettingsController.ts b/src/components/settings/hooks/useSettingsController.ts
index abf62cec..fa3b9f14 100644
--- a/src/components/settings/hooks/useSettingsController.ts
+++ b/src/components/settings/hooks/useSettingsController.ts
@@ -164,6 +164,8 @@ export function useSettingsController({ isOpen, initialTab }: UseSettingsControl
const [showLoginModal, setShowLoginModal] = useState(false);
const [loginProvider, setLoginProvider] = useState('');
+ const [loginCommand, setLoginCommand] = useState(undefined);
+ const [loginTitle, setLoginTitle] = useState(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,
};
}
diff --git a/src/components/settings/view/Settings.tsx b/src/components/settings/view/Settings.tsx
index 96eaa0c6..0e5a7635 100644
--- a/src/components/settings/view/Settings.tsx
+++ b/src/components/settings/view/Settings.tsx
@@ -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}
/>
diff --git a/src/components/settings/view/tabs/agents-settings/AgentListItem.tsx b/src/components/settings/view/tabs/agents-settings/AgentListItem.tsx
index b23784ee..fd524498 100644
--- a/src/components/settings/view/tabs/agents-settings/AgentListItem.tsx
+++ b/src/components/settings/view/tabs/agents-settings/AgentListItem.tsx
@@ -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 = {
@@ -36,6 +36,10 @@ const agentConfig: Record = {
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({
diff --git a/src/components/settings/view/tabs/agents-settings/AgentsSettingsTab.tsx b/src/components/settings/view/tabs/agents-settings/AgentsSettingsTab.tsx
index a5221f83..19ce5181 100644
--- a/src/components/settings/view/tabs/agents-settings/AgentsSettingsTab.tsx
+++ b/src/components/settings/view/tabs/agents-settings/AgentsSettingsTab.tsx
@@ -29,29 +29,33 @@ export default function AgentsSettingsTab({
), [selectedAgent]);
const visibleAgents = useMemo(() => {
- return ['claude', 'cursor', 'codex', 'gemini', 'opencode'];
+ return ['claude', 'cursor', 'codex', 'gemini', 'opencode', 'hermes'];
}, []);
const agentContextById = useMemo>(() => ({
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(() => {
diff --git a/src/components/settings/view/tabs/agents-settings/sections/AgentSelectorSection.tsx b/src/components/settings/view/tabs/agents-settings/sections/AgentSelectorSection.tsx
index a6d017fa..356d66f6 100644
--- a/src/components/settings/view/tabs/agents-settings/sections/AgentSelectorSection.tsx
+++ b/src/components/settings/view/tabs/agents-settings/sections/AgentSelectorSection.tsx
@@ -9,6 +9,7 @@ const AGENT_NAMES: Record = {
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 (
void;
+ onLogin: (customCommand?: string, customTitle?: string) => void;
};
type AgentVisualConfig = {
@@ -63,8 +69,59 @@ const agentConfig: Record = {
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
onLogin()}
className={`${config.buttonClass} text-white`}
size="sm"
>
@@ -144,6 +201,43 @@ export default function AccountContent({ agent, authStatus, onLogin }: AccountCo
)}
+ {agent === 'hermes' && (
+
+
+ {t('agents.hermes.actions.title', { defaultValue: 'Hermes tools' })}
+
+
+ {hermesActionGroups.map((group) => (
+
+
+ {group.title}
+
+
+ {group.actions.map((action) => {
+ const Icon = action.icon;
+ return (
+ onLogin(action.command, action.title)}
+ >
+
+
+ {action.label}
+ {action.description}
+
+
+ );
+ })}
+
+
+ ))}
+
+
+ )}
+
{authStatus.error && (
diff --git a/src/components/settings/view/tabs/agents-settings/types.ts b/src/components/settings/view/tabs/agents-settings/types.ts
index 731ce8d3..a63a841f 100644
--- a/src/components/settings/view/tabs/agents-settings/types.ts
+++ b/src/components/settings/view/tabs/agents-settings/types.ts
@@ -11,7 +11,7 @@ import type {
export type AgentContext = {
authStatus: AuthStatus;
- onLogin: () => void;
+ onLogin: (customCommand?: string, customTitle?: string) => void;
};
export type AgentContextByProvider = Record
;
@@ -19,7 +19,7 @@ export type ProviderAuthStatusByProvider = Record;
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;
diff --git a/src/components/skills/hooks/useProviderSkills.ts b/src/components/skills/hooks/useProviderSkills.ts
index 4655acd5..8db2a0d9 100644
--- a/src/components/skills/hooks/useProviderSkills.ts
+++ b/src/components/skills/hooks/useProviderSkills.ts
@@ -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 => {
+ const params = new URLSearchParams({ query, limit: String(limit) });
+ const response = await authenticatedFetch(`/api/providers/${provider}/skills/registry/search?${params.toString()}`);
+ const data = await toResponseJson>(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,
+): Promise => {
+ const response = await authenticatedFetch(`/api/providers/${provider}/skills/registry/${action}`, {
+ method: 'POST',
+ body: payload ? JSON.stringify(payload) : undefined,
+ });
+ const data = await toResponseJson>(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 => {
+ const response = await authenticatedFetch(`/api/providers/${provider}/skills/registry/${encodeURIComponent(name)}`, {
+ method: 'DELETE',
+ });
+ const data = await toResponseJson>(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(null);
const [saveStatus, setSaveStatus] = useState<'success' | 'error' | null>(null);
+ const [registryResults, setRegistryResults] = useState([]);
+ const [registryError, setRegistryError] = useState(null);
+ const [registryStatus, setRegistryStatus] = useState(null);
+ const [registryBusyKey, setRegistryBusyKey] = useState(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,
};
}
diff --git a/src/components/skills/types.ts b/src/components/skills/types.ts
index cbebe582..6a92fcab 100644
--- a/src/components/skills/types.ts
+++ b/src/components/skills/types.ts
@@ -43,6 +43,28 @@ export type ProviderSkillsResponse = {
skills: Array>;
};
+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 = {
success: true;
data: T;
diff --git a/src/components/skills/view/ProviderSkills.tsx b/src/components/skills/view/ProviderSkills.tsx
index 186b6d35..05ad4628 100644
--- a/src/components/skills/view/ProviderSkills.tsx
+++ b/src/components/skills/view/ProviderSkills.tsx
@@ -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 = {
cursor: 'Cursor',
gemini: 'Gemini',
opencode: 'OpenCode',
+ hermes: 'Hermes',
};
const PROVIDER_SKILL_PATHS: Record, string> = {
@@ -69,8 +71,30 @@ const PROVIDER_SKILL_PATHS: Record, string>
codex: '~/.agents/skills//SKILL.md',
cursor: '~/.cursor/skills//SKILL.md',
gemini: '~/.gemini/skills//SKILL.md',
+ hermes: '~/.hermes/skills//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 = {
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([]);
const [submitError, setSubmitError] = useState(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
+ const [registryQuery, setRegistryQuery] = useState('');
const fileInputRef = useRef(null);
const folderInputRef = useRef(null);
@@ -388,6 +420,125 @@ export default function ProviderSkills({ selectedProvider, currentProjects }: Pr
+ {selectedProvider === 'hermes' && (
+
+
+
+
+
+ Hermes Skills Hub
+
+
+ Search the Hermes registry, install skills, and keep installed hub skills current.
+
+
+
+
+
+
+ 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"
+ />
+
+
void searchRegistry(registryQuery)}
+ >
+ {registryBusyKey === 'search' ? : }
+ Search
+
+
+
+
+ {HERMES_SKILL_ACTIONS.map((action) => {
+ const Icon = action.icon;
+ return (
+ void runRegistryMaintenance(action.action)}
+ >
+ {registryBusyKey === action.action
+ ?
+ : }
+
+ {action.label}
+ {action.description}
+
+
+ );
+ })}
+
+
+ {(registryError || registryStatus) && (
+
+ {registryError || registryStatus}
+
+ )}
+
+ {registryResults.length > 0 && (
+
+ {registryResults.map((result) => (
+
+
+
+ {result.name}
+ {result.source && (
+ {result.source}
+ )}
+ {result.trustLevel && (
+ {result.trustLevel}
+ )}
+
+
{result.identifier}
+ {result.description && (
+
{result.description}
+ )}
+
+
void installRegistrySkill(result.identifier)}
+ >
+ {registryBusyKey === `install:${result.identifier}`
+ ?
+ : }
+ Install
+
+
+ ))}
+
+ )}
+
+ )}
+
diff --git a/src/types/app.ts b/src/types/app.ts
index f81c3e26..1d629ddc 100644
--- a/src/types/app.ts
+++ b/src/types/app.ts
@@ -1,4 +1,4 @@
-export type LLMProvider = 'claude' | 'cursor' | 'codex' | 'gemini' | 'opencode';
+export type LLMProvider = 'claude' | 'cursor' | 'codex' | 'gemini' | 'opencode' | 'hermes';
export type ProviderModelOption = {
value: string;