feat(slash-commands): portable project-scoped slash commands (Phase 1.1-1.4)

Net-new Scarf primitive — Hermes has no project-scoped slash command
concept. Commands live at <project>/.scarf/slash-commands/<name>.md as
Markdown files with YAML frontmatter; Scarf intercepts the chat slash
menu, expands {{argument}} substitution client-side, and sends the
expanded prompt as a normal user message. Works uniformly on Mac + iOS,
local + remote SSH, against any Hermes version (no upstream dep).

Lands the model + service + chat wiring; editor UI (Mac), read-only
browser (iOS), AGENTS.md block extension, .scarftemplate format
extension, and tests follow in subsequent commits.

What this commit ships:

- ScarfCore Models/ProjectSlashCommand.swift — Sendable struct
  carrying name + description + argumentHint? + model? + tags? + body
  + sourcePath. Validates name shape (lowercase, hyphens, starts with
  letter, ≤64 chars).
- ScarfCore Services/ProjectSlashCommandService.swift — transport-
  based loadCommands(at:), loadCommand(at:), save(_:at:),
  delete(named:at:), expand(_:withArgument:). Markdown-with-
  frontmatter parser reuses HermesYAML so no new dep. Substitution
  supports `{{argument}}` and `{{argument | default: "..."}}`.
- HermesSlashCommand.Source gains .projectScoped (full payload looked
  up in RichChatViewModel by name) and .acpNonInterruptive (reserved
  for /steer in Phase 2.1).
- RichChatViewModel.projectScopedCommands + projectScopedCommand(named:)
  + loadProjectScopedCommands(at:); availableCommands precedence is
  ACP > project-scoped > quick_commands, all de-duped by name.
- Mac ChatViewModel: expandIfProjectScoped(_:) helper called in
  sendViaACP; loads commands when currentProjectPath is set in
  startACPSession's resolution branch.
- iOS ChatController: same pattern in send(); loads commands in both
  resetAndStartInProject and startResuming(sessionID:); resume now
  resolves both path AND name so we can read the slash-commands dir.

