mirror of
https://github.com/awizemann/scarf.git
synced 2026-05-10 10:36:35 +00:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b2a29ab68d | |||
| 117a0ee9dd | |||
| 61d59ba0e4 |
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -407,7 +407,7 @@
|
||||
CODE_SIGN_ENTITLEMENTS = scarf/scarf.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
COMBINE_HIDPI_IMAGES = YES;
|
||||
CURRENT_PROJECT_VERSION = 8;
|
||||
CURRENT_PROJECT_VERSION = 10;
|
||||
DEVELOPMENT_TEAM = 3Q6X2L86C4;
|
||||
ENABLE_APP_SANDBOX = NO;
|
||||
ENABLE_HARDENED_RUNTIME = YES;
|
||||
@@ -422,7 +422,7 @@
|
||||
"@executable_path/../Frameworks",
|
||||
);
|
||||
MACOSX_DEPLOYMENT_TARGET = 14.6;
|
||||
MARKETING_VERSION = 1.5.6;
|
||||
MARKETING_VERSION = 1.5.8;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.scarf;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
REGISTER_APP_GROUPS = YES;
|
||||
@@ -444,7 +444,7 @@
|
||||
CODE_SIGN_ENTITLEMENTS = scarf/scarf.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
COMBINE_HIDPI_IMAGES = YES;
|
||||
CURRENT_PROJECT_VERSION = 8;
|
||||
CURRENT_PROJECT_VERSION = 10;
|
||||
DEVELOPMENT_TEAM = 3Q6X2L86C4;
|
||||
ENABLE_APP_SANDBOX = NO;
|
||||
ENABLE_HARDENED_RUNTIME = YES;
|
||||
@@ -459,7 +459,7 @@
|
||||
"@executable_path/../Frameworks",
|
||||
);
|
||||
MACOSX_DEPLOYMENT_TARGET = 14.6;
|
||||
MARKETING_VERSION = 1.5.6;
|
||||
MARKETING_VERSION = 1.5.8;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.scarf;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
REGISTER_APP_GROUPS = YES;
|
||||
|
||||
@@ -66,8 +66,10 @@ actor ACPClient {
|
||||
proc.standardOutput = stdout
|
||||
proc.standardError = stderr
|
||||
|
||||
// ACP uses JSON-RPC over pipes — do NOT set TERM to avoid terminal escape pollution
|
||||
var env = ProcessInfo.processInfo.environment
|
||||
// ACP uses JSON-RPC over pipes — do NOT set TERM to avoid terminal escape pollution.
|
||||
// Use the enriched environment so any tools hermes spawns (MCP servers,
|
||||
// shell commands) can find brew/nvm/asdf binaries on PATH.
|
||||
var env = HermesFileService.enrichedEnvironment()
|
||||
env.removeValue(forKey: "TERM")
|
||||
proc.environment = env
|
||||
|
||||
|
||||
@@ -323,10 +323,18 @@ struct HermesFileService: Sendable {
|
||||
}.value
|
||||
let elapsed = Date().timeIntervalSince(started)
|
||||
let tools = Self.parseToolListFromTestOutput(result.1)
|
||||
// hermes mcp test exits 0 even when the inner connection fails — it
|
||||
// reports the failure on stdout instead. Look for explicit failure
|
||||
// markers so the UI doesn't show a green check on a broken server.
|
||||
let output = result.1
|
||||
let hasFailureMarker = output.contains("✗")
|
||||
|| output.range(of: "Connection failed", options: .caseInsensitive) != nil
|
||||
|| output.range(of: "No such file or directory", options: .caseInsensitive) != nil
|
||||
|| output.range(of: "Error:", options: .caseInsensitive) != nil
|
||||
return MCPTestResult(
|
||||
serverName: name,
|
||||
succeeded: result.0 == 0,
|
||||
output: result.1,
|
||||
succeeded: result.0 == 0 && !hasFailureMarker,
|
||||
output: output,
|
||||
tools: tools,
|
||||
elapsed: elapsed
|
||||
)
|
||||
@@ -437,6 +445,20 @@ struct HermesFileService: Sendable {
|
||||
if blockStart < 0 {
|
||||
return MCPBlockLocation(prefix: lines, block: [], suffix: [])
|
||||
}
|
||||
// Trim trailing blank lines and comments from the block — they belong
|
||||
// to the file footer, not the mcp_servers section. Without this, when
|
||||
// mcp_servers is the last top-level key, the block would extend to EOF
|
||||
// and any inserted content (args, env, headers, tools) would land
|
||||
// after the trailing comments.
|
||||
while blockEnd > blockStart + 1 {
|
||||
let line = lines[blockEnd - 1]
|
||||
let trimmed = line.trimmingCharacters(in: .whitespaces)
|
||||
if trimmed.isEmpty || trimmed.hasPrefix("#") {
|
||||
blockEnd -= 1
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
return MCPBlockLocation(
|
||||
prefix: Array(lines[0..<blockStart]),
|
||||
block: Array(lines[blockStart..<blockEnd]),
|
||||
@@ -606,6 +628,20 @@ struct HermesFileService: Sendable {
|
||||
}
|
||||
guard entryStart >= 0 else { return false }
|
||||
|
||||
// Trim trailing blank lines and comments off the entry so inserts land
|
||||
// immediately after the entry's last real key, not after intervening
|
||||
// comments that conceptually belong to the next entry (or the file
|
||||
// footer when this is the last entry in the block).
|
||||
while entryEnd > entryStart + 1 {
|
||||
let line = block[entryEnd - 1]
|
||||
let trimmed = line.trimmingCharacters(in: .whitespaces)
|
||||
if trimmed.isEmpty || trimmed.hasPrefix("#") {
|
||||
entryEnd -= 1
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
var entryLines = Array(block[entryStart..<entryEnd])
|
||||
mutate(&entryLines)
|
||||
|
||||
@@ -826,9 +862,20 @@ struct HermesFileService: Sendable {
|
||||
|
||||
private static func yamlScalar(_ value: String) -> String {
|
||||
if value.isEmpty { return "\"\"" }
|
||||
// YAML 1.2 reserved indicators that change meaning at the start of a
|
||||
// scalar: @ * & ? | > ! % , [ ] { } < ` ' " — plus space (would be
|
||||
// trimmed) and dash (looks like a sequence). Anything starting with
|
||||
// one of these must be quoted or YAML treats the value as an alias,
|
||||
// tag, flow collection, etc., and parsing breaks.
|
||||
let reservedFirstChars: Set<Character> = [
|
||||
"@", "*", "&", "?", "|", ">", "!", "%", ",",
|
||||
"[", "]", "{", "}", "<", "`", "'", "\""
|
||||
]
|
||||
let firstCharNeedsQuoting = value.first.map { reservedFirstChars.contains($0) } ?? false
|
||||
let needsQuoting = value.contains(":") || value.contains("#") || value.contains("\"")
|
||||
|| value.hasPrefix(" ") || value.hasSuffix(" ") || value.hasPrefix("-")
|
||||
|| ["true", "false", "null", "yes", "no"].contains(value.lowercased())
|
||||
|| firstCharNeedsQuoting
|
||||
if needsQuoting {
|
||||
let escaped = value.replacingOccurrences(of: "\\", with: "\\\\")
|
||||
.replacingOccurrences(of: "\"", with: "\\\"")
|
||||
@@ -891,6 +938,70 @@ struct HermesFileService: Sendable {
|
||||
return candidates.first { FileManager.default.isExecutableFile(atPath: $0) }
|
||||
}
|
||||
|
||||
/// PATH cobbled together from the user's login shell — needed because
|
||||
/// .app bundles launched from Finder/Dock get a minimal PATH (no Homebrew,
|
||||
/// no nvm, no asdf, no mise). Without this, MCP servers using `npx`,
|
||||
/// `node`, `python`, `uv`, etc. fail to launch with `[Errno 2] No such
|
||||
/// file or directory`. Computed once and cached.
|
||||
private static let enrichedPath: String = {
|
||||
let pipe = Pipe()
|
||||
let errPipe = Pipe()
|
||||
let process = Process()
|
||||
process.executableURL = URL(fileURLWithPath: "/bin/zsh")
|
||||
// -l sources the user's login files (.zprofile, .zshrc via /etc/zshrc
|
||||
// chain on macOS) so PATH manipulations made there are picked up.
|
||||
// Skip -i to avoid hangs from interactive prompts.
|
||||
process.arguments = ["-l", "-c", "echo $PATH"]
|
||||
process.standardOutput = pipe
|
||||
process.standardError = errPipe
|
||||
defer {
|
||||
try? pipe.fileHandleForReading.close()
|
||||
try? pipe.fileHandleForWriting.close()
|
||||
try? errPipe.fileHandleForReading.close()
|
||||
try? errPipe.fileHandleForWriting.close()
|
||||
}
|
||||
do {
|
||||
try process.run()
|
||||
let deadline = Date().addingTimeInterval(3)
|
||||
while process.isRunning && Date() < deadline {
|
||||
Thread.sleep(forTimeInterval: 0.05)
|
||||
}
|
||||
if process.isRunning { process.terminate() }
|
||||
process.waitUntilExit()
|
||||
let data = pipe.fileHandleForReading.readDataToEndOfFile()
|
||||
let path = (String(data: data, encoding: .utf8) ?? "")
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if process.terminationStatus == 0 && !path.isEmpty {
|
||||
return path
|
||||
}
|
||||
} catch {
|
||||
// Fall through to default below.
|
||||
}
|
||||
// Fallback when the login shell can't be queried (zsh missing,
|
||||
// sandbox restriction, timeout). Covers Apple Silicon + Intel
|
||||
// Homebrew plus the standard system paths.
|
||||
let home = NSHomeDirectory()
|
||||
return [
|
||||
"\(home)/.local/bin",
|
||||
"/opt/homebrew/bin",
|
||||
"/usr/local/bin",
|
||||
"/usr/bin",
|
||||
"/bin",
|
||||
"/usr/sbin",
|
||||
"/sbin"
|
||||
].joined(separator: ":")
|
||||
}()
|
||||
|
||||
/// Environment to hand any subprocess that may itself spawn user-installed
|
||||
/// binaries (Hermes spawning MCP servers, ACP tool calls, etc.). Identical
|
||||
/// to ProcessInfo.processInfo.environment but with PATH replaced by the
|
||||
/// login-shell PATH.
|
||||
nonisolated static func enrichedEnvironment() -> [String: String] {
|
||||
var env = ProcessInfo.processInfo.environment
|
||||
env["PATH"] = enrichedPath
|
||||
return env
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
nonisolated func runHermesCLI(args: [String], timeout: TimeInterval = 60, stdinInput: String? = nil) -> (exitCode: Int32, output: String) {
|
||||
guard let binary = hermesBinaryPath() else { return (-1, "") }
|
||||
@@ -900,6 +1011,7 @@ struct HermesFileService: Sendable {
|
||||
let process = Process()
|
||||
process.executableURL = URL(fileURLWithPath: binary)
|
||||
process.arguments = args
|
||||
process.environment = Self.enrichedEnvironment()
|
||||
process.standardOutput = stdoutPipe
|
||||
process.standardError = stderrPipe
|
||||
if let stdinPipe { process.standardInput = stdinPipe }
|
||||
|
||||
Reference in New Issue
Block a user