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 {
|
private struct SkillDetailView: View {
|
||||||
let skill: HermesSkill
|
let skill: HermesSkill
|
||||||
|
|
||||||
|
@Environment(\.serverContext) private var serverContext
|
||||||
|
@State private var npxStatus: SkillPrereqService.Status?
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
List {
|
List {
|
||||||
Section("Location") {
|
Section("Location") {
|
||||||
@@ -89,6 +92,30 @@ private struct SkillDetailView: View {
|
|||||||
.textSelection(.enabled)
|
.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" {
|
if skill.name.lowercased() == "spotify" {
|
||||||
Section("Authentication") {
|
Section("Authentication") {
|
||||||
Label {
|
Label {
|
||||||
@@ -119,5 +146,17 @@ private struct SkillDetailView: View {
|
|||||||
}
|
}
|
||||||
.navigationTitle(skill.name)
|
.navigationTitle(skill.name)
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.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 {
|
struct SkillsView: View {
|
||||||
@State private var viewModel: SkillsViewModel
|
@State private var viewModel: SkillsViewModel
|
||||||
@State private var showSpotifySignIn: Bool = false
|
@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
|
@Environment(\.serverContext) private var serverContext
|
||||||
@State private var currentTab: Tab = .installed
|
@State private var currentTab: Tab = .installed
|
||||||
|
|
||||||
@@ -39,6 +44,20 @@ struct SkillsView: View {
|
|||||||
}
|
}
|
||||||
.navigationTitle("Skills (\(viewModel.totalSkillCount))")
|
.navigationTitle("Skills (\(viewModel.totalSkillCount))")
|
||||||
.onAppear { viewModel.load() }
|
.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 {
|
private var modePicker: some View {
|
||||||
@@ -144,6 +163,13 @@ struct SkillsView: View {
|
|||||||
if skill.name.lowercased() == "spotify" {
|
if skill.name.lowercased() == "spotify" {
|
||||||
spotifyAuthRow
|
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()
|
Divider()
|
||||||
if !skill.files.isEmpty {
|
if !skill.files.isEmpty {
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
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
|
/// Renders the v2.5 Spotify auth row when the user has the
|
||||||
/// `spotify` skill selected. Tapping opens `SpotifySignInSheet`
|
/// `spotify` skill selected. Tapping opens `SpotifySignInSheet`
|
||||||
/// which drives `hermes auth spotify` end-to-end in-app.
|
/// which drives `hermes auth spotify` end-to-end in-app.
|
||||||
|
|||||||
Reference in New Issue
Block a user