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