Merge branch 'claude/pedantic-mcnulty-bac7cc' (iOS UI refactor)

Brings the major iOS UI refactor into scarf-mobile-development on top
of the v0.11 work that landed after the merge base (commit 6808adf).

Reconciled in this merge:

- iOS Chat/ChatView.swift — auto-merged. Their project-chat handoff
  (lines 75-148: pendingProjectChat consumer + consumePendingProjectChat
  helper) sits cleanly alongside my v0.11 chat additions at lines 350+
  (slash command chip + browser sheet), 500+ (/steer toast), 700+
  (per-turn stopwatch + git branch chip).
- Mac Features/Skills/Views/SkillsView.swift — manual resolution.
  Took their async-wrap of viewModel.load() (the new ScarfCore
  SkillsViewModel.load is async) AND kept my v0.11 modifiers
  (designMdNpxStatus probe + recomputeSnapshotDiff + .onChange + .task)
  + helpers (recomputeSnapshotDiff, whatsNewPill).
- M5FeatureVMTests.swift — auto-merged. Their 3-line rename of
  IOSSkillsViewModel → SkillsViewModel is in a different region from
  my Phase 1.10 slash-command tests.
- iOS Skills/SkillsListView.swift — resolved as DELETE (their
  refactor replaces it with Skills/Installed/SkillDetailView and
  Skills/SkillsView). My v0.11 features there (Spotify info row,
  design-md banner, frontmatter chips, What's New pill) get re-ported
  to the new files in follow-up commits.
- ScarfCore IOSSkillsViewModel.swift — resolved as DELETE (replaced
  by the shared SkillsViewModel in ScarfCore). My parseFrontmatter
  function relocates to SkillFrontmatterParser via Phase C.
- ProjectSlashCommandsViewModel.swift — git's location-conflict
  heuristic moved my Mac VM into ScarfCore (because the parent dir
  was renamed). Manually relocated back to scarf/scarf/Features/Projects/ViewModels/
  where it belongs (the file imports ScarfCore as a dependency, can't
  live inside it).

Wholesale-accepted (no overlap with v0.11):
- ScarfCore: SkillsScanner, SkillFrontmatterParser, HermesSkillsHubParser,
  SkillsViewModel, ProjectSessionsViewModel + new tests.
- iOS Projects/ feature (NEW): ProjectsListView, ProjectDetailView,
  ProjectSessionsView_iOS, ProjectSiteView, Widgets/ subdir.
- iOS Skills/ refactor (NEW): SkillsView (3-sub-tab switcher),
  Hub/HubBrowseView, Installed/{InstalledSkillsListView, SkillDetailView,
  SkillEditorSheet}, Updates/UpdatesView.
- ScarfGoCoordinator: pendingProjectChat, startChatInProject(path:).
- ScarfGoTabRoot: 5-tab nav (Dashboard / Projects / Chat / Skills /
  System) replacing the old Chat / Dashboard / Memory / More.

Verified: ScarfCore + Mac + iOS schemes all build clean on first try
post-merge. Phase C/D/E follow-up commits will:
1. Extend SkillsScanner so HermesSkill.allowedTools / relatedSkills /
   dependencies populate (currently nil because the new scanner only
   parses skill.yaml's required_config).
2. Port my v0.11 iOS Skills features into the new SkillDetailView /
   SkillsView (Spotify info row, design-md npx banner, frontmatter
   chips, What's New pill).
3. Clean up Mac dead code (HermesFileService.parseSkillFrontmatter,
   parseSkillRequiredConfig — superseded by SkillsScanner /
   SkillFrontmatterParser).
This commit is contained in:
Alan Wizemann
2026-04-25 09:56:13 +02:00
34 changed files with 2624 additions and 842 deletions
@@ -0,0 +1,157 @@
import Foundation
/// Pure-Swift parsers for `hermes skills` CLI output. Extracted from
/// the Mac `SkillsViewModel` in v2.5 so iOS can share the same parse
/// logic both targets call `transport.runProcess(executable: hermes)`
/// and feed the captured stdout/stderr through these parsers.
///
/// Marked `Sendable` so they can run inside `Task.detached` blocks
/// without isolation gymnastics. All members are `nonisolated`.
public enum HermesSkillsHubParser: Sendable {
/// Parse `hermes skills browse|search` output.
///
/// Hermes emits a Rich box-drawn table with vertical bars as column
/// separators:
///
/// # Name Description Source Trust
///
/// 1 1password Set up and use 1Pass official official
///
/// Description cells can wrap across multiple rows the
/// continuation rows have an empty `#` column. We join consecutive
/// rows with the same skill by checking whether the first column
/// (after ``) is whitespace-only.
public static func parseHubList(_ output: String) -> [HermesHubSkill] {
var results: [HermesHubSkill] = []
for raw in output.components(separatedBy: "\n") {
let line = raw
// Skip everything that isn't a data row. Data rows start
// with `` and contain multiple `` separators. Border
// rows (``, ``, ``, ``, etc.) are drawn with `` or
// `` and should be skipped.
guard line.contains("") else { continue }
let cells = line
.split(separator: "", omittingEmptySubsequences: false)
.map { $0.trimmingCharacters(in: .whitespaces) }
// Expect at least: leading empty, #, Name, Description,
// Source, Trust, trailing empty
guard cells.count >= 6 else { continue }
let numCell = cells[1]
let nameCell = cells[2]
let descCell = cells[3]
let sourceCell = cells[4]
// Trust column (index 5) is informational only we ignore
// it in the UI.
// Continuation row: `#` column is empty. Merge its
// description into the last-added entry if present.
if numCell.isEmpty {
guard !results.isEmpty else { continue }
let last = results.removeLast()
let merged = [last.description, descCell]
.filter { !$0.isEmpty }
.joined(separator: " ")
results.append(HermesHubSkill(
identifier: last.identifier,
name: last.name,
description: merged,
source: last.source
))
continue
}
// Header row first data-looking row whose number cell
// isn't a digit.
if Int(numCell) == nil { continue }
// Empty name cell shouldn't happen but guard anyway.
guard !nameCell.isEmpty else { continue }
// Identifier: `hermes skills browse` shows the short name
// in the Name column. For install we need the full
// identifier like `<source>/<name>`. The CLI accepts just
// the name for official hub, so we use that as the install
// target.
let source = sourceCell
.replacingOccurrences(of: "", with: "")
.trimmingCharacters(in: .whitespaces)
results.append(HermesHubSkill(
identifier: nameCell,
name: nameCell,
description: descCell,
source: source
))
}
return results
}
/// Parse `hermes skills check` output for available updates. Format
/// is undocumented; we look for `` (U+2192) or `->` arrow markers
/// between version strings.
public static func parseUpdateList(_ output: String) -> [HermesSkillUpdate] {
var results: [HermesSkillUpdate] = []
for raw in output.components(separatedBy: "\n") {
let line = raw.trimmingCharacters(in: .whitespaces)
guard line.contains("") || line.contains("->") else { continue }
let marker = line.contains("") ? "" : "->"
let parts = line.components(separatedBy: marker)
guard parts.count == 2 else { continue }
let left = parts[0].trimmingCharacters(in: .whitespaces)
let available = parts[1].trimmingCharacters(in: .whitespaces)
let leftTokens = left
.split(separator: " ", omittingEmptySubsequences: true)
.map(String.init)
guard leftTokens.count >= 2 else { continue }
let identifier = leftTokens[0]
let current = leftTokens[leftTokens.count - 1]
results.append(HermesSkillUpdate(
identifier: identifier,
currentVersion: current,
availableVersion: available
))
}
return results
}
}
// MARK: - Public model types
/// A single search/browse result from a skill registry. Mirrors the
/// shape `SkillsViewModel` had on Mac before the v2.5 ScarfCore promotion.
public struct HermesHubSkill: Identifiable, Sendable, Equatable {
public var id: String { identifier }
public let identifier: String // e.g. "openai/skills/skill-creator"
public let name: String
public let description: String
public let source: String // "official" | "skills-sh" | etc.
public init(
identifier: String,
name: String,
description: String,
source: String
) {
self.identifier = identifier
self.name = name
self.description = description
self.source = source
}
}
/// A local skill that has an upstream version available.
public struct HermesSkillUpdate: Identifiable, Sendable, Equatable {
public var id: String { identifier }
public let identifier: String
public let currentVersion: String
public let availableVersion: String
public init(
identifier: String,
currentVersion: String,
availableVersion: String
) {
self.identifier = identifier
self.currentVersion = currentVersion
self.availableVersion = availableVersion
}
}
@@ -0,0 +1,39 @@
import Foundation
/// Pure-Swift YAML-frontmatter parser for skill manifests' `required_config:`
/// list. Extracted from `HermesFileService.parseSkillRequiredConfig` in
/// v2.5 so iOS can flag missing config keys without depending on the
/// Mac target.
///
/// Intentionally not a full YAML parser Hermes skill manifests use a
/// very narrow subset of YAML for this list. We look for a top-level
/// `required_config:` key followed by `- key` entries with at least one
/// space of indent. Lines outside that section are ignored.
public enum SkillFrontmatterParser: Sendable {
/// Parse the `required_config:` list from a skill.yaml's text. Empty
/// result on any kind of malformation callers treat it as "no
/// required config, proceed".
public static func parseRequiredConfig(_ content: String) -> [String] {
var result: [String] = []
var inRequiredConfig = false
for line in content.components(separatedBy: "\n") {
let trimmed = line.trimmingCharacters(in: .whitespaces)
if trimmed.isEmpty || trimmed.hasPrefix("#") { continue }
let indent = line.prefix(while: { $0 == " " }).count
if trimmed == "required_config:" || trimmed.hasPrefix("required_config:") {
inRequiredConfig = true
continue
}
if inRequiredConfig {
if indent < 2 && !trimmed.isEmpty {
break
}
if trimmed.hasPrefix("- ") {
result.append(String(trimmed.dropFirst(2)))
}
}
}
return result
}
}
@@ -0,0 +1,65 @@
import Foundation
import os
/// Walks `~/.hermes/skills/<category>/<name>/` and returns a populated
/// list of `HermesSkillCategory`. Body ported from
/// `HermesFileService.loadSkills` in v2.5 so iOS and Mac share the same
/// scan logic only difference vs the Mac function is that this one
/// reads through the supplied transport rather than holding its own.
///
/// Synchronous + transport-backed: callers running on the MainActor
/// should wrap in `Task.detached` (the iOS pattern) since SFTP `stat` /
/// `listDirectory` calls block.
public enum SkillsScanner: Sendable {
private static let logger = Logger(subsystem: "com.scarf", category: "SkillsScanner")
public static func scan(context: ServerContext, transport: any ServerTransport) -> [HermesSkillCategory] {
let dir = context.paths.skillsDir
// Fresh install: skills/ may not exist yet return [] without
// logging an error.
guard transport.fileExists(dir) else { return [] }
guard let categories = try? transport.listDirectory(dir) else { return [] }
return categories
.filter { !$0.hasPrefix(".") }
.sorted()
.compactMap { categoryName -> HermesSkillCategory? in
let categoryPath = dir + "/" + categoryName
guard transport.stat(categoryPath)?.isDirectory == true else { return nil }
guard let skillNames = try? transport.listDirectory(categoryPath) else { return nil }
let skills = skillNames
.filter { !$0.hasPrefix(".") }
.sorted()
.compactMap { skillName -> HermesSkill? in
let skillPath = categoryPath + "/" + skillName
guard transport.stat(skillPath)?.isDirectory == true else { return nil }
let files = ((try? transport.listDirectory(skillPath)) ?? [])
.filter { !$0.hasPrefix(".") }
.sorted()
let requiredConfig = readRequiredConfig(
yamlPath: skillPath + "/skill.yaml",
transport: transport
)
return HermesSkill(
id: categoryName + "/" + skillName,
name: skillName,
category: categoryName,
path: skillPath,
files: files,
requiredConfig: requiredConfig
)
}
guard !skills.isEmpty else { return nil }
return HermesSkillCategory(id: categoryName, name: categoryName, skills: skills)
}
}
private static func readRequiredConfig(yamlPath: String, transport: any ServerTransport) -> [String] {
guard let data = try? transport.readFile(yamlPath),
let content = String(data: data, encoding: .utf8)
else { return [] }
return SkillFrontmatterParser.parseRequiredConfig(content)
}
}
@@ -1,140 +0,0 @@
import Foundation
import Observation
/// iOS read-only Skills view-state. Scans `~/.hermes/skills/` for
/// category directories, then each category for skill directories,
/// then each skill directory for its file list. Mirrors what the
/// Mac app's `HermesFileService.loadSkills` does but scoped to what
/// the transport's `listDirectory` primitive can surface (no deep
/// YAML frontmatter parsing users who want to inspect a skill's
/// full definition still do that on the Mac).
///
/// M5 is read-only by design. Installing new skills would need a
/// git-clone over SSH plus schema validation; that's a separate
/// feature in a later phase.
@Observable
@MainActor
public final class IOSSkillsViewModel {
public let context: ServerContext
public private(set) var categories: [HermesSkillCategory] = []
public private(set) var isLoading: Bool = true
public private(set) var lastError: String?
public init(context: ServerContext) {
self.context = context
}
public func load() async {
isLoading = true
lastError = nil
let ctx = context
let skillsRoot = ctx.paths.skillsDir
let loaded: Result<[HermesSkillCategory], Error> = await Task.detached {
let transport = ctx.makeTransport()
guard transport.fileExists(skillsRoot) else {
// Fresh install no skills/ dir yet.
return .success([])
}
do {
let categoryNames = try transport.listDirectory(skillsRoot)
.filter { !$0.hasPrefix(".") }
.sorted()
var categories: [HermesSkillCategory] = []
for categoryName in categoryNames {
let categoryPath = skillsRoot + "/" + categoryName
// Only include directories.
guard transport.stat(categoryPath)?.isDirectory == true else { continue }
var skills: [HermesSkill] = []
let skillNames: [String]
do {
skillNames = try transport.listDirectory(categoryPath)
.filter { !$0.hasPrefix(".") }
.sorted()
} catch {
// Skip categories we can't read (permissions etc.)
// rather than failing the whole load.
continue
}
for skillName in skillNames {
let skillPath = categoryPath + "/" + skillName
guard transport.stat(skillPath)?.isDirectory == true else { continue }
let files: [String] = (try? transport.listDirectory(skillPath)) ?? []
// v2.5: parse SKILL.md frontmatter for the
// Hermes v2026.4.23 fields (allowed_tools,
// related_skills, dependencies). Falls back
// to nil-everything on absent/malformed
// frontmatter old skills behave as before.
let frontmatter = Self.parseFrontmatter(
skillMdPath: skillPath + "/SKILL.md",
transport: transport
)
skills.append(HermesSkill(
id: categoryName + "/" + skillName,
name: skillName,
category: categoryName,
path: skillPath,
files: files.filter { !$0.hasPrefix(".") }.sorted(),
requiredConfig: [], // skill.yaml parsing still deferred for iOS
allowedTools: frontmatter.allowedTools,
relatedSkills: frontmatter.relatedSkills,
dependencies: frontmatter.dependencies
))
}
if !skills.isEmpty {
categories.append(HermesSkillCategory(
id: categoryName,
name: categoryName,
skills: skills
))
}
}
return .success(categories)
} catch {
return .failure(error)
}
}.value
switch loaded {
case .success(let cats):
categories = cats
case .failure(let err):
categories = []
lastError = "Couldn't list skills: \(err.localizedDescription)"
}
isLoading = false
}
/// Read `<skill>/SKILL.md`'s YAML frontmatter and pull the v2.5
/// fields (allowed_tools, related_skills, dependencies). Returns
/// nil-filled tuple on missing file, missing frontmatter, or empty
/// fields. Mirrors Mac's `HermesFileService.parseSkillFrontmatter`.
nonisolated static func parseFrontmatter(
skillMdPath: String,
transport: any ServerTransport
) -> (allowedTools: [String]?, relatedSkills: [String]?, dependencies: [String]?) {
guard transport.fileExists(skillMdPath),
let data = try? transport.readFile(skillMdPath),
let raw = String(data: data, encoding: .utf8)
else { return (nil, nil, nil) }
let lines = raw.components(separatedBy: "\n")
guard lines.first == "---",
let endIdx = lines.dropFirst().firstIndex(of: "---")
else { return (nil, nil, nil) }
let frontmatter = lines[1..<endIdx].joined(separator: "\n")
let parsed = HermesYAML.parseNestedYAML(frontmatter)
let allowed = parsed.lists["allowed_tools"]
let related = parsed.lists["related_skills"]
let deps = parsed.lists["dependencies"]
return (
allowedTools: (allowed?.isEmpty ?? true) ? nil : allowed,
relatedSkills: (related?.isEmpty ?? true) ? nil : related,
dependencies: (deps?.isEmpty ?? true) ? nil : deps
)
}
}
@@ -0,0 +1,99 @@
import Foundation
import os
/// Drives the per-project Sessions tab introduced in v2.3 and reused
/// by the iOS Project Detail view in v2.5. Pulls the global session
/// list from `HermesDataService`, filters by the attribution sidecar,
/// and exposes a minimal surface for the view: the filtered sessions
/// array, loading state, and a refresh entry point that the view can
/// call on appearance + on file-watcher change.
///
/// Promoted from the Mac target into ScarfCore in v2.5 so iOS and Mac
/// share the exact same filtering + attribution semantics there's
/// nothing AppKit-specific here, just transport-backed I/O.
@Observable
@MainActor
public final class ProjectSessionsViewModel {
private static let logger = Logger(subsystem: "com.scarf", category: "ProjectSessionsViewModel")
private let dataService: HermesDataService
private let attribution: SessionAttributionService
private let project: ProjectEntry
public init(context: ServerContext, project: ProjectEntry) {
self.dataService = HermesDataService(context: context)
self.attribution = SessionAttributionService(context: context)
self.project = project
}
/// Sessions attributed to the owning project, in the order
/// `HermesDataService.fetchSessions` returns them (newest first).
public var sessions: [HermesSession] = []
/// True from `load()` start to its completion. The view renders
/// a ProgressView during the first fetch; afterwards, re-fetches
/// triggered by file-watcher changes happen silently.
public var isLoading: Bool = false
/// Short diagnostic string for an empty list nil when sessions
/// are loaded and populated, otherwise explains the empty state
/// (no sessions ever created in this project, vs. no sessions
/// matched the project's attribution map).
public var emptyStateHint: String?
/// Refresh the session list. Safe to call repeatedly; the data
/// service reconnects to state.db on demand and the attribution
/// service reads the sidecar afresh each call.
public func load() async {
isLoading = true
defer { isLoading = false }
let attributed = attribution.sessionIDs(forProject: project.path)
if attributed.isEmpty {
sessions = []
emptyStateHint = "No chats have been started in this project yet. Click New Chat to begin."
return
}
// Open (or re-open for remote) the DB handle before querying.
// `HermesDataService` is an actor with a lazily-initialised
// SQLite pointer; every query method short-circuits to `[]`
// when `db == nil`. This VM constructs its own service
// instance (separate from ChatViewModel / InsightsVM /
// ActivityVM), so we have to open it ourselves. Same
// pattern used by those other VMs (`refresh()` rather than
// `open()` because refresh also re-pulls the remote-server
// snapshot on each call local is a cheap no-op).
_ = await dataService.refresh()
// Fetch a generous page; we filter client-side by attribution
// map membership. The 200 ceiling matches other feature VMs
// (ActivityViewModel, InsightsViewModel). HermesDataService
// is an actor so this crosses the isolation boundary the
// SQLite read happens off the MainActor. If a single project
// accumulates more than 200 attributed sessions, we'll need
// a paged query; roadmap item, not a v2.3 problem.
let all = await dataService.fetchSessions(limit: 200)
let filtered = all.filter { attributed.contains($0.id) }
sessions = filtered
if filtered.isEmpty {
// Attribution map has entries but none appear in the
// recent session fetch likely stale sidecar entries
// for sessions Hermes has since deleted. The view shows
// an informational empty state; pruning stale entries
// is a roadmap follow-up, not a blocker.
emptyStateHint = "This project has \(attributed.count) attributed session\(attributed.count == 1 ? "" : "s"), but none are in the recent history. They may have been deleted from Hermes."
} else {
emptyStateHint = nil
}
}
/// Release the underlying DB handle. Safe to call repeatedly; the
/// service re-opens on the next `load()`. View calls this on
/// `.onDisappear` so file descriptors and the SQLite cache don't
/// dangle once the tab isn't visible.
public func close() async {
await dataService.close()
}
}
@@ -0,0 +1,347 @@
import Foundation
import os
/// Unified Skills viewmodel. Promoted from the Mac target into ScarfCore
/// in v2.5 so iOS and Mac share the exact same Installed / Hub / Updates
/// state machine. Replaces the old Mac `SkillsViewModel` and the
/// minimal iOS `IOSSkillsViewModel`.
///
/// Transport-backed throughout: skill scanning goes through
/// `SkillsScanner.scan(context:transport:)`, file I/O goes through
/// `transport.readFile / writeFile`, and CLI invocations go through
/// `transport.runProcess(executable:args:stdin:timeout:)`. iOS gets the
/// same hub features as Mac without a target-specific code path.
@Observable
public final class SkillsViewModel {
private let logger = Logger(subsystem: "com.scarf", category: "SkillsViewModel")
public let context: ServerContext
private let transport: any ServerTransport
public init(context: ServerContext = .local) {
self.context = context
self.transport = context.makeTransport()
}
// MARK: - Installed skills
public var categories: [HermesSkillCategory] = []
public var selectedSkill: HermesSkill?
public var skillContent = ""
public var selectedFileName: String?
public var searchText = ""
public var missingConfig: [String] = []
public var isEditing = false
public var editText = ""
/// True while the installed-skills scan is in flight. Renders a
/// progress indicator on iOS; Mac historically didn't surface this
/// from VM state but adding it doesn't break the existing UI.
public var isLoading: Bool = false
/// Diagnostic for a failed scan. Nil on success or when the dir
/// is simply missing (fresh install).
public var lastError: String?
// MARK: - Hub integration
public var hubQuery = ""
public var hubResults: [HermesHubSkill] = []
public var updates: [HermesSkillUpdate] = []
public var isHubLoading = false
public var hubMessage: String?
public var hubSource: String = "all"
public let hubSources = ["all", "official", "skills-sh", "well-known", "github", "clawhub", "lobehub"]
public var filteredCategories: [HermesSkillCategory] {
guard !searchText.isEmpty else { return categories }
return categories.compactMap { category in
let filtered = category.skills.filter {
$0.name.localizedCaseInsensitiveContains(searchText) ||
$0.category.localizedCaseInsensitiveContains(searchText)
}
guard !filtered.isEmpty else { return nil }
return HermesSkillCategory(id: category.id, name: category.name, skills: filtered)
}
}
public var totalSkillCount: Int {
categories.reduce(0) { $0 + $1.skills.count }
}
/// Awaitable scan. iOS's `.task { await vm.load() }` and the
/// ScarfCore unit tests use this directly; Mac call sites wrap in
/// `Task { await ... }` from `onAppear`.
@MainActor
public func load() async {
isLoading = true
lastError = nil
let ctx = context
let xport = transport
let cats: [HermesSkillCategory] = await Task.detached {
SkillsScanner.scan(context: ctx, transport: xport)
}.value
categories = cats
isLoading = false
}
public func selectSkill(_ skill: HermesSkill) {
selectedSkill = skill
let mainFile = skill.files.first(where: { $0.hasSuffix(".md") }) ?? skill.files.first
if let file = mainFile {
selectedFileName = file
skillContent = loadSkillContent(path: skill.path + "/" + file)
} else {
selectedFileName = nil
skillContent = ""
}
missingConfig = computeMissingConfig(for: skill)
}
private func computeMissingConfig(for skill: HermesSkill) -> [String] {
guard !skill.requiredConfig.isEmpty else { return [] }
guard let yaml = context.readText(context.paths.configYAML) else {
return skill.requiredConfig
}
return skill.requiredConfig.filter { key in
!yaml.contains(key)
}
}
public func selectFile(_ file: String) {
guard let skill = selectedSkill else { return }
selectedFileName = file
skillContent = loadSkillContent(path: skill.path + "/" + file)
}
public var isMarkdownFile: Bool {
selectedFileName?.hasSuffix(".md") == true
}
private var currentFilePath: String? {
guard let skill = selectedSkill, let file = selectedFileName else { return nil }
return skill.path + "/" + file
}
public func startEditing() {
editText = skillContent
isEditing = true
}
public func saveEdit() {
guard let path = currentFilePath else { return }
saveSkillContent(path: path, content: editText)
skillContent = editText
isEditing = false
}
public func cancelEditing() {
isEditing = false
}
// MARK: - Hub browse / search / install / update
public func browseHub() {
isHubLoading = true
let bin = context.paths.hermesBinary
let xport = transport
let source = hubSource
Task.detached { [weak self] in
var args = ["skills", "browse", "--size", "40"]
if source != "all" { args += ["--source", source] }
let result = Self.runHermes(executable: bin, args: args, transport: xport, timeout: 30)
let parsed = HermesSkillsHubParser.parseHubList(result.output)
await self?.finishBrowse(results: parsed, exitCode: result.exitCode, isSearch: false)
}
}
public func searchHub() {
guard !hubQuery.isEmpty else {
browseHub()
return
}
isHubLoading = true
let bin = context.paths.hermesBinary
let xport = transport
let source = hubSource
let query = hubQuery
Task.detached { [weak self] in
var args = ["skills", "search", query, "--limit", "40"]
if source != "all" { args += ["--source", source] }
let result = Self.runHermes(executable: bin, args: args, transport: xport, timeout: 30)
let parsed = HermesSkillsHubParser.parseHubList(result.output)
await self?.finishBrowse(results: parsed, exitCode: result.exitCode, isSearch: true)
}
}
public func installHubSkill(_ skill: HermesHubSkill) {
isHubLoading = true
hubMessage = "Installing \(skill.identifier)"
let bin = context.paths.hermesBinary
let xport = transport
let identifier = skill.identifier
Task.detached { [weak self] in
// --yes skips confirmation since we're running non-interactively.
let result = Self.runHermes(
executable: bin,
args: ["skills", "install", identifier, "--yes"],
transport: xport,
timeout: 120
)
await self?.finishInstall(identifier: identifier, exitCode: result.exitCode)
}
}
public func uninstallHubSkill(_ identifier: String) {
let bin = context.paths.hermesBinary
let xport = transport
Task.detached { [weak self] in
let result = Self.runHermes(
executable: bin,
args: ["skills", "uninstall", identifier, "--yes"],
transport: xport,
timeout: 60
)
await self?.finishUninstall(exitCode: result.exitCode)
}
}
public func checkForUpdates() {
isHubLoading = true
let bin = context.paths.hermesBinary
let xport = transport
Task.detached { [weak self] in
let result = Self.runHermes(
executable: bin,
args: ["skills", "check"],
transport: xport,
timeout: 60
)
let parsed = HermesSkillsHubParser.parseUpdateList(result.output)
await self?.finishCheckForUpdates(updates: parsed)
}
}
public func updateAll() {
let bin = context.paths.hermesBinary
let xport = transport
Task.detached { [weak self] in
let result = Self.runHermes(
executable: bin,
args: ["skills", "update", "--yes"],
transport: xport,
timeout: 300
)
await self?.finishUpdateAll(exitCode: result.exitCode)
}
}
// MARK: - Hub action finishers
//
// Each detached task above bounces through exactly one of these
// MainActor-isolated finishers. Keeping the post-CLI sequencing
// (load + sleep + clear status) here means the detached closure
// crosses the `self?` weak boundary only once required for clean
// builds under Swift 6 strict concurrency, and clearer to reason
// about than the prior interleaved `MainActor.run` chains.
@MainActor
private func finishBrowse(results: [HermesHubSkill], exitCode: Int32, isSearch: Bool) async {
isHubLoading = false
hubResults = results
if results.isEmpty {
hubMessage = isSearch
? "No matches"
: (exitCode == 0 ? "No results" : "Browse failed")
} else {
hubMessage = nil
}
}
@MainActor
private func finishInstall(identifier: String, exitCode: Int32) async {
isHubLoading = false
hubMessage = exitCode == 0 ? "Installed \(identifier)" : "Install failed"
await load()
try? await Task.sleep(nanoseconds: 3_000_000_000)
hubMessage = nil
}
@MainActor
private func finishUninstall(exitCode: Int32) async {
hubMessage = exitCode == 0 ? "Uninstalled" : "Uninstall failed"
await load()
try? await Task.sleep(nanoseconds: 2_000_000_000)
hubMessage = nil
}
@MainActor
private func finishCheckForUpdates(updates: [HermesSkillUpdate]) async {
isHubLoading = false
self.updates = updates
hubMessage = updates.isEmpty ? "No updates available" : "\(updates.count) update(s)"
try? await Task.sleep(nanoseconds: 3_000_000_000)
hubMessage = nil
}
@MainActor
private func finishUpdateAll(exitCode: Int32) async {
hubMessage = exitCode == 0 ? "Updated" : "Update failed"
await load()
checkForUpdates()
try? await Task.sleep(nanoseconds: 3_000_000_000)
hubMessage = nil
}
// MARK: - Transport helpers
/// Combined stdout+stderr CLI runner. Mirrors the legacy
/// `HermesFileService.runHermesCLI` shape so callers grepping
/// through `output` keep working.
nonisolated private static func runHermes(
executable: String,
args: [String],
transport: any ServerTransport,
timeout: TimeInterval
) -> (exitCode: Int32, output: String) {
do {
let result = try transport.runProcess(
executable: executable,
args: args,
stdin: nil,
timeout: timeout
)
return (result.exitCode, result.stdoutString + result.stderrString)
} catch let error as TransportError {
return (-1, error.diagnosticStderr.isEmpty
? (error.errorDescription ?? "transport error")
: error.diagnosticStderr)
} catch {
return (-1, error.localizedDescription)
}
}
private func loadSkillContent(path: String) -> String {
guard isValidSkillPath(path) else { return "" }
guard let data = try? transport.readFile(path),
let s = String(data: data, encoding: .utf8)
else { return "" }
return s
}
private func saveSkillContent(path: String, content: String) {
guard isValidSkillPath(path) else { return }
guard let data = content.data(using: .utf8) else { return }
do {
try transport.writeFile(path, data: data)
} catch {
logger.error("saveSkillContent(\(path, privacy: .public)) failed: \(error.localizedDescription, privacy: .public)")
}
}
private func isValidSkillPath(_ path: String) -> Bool {
guard !path.contains(".."), path.hasPrefix(context.paths.skillsDir) else {
logger.warning("Rejected skill path outside skills dir: \(path, privacy: .public)")
return false
}
return true
}
}
@@ -257,7 +257,7 @@ import Foundation
try FileManager.default.removeItem(
at: home.appendingPathComponent("skills")
)
let vm = IOSSkillsViewModel(context: ctx)
let vm = SkillsViewModel(context: ctx)
await vm.load()
#expect(vm.categories.isEmpty)
#expect(vm.lastError == nil)
@@ -284,7 +284,7 @@ import Foundation
// Dotfile should be filtered
try "".write(to: pJournal.appendingPathComponent(".DS_Store"), atomically: true, encoding: .utf8)
let vm = IOSSkillsViewModel(context: ctx)
let vm = SkillsViewModel(context: ctx)
await vm.load()
#expect(vm.categories.count == 2)
#expect(vm.categories[0].name == "dev")
@@ -305,7 +305,7 @@ import Foundation
at: home.appendingPathComponent("skills/empty-cat"),
withIntermediateDirectories: true
)
let vm = IOSSkillsViewModel(context: ctx)
let vm = SkillsViewModel(context: ctx)
await vm.load()
#expect(vm.categories.isEmpty)
}
@@ -0,0 +1,61 @@
import Testing
@testable import ScarfCore
/// Coverage for `SkillFrontmatterParser` narrow YAML reader for the
/// `required_config:` list in a skill's `skill.yaml`. The parser was
/// extracted from the Mac `HermesFileService` in v2.5 so iOS can flag
/// missing config keys with the same semantics.
@Suite("SkillFrontmatterParser")
struct SkillFrontmatterParserTests {
@Test func parsesSimpleRequiredConfigList() {
let yaml = """
name: example
required_config:
- api_key
- api_secret
version: 1.0.0
"""
let keys = SkillFrontmatterParser.parseRequiredConfig(yaml)
#expect(keys == ["api_key", "api_secret"])
}
@Test func returnsEmptyWhenSectionMissing() {
let yaml = """
name: example
version: 1.0.0
"""
#expect(SkillFrontmatterParser.parseRequiredConfig(yaml).isEmpty)
}
@Test func skipsCommentsAndEmptyLines() {
let yaml = """
# top comment
required_config:
# in-section comment
- first
- second
"""
let keys = SkillFrontmatterParser.parseRequiredConfig(yaml)
#expect(keys == ["first", "second"])
}
@Test func breaksOnNextTopLevelKey() {
let yaml = """
required_config:
- one
- two
next_key: hello
- three
"""
let keys = SkillFrontmatterParser.parseRequiredConfig(yaml)
// `next_key:` is at indent 0, terminating the list `three`
// is no longer in scope and shouldn't be picked up.
#expect(keys == ["one", "two"])
}
@Test func handlesEmptyInput() {
#expect(SkillFrontmatterParser.parseRequiredConfig("").isEmpty)
}
}
@@ -0,0 +1,116 @@
import Testing
@testable import ScarfCore
/// Coverage for `HermesSkillsHubParser` the Rich-table parser that
/// translates `hermes skills browse|search|check` stdout into typed
/// `HermesHubSkill` / `HermesSkillUpdate` arrays. The parser is shared
/// by Mac + iOS in v2.5; this suite locks in the canonical fixtures
/// so regressions on either platform fail here first.
@Suite("HermesSkillsHubParser")
struct SkillsHubParserTests {
// MARK: - parseHubList
@Test func parsesSingleRowFromBrowseOutput() {
let output = """
┏━━━━━━┳━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━┳━━━━━━━━━━━━┓
┃ # ┃ Name ┃ Description ┃ Source ┃ Trust ┃
┡━━━━━━╇━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━╇━━━━━━━━━━━━┩
│ 1 │ 1password │ Set up and use 1Password integration. │ official │ ★ official │
└──────┴────────────────┴────────────────────────────────────────────────────────┴──────────────┴────────────┘
"""
let result = HermesSkillsHubParser.parseHubList(output)
#expect(result.count == 1)
#expect(result[0].identifier == "1password")
#expect(result[0].name == "1password")
#expect(result[0].description == "Set up and use 1Password integration.")
#expect(result[0].source == "official")
}
@Test func mergesContinuationRowsIntoDescription() {
// Continuation rows have an empty `#` cell the parser should
// append their description to the previous skill rather than
// emit a blank entry.
let output = """
│ 1 │ skill-creator │ Create new skills, modify and improve existing skills, │ official │ ★ official │
│ │ │ and measure skill performance. │ │ │
"""
let result = HermesSkillsHubParser.parseHubList(output)
#expect(result.count == 1)
#expect(result[0].identifier == "skill-creator")
#expect(result[0].description.contains("Create new skills"))
#expect(result[0].description.contains("measure skill performance"))
}
@Test func skipsHeaderAndBorderRows() {
let output = """
┏━━━━━━┳━━━━━━━━┳━━━━━━━━━━━━━━━┳━━━━━━━━━━━┳━━━━━━━━━━━━┓
┃ # ┃ Name ┃ Description ┃ Source ┃ Trust ┃
┡━━━━━━╇━━━━━━━━╇━━━━━━━━━━━━━━━╇━━━━━━━━━━━╇━━━━━━━━━━━━┩
│ 1 │ alpha │ alpha skill │ official │ ★ official │
│ 2 │ beta │ beta skill │ skills-sh │ │
└──────┴────────┴───────────────┴───────────┴────────────┘
"""
let result = HermesSkillsHubParser.parseHubList(output)
#expect(result.count == 2)
#expect(result[0].name == "alpha")
#expect(result[1].name == "beta")
}
@Test func stripsStarFromSourceCell() {
// The Trust column shows ` official` for trusted sources;
// the Source column itself doesn't, but if the layout shifts
// and we end up with the star in our captured cell we should
// strip it.
let output = """
│ 1 │ widget │ a widget │ ★ official │ official │
"""
let result = HermesSkillsHubParser.parseHubList(output)
#expect(result.count == 1)
#expect(result[0].source == "official")
}
@Test func returnsEmptyOnNoTable() {
let result = HermesSkillsHubParser.parseHubList("Just plain text\n no table here")
#expect(result.isEmpty)
}
// MARK: - parseUpdateList
@Test func parsesArrowVersionMarker() {
let output = """
Checking for updates…
skill-creator 1.0.0 → 1.1.0
another-skill 2.3.4 → 2.3.5
"""
let result = HermesSkillsHubParser.parseUpdateList(output)
#expect(result.count == 2)
#expect(result[0].identifier == "skill-creator")
#expect(result[0].currentVersion == "1.0.0")
#expect(result[0].availableVersion == "1.1.0")
#expect(result[1].identifier == "another-skill")
#expect(result[1].currentVersion == "2.3.4")
#expect(result[1].availableVersion == "2.3.5")
}
@Test func parsesAsciiArrowMarker() {
// Some terminals or older Hermes versions emit `->` instead of
// the unicode ``. Both should parse identically.
let output = "skill-creator 1.0.0 -> 1.1.0"
let result = HermesSkillsHubParser.parseUpdateList(output)
#expect(result.count == 1)
#expect(result[0].identifier == "skill-creator")
#expect(result[0].availableVersion == "1.1.0")
}
@Test func updateListIgnoresLinesWithoutArrow() {
let output = """
Checking for updates…
Skill named foo is up to date.
skill-creator 1.0.0 → 1.1.0
"""
let result = HermesSkillsHubParser.parseUpdateList(output)
#expect(result.count == 1)
#expect(result[0].identifier == "skill-creator")
}
}