mirror of
https://github.com/awizemann/scarf.git
synced 2026-05-10 18:44:45 +00:00
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:
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user