feat(skills): design-md npx prereq check (Phase 3.2)

design-md (Hermes v2026.4.23) requires `npx` (Node.js 18+) on the
host to invoke `npx @google/design.md`. Probe the host's PATH when
the skill is selected; surface a yellow banner with an install hint
when missing.

ScarfCore SkillPrereqService:
- probe(binary:installHint:) async -> Status — runs `/usr/bin/env
  which <binary>` via the transport with a 4s timeout. Returns
  .present / .missing(hint) / .unknown(reason).
- installHints table for npx / node / gws / ffmpeg with terse
  per-OS install guidance. Skills can pass custom hints if their
  install path is more involved.

Mac SkillsView:
- @State designMdNpxStatus + .onChange(of: selectedSkill.name)
  triggers the probe whenever the user lands on the design-md skill.
  Banner renders only on .missing — present and unknown cases stay
  silent (avoids false-alarm noise on transient SSH errors).

iOS SkillDetailView:
- @State npxStatus + .task(id: skill.id) per-skill probe.
- Same banner with the same hint copy; no install button (user is
  already on iPhone, fixing the host needs a shell anyway).

Verified: ScarfCore + Mac + iOS builds clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Alan Wizemann
2026-04-25 09:15:28 +02:00
parent 97aa988762
commit 7ec7282f36
3 changed files with 168 additions and 0 deletions
@@ -0,0 +1,82 @@
import Foundation
#if canImport(os)
import os
#endif
/// Runs lightweight host-side `which <bin>` 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`).",
]
}
@@ -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")
}
}
}
@@ -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.