feat(skills): hub sources + featured landing and security scan (#779) (#793)

Co-authored-by: M57 <hy4ri@users.noreply.github.com>
This commit is contained in:
M57andM57 authored and GitHub committed 2026-08-03 22:13:28 +03:00
1 parent 00a0eedc77
commit 1870b375b1
7 files changed
+812 -304

No files matched your search

@@ -56,7 +56,39 @@ data class SkillHubUninstallRequest(
@Serializable
data class SkillScanResponse(
val identifier: String,
val passed: Boolean? = null,
val issues: List<String>? = null,
val warnings: List<String>? = null,
val name: String? = null,
val source: String? = null,
@SerialName("trust_level") val trustLevel: String? = null,
val verdict: String? = null,
val summary: String? = null,
val policy: String? = null,
@SerialName("policy_reason") val policyReason: String? = null,
val findings: List<SkillScanFinding> = emptyList(),
@SerialName("severity_counts") val severityCounts: Map<String, Int> = emptyMap(),
)
@Serializable
data class SkillScanFinding(
val severity: String,
val category: String,
val file: String? = null,
val line: Int? = null,
val description: String? = null,
)
@Serializable
data class SkillHubSource(
val id: String,
val label: String? = null,
@SerialName("rate_limited") val rateLimited: Boolean? = null,
val available: Boolean? = null,
val searchable: Boolean? = null,
)
@Serializable
data class SkillHubSourcesResponse(
val sources: List<SkillHubSource> = emptyList(),
@SerialName("index_available") val indexAvailable: Boolean? = null,
val featured: List<HubSkill> = emptyList(),
val installed: Map<String, SkillHubInstalledEntry>? = null,
)
@@ -88,7 +88,9 @@ import com.m57.hermescontrol.data.model.Skill
import com.m57.hermescontrol.data.model.SkillContentResponse
import com.m57.hermescontrol.data.model.SkillHubInstallRequest
import com.m57.hermescontrol.data.model.SkillHubSearchResponse
import com.m57.hermescontrol.data.model.SkillHubSourcesResponse
import com.m57.hermescontrol.data.model.SkillHubUninstallRequest
import com.m57.hermescontrol.data.model.SkillScanResponse
import com.m57.hermescontrol.data.model.StatusResponse
import com.m57.hermescontrol.data.model.SystemStatsResponse
import com.m57.hermescontrol.data.model.TelegramOnboardingApplyRequest
@@ -240,6 +242,11 @@ interface HermesApiService {
@Query("identifier") identifier: String,
): Response<SkillContentResponse>
@GET("api/skills/hub/scan")
suspend fun scanHubSkill(
@Query("identifier") identifier: String,
): Response<SkillScanResponse>
@POST("api/skills/hub/install")
suspend fun installHubSkill(
@Body body: SkillHubInstallRequest,
@@ -250,6 +257,9 @@ interface HermesApiService {
@Body body: SkillHubUninstallRequest,
): Response<ActionResponse>
@GET("api/skills/hub/sources")
suspend fun getSkillHubSources(): Response<SkillHubSourcesResponse>
@PUT("api/skills/toggle")
suspend fun toggleSkill(
@Body body: ToggleSkillRequest,
@@ -2,6 +2,7 @@ package com.m57.hermescontrol.ui.skills
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -30,12 +31,14 @@ import androidx.compose.material.icons.filled.Extension
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.Save
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.Shield
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
@@ -55,6 +58,7 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
@@ -69,6 +73,9 @@ import androidx.lifecycle.viewmodel.compose.viewModel
import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.model.HubSkill
import com.m57.hermescontrol.data.model.Skill
import com.m57.hermescontrol.data.model.SkillHubSource
import com.m57.hermescontrol.data.model.SkillScanFinding
import com.m57.hermescontrol.data.model.SkillScanResponse
import com.m57.hermescontrol.theme.LocalHermesStatusColors
import com.m57.hermescontrol.ui.common.DetailDialog
import com.m57.hermescontrol.ui.common.DetailRow
@@ -174,6 +181,9 @@ fun SkillsScreen(
onClearSearch = viewModel::clearHubSearch,
onInstall = viewModel::installSkill,
onPreviewHubSkill = viewModel::previewHubSkill,
onScanHubSkill = viewModel::scanHubSkill,
onClearHubScan = viewModel::clearHubScan,
onLoadHubSources = viewModel::loadHubSources,
isInstalling = state.isInstalling,
installingSkillName = state.installingSkillName,
)
@@ -533,6 +543,9 @@ private fun HubBrowseView(
onClearSearch: () -> Unit,
onInstall: (String) -> Unit,
onPreviewHubSkill: (String) -> Unit,
onScanHubSkill: (String) -> Unit,
onClearHubScan: () -> Unit,
onLoadHubSources: () -> Unit,
isInstalling: Boolean,
installingSkillName: String?,
) {
@@ -629,9 +642,16 @@ private fun HubBrowseView(
}
else -> {
EmptyState(
icon = Icons.Filled.Search,
title = stringResource(R.string.skills_hub_search_hint),
HubLandingView(
state = state,
onLoadHubSources = onLoadHubSources,
onInstall = onInstall,
isInstalling = isInstalling,
installingSkillName = installingSkillName,
onOpenDetail = { skill ->
hubShowDetail = skill
skill.identifier?.let { onPreviewHubSkill(it) }
},
)
}
}
@@ -655,17 +675,435 @@ private fun HubBrowseView(
DetailRow(stringResource(R.string.detail_dialog_tags), hubSkill.tags.orEmpty().joinToString(", ")),
DetailRow(stringResource(R.string.detail_dialog_trust_level), hubSkill.trustLevel),
),
actions =
if (previewReady && state.isHubPreviewing) {
{ CircularProgressIndicator(modifier = Modifier.padding(top = 8.dp)) }
} else {
null
},
onDismiss = { hubShowDetail = null },
actions = {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
if (previewReady && state.isHubPreviewing) {
CircularProgressIndicator(modifier = Modifier.padding(top = 8.dp))
}
hubSkill.identifier?.let { identifier ->
SkillScanSection(
scanResult =
state.hubScanResult.takeIf {
state.hubScanIdentifier == identifier
},
isScanning =
state.isHubScanning &&
state.hubScanIdentifier == identifier,
scanError =
state.hubScanError.takeIf {
state.hubScanIdentifier == identifier
},
onScan = { onScanHubSkill(identifier) },
)
}
}
},
onDismiss = {
hubShowDetail = null
onClearHubScan()
},
)
}
}
// ── Hub Landing (sources + featured) ───────────────────────────────────
@Composable
private fun HubLandingView(
state: SkillsUiState,
onLoadHubSources: () -> Unit,
onInstall: (String) -> Unit,
isInstalling: Boolean,
installingSkillName: String?,
onOpenDetail: (HubSkill) -> Unit,
) {
when {
state.isHubSourcesLoading -> {
SkeletonListState()
}
state.hubSourcesError != null -> {
ErrorState(
message = state.hubSourcesError,
onRetry = onLoadHubSources,
)
}
state.hubSources.isEmpty() && state.hubFeatured.isEmpty() -> {
EmptyState(
icon = Icons.Filled.Extension,
title = stringResource(R.string.skills_hub_landing_hint),
)
}
else -> {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(horizontal = 8.dp, vertical = 8.dp),
verticalArrangement = listItemSpacing,
) {
if (state.hubSources.isNotEmpty()) {
item(key = "hub_sources_header") {
Column(
modifier = Modifier.padding(horizontal = 4.dp, vertical = 4.dp),
) {
Text(
text = stringResource(R.string.skills_hub_sources_title),
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.primary,
)
Spacer(modifier = Modifier.height(8.dp))
Row(
modifier =
Modifier.horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
state.hubSources.forEach { source ->
SourceChip(source = source)
}
}
}
}
}
if (state.hubFeatured.isNotEmpty()) {
item(key = "hub_featured_header") {
Column(
modifier = Modifier.padding(horizontal = 4.dp, vertical = 4.dp),
) {
Text(
text = stringResource(R.string.skills_hub_featured_title),
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.primary,
)
Text(
text = stringResource(R.string.skills_hub_featured_subtitle),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
itemsIndexed(
state.hubFeatured,
key = { index, hubSkill ->
"featured:${hubSkill.name}:${hubSkill.source ?: "unknown"}:$index"
},
) { _, hubSkill ->
HubSkillCard(
hubSkill = hubSkill,
onInstall = { onInstall(hubSkill.name) },
isInstalling =
isInstalling && installingSkillName == hubSkill.name,
onClick = { onOpenDetail(hubSkill) },
)
}
}
}
}
}
}
@Composable
private fun SourceChip(source: SkillHubSource) {
val statusColors = LocalHermesStatusColors.current
val label =
buildString {
append(source.label ?: source.id)
when {
source.rateLimited == true -> {
append(" · ")
append(stringResource(R.string.skills_hub_source_rate_limited))
}
source.available == false -> {
append(" · ")
append(stringResource(R.string.skills_hub_source_offline))
}
source.searchable == false -> {
append(" · ")
append(stringResource(R.string.skills_hub_source_via_index))
}
}
}
val color =
when {
source.rateLimited == true -> statusColors.warning
source.available == false -> statusColors.error
else -> MaterialTheme.colorScheme.primary
}
Surface(
color = color.copy(alpha = 0.12f),
shape = RoundedCornerShape(50),
) {
Text(
text = label,
style = MaterialTheme.typography.labelMedium,
color = color,
modifier = Modifier.padding(horizontal = 10.dp, vertical = 5.dp),
)
}
}
// ── Hub security scan ──────────────────────────────────────────────────
@Composable
private fun SkillScanSection(
scanResult: SkillScanResponse?,
isScanning: Boolean,
scanError: String?,
onScan: () -> Unit,
) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Button(
onClick = onScan,
enabled = !isScanning,
) {
if (isScanning) {
CircularProgressIndicator(
modifier = Modifier.size(18.dp),
strokeWidth = 2.dp,
color = MaterialTheme.colorScheme.onPrimary,
)
} else {
Icon(
imageVector = Icons.Filled.Shield,
contentDescription = null,
modifier = Modifier.size(18.dp),
)
Spacer(modifier = Modifier.width(6.dp))
Text(stringResource(R.string.skills_hub_scan))
}
}
when {
isScanning -> Unit
scanError != null -> {
Surface(
color = LocalHermesStatusColors.current.errorContainer,
shape = RoundedCornerShape(8.dp),
) {
Text(
text = scanError,
color = MaterialTheme.colorScheme.onErrorContainer,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(12.dp),
)
}
}
scanResult != null -> {
ScanResultCard(result = scanResult)
}
}
}
}
@Composable
private fun ScanResultCard(result: SkillScanResponse) {
val statusColors = LocalHermesStatusColors.current
val verdictColor =
when (result.verdict) {
"safe" -> statusColors.success
"dangerous" -> statusColors.error
else -> statusColors.warning
}
val policyLabel =
when (result.policy) {
"allow" -> stringResource(R.string.skills_hub_scan_policy_allow)
"ask" -> stringResource(R.string.skills_hub_scan_policy_ask)
"block" -> stringResource(R.string.skills_hub_scan_policy_block)
else -> result.policy.orEmpty()
}
val policyColor =
when (result.policy) {
"allow" -> statusColors.success
"ask" -> statusColors.warning
"block" -> statusColors.error
else -> statusColors.neutral
}
Card(
modifier = Modifier.fillMaxWidth(),
colors =
CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainerHigh,
),
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)),
) {
Column(
modifier = Modifier.fillMaxWidth().padding(12.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
// ── Verdict header ──
Row(
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
imageVector = Icons.Filled.Shield,
contentDescription = null,
tint = verdictColor,
modifier = Modifier.size(20.dp),
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = stringResource(R.string.skills_hub_scan_verdict_label, result.verdict.orEmpty()),
style = MaterialTheme.typography.titleSmall,
color = verdictColor,
modifier = Modifier.weight(1f),
)
Surface(
color = policyColor.copy(alpha = 0.15f),
shape = RoundedCornerShape(50),
) {
Text(
text = policyLabel,
style = MaterialTheme.typography.labelSmall,
color = policyColor,
modifier = Modifier.padding(horizontal = 8.dp, vertical = 3.dp),
)
}
}
// ── Trust + finding count ──
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
if (!result.trustLevel.isNullOrBlank()) {
Text(
text = result.trustLevel,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Text(
text =
pluralStringResource(
R.plurals.skills_hub_scan_findings_count,
result.findings.size,
result.findings.size,
),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
// ── Severity tally ──
val severityColors =
mapOf(
"critical" to statusColors.error,
"high" to statusColors.error,
"medium" to statusColors.warning,
"low" to statusColors.neutral,
)
Row(
modifier = Modifier.horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
listOf("critical", "high", "medium", "low").forEach { sev ->
val count = result.severityCounts[sev] ?: 0
if (count > 0) {
val sevColor = severityColors[sev] ?: statusColors.neutral
Surface(
color = sevColor.copy(alpha = 0.15f),
shape = RoundedCornerShape(50),
) {
Text(
text = "$count $sev",
style = MaterialTheme.typography.labelSmall,
color = sevColor,
modifier = Modifier.padding(horizontal = 8.dp, vertical = 3.dp),
)
}
}
}
if (result.findings.isEmpty()) {
Text(
text = stringResource(R.string.skills_hub_scan_no_findings),
style = MaterialTheme.typography.labelSmall,
color = statusColors.success,
)
}
}
// ── Policy reason ──
if (!result.policyReason.isNullOrBlank()) {
Text(
text = result.policyReason,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
// ── Findings ──
if (result.findings.isNotEmpty()) {
HorizontalDivider()
result.findings.forEach { finding ->
ScanFindingRow(finding = finding)
}
}
}
}
}
@Composable
private fun ScanFindingRow(finding: SkillScanFinding) {
val statusColors = LocalHermesStatusColors.current
val severityColor =
when (finding.severity) {
"critical", "high" -> statusColors.error
"medium" -> statusColors.warning
else -> statusColors.neutral
}
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
Surface(
color = severityColor.copy(alpha = 0.15f),
shape = RoundedCornerShape(4.dp),
) {
Text(
text = finding.severity,
style = MaterialTheme.typography.labelSmall,
color = severityColor,
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp),
)
}
Text(
text = finding.category,
style = MaterialTheme.typography.labelMedium,
fontWeight = FontWeight.SemiBold,
)
val location =
buildString {
finding.file?.let { append(it) }
finding.line?.let { append(":").append(it) }
}
if (location.isNotEmpty()) {
Text(
text = location,
style = MaterialTheme.typography.labelSmall,
fontFamily = FontFamily.Monospace,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
finding.description?.let { description ->
Text(
text = description,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
// ── Hub Skill Card ─────────────────────────────────────────────────────
@Composable
@@ -8,7 +8,9 @@ import com.m57.hermescontrol.data.model.HubSkill
import com.m57.hermescontrol.data.model.SaveSkillContentRequest
import com.m57.hermescontrol.data.model.Skill
import com.m57.hermescontrol.data.model.SkillHubInstallRequest
import com.m57.hermescontrol.data.model.SkillHubSource
import com.m57.hermescontrol.data.model.SkillHubUninstallRequest
import com.m57.hermescontrol.data.model.SkillScanResponse
import com.m57.hermescontrol.data.model.ToggleSkillRequest
import com.m57.hermescontrol.data.remote.ApiClient
import com.m57.hermescontrol.data.remote.NetworkResult
@@ -58,6 +60,16 @@ data class SkillsUiState(
val hubResults: List<HubSkill> = emptyList(),
val isHubSearching: Boolean = false,
val hubSearchError: String? = null,
// Hub sources + featured (landing, before any search)
val hubSources: List<SkillHubSource> = emptyList(),
val hubFeatured: List<HubSkill> = emptyList(),
val isHubSourcesLoading: Boolean = false,
val hubSourcesError: String? = null,
// Hub security scan
val hubScanIdentifier: String? = null,
val hubScanResult: SkillScanResponse? = null,
val isHubScanning: Boolean = false,
val hubScanError: String? = null,
// Hub detail preview (full description/content via preview endpoint)
val hubPreviewIdentifier: String? = null,
val hubPreviewContent: String? = null,
@@ -111,6 +123,9 @@ class SkillsViewModel(
if (mode == SkillsViewMode.INSTALLED && _uiState.value.skills.isEmpty()) {
loadSkills()
}
if (mode == SkillsViewMode.HUB && _uiState.value.hubSources.isEmpty()) {
loadHubSources()
}
}
fun setSourceFilter(source: String?) {
@@ -161,6 +176,94 @@ class SkillsViewModel(
}
}
// ── Hub sources + featured landing ────────────────────────────────────
/**
* Loads the configured hub sources and featured skills (the browse-hub
* landing state shown before any search runs).
*/
fun loadHubSources() {
safeLaunchLoad(
apiCall = { safeApiCall { ApiClient.hermesApi.getSkillHubSources() } },
onStart = {
_uiState.update {
it.copy(isHubSourcesLoading = true, hubSourcesError = null)
}
},
onSuccess = { data ->
_uiState.update {
it.copy(
isHubSourcesLoading = false,
hubSources = data.sources,
hubFeatured = data.featured,
)
}
},
onError = { errorMsg ->
_uiState.update {
it.copy(
isHubSourcesLoading = false,
hubSourcesError = "Failed to load hub sources: $errorMsg",
)
}
},
)
}
// ── Hub security scan ─────────────────────────────────────────────────
/**
* Runs the install-time security scan on a hub skill WITHOUT installing
* it, so the user sees the safety verdict before deciding.
*/
fun scanHubSkill(identifier: String) {
if (identifier.isBlank()) return
_uiState.update {
it.copy(
hubScanIdentifier = identifier,
isHubScanning = true,
hubScanResult = null,
hubScanError = null,
)
}
viewModelScope.launch {
val result =
withContext(Dispatchers.IO) {
safeApiCall { ApiClient.hermesApi.scanHubSkill(identifier = identifier) }
}
when (result) {
is NetworkResult.Success -> {
_uiState.update {
it.copy(
isHubScanning = false,
hubScanResult = result.data,
)
}
}
is NetworkResult.Failure -> {
_uiState.update {
it.copy(
isHubScanning = false,
hubScanError = result.error.message,
)
}
}
}
}
}
fun clearHubScan() {
_uiState.update {
it.copy(
hubScanIdentifier = null,
hubScanResult = null,
isHubScanning = false,
hubScanError = null,
)
}
}
// ── Hub detail preview ──────────────────────────────────────────────────
/**
@@ -1,291 +0,0 @@
package com.m57.hermescontrol.ui.skills.components
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
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.itemsIndexed
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.CloudDownload
import androidx.compose.material.icons.filled.Extension
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.model.HubSkill
import com.m57.hermescontrol.ui.common.DetailDialog
import com.m57.hermescontrol.ui.common.DetailRow
import com.m57.hermescontrol.ui.common.EmptyState
import com.m57.hermescontrol.ui.common.ErrorState
import com.m57.hermescontrol.ui.common.SkeletonListState
import com.m57.hermescontrol.ui.common.listItemSpacing
import com.m57.hermescontrol.ui.skills.SkillsUiState
@Composable
internal fun HubBrowseView(
state: SkillsUiState,
hubQuery: String,
onHubQueryChange: (String) -> Unit,
onSearch: (String) -> Unit,
onClearSearch: () -> Unit,
onInstall: (String) -> Unit,
onPreviewHubSkill: (String) -> Unit,
isInstalling: Boolean,
installingSkillName: String?,
) {
var showDetail by remember { mutableStateOf<HubSkill?>(null) }
Column(modifier = Modifier.fillMaxSize()) {
OutlinedTextField(
value = hubQuery,
onValueChange = onHubQueryChange,
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 6.dp),
placeholder = { Text(stringResource(R.string.skills_hub_search_placeholder)) },
trailingIcon = {
Row(
verticalAlignment = Alignment.CenterVertically,
) {
if (hubQuery.isNotEmpty()) {
IconButton(onClick = {
onHubQueryChange("")
onClearSearch()
}) {
Icon(
imageVector = Icons.Filled.Close,
contentDescription = stringResource(R.string.action_clear),
)
}
}
IconButton(
onClick = { onSearch(hubQuery) },
enabled = hubQuery.isNotBlank(),
) {
Icon(
imageVector = Icons.Filled.Search,
contentDescription = stringResource(R.string.skills_hub_search_button),
)
}
}
},
singleLine = true,
shape = RoundedCornerShape(12.dp),
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
keyboardActions =
KeyboardActions(
onSearch = {
if (hubQuery.isNotBlank()) {
onSearch(hubQuery)
}
},
),
)
when {
state.isHubSearching -> {
SkeletonListState()
}
state.hubSearchError != null -> {
ErrorState(
message = state.hubSearchError,
onRetry = { onSearch(hubQuery) },
)
}
state.hubResults.isEmpty() && hubQuery.isNotBlank() && !state.isHubSearching -> {
EmptyState(
icon = Icons.Filled.Extension,
title = stringResource(R.string.skills_hub_empty_results),
actionLabel = stringResource(R.string.empty_action_retry_search),
onAction = { onSearch(hubQuery) },
)
}
state.hubResults.isNotEmpty() -> {
LazyColumn(
modifier = Modifier.weight(1f),
contentPadding = PaddingValues(horizontal = 8.dp, vertical = 8.dp),
verticalArrangement = listItemSpacing,
) {
itemsIndexed(
state.hubResults,
key = { index, hubSkill -> "${hubSkill.name}:${hubSkill.source ?: "unknown"}:$index" },
) { _, hubSkill ->
HubSkillCard(
hubSkill = hubSkill,
onInstall = { onInstall(hubSkill.name) },
isInstalling = isInstalling && installingSkillName == hubSkill.name,
onClick = {
showDetail = hubSkill
hubSkill.identifier?.let { onPreviewHubSkill(it) }
},
)
}
}
}
else -> {
EmptyState(
icon = Icons.Filled.Search,
title = stringResource(R.string.skills_hub_search_hint),
)
}
}
}
showDetail?.let { hubSkill ->
val previewReady = state.hubPreviewIdentifier == hubSkill.identifier
val fullContent =
if (previewReady) {
state.hubPreviewContent ?: hubSkill.description
} else {
hubSkill.description
}
DetailDialog(
title = hubSkill.name,
rows =
listOf(
DetailRow(stringResource(R.string.detail_dialog_category), hubSkill.category),
DetailRow(stringResource(R.string.detail_dialog_source), hubSkill.source),
DetailRow(stringResource(R.string.detail_dialog_description), fullContent),
DetailRow(stringResource(R.string.detail_dialog_tags), hubSkill.tags.orEmpty().joinToString(", ")),
DetailRow(stringResource(R.string.detail_dialog_trust_level), hubSkill.trustLevel),
),
actions =
if (previewReady && state.isHubPreviewing) {
{ CircularProgressIndicator(modifier = Modifier.padding(top = 8.dp)) }
} else {
null
},
onDismiss = { showDetail = null },
)
}
}
@Composable
private fun HubSkillCard(
hubSkill: HubSkill,
onInstall: () -> Unit,
isInstalling: Boolean,
onClick: () -> Unit,
) {
Card(
modifier = Modifier.fillMaxWidth().clickable(onClick = onClick),
colors =
CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainerHigh,
),
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)),
) {
Column(
modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 8.dp),
) {
// ── Top row: name + source badge ──
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = hubSkill.name,
style =
MaterialTheme.typography.titleMedium.copy(
fontWeight = FontWeight.SemiBold,
),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
hubSkill.source?.let { source ->
Spacer(modifier = Modifier.width(6.dp))
SourceBadge(source = source)
}
}
// ── Category ──
hubSkill.category?.let { cat ->
Spacer(modifier = Modifier.height(2.dp))
Text(
text = cat,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
// ── Description ──
hubSkill.description?.let { desc ->
Spacer(modifier = Modifier.height(4.dp))
Text(
text = desc,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 3,
overflow = TextOverflow.Ellipsis,
)
}
// ── Install button ──
Spacer(modifier = Modifier.height(8.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End,
) {
Button(
onClick = onInstall,
enabled = !isInstalling,
) {
if (isInstalling) {
CircularProgressIndicator(
modifier = Modifier.size(18.dp),
strokeWidth = 2.dp,
color = MaterialTheme.colorScheme.onPrimary,
)
} else {
Icon(
imageVector = Icons.Filled.CloudDownload,
contentDescription = null,
modifier = Modifier.size(18.dp),
)
Spacer(Modifier.width(4.dp))
Text(stringResource(R.string.skills_hub_install))
}
}
}
}
}
}
+17
View File
@@ -277,6 +277,23 @@
<string name="skills_hub_empty_results">No skills found in hub</string>
<string name="skills_hub_search_hint">Type a search term to browse the skill hub</string>
<string name="skills_hub_install">Install</string>
<string name="skills_hub_sources_title">Connected sources</string>
<string name="skills_hub_source_rate_limited">rate limited</string>
<string name="skills_hub_source_offline">offline</string>
<string name="skills_hub_source_via_index">via index</string>
<string name="skills_hub_featured_title">Featured skills</string>
<string name="skills_hub_featured_subtitle">from the Hermes index — search above for more</string>
<string name="skills_hub_landing_hint">Search the hub above to browse installable skills from the connected sources</string>
<string name="skills_hub_scan">Scan</string>
<string name="skills_hub_scan_verdict_label">Verdict: %1$s</string>
<string name="skills_hub_scan_policy_allow">Install allowed</string>
<string name="skills_hub_scan_policy_ask">Needs confirmation</string>
<string name="skills_hub_scan_policy_block">Install blocked</string>
<string name="skills_hub_scan_no_findings">No risky patterns detected</string>
<plurals name="skills_hub_scan_findings_count">
<item quantity="one">%1$d finding</item>
<item quantity="other">%1$d findings</item>
</plurals>
<string name="skills_content_placeholder">Enter skill content\u2026</string>
<string name="skills_empty_message">No skills found</string>
<string name="skills_discard_title">Discard changes?</string>
@@ -0,0 +1,199 @@
package com.m57.hermescontrol.ui.skills
import android.app.Application
import com.m57.hermescontrol.data.model.HubSkill
import com.m57.hermescontrol.data.model.SkillHubSource
import com.m57.hermescontrol.data.model.SkillHubSourcesResponse
import com.m57.hermescontrol.data.model.SkillScanFinding
import com.m57.hermescontrol.data.model.SkillScanResponse
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.MediaType.Companion.toMediaTypeOrNull
import okhttp3.ResponseBody.Companion.toResponseBody
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Before
import org.junit.Test
import retrofit2.Response
@OptIn(ExperimentalCoroutinesApi::class)
class SkillsViewModelTest {
private val testDispatcher = StandardTestDispatcher()
private val mockApi = mockk<HermesApiService>(relaxed = true)
private val app = mockk<Application>(relaxed = true)
/**
* Pump the test scheduler while letting the real Dispatchers.IO hops
* (safeLaunchLoad / withContext(IO)) land their resumptions.
*/
private fun settle() {
repeat(20) {
testDispatcher.scheduler.advanceUntilIdle()
Thread.sleep(10)
}
testDispatcher.scheduler.advanceUntilIdle()
}
private val sourcesResponse =
SkillHubSourcesResponse(
sources =
listOf(
SkillHubSource(id = "hermes-index", label = "Hermes Index", available = true),
SkillHubSource(id = "github", label = "GitHub", rateLimited = true),
),
indexAvailable = true,
featured =
listOf(
HubSkill(
name = "web-research",
description = "Find and verify current web facts",
source = "hermes-index",
identifier = "hermes-index:web-research",
),
),
)
private val scanResponse =
SkillScanResponse(
identifier = "hermes-index:web-research",
name = "web-research",
source = "hermes-index",
trustLevel = "trusted",
verdict = "safe",
summary = "No issues found",
policy = "allow",
policyReason = "No dangerous patterns detected.",
findings =
listOf(
SkillScanFinding(
severity = "low",
category = "network",
file = "scripts/fetch.py",
line = 12,
description = "Uses urllib without a timeout.",
),
),
severityCounts = mapOf("low" to 1),
)
@Before
fun setUp() {
Dispatchers.setMain(testDispatcher)
mockkObject(ApiClient)
every { ApiClient.hermesApi } returns mockApi
}
@After
fun tearDown() {
Dispatchers.resetMain()
unmockkAll()
}
@Test
fun `loadHubSources stores sources and featured skills`() {
coEvery { mockApi.getSkillHubSources() } returns Response.success(sourcesResponse)
val vm = SkillsViewModel(app)
vm.loadHubSources()
settle()
assertEquals(sourcesResponse.sources, vm.uiState.value.hubSources)
assertEquals(sourcesResponse.featured, vm.uiState.value.hubFeatured)
assertEquals(false, vm.uiState.value.isHubSourcesLoading)
assertNull(vm.uiState.value.hubSourcesError)
}
@Test
fun `loadHubSources surfaces an error`() {
coEvery { mockApi.getSkillHubSources() } returns
Response.error(
404,
"""{"detail":"Hub sources unavailable"}""".toResponseBody("application/json".toMediaTypeOrNull()),
)
val vm = SkillsViewModel(app)
vm.loadHubSources()
settle()
assertEquals(true, vm.uiState.value.hubSourcesError?.contains("Failed to load hub sources"))
assertEquals(false, vm.uiState.value.isHubSourcesLoading)
}
@Test
fun `setViewMode HUB triggers sources load when landing is empty`() {
coEvery { mockApi.getSkillHubSources() } returns Response.success(sourcesResponse)
val vm = SkillsViewModel(app)
vm.setViewMode(SkillsViewMode.HUB)
settle()
coVerify { mockApi.getSkillHubSources() }
assertEquals(sourcesResponse.featured, vm.uiState.value.hubFeatured)
}
@Test
fun `scanHubSkill stores the scan result for the identifier`() {
coEvery { mockApi.scanHubSkill(identifier = "hermes-index:web-research") } returns
Response.success(scanResponse)
val vm = SkillsViewModel(app)
vm.scanHubSkill("hermes-index:web-research")
settle()
assertEquals("hermes-index:web-research", vm.uiState.value.hubScanIdentifier)
assertEquals(scanResponse, vm.uiState.value.hubScanResult)
assertEquals(false, vm.uiState.value.isHubScanning)
assertNull(vm.uiState.value.hubScanError)
}
@Test
fun `scanHubSkill surfaces an error`() {
coEvery { mockApi.scanHubSkill(identifier = "broken:skill") } returns
Response.error(
404,
"""{"detail":"Skill not found"}""".toResponseBody("application/json".toMediaTypeOrNull()),
)
val vm = SkillsViewModel(app)
vm.scanHubSkill("broken:skill")
settle()
assertEquals("broken:skill", vm.uiState.value.hubScanIdentifier)
assertNull(vm.uiState.value.hubScanResult)
assertEquals(false, vm.uiState.value.isHubScanning)
assertEquals("Skill not found", vm.uiState.value.hubScanError)
}
@Test
fun `scanHubSkill ignores blank identifiers`() {
val vm = SkillsViewModel(app)
vm.scanHubSkill(" ")
settle()
assertNull(vm.uiState.value.hubScanIdentifier)
coVerify(exactly = 0) { mockApi.scanHubSkill(any()) }
}
@Test
fun `clearHubScan resets scan state`() {
coEvery { mockApi.scanHubSkill(identifier = "hermes-index:web-research") } returns
Response.success(scanResponse)
val vm = SkillsViewModel(app)
vm.scanHubSkill("hermes-index:web-research")
settle()
vm.clearHubScan()
assertNull(vm.uiState.value.hubScanIdentifier)
assertNull(vm.uiState.value.hubScanResult)
assertNull(vm.uiState.value.hubScanError)
assertEquals(false, vm.uiState.value.isHubScanning)
}
}