diff --git a/scarf/Packages/ScarfCore/Sources/ScarfCore/Services/SkillPrereqService.swift b/scarf/Packages/ScarfCore/Sources/ScarfCore/Services/SkillPrereqService.swift new file mode 100644 index 0000000..8540f49 --- /dev/null +++ b/scarf/Packages/ScarfCore/Sources/ScarfCore/Services/SkillPrereqService.swift @@ -0,0 +1,82 @@ +import Foundation +#if canImport(os) +import os +#endif + +/// Runs lightweight host-side `which ` probes for skills that +/// declare runtime dependencies. Used by the Skills view (Mac + iOS) +/// to surface a yellow banner when a prereq is missing on the Hermes +/// host — e.g. the `design-md` skill needs `npx` (Node.js 18+). +/// +/// Pure transport-driven probes; never blocks the UI thread (callers +/// invoke from async context). No state — call once per skill view per +/// appear and cache in the calling view-model. +public struct SkillPrereqService: Sendable { + #if canImport(os) + private static let logger = Logger( + subsystem: "com.scarf", + category: "SkillPrereqService" + ) + #endif + + public let context: ServerContext + + public nonisolated init(context: ServerContext = .local) { + self.context = context + } + + /// Result of a single prereq probe. Surfaced verbatim by the UI: + /// `present` → no banner; `missing` → yellow banner with `installHint`. + public enum Status: Sendable, Equatable { + case present + case missing(installHint: String) + case unknown(reason: String) + } + + /// Check whether `binary` resolves on the host's PATH. Returns + /// `.present` on exit code 0, `.missing(installHint:)` on a clean + /// not-found exit, `.unknown(reason:)` on any transport error. + /// `installHint` is the `installHints` table entry below if known; + /// callers can override for skills with bespoke install steps. + public nonisolated func probe( + binary: String, + installHint: String? = nil + ) async -> Status { + let ctx = context + let resolvedHint = installHint ?? Self.installHints[binary] ?? "Install `\(binary)` on the Hermes host." + return await Task.detached { + let transport = ctx.makeTransport() + do { + let result = try transport.runProcess( + executable: "/usr/bin/env", + args: ["which", binary], + stdin: nil, + timeout: 4 + ) + if result.exitCode == 0, + !result.stdoutString.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + return .present + } + return .missing(installHint: resolvedHint) + } catch { + #if canImport(os) + Self.logger.warning( + "prereq probe for \(binary, privacy: .public) failed: \(error.localizedDescription, privacy: .public)" + ) + #endif + return .unknown(reason: error.localizedDescription) + } + }.value + } + + /// Built-in install hints for the binaries we know about. Skills + /// can pass a custom hint via `probe(binary:installHint:)` if they + /// want bespoke language. Keep these short — the banner has limited + /// vertical real estate on iPhone. + public static let installHints: [String: String] = [ + "npx": "Install Node.js 18+ on the Hermes host (`brew install node` on macOS, `apt install nodejs npm` on Debian/Ubuntu).", + "node": "Install Node.js 18+ on the Hermes host.", + "gws": "Install the `gws` CLI on the Hermes host (Google Workspace skill).", + "ffmpeg": "Install `ffmpeg` on the Hermes host (`brew install ffmpeg` / `apt install ffmpeg`).", + ] +} diff --git a/scarf/Scarf iOS/Skills/SkillsListView.swift b/scarf/Scarf iOS/Skills/SkillsListView.swift index 015f0f7..5146801 100644 --- a/scarf/Scarf iOS/Skills/SkillsListView.swift +++ b/scarf/Scarf iOS/Skills/SkillsListView.swift @@ -79,6 +79,9 @@ struct SkillsListView: View { private struct SkillDetailView: View { let skill: HermesSkill + @Environment(\.serverContext) private var serverContext + @State private var npxStatus: SkillPrereqService.Status? + var body: some View { List { Section("Location") { @@ -89,6 +92,30 @@ private struct SkillDetailView: View { .textSelection(.enabled) } + // v2.5 design-md prereq surface — the skill needs `npx` + // (Node.js 18+) on the host. iOS read-only banner: same + // wording as the Mac one, no install button (the user is + // already going to need a shell to fix this). + if skill.name.lowercased() == "design-md", + case .missing(let hint) = npxStatus { + Section("Prerequisite missing") { + Label { + VStack(alignment: .leading, spacing: 4) { + Text("`npx` not found on the Hermes host.") + .font(.callout.weight(.medium)) + Text(hint) + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + } icon: { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(.orange) + } + .padding(.vertical, 4) + } + } + if skill.name.lowercased() == "spotify" { Section("Authentication") { Label { @@ -119,5 +146,17 @@ private struct SkillDetailView: View { } .navigationTitle(skill.name) .navigationBarTitleDisplayMode(.inline) + .task(id: skill.id) { + // Only probe when this skill needs it. design-md is the + // only skill in v2.5 with a host-side prereq surface; the + // probe runs once per appear and isn't cached across + // navigation events (cheap — single SSH `which` call). + guard skill.name.lowercased() == "design-md" else { + npxStatus = nil + return + } + let svc = SkillPrereqService(context: serverContext) + npxStatus = await svc.probe(binary: "npx") + } } } diff --git a/scarf/scarf/Features/Skills/Views/SkillsView.swift b/scarf/scarf/Features/Skills/Views/SkillsView.swift index f2a26c9..fabfb1f 100644 --- a/scarf/scarf/Features/Skills/Views/SkillsView.swift +++ b/scarf/scarf/Features/Skills/Views/SkillsView.swift @@ -4,6 +4,11 @@ import ScarfCore struct SkillsView: View { @State private var viewModel: SkillsViewModel @State private var showSpotifySignIn: Bool = false + /// Result of the npx prereq probe for the design-md skill, when + /// selected. Re-fetched on each skill change. Nil while the probe + /// is in flight; populated with `.present` / `.missing(...)` / + /// `.unknown(...)` on completion. + @State private var designMdNpxStatus: SkillPrereqService.Status? @Environment(\.serverContext) private var serverContext @State private var currentTab: Tab = .installed @@ -39,6 +44,20 @@ struct SkillsView: View { } .navigationTitle("Skills (\(viewModel.totalSkillCount))") .onAppear { viewModel.load() } + // v2.5: re-probe `npx` whenever the selected skill changes; + // only the design-md skill cares about the result, but binding + // to the selection makes the probe automatic across switches. + .onChange(of: viewModel.selectedSkill?.name) { _, newName in + guard newName?.lowercased() == "design-md" else { + designMdNpxStatus = nil + return + } + designMdNpxStatus = nil + let svc = SkillPrereqService(context: serverContext) + Task { @MainActor in + designMdNpxStatus = await svc.probe(binary: "npx") + } + } } private var modePicker: some View { @@ -144,6 +163,13 @@ struct SkillsView: View { if skill.name.lowercased() == "spotify" { spotifyAuthRow } + // v2.5 design-md prereq surface. The skill needs + // `npx` (Node.js 18+) on the host; show a yellow + // banner with an install hint when it's missing. + if skill.name.lowercased() == "design-md", + case .missing(let hint) = designMdNpxStatus { + designMdNpxBanner(hint: hint) + } Divider() if !skill.files.isEmpty { VStack(alignment: .leading, spacing: 4) { @@ -205,6 +231,27 @@ struct SkillsView: View { } } + /// Yellow banner surfaced on the design-md skill detail when the + /// host's `npx` probe came back missing. Reuses the same color + /// language as the missing-config banner. + private func designMdNpxBanner(hint: String) -> some View { + HStack(alignment: .top, spacing: 8) { + Image(systemName: "exclamationmark.triangle") + VStack(alignment: .leading, spacing: 2) { + Text("`npx` not found on the Hermes host.") + .font(.caption.bold()) + Text(hint) + .font(.caption) + .fixedSize(horizontal: false, vertical: true) + } + } + .foregroundStyle(.orange) + .padding(10) + .frame(maxWidth: .infinity, alignment: .leading) + .background(.orange.opacity(0.1)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } + /// Renders the v2.5 Spotify auth row when the user has the /// `spotify` skill selected. Tapping opens `SpotifySignInSheet` /// which drives `hermes auth spotify` end-to-end in-app.