Verified: ScarfCore + Mac + iOS all build clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Alan Wizemann
2026-04-25 08:35:30 +02:00
parent bdc271c2b8
commit 6808adfa98
6 changed files with 565 additions and 10 deletions
@@ -1,12 +1,29 @@
import Foundation
/// A slash command available in chat. Sourced either from the ACP server
/// (`available_commands_update`) or from user-defined `quick_commands` in
/// `config.yaml`.
/// A slash command available in chat. Sourced from one of four places
/// see `Source` for which.
public struct HermesSlashCommand: Identifiable, Sendable, Equatable {
/// Where this command came from. Drives the slash-menu badge and the
/// chat view model's invocation path (literal-send vs client-side
/// expansion vs non-interruptive flag).
public enum Source: Sendable, Equatable {
/// Advertised by the ACP server via `available_commands_update`.
/// Sent to the agent as the literal slash text.
case acp
/// User-defined `quick_commands.<name>` in `~/.hermes/config.yaml`
/// (legacy). Sent to the agent as the literal slash text.
case quickCommand
/// Project-scoped, Scarf-managed command at
/// `<project>/.scarf/slash-commands/<name>.md`. Scarf intercepts
/// the invocation, expands `{{argument}}` substitution against the
/// command's body, and sends the result as a normal user prompt
/// (the agent never sees the slash trigger). Added in v2.5.
case projectScoped
/// ACP-native commands that don't interrupt the current turn
/// `/steer` is the flagship case. The chat UI keeps the
/// "agent working" indicator on; the guidance applies after the
/// next tool call. Added in v2.5 alongside Hermes v2026.4.23.
case acpNonInterruptive
}
public var id: String { name }
@@ -0,0 +1,89 @@
import Foundation
/// A user-authored, project-scoped slash command. Lives at
/// `<project>/.scarf/slash-commands/<name>.md` as a Markdown file with
/// YAML frontmatter Scarf-side primitive, not a Hermes feature.
///
/// The command is invoked via the chat slash menu like any other command,
/// but Scarf intercepts the invocation client-side: the body is treated as
/// a prompt template (with `{{argument}}` substitution from whatever
/// followed the slash), expanded to a regular user prompt, and sent to
/// Hermes as a normal message. The agent never sees the slash trigger;
/// it sees the expanded prompt prefixed with a `<!-- scarf-slash:<name> -->`
/// marker so it can correlate.
///
/// **Why client-side expansion.** Hermes has no project-scoped slash
/// command concept. Doing the substitution in Scarf means commands work
/// uniformly on Mac + iOS, local + remote SSH transports, against any
/// Hermes version (no upstream dependency).
public struct ProjectSlashCommand: Sendable, Equatable, Identifiable {
/// Stable identity is the command's `name` (must be unique within a
/// project's `slash-commands/` dir).
public var id: String { name }
/// Slash trigger drives the menu and the on-disk filename.
/// Must match `[a-z][a-z0-9-]*`. Validated by the service on save.
public let name: String
/// Human-readable subtitle shown in the slash menu.
public let description: String
/// Optional placeholder shown after `/<name> ` in the menu (e.g. `<focus area>`).
public let argumentHint: String?
/// Optional per-command model override. When set, the expanded prompt
/// is sent with this model in the ACP envelope, regardless of the
/// session's default.
public let model: String?
/// Optional grouping tags for the catalog / editor UI. Not surfaced
/// to the agent.
public let tags: [String]?
/// The prompt template body (everything after the YAML frontmatter
/// closer). Mustache-style `{{argument}}` substitution; supports
/// `{{argument | default: "..."}}` for fallbacks.
public let body: String
/// Absolute path the command was loaded from (used by the editor's
/// save/delete affordances + by the uninstaller's lock-file tracking).
public let sourcePath: String
public init(
name: String,
description: String,
argumentHint: String? = nil,
model: String? = nil,
tags: [String]? = nil,
body: String,
sourcePath: String
) {
self.name = name
self.description = description
self.argumentHint = argumentHint
self.model = model
self.tags = tags
self.body = body
self.sourcePath = sourcePath
}
}
// MARK: - Validation
public extension ProjectSlashCommand {
/// Allowed name shape: lowercase, digits, hyphens; must start with a
/// letter. Mirrors the catalog validator's rule so on-disk files
/// authored in Scarf round-trip cleanly through `.scarftemplate`.
static let validNamePattern = #"^[a-z][a-z0-9-]*$"#
/// Returns nil when the name is well-formed; otherwise a human-readable
/// reason suitable for inline editor UX.
static func validateName(_ name: String) -> String? {
if name.isEmpty { return "Name can't be empty." }
if name.count > 64 { return "Name must be 64 characters or fewer." }
if name.range(of: validNamePattern, options: .regularExpression) == nil {
return "Name must start with a letter and contain only lowercase letters, digits, and hyphens."
}
return nil
}
}
@@ -0,0 +1,316 @@
import Foundation
#if canImport(os)
import os
#endif
/// Loads, saves, and expands user-authored project-scoped slash commands
/// stored at `<project>/.scarf/slash-commands/<name>.md`.
///
/// Each command is a Markdown file with a YAML frontmatter block:
///
/// ```markdown
/// ---
/// name: review
/// description: Code-review the current branch
/// argumentHint: <focus area>
/// model: claude-sonnet-4.5
/// tags:
/// - code-review
/// - git
/// ---
/// You are reviewing changes on the current git branch.
/// Focus area: {{argument | default: "general code quality"}}.
/// ```
///
/// The service is transport-based `Mac` reads the local filesystem,
/// `ScarfGo` reads over SFTP via Citadel so the same code path works
/// on both platforms. Failures are logged but not thrown for `load*`
/// methods because the slash menu degrades gracefully (no commands =
/// menu just shows ACP + quick-command sources).
public struct ProjectSlashCommandService: Sendable {
#if canImport(os)
private static let logger = Logger(
subsystem: "com.scarf",
category: "ProjectSlashCommandService"
)
#endif
public let context: ServerContext
public nonisolated init(context: ServerContext = .local) {
self.context = context
}
// MARK: - Read
/// List every slash command at `<project>/.scarf/slash-commands/`.
/// Sorted by `name` ascending. Returns `[]` for projects that have no
/// `slash-commands/` directory yet that's the default state for any
/// project that hasn't authored one.
public nonisolated func loadCommands(at projectPath: String) -> [ProjectSlashCommand] {
let dir = Self.slashCommandsDir(for: projectPath)
let transport = context.makeTransport()
guard transport.fileExists(dir) else { return [] }
let entries: [String]
do {
entries = try transport.listDirectory(dir)
} catch {
#if canImport(os)
Self.logger.warning(
"listDirectory failed at \(dir, privacy: .public): \(error.localizedDescription, privacy: .public); returning empty list"
)
#endif
return []
}
var commands: [ProjectSlashCommand] = []
for entry in entries where entry.hasSuffix(".md") {
let path = dir + "/" + entry
if let cmd = loadCommand(at: path) {
commands.append(cmd)
}
}
return commands.sorted { $0.name < $1.name }
}
/// Load a single command file by absolute path. Returns nil on any
/// parse / IO failure (logged).
public nonisolated func loadCommand(at path: String) -> ProjectSlashCommand? {
let transport = context.makeTransport()
do {
let data = try transport.readFile(path)
guard let raw = String(data: data, encoding: .utf8) else {
#if canImport(os)
Self.logger.warning("non-UTF8 contents at \(path, privacy: .public)")
#endif
return nil
}
return Self.parse(raw, sourcePath: path)
} catch {
#if canImport(os)
Self.logger.warning(
"readFile failed at \(path, privacy: .public): \(error.localizedDescription, privacy: .public)"
)
#endif
return nil
}
}
// MARK: - Write
/// Persist the given command. Throws if the name is invalid or the
/// transport rejects the write. Creates `<project>/.scarf/slash-commands/`
/// on demand.
public nonisolated func save(
_ command: ProjectSlashCommand,
at projectPath: String
) throws {
if let reason = ProjectSlashCommand.validateName(command.name) {
throw ServiceError.invalidName(reason)
}
let dir = Self.slashCommandsDir(for: projectPath)
let transport = context.makeTransport()
if !transport.fileExists(dir) {
try transport.createDirectory(dir)
}
let path = dir + "/" + command.name + ".md"
let serialised = Self.serialise(command)
guard let data = serialised.data(using: .utf8) else {
throw ServiceError.encodingFailed
}
try transport.writeFile(path, data: data)
}
/// Remove the command with the given name. No-op if it doesn't exist.
public nonisolated func delete(
named name: String,
at projectPath: String
) throws {
let path = Self.slashCommandsDir(for: projectPath) + "/" + name + ".md"
let transport = context.makeTransport()
guard transport.fileExists(path) else { return }
try transport.removeFile(path)
}
// MARK: - Expansion
/// Render the command's body for sending to the agent. Substitutes
/// `{{argument}}` (and `{{argument | default: "..."}}`) with the
/// supplied argument. The result is what `ChatViewModel.sendPrompt`
/// transmits as a normal user message.
///
/// The expansion also prepends a Scarf-managed marker so the agent
/// can correlate the prompt back to the slash command useful when
/// the agent is asked "what command did the user run?".
public nonisolated func expand(
_ command: ProjectSlashCommand,
withArgument argument: String
) -> String {
let trimmed = argument.trimmingCharacters(in: .whitespacesAndNewlines)
let body = Self.substituteArgument(in: command.body, with: trimmed)
return "<!-- scarf-slash:\(command.name) -->\n\(body)"
}
// MARK: - Errors
public enum ServiceError: Error, LocalizedError {
case invalidName(String)
case encodingFailed
public var errorDescription: String? {
switch self {
case .invalidName(let reason): return reason
case .encodingFailed: return "Couldn't encode the slash command — please check for unusual characters in the body."
}
}
}
// MARK: - Path helpers
/// `<project>/.scarf/slash-commands` same path on Mac + iOS.
public static func slashCommandsDir(for projectPath: String) -> String {
let trimmed = projectPath.hasSuffix("/")
? String(projectPath.dropLast())
: projectPath
return trimmed + "/.scarf/slash-commands"
}
}
// MARK: - Frontmatter parsing + serialisation
extension ProjectSlashCommandService {
/// Parse a Markdown file with YAML frontmatter into a
/// `ProjectSlashCommand`. Returns nil when the frontmatter is missing
/// or required fields can't be extracted. Reuses `HermesYAML` so we
/// don't pull in a third-party YAML dependency.
static func parse(_ raw: String, sourcePath: String) -> ProjectSlashCommand? {
guard let (frontmatter, body) = splitFrontmatter(raw) else {
#if canImport(os)
logger.warning(
"missing frontmatter at \(sourcePath, privacy: .public); skipping"
)
#endif
return nil
}
let parsed = HermesYAML.parseNestedYAML(frontmatter)
guard let name = parsed.values["name"], !name.isEmpty,
let description = parsed.values["description"], !description.isEmpty
else {
#if canImport(os)
logger.warning(
"frontmatter missing required name/description at \(sourcePath, privacy: .public); skipping"
)
#endif
return nil
}
return ProjectSlashCommand(
name: name,
description: description,
argumentHint: parsed.values["argumentHint"],
model: parsed.values["model"],
tags: parsed.lists["tags"],
body: body,
sourcePath: sourcePath
)
}
/// Serialise a command to the on-disk format. Round-trip-safe with
/// `parse(_:sourcePath:)` for any value that doesn't contain newlines
/// or YAML-reserved characters in its frontmatter scalars.
static func serialise(_ command: ProjectSlashCommand) -> String {
var fm = "---\n"
fm += "name: \(command.name)\n"
fm += "description: \(yamlScalar(command.description))\n"
if let hint = command.argumentHint, !hint.isEmpty {
fm += "argumentHint: \(yamlScalar(hint))\n"
}
if let model = command.model, !model.isEmpty {
fm += "model: \(yamlScalar(model))\n"
}
if let tags = command.tags, !tags.isEmpty {
fm += "tags:\n"
for tag in tags {
fm += " - \(yamlScalar(tag))\n"
}
}
fm += "---\n"
// Body always ends with one trailing newline so editors don't
// produce diffs on save when the user typed cleanly.
var body = command.body
if !body.hasSuffix("\n") { body += "\n" }
return fm + body
}
/// Wrap a scalar in double quotes when it contains characters that
/// HermesYAML's parser treats as structural (`:`, `#`, leading `-`,
/// etc.). Otherwise emit it bare.
private static func yamlScalar(_ value: String) -> String {
let needsQuoting = value.contains(":")
|| value.contains("#")
|| value.contains("\"")
|| value.hasPrefix("-")
|| value.hasPrefix("[")
|| value.hasPrefix("{")
|| value.hasPrefix(">")
|| value.hasPrefix("|")
if !needsQuoting { return value }
let escaped = value.replacingOccurrences(of: "\"", with: "\\\"")
return "\"\(escaped)\""
}
/// Split a Markdown-with-frontmatter string at the closing `---`.
/// Returns `(frontmatter, body)` or nil when no frontmatter is found.
/// The opening `---` must be the very first line of the file (any
/// leading whitespace/newlines disqualify the file keeps the
/// detection unambiguous).
static func splitFrontmatter(_ raw: String) -> (frontmatter: String, body: String)? {
let lines = raw.components(separatedBy: "\n")
guard lines.first == "---" else { return nil }
for (idx, line) in lines.enumerated() where idx > 0 && line == "---" {
let frontmatter = lines[1..<idx].joined(separator: "\n")
let bodyStart = idx + 1
let body: String
if bodyStart >= lines.count {
body = ""
} else {
// Drop a single blank line right after `---` (common
// Markdown style; preserves the body's first real line).
var bodyLines = Array(lines[bodyStart...])
if bodyLines.first == "" { bodyLines.removeFirst() }
body = bodyLines.joined(separator: "\n")
}
return (frontmatter, body)
}
return nil
}
/// Replace `{{argument}}` and `{{argument | default: "..."}}` in the
/// template body with the user-supplied argument. Default value is
/// used when the argument is empty / whitespace-only.
static func substituteArgument(in template: String, with argument: String) -> String {
var result = template
// Match {{argument | default: "..."}} first (more specific).
let defaultPattern = #"\{\{\s*argument\s*\|\s*default:\s*"((?:[^"\\]|\\.)*)"\s*\}\}"#
if let regex = try? NSRegularExpression(pattern: defaultPattern) {
let range = NSRange(result.startIndex..., in: result)
let matches = regex.matches(in: result, range: range).reversed()
for match in matches {
guard let fullRange = Range(match.range, in: result),
let defaultRange = Range(match.range(at: 1), in: result)
else { continue }
let replacement = argument.isEmpty
? String(result[defaultRange])
: argument
result.replaceSubrange(fullRange, with: replacement)
}
}
// Then plain {{argument}} for anything that didn't have a default.
result = result.replacingOccurrences(
of: #"\{\{\s*argument\s*\}\}"#,
with: argument,
options: .regularExpression
)
return result
}
}
@@ -189,11 +189,41 @@ public final class RichChatViewModel {
public private(set) var acpCommands: [HermesSlashCommand] = []
/// User-defined commands parsed from `config.yaml` `quick_commands`.
public private(set) var quickCommands: [HermesSlashCommand] = []
/// Project-scoped, Scarf-managed commands at
/// `<project>/.scarf/slash-commands/<name>.md`. Loaded by
/// `loadProjectScopedCommands(at:)` when a project chat starts; cleared
/// on `reset()`. The full `ProjectSlashCommand` payload is kept here
/// (not just the surface metadata) because expansion happens in
/// `ChatViewModel.sendPrompt` and needs the body + model override.
public private(set) var projectScopedCommands: [ProjectSlashCommand] = []
/// Merged list, ACP-first, de-duplicated by name.
/// Merged slash-menu list. Precedence: **ACP > project-scoped >
/// quick_commands** (most specific source wins). De-duplicated by name.
public var availableCommands: [HermesSlashCommand] {
let acpNames = Set(acpCommands.map(\.name))
return acpCommands + quickCommands.filter { !acpNames.contains($0.name) }
let projectAsHermes: [HermesSlashCommand] = projectScopedCommands
.filter { !acpNames.contains($0.name) }
.map { cmd in
HermesSlashCommand(
name: cmd.name,
description: cmd.description,
argumentHint: cmd.argumentHint,
source: .projectScoped
)
}
let projectNames = Set(projectAsHermes.map(\.name))
let quicks = quickCommands.filter {
!acpNames.contains($0.name) && !projectNames.contains($0.name)
}
return acpCommands + projectAsHermes + quicks
}
/// Look up the full project-scoped command payload by slash trigger.
/// `ChatViewModel.sendPrompt` calls this when the input matches a
/// `.projectScoped` source and needs the body for client-side
/// expansion.
public func projectScopedCommand(named name: String) -> ProjectSlashCommand? {
projectScopedCommands.first { $0.name == name }
}
public var supportsCompress: Bool { availableCommands.contains { $0.name == "compress" } }
@@ -270,6 +300,7 @@ public final class RichChatViewModel {
acpErrorDetails = nil
acpCachedReadTokens = 0
acpCommands = []
projectScopedCommands = []
pendingPermission = nil
loadQuickCommands()
}
@@ -408,6 +439,27 @@ public final class RichChatViewModel {
}
}
/// Load project-scoped slash commands from
/// `<projectPath>/.scarf/slash-commands/` off the main actor and
/// publish them. Safe to call repeatedly replaces the existing
/// list (e.g., when the user adds / edits / deletes commands).
/// Pass `nil` to clear (e.g., on session de-attribution from a
/// project, or quick-chat sessions).
public func loadProjectScopedCommands(at projectPath: String?) {
guard let projectPath else {
projectScopedCommands = []
return
}
let ctx = context
Task.detached { [weak self] in
let svc = ProjectSlashCommandService(context: ctx)
let loaded = svc.loadCommands(at: projectPath)
await MainActor.run { [weak self] in
self?.projectScopedCommands = loaded
}
}
}
/// Parse `quick_commands` from `<context>/config.yaml`. Returns
/// `[(name, command)]` for every well-formed `type: exec` entry.
/// Mac-side `QuickCommandsViewModel` uses a richer model + adds