feat(plugins): browse and install official plugins from catalog (#1088) (#1096)

This commit is contained in:
M57 authored and GitHub committed 2026-09-12 22:32:52 +03:00
1 parent 47b6955c9a
commit d774a63663
8 files changed
+840 -71

No files matched your search

@@ -80,9 +80,63 @@ data class PluginsHubResponse(
@Serializable
data class AgentPluginInstallBody(
val identifier: String,
val identifier: String = "",
val force: Boolean = false,
val enable: Boolean = true,
@SerialName("catalog_name") val catalogName: String? = null,
val ref: String? = null,
)
@Serializable
data class PluginCatalogCapabilities(
@SerialName("provides_tools") val providesTools: List<String> = emptyList(),
@SerialName("provides_hooks") val providesHooks: List<String> = emptyList(),
@SerialName("provides_middleware") val providesMiddleware: List<String> = emptyList(),
@SerialName("requires_env") val requiresEnv: List<String> = emptyList(),
)
@Serializable
data class PluginCatalogRemovedEntry(
val name: String? = null,
val repo: String? = null,
val reason: String? = null,
val date: String? = null,
)
@Serializable
data class PluginCatalogEntry(
val name: String,
@SerialName("catalog_name") val catalogName: String? = null,
val repo: String? = null,
val sha: String? = null,
@SerialName("sha_short") val shaShort: String? = null,
val description: String? = null,
val maintainer: String? = null,
val tier: String? = null,
val category: String? = null,
@SerialName("requires_hermes") val requiresHermes: String? = null,
val subdir: String? = null,
@SerialName("docs_url") val docsUrl: String? = null,
val platforms: List<String> = emptyList(),
val capabilities: PluginCatalogCapabilities? = null,
@SerialName("capability_summary") val capabilitySummary: String? = null,
val installed: Boolean = false,
@SerialName("installed_sha") val installedSha: String? = null,
@SerialName("update_available") val updateAvailable: Boolean = false,
@SerialName("runtime_status") val runtimeStatus: String? = null,
) {
val displayName: String
get() = catalogName?.takeIf { it.isNotBlank() } ?: name
val isOfficial: Boolean
get() = tier?.equals("official", ignoreCase = true) == true
}
@Serializable
data class PluginCatalogResponse(
val entries: List<PluginCatalogEntry> = emptyList(),
val removed: List<PluginCatalogRemovedEntry> = emptyList(),
@SerialName("generated_at") val generatedAt: String? = null,
)
@Serializable
@@ -80,6 +80,7 @@ import com.m57.hermescontrol.data.model.OAuthSubmitResponse
import com.m57.hermescontrol.data.model.PairingApproveRequest
import com.m57.hermescontrol.data.model.PairingResponse
import com.m57.hermescontrol.data.model.PairingRevokeRequest
import com.m57.hermescontrol.data.model.PluginCatalogResponse
import com.m57.hermescontrol.data.model.PluginProvidersPutRequest
import com.m57.hermescontrol.data.model.PluginsHubResponse
import com.m57.hermescontrol.data.model.PortalResponse
@@ -625,6 +626,9 @@ interface HermesApiService {
@GET("api/dashboard/plugins/hub")
suspend fun getPlugins(): Response<PluginsHubResponse>
@GET("api/dashboard/plugins/catalog")
suspend fun getPluginCatalog(): Response<PluginCatalogResponse>
@POST("api/dashboard/agent-plugins/install")
suspend fun installPlugin(
@Body body: AgentPluginInstallBody,
@@ -5,6 +5,7 @@ import androidx.compose.ui.res.stringResource
import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.model.HubSkill
import com.m57.hermescontrol.data.model.McpServer
import com.m57.hermescontrol.data.model.PluginCatalogEntry
import com.m57.hermescontrol.data.model.PluginInfo
import com.m57.hermescontrol.data.model.Skill
import com.m57.hermescontrol.data.model.Toolset
@@ -68,6 +69,26 @@ fun PluginInfo.toDetailRows(): List<DetailRow> =
DetailRow(stringResource(R.string.detail_dialog_description), description),
)
@Composable
fun PluginCatalogEntry.toDetailRows(): List<DetailRow> =
listOfNotNull(
tier?.let { DetailRow(stringResource(R.string.detail_dialog_tier), it) },
maintainer?.let { DetailRow(stringResource(R.string.detail_dialog_maintainer), it) },
category?.let { DetailRow(stringResource(R.string.detail_dialog_category), it) },
repo?.let { DetailRow(stringResource(R.string.detail_dialog_repository), it) },
shaShort?.let { DetailRow(stringResource(R.string.detail_dialog_commit), it) },
requiresHermes?.takeIf { it.isNotBlank() }?.let {
DetailRow(stringResource(R.string.detail_dialog_requires_hermes), it)
},
docsUrl?.takeIf { it.isNotBlank() }?.let {
DetailRow(stringResource(R.string.detail_dialog_docs), it)
},
capabilitySummary?.takeIf { it.isNotBlank() }?.let {
DetailRow(stringResource(R.string.detail_dialog_capabilities), it)
},
description?.let { DetailRow(stringResource(R.string.detail_dialog_description), it) },
)
@Composable
fun Toolset.toDetailRows(): List<DetailRow> =
listOf(
@@ -29,6 +29,9 @@ import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.SegmentedButton
import androidx.compose.material3.SegmentedButtonDefaults
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
@@ -47,6 +50,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel
import com.m57.hermescontrol.MemoryProviderDetailKey
import com.m57.hermescontrol.NavigationController
import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.model.PluginCatalogEntry
import com.m57.hermescontrol.data.model.PluginInfo
import com.m57.hermescontrol.theme.LocalHermesStatusColors
import com.m57.hermescontrol.ui.common.DetailDialog
@@ -61,6 +65,7 @@ import com.m57.hermescontrol.ui.common.ToastEffect
import com.m57.hermescontrol.ui.common.listContentPadding
import com.m57.hermescontrol.ui.common.listItemSpacing
import com.m57.hermescontrol.ui.common.toDetailRows
import com.m57.hermescontrol.ui.plugins.components.CatalogPluginsView
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -73,6 +78,7 @@ fun PluginsScreen(
var query by remember { mutableStateOf("") }
var showDetail by remember { mutableStateOf<PluginInfo?>(null) }
var showCatalogDetail by remember { mutableStateOf<PluginCatalogEntry?>(null) }
val filteredPlugins =
remember(query, state.plugins) {
@@ -82,6 +88,29 @@ fun PluginsScreen(
}
}
val filteredCatalogEntries =
remember(state.catalogEntries, state.catalogQuery, state.catalogTierFilter) {
state.catalogEntries.filter { entry ->
val matchesQuery =
state.catalogQuery.isBlank() ||
entry.displayName.contains(state.catalogQuery, ignoreCase = true) ||
entry.description?.contains(state.catalogQuery, ignoreCase = true) == true ||
entry.maintainer?.contains(state.catalogQuery, ignoreCase = true) == true ||
entry.capabilities?.providesTools?.any {
it.contains(
state.catalogQuery,
ignoreCase = true,
)
} == true
val matchesTier =
state.catalogTierFilter == null ||
entry.tier?.equals(state.catalogTierFilter, ignoreCase = true) == true
matchesQuery && matchesTier
}
}
LaunchedEffect(Unit) {
viewModel.loadPlugins()
}
@@ -120,85 +149,130 @@ fun PluginsScreen(
HermesScaffold(
title = { Text(stringResource(R.string.screen_plugins)) },
navigationIcon = onOpenDrawer?.let { NavIcon.Menu(it) },
isRefreshing = state.isLoading,
onRefresh = { viewModel.loadPlugins() },
) { paddingValues ->
when {
state.isLoading && state.plugins.isEmpty() -> {
SkeletonListState(modifier = Modifier.padding(paddingValues))
isRefreshing = if (state.selectedTab == PluginsTab.INSTALLED) state.isLoading else state.isCatalogLoading,
onRefresh = {
if (state.selectedTab == PluginsTab.INSTALLED) {
viewModel.loadPlugins()
} else {
viewModel.loadCatalog(isRefresh = true)
}
state.errorMessage != null -> {
ErrorState(
message = state.errorMessage ?: "",
onRetry = { viewModel.loadPlugins() },
modifier = Modifier.padding(paddingValues),
)
}
state.plugins.isEmpty() && state.orphanPlugins.isEmpty() -> {
EmptyState(
title = stringResource(R.string.plugins_empty_title),
subtitle = stringResource(R.string.plugins_empty_desc),
onAction = { viewModel.loadPlugins() },
actionLabel = stringResource(R.string.content_desc_refresh),
modifier = Modifier.padding(paddingValues),
)
}
else -> {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = listContentPadding,
verticalArrangement = listItemSpacing,
},
) { _ ->
Column(modifier = Modifier.fillMaxSize()) {
SingleChoiceSegmentedButtonRow(
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
) {
SegmentedButton(
selected = state.selectedTab == PluginsTab.INSTALLED,
onClick = { viewModel.setTab(PluginsTab.INSTALLED) },
shape = SegmentedButtonDefaults.itemShape(index = 0, count = 2),
) {
// Provider selection section
if (state.memoryOptions.isNotEmpty() || state.contextOptions.isNotEmpty()) {
item(key = "providers") {
ProviderSelectionSection(state, viewModel)
Text(stringResource(R.string.plugins_tab_installed))
}
SegmentedButton(
selected = state.selectedTab == PluginsTab.CATALOG,
onClick = { viewModel.setTab(PluginsTab.CATALOG) },
shape = SegmentedButtonDefaults.itemShape(index = 1, count = 2),
) {
Text(stringResource(R.string.plugins_tab_catalog))
}
}
when (state.selectedTab) {
PluginsTab.INSTALLED -> {
when {
state.isLoading && state.plugins.isEmpty() -> {
SkeletonListState()
}
}
// Install section
item(key = "install") {
InstallSection(state, viewModel)
}
// Search bar
item(key = "search") {
SearchBar(
query = query,
onQueryChange = { query = it },
placeholder = stringResource(R.string.plugins_search_placeholder),
)
}
// Plugin list
items(filteredPlugins, key = { it.name }) { plugin ->
PluginCard(
plugin = plugin,
state = state,
viewModel = viewModel,
onClick = { showDetail = plugin },
)
}
// Orphan dashboard plugins section
if (state.orphanPlugins.isNotEmpty()) {
item(key = "orphan-header") {
Spacer(modifier = Modifier.height(8.dp))
Text(
text = stringResource(R.string.plugins_orphan_heading),
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(vertical = 8.dp),
state.errorMessage != null && state.plugins.isEmpty() -> {
ErrorState(
message = state.errorMessage ?: "",
onRetry = { viewModel.loadPlugins() },
)
}
items(state.orphanPlugins, key = { "orphan-${it.name}" }) { plugin ->
OrphanPluginCard(plugin = plugin, onClick = { showDetail = plugin })
state.plugins.isEmpty() && state.orphanPlugins.isEmpty() -> {
EmptyState(
title = stringResource(R.string.plugins_empty_title),
subtitle = stringResource(R.string.plugins_empty_desc),
onAction = { viewModel.loadPlugins() },
actionLabel = stringResource(R.string.content_desc_refresh),
)
}
else -> {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = listContentPadding,
verticalArrangement = listItemSpacing,
) {
// Provider selection section
if (state.memoryOptions.isNotEmpty() || state.contextOptions.isNotEmpty()) {
item(key = "providers") {
ProviderSelectionSection(state, viewModel)
}
}
// Install section
item(key = "install") {
InstallSection(state, viewModel)
}
// Search bar
item(key = "search") {
SearchBar(
query = query,
onQueryChange = { query = it },
placeholder = stringResource(R.string.plugins_search_placeholder),
)
}
// Plugin list
items(filteredPlugins, key = { it.name }) { plugin ->
PluginCard(
plugin = plugin,
state = state,
viewModel = viewModel,
onClick = { showDetail = plugin },
)
}
// Orphan dashboard plugins section
if (state.orphanPlugins.isNotEmpty()) {
item(key = "orphan-header") {
Spacer(modifier = Modifier.height(8.dp))
Text(
text = stringResource(R.string.plugins_orphan_heading),
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(vertical = 8.dp),
)
}
items(state.orphanPlugins, key = { "orphan-${it.name}" }) { plugin ->
OrphanPluginCard(plugin = plugin, onClick = { showDetail = plugin })
}
}
}
}
}
}
PluginsTab.CATALOG -> {
CatalogPluginsView(
state = state,
filteredEntries = filteredCatalogEntries,
onQueryChange = viewModel::setCatalogQuery,
onTierFilterChange = viewModel::setCatalogTierFilter,
onInstall = { viewModel.installCatalogPlugin(it) },
onUpdate = { viewModel.installCatalogPlugin(it, force = true) },
onShowDetail = { showCatalogDetail = it },
onRetry = { viewModel.loadCatalog(isRefresh = true) },
)
}
}
}
}
@@ -210,6 +284,14 @@ fun PluginsScreen(
onDismiss = { showDetail = null },
)
}
showCatalogDetail?.let { entry ->
DetailDialog(
title = entry.displayName,
rows = entry.toDetailRows(),
onDismiss = { showCatalogDetail = null },
)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@@ -2,6 +2,8 @@ package com.m57.hermescontrol.ui.plugins
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.m57.hermescontrol.data.model.AgentPluginInstallBody
import com.m57.hermescontrol.data.model.PluginCatalogEntry
import com.m57.hermescontrol.data.model.PluginInfo
import com.m57.hermescontrol.data.model.PluginProvidersPutRequest
import com.m57.hermescontrol.data.model.ProviderOption
@@ -9,6 +11,7 @@ import com.m57.hermescontrol.data.remote.ApiClient
import com.m57.hermescontrol.data.remote.NetworkResult
import com.m57.hermescontrol.data.remote.safeApiCall
import com.m57.hermescontrol.ui.common.ToastHost
import com.m57.hermescontrol.ui.common.safeLaunchAction
import com.m57.hermescontrol.ui.common.safeLaunchLoad
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
@@ -18,7 +21,13 @@ import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
enum class PluginsTab {
INSTALLED,
CATALOG,
}
data class PluginsUiState(
val selectedTab: PluginsTab = PluginsTab.INSTALLED,
val isLoading: Boolean = false,
val plugins: List<PluginInfo> = emptyList(),
val orphanPlugins: List<PluginInfo> = emptyList(),
@@ -41,6 +50,13 @@ data class PluginsUiState(
val removeConfirmPlugin: String? = null,
// Rescan
val rescanBusy: Boolean = false,
// Catalog state
val catalogEntries: List<PluginCatalogEntry> = emptyList(),
val isCatalogLoading: Boolean = false,
val catalogErrorMessage: String? = null,
val catalogInstallingName: String? = null,
val catalogQuery: String = "",
val catalogTierFilter: String? = null,
) {
/** Built-in memory provider sentinel — empty string means "use config defaults" */
val isMemoryBuiltin: Boolean
@@ -382,6 +398,93 @@ class PluginsViewModel :
_uiState.update { if (it.rowBusy == name) it.copy(rowBusy = null) else it }
}
fun setTab(tab: PluginsTab) {
_uiState.update { it.copy(selectedTab = tab) }
if (tab == PluginsTab.CATALOG && _uiState.value.catalogEntries.isEmpty()) {
loadCatalog()
}
}
fun loadCatalog(isRefresh: Boolean = false) {
safeLaunchLoad(
apiCall = { safeApiCall { ApiClient.hermesApi.getPluginCatalog() } },
onStart = {
_uiState.update {
it.copy(
isCatalogLoading = true,
catalogErrorMessage = if (isRefresh) it.catalogErrorMessage else null,
)
}
},
onSuccess = { response ->
_uiState.update {
it.copy(
isCatalogLoading = false,
catalogEntries = response.entries,
catalogErrorMessage = null,
)
}
},
onError = { errorMsg ->
_uiState.update {
it.copy(
isCatalogLoading = false,
catalogErrorMessage = "Failed to load catalog: $errorMsg",
)
}
},
)
}
fun setCatalogQuery(query: String) {
_uiState.update { it.copy(catalogQuery = query) }
}
fun setCatalogTierFilter(tier: String?) {
_uiState.update { it.copy(catalogTierFilter = tier) }
}
fun installCatalogPlugin(
entry: PluginCatalogEntry,
force: Boolean = false,
) {
val catalogName = entry.name
safeLaunchAction(
onStart = { _uiState.update { it.copy(catalogInstallingName = catalogName) } },
apiCall = {
safeApiCall {
ApiClient.hermesApi.installPlugin(
AgentPluginInstallBody(
identifier = entry.repo ?: "",
catalogName = catalogName,
force = force,
enable = true,
),
)
}
},
onSuccess = {
_uiState.update {
it.copy(
toastMessage = "Plugin \"${entry.displayName}\" installed successfully",
)
}
loadPlugins()
loadCatalog(isRefresh = true)
},
onError = { errorMsg ->
_uiState.update {
it.copy(
toastMessage = "Failed to install \"${entry.displayName}\": $errorMsg",
)
}
},
onComplete = {
_uiState.update { it.copy(catalogInstallingName = null) }
},
)
}
override fun clearToast() {
_uiState.update { it.copy(toastMessage = null) }
}
@@ -0,0 +1,289 @@
package com.m57.hermescontrol.ui.plugins.components
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.InstallDesktop
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.model.PluginCatalogEntry
import com.m57.hermescontrol.theme.LocalHermesStatusColors
import com.m57.hermescontrol.ui.common.EmptyState
import com.m57.hermescontrol.ui.common.ErrorState
import com.m57.hermescontrol.ui.common.FilterChipRow
import com.m57.hermescontrol.ui.common.SearchBar
import com.m57.hermescontrol.ui.common.SkeletonListState
import com.m57.hermescontrol.ui.common.listContentPadding
import com.m57.hermescontrol.ui.common.listItemSpacing
import com.m57.hermescontrol.ui.plugins.PluginsUiState
@Composable
fun CatalogPluginsView(
state: PluginsUiState,
filteredEntries: List<PluginCatalogEntry>,
onQueryChange: (String) -> Unit,
onTierFilterChange: (String?) -> Unit,
onInstall: (PluginCatalogEntry) -> Unit,
onUpdate: (PluginCatalogEntry) -> Unit,
onShowDetail: (PluginCatalogEntry) -> Unit,
onRetry: () -> Unit,
modifier: Modifier = Modifier,
) {
Column(modifier = modifier.fillMaxSize()) {
SearchBar(
query = state.catalogQuery,
onQueryChange = onQueryChange,
placeholder = stringResource(R.string.plugins_catalog_search_placeholder),
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp),
)
FilterChipRow(
chips = listOf(null, "official", "community"),
selectedChip = state.catalogTierFilter,
onChipSelected = onTierFilterChange,
modifier = Modifier.padding(vertical = 2.dp),
chipLabel = { tier ->
Text(
text =
when (tier) {
null -> stringResource(R.string.plugins_catalog_filter_all)
"official" -> stringResource(R.string.plugins_catalog_filter_official)
"community" -> stringResource(R.string.plugins_catalog_filter_community)
else -> tier
},
)
},
)
when {
state.isCatalogLoading && state.catalogEntries.isEmpty() -> {
SkeletonListState()
}
state.catalogErrorMessage != null && state.catalogEntries.isEmpty() -> {
ErrorState(
message = state.catalogErrorMessage,
onRetry = onRetry,
)
}
filteredEntries.isEmpty() -> {
EmptyState(
title = stringResource(R.string.plugins_catalog_empty_title),
subtitle = stringResource(R.string.plugins_catalog_empty_desc),
onAction = onRetry,
actionLabel = stringResource(R.string.content_desc_refresh),
)
}
else -> {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = listContentPadding,
verticalArrangement = listItemSpacing,
) {
items(filteredEntries, key = { it.name }) { entry ->
CatalogPluginCard(
entry = entry,
isInstalling = state.catalogInstallingName == entry.name,
onInstall = { onInstall(entry) },
onUpdate = { onUpdate(entry) },
onClick = { onShowDetail(entry) },
)
}
}
}
}
}
}
@Composable
fun CatalogPluginCard(
entry: PluginCatalogEntry,
isInstalling: Boolean,
onInstall: () -> Unit,
onUpdate: () -> Unit,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val statusColors = LocalHermesStatusColors.current
Card(
modifier =
modifier
.fillMaxWidth()
.clickable(onClick = onClick),
) {
Column(modifier = Modifier.padding(16.dp)) {
// Header row: Title + Maintainer on left, Tier badge on right
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = entry.displayName,
style = MaterialTheme.typography.titleMedium,
)
if (!entry.maintainer.isNullOrBlank()) {
Text(
text = stringResource(R.string.plugins_catalog_maintainer, entry.maintainer),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
val isOfficial = entry.isOfficial
Surface(
shape = RoundedCornerShape(12.dp),
color =
if (isOfficial) {
MaterialTheme.colorScheme.primaryContainer
} else {
MaterialTheme.colorScheme.surfaceContainerHigh
},
) {
Text(
text =
if (isOfficial) {
stringResource(R.string.plugins_catalog_tier_official)
} else {
stringResource(R.string.plugins_catalog_tier_community)
},
style = MaterialTheme.typography.labelSmall,
color =
if (isOfficial) {
MaterialTheme.colorScheme.onPrimaryContainer
} else {
MaterialTheme.colorScheme.onSurfaceVariant
},
modifier = Modifier.padding(horizontal = 8.dp, vertical = 2.dp),
)
}
}
Spacer(modifier = Modifier.height(6.dp))
// Description
entry.description?.let { desc ->
Text(
text = desc,
style = MaterialTheme.typography.bodyMedium,
maxLines = 3,
overflow = TextOverflow.Ellipsis,
)
Spacer(modifier = Modifier.height(8.dp))
}
// Bottom row: Status badge on left, Action button on right
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
if (entry.installed) {
Text(
text = stringResource(R.string.plugins_catalog_installed_badge),
style = MaterialTheme.typography.labelSmall,
color = statusColors.success,
)
}
if (entry.updateAvailable) {
Text(
text = stringResource(R.string.plugins_catalog_update),
style = MaterialTheme.typography.labelSmall,
color = statusColors.warning,
)
}
}
if (entry.installed && entry.updateAvailable) {
OutlinedButton(
onClick = onUpdate,
enabled = !isInstalling,
) {
if (isInstalling) {
CircularProgressIndicator(
modifier = Modifier.size(16.dp),
strokeWidth = 2.dp,
)
Spacer(modifier = Modifier.width(8.dp))
}
Text(stringResource(R.string.plugins_catalog_update))
}
} else if (entry.installed) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
modifier = Modifier.padding(vertical = 8.dp),
) {
Icon(
imageVector = Icons.Default.Check,
contentDescription = null,
modifier = Modifier.size(18.dp),
tint = statusColors.success,
)
Text(
text = stringResource(R.string.plugins_catalog_installed_badge),
style = MaterialTheme.typography.labelMedium,
color = statusColors.success,
)
}
} else {
Button(
onClick = onInstall,
enabled = !isInstalling,
) {
if (isInstalling) {
CircularProgressIndicator(
modifier = Modifier.size(16.dp),
strokeWidth = 2.dp,
)
Spacer(modifier = Modifier.width(8.dp))
} else {
Icon(
imageVector = Icons.Default.InstallDesktop,
contentDescription = null,
modifier = Modifier.size(18.dp),
)
Spacer(modifier = Modifier.width(8.dp))
}
Text(stringResource(R.string.plugins_action_install))
}
}
}
}
}
}
+22
View File
@@ -796,6 +796,21 @@
<string name="plugins_orphan_heading">Orphaned Dashboard Plugins</string>
<string name="plugins_show_in_sidebar">Show in sidebar</string>
<string name="plugins_hide_from_sidebar">Hide from sidebar</string>
<string name="plugins_tab_installed">Installed</string>
<string name="plugins_tab_catalog">Catalog</string>
<string name="plugins_catalog_search_placeholder">Search catalog plugins...</string>
<string name="plugins_catalog_empty_title">No catalog plugins found</string>
<string name="plugins_catalog_empty_desc">No plugins match your filter or search query</string>
<string name="plugins_catalog_installing">Installing %1$s…</string>
<string name="plugins_catalog_installed_success">Plugin \"%1$s\" installed successfully</string>
<string name="plugins_catalog_update">Update</string>
<string name="plugins_catalog_installed_badge">Installed</string>
<string name="plugins_catalog_filter_all">All</string>
<string name="plugins_catalog_filter_official">Official</string>
<string name="plugins_catalog_filter_community">Community</string>
<string name="plugins_catalog_maintainer">by %1$s</string>
<string name="plugins_catalog_tier_official">Official</string>
<string name="plugins_catalog_tier_community">Community</string>
<!-- Shared detail dialog (issue #500) -->
<string name="detail_dialog_category">Category</string>
@@ -819,6 +834,13 @@
<string name="detail_dialog_status_enabled">Enabled</string>
<string name="detail_dialog_status_disabled">Disabled</string>
<string name="detail_dialog_trust_level">Trust Level</string>
<string name="detail_dialog_tier">Tier</string>
<string name="detail_dialog_maintainer">Maintainer</string>
<string name="detail_dialog_repository">Repository</string>
<string name="detail_dialog_commit">Commit SHA</string>
<string name="detail_dialog_requires_hermes">Requires Hermes</string>
<string name="detail_dialog_docs">Documentation</string>
<string name="detail_dialog_capabilities">Capabilities</string>
<!-- McpServers Screen -->
<string name="mcp_servers_label_transport">Transport: %1$s</string>
@@ -0,0 +1,194 @@
package com.m57.hermescontrol.ui.plugins
import com.m57.hermescontrol.data.model.AgentPluginInstallBody
import com.m57.hermescontrol.data.model.PluginCatalogCapabilities
import com.m57.hermescontrol.data.model.PluginCatalogEntry
import com.m57.hermescontrol.data.model.PluginCatalogResponse
import com.m57.hermescontrol.data.model.PluginInfo
import com.m57.hermescontrol.data.model.PluginsHubResponse
import com.m57.hermescontrol.data.remote.ApiClient
import com.m57.hermescontrol.data.remote.HermesApiService
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkObject
import io.mockk.unmockkAll
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.setMain
import okhttp3.ResponseBody.Companion.toResponseBody
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import retrofit2.Response
@OptIn(ExperimentalCoroutinesApi::class)
class PluginsViewModelTest {
private val testDispatcher = StandardTestDispatcher()
private lateinit var mockApi: HermesApiService
private val sampleCatalogEntry =
PluginCatalogEntry(
name = "snyk",
catalogName = "snyk",
repo = "https://github.com/NousResearch/hermes-plugin-snyk",
sha = "30e0adfeaa181190bc1f81cf434a94aa6a56015f",
shaShort = "30e0adf",
description = "Security scanning for dependencies and code.",
maintainer = "NousResearch",
tier = "official",
capabilities =
PluginCatalogCapabilities(
providesTools = listOf("snyk_test"),
),
installed = false,
updateAvailable = false,
)
@Before
fun setUp() {
Dispatchers.setMain(testDispatcher)
mockApi = mockk()
mockkObject(ApiClient)
every { ApiClient.hermesApi } returns mockApi
}
@After
fun tearDown() {
Dispatchers.resetMain()
unmockkAll()
}
@Test
fun `switch tab to CATALOG automatically triggers catalog load`() {
coEvery { mockApi.getPluginCatalog() } returns
Response.success(PluginCatalogResponse(entries = listOf(sampleCatalogEntry)))
val viewModel = PluginsViewModel()
viewModel.setTab(PluginsTab.CATALOG)
assertEquals(PluginsTab.CATALOG, viewModel.uiState.value.selectedTab)
assertTrue(viewModel.uiState.value.isCatalogLoading)
testDispatcher.scheduler.advanceUntilIdle()
assertFalse(viewModel.uiState.value.isCatalogLoading)
assertEquals(1, viewModel.uiState.value.catalogEntries.size)
assertEquals(
"snyk",
viewModel.uiState.value.catalogEntries
.first()
.name,
)
assertTrue(
viewModel.uiState.value.catalogEntries
.first()
.isOfficial,
)
assertNull(viewModel.uiState.value.catalogErrorMessage)
coVerify(exactly = 1) { mockApi.getPluginCatalog() }
}
@Test
fun `loadCatalog handles api error gracefully`() {
coEvery { mockApi.getPluginCatalog() } returns
Response.error(500, "Internal Server Error".toResponseBody())
val viewModel = PluginsViewModel()
viewModel.loadCatalog()
testDispatcher.scheduler.advanceUntilIdle()
assertFalse(viewModel.uiState.value.isCatalogLoading)
assertTrue(
viewModel.uiState.value.catalogEntries
.isEmpty(),
)
assertTrue(
viewModel.uiState.value.catalogErrorMessage
?.contains("500") == true,
)
}
@Test
fun `filter and query updates affect state`() {
val viewModel = PluginsViewModel()
viewModel.setCatalogQuery("security")
viewModel.setCatalogTierFilter("official")
assertEquals("security", viewModel.uiState.value.catalogQuery)
assertEquals("official", viewModel.uiState.value.catalogTierFilter)
}
@Test
fun `installCatalogPlugin executes install and refreshes lists`() {
coEvery {
mockApi.installPlugin(
AgentPluginInstallBody(
identifier = sampleCatalogEntry.repo ?: "",
catalogName = sampleCatalogEntry.name,
force = false,
enable = true,
),
)
} returns Response.success(Unit)
coEvery { mockApi.getPlugins() } returns
Response.success(PluginsHubResponse(plugins = listOf(PluginInfo(name = "snyk", runtimeStatus = "enabled"))))
coEvery { mockApi.getPluginCatalog() } returns
Response.success(
PluginCatalogResponse(
entries = listOf(sampleCatalogEntry.copy(installed = true)),
),
)
val viewModel = PluginsViewModel()
viewModel.installCatalogPlugin(sampleCatalogEntry)
assertEquals("snyk", viewModel.uiState.value.catalogInstallingName)
testDispatcher.scheduler.advanceUntilIdle()
assertNull(viewModel.uiState.value.catalogInstallingName)
assertEquals("Plugin \"snyk\" installed successfully", viewModel.uiState.value.toastMessage)
assertEquals(1, viewModel.uiState.value.plugins.size)
assertTrue(
viewModel.uiState.value.catalogEntries
.first()
.installed,
)
coVerify(exactly = 1) {
mockApi.installPlugin(
match { it.catalogName == "snyk" && it.enable && !it.force },
)
}
}
@Test
fun `installCatalogPlugin failure sets toast error`() {
coEvery {
mockApi.installPlugin(any())
} returns Response.error(400, "Clone failed".toResponseBody())
val viewModel = PluginsViewModel()
viewModel.installCatalogPlugin(sampleCatalogEntry)
testDispatcher.scheduler.advanceUntilIdle()
assertNull(viewModel.uiState.value.catalogInstallingName)
assertTrue(
viewModel.uiState.value.toastMessage
?.contains("Failed to install") == true,
)
}
}