mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-07-03 02:52:59 +08:00
feat: add Hermes provider
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -63,6 +63,7 @@ const PROVIDER_LABELS: Record<string, string> = {
|
||||
codex: 'Codex',
|
||||
gemini: 'Gemini',
|
||||
opencode: 'OpenCode',
|
||||
hermes: 'Hermes',
|
||||
};
|
||||
|
||||
const FALLBACK_COMMANDS: CommandEntry[] = [
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
16
src/components/llm-logo-provider/HermesLogo.tsx
Normal file
16
src/components/llm-logo-provider/HermesLogo.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user