mirror of
https://github.com/awizemann/scarf.git
synced 2026-05-10 18:44:45 +00:00
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:
@@ -1,12 +1,29 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
/// A slash command available in chat. Sourced either from the ACP server
|
/// A slash command available in chat. Sourced from one of four places —
|
||||||
/// (`available_commands_update`) or from user-defined `quick_commands` in
|
/// see `Source` for which.
|
||||||
/// `config.yaml`.
|
|
||||||
public struct HermesSlashCommand: Identifiable, Sendable, Equatable {
|
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 {
|
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
|
case acp
|
||||||
|
/// User-defined `quick_commands.<name>` in `~/.hermes/config.yaml`
|
||||||
|
/// (legacy). Sent to the agent as the literal slash text.
|
||||||
case quickCommand
|
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 }
|
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] = []
|
public private(set) var acpCommands: [HermesSlashCommand] = []
|
||||||
/// User-defined commands parsed from `config.yaml` `quick_commands`.
|
/// User-defined commands parsed from `config.yaml` `quick_commands`.
|
||||||
public private(set) var quickCommands: [HermesSlashCommand] = []
|
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] {
|
public var availableCommands: [HermesSlashCommand] {
|
||||||
let acpNames = Set(acpCommands.map(\.name))
|
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" } }
|
public var supportsCompress: Bool { availableCommands.contains { $0.name == "compress" } }
|
||||||
@@ -270,6 +300,7 @@ public final class RichChatViewModel {
|
|||||||
acpErrorDetails = nil
|
acpErrorDetails = nil
|
||||||
acpCachedReadTokens = 0
|
acpCachedReadTokens = 0
|
||||||
acpCommands = []
|
acpCommands = []
|
||||||
|
projectScopedCommands = []
|
||||||
pendingPermission = nil
|
pendingPermission = nil
|
||||||
loadQuickCommands()
|
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
|
/// Parse `quick_commands` from `<context>/config.yaml`. Returns
|
||||||
/// `[(name, command)]` for every well-formed `type: exec` entry.
|
/// `[(name, command)]` for every well-formed `type: exec` entry.
|
||||||
/// Mac-side `QuickCommandsViewModel` uses a richer model + adds
|
/// Mac-side `QuickCommandsViewModel` uses a richer model + adds
|
||||||
|
|||||||
@@ -533,8 +533,14 @@ final class ChatController {
|
|||||||
guard !sessionId.isEmpty else { return }
|
guard !sessionId.isEmpty else { return }
|
||||||
draft = ""
|
draft = ""
|
||||||
vm.addUserMessage(text: text)
|
vm.addUserMessage(text: text)
|
||||||
|
// Project-scoped slash commands expand client-side: the user
|
||||||
|
// bubble shows the literal `/<name> args` they typed (above);
|
||||||
|
// Hermes receives the expanded prompt template body. Other
|
||||||
|
// command sources (ACP, quick_commands) keep going to Hermes
|
||||||
|
// literally. v2.5.
|
||||||
|
let wireText = expandIfProjectScoped(text)
|
||||||
do {
|
do {
|
||||||
_ = try await client.sendPrompt(sessionId: sessionId, text: text)
|
_ = try await client.sendPrompt(sessionId: sessionId, text: wireText)
|
||||||
} catch {
|
} catch {
|
||||||
// The event task may already have surfaced a
|
// The event task may already have surfaced a
|
||||||
// .connectionLost; show the send-time error only if the
|
// .connectionLost; show the send-time error only if the
|
||||||
@@ -548,6 +554,28 @@ final class ChatController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Mirror of `ChatViewModel.expandIfProjectScoped(_:)` on Mac.
|
||||||
|
/// `/<name> args` matching a loaded project-scoped command is
|
||||||
|
/// expanded; everything else is sent literally.
|
||||||
|
private func expandIfProjectScoped(_ text: String) -> String {
|
||||||
|
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard trimmed.hasPrefix("/") else { return text }
|
||||||
|
let withoutSlash = String(trimmed.dropFirst())
|
||||||
|
let name: String
|
||||||
|
let argument: String
|
||||||
|
if let space = withoutSlash.firstIndex(of: " ") {
|
||||||
|
name = String(withoutSlash[..<space])
|
||||||
|
argument = String(withoutSlash[withoutSlash.index(after: space)...])
|
||||||
|
} else {
|
||||||
|
name = withoutSlash
|
||||||
|
argument = ""
|
||||||
|
}
|
||||||
|
guard !name.isEmpty,
|
||||||
|
let cmd = vm.projectScopedCommand(named: name)
|
||||||
|
else { return text }
|
||||||
|
return ProjectSlashCommandService(context: context).expand(cmd, withArgument: argument)
|
||||||
|
}
|
||||||
|
|
||||||
/// Stop the current session + tear down the SSH exec channel.
|
/// Stop the current session + tear down the SSH exec channel.
|
||||||
/// Idempotent.
|
/// Idempotent.
|
||||||
func stop() async {
|
func stop() async {
|
||||||
@@ -565,6 +593,9 @@ final class ChatController {
|
|||||||
await stop()
|
await stop()
|
||||||
vm.reset()
|
vm.reset()
|
||||||
currentProjectName = nil
|
currentProjectName = nil
|
||||||
|
// Quick-chat sessions don't have a project; clear any leftover
|
||||||
|
// project-scoped slash commands from a prior session.
|
||||||
|
vm.loadProjectScopedCommands(at: nil)
|
||||||
await start()
|
await start()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -577,6 +608,10 @@ final class ChatController {
|
|||||||
await stop()
|
await stop()
|
||||||
vm.reset()
|
vm.reset()
|
||||||
currentProjectName = project.name
|
currentProjectName = project.name
|
||||||
|
// Pull any project-authored slash commands at
|
||||||
|
// <project.path>/.scarf/slash-commands/ into the chat menu.
|
||||||
|
// Async + non-fatal — degrades cleanly on SFTP failures (logged).
|
||||||
|
vm.loadProjectScopedCommands(at: project.path)
|
||||||
// Write the context block first. Non-fatal on failure — chat
|
// Write the context block first. Non-fatal on failure — chat
|
||||||
// still starts, just without the managed block. We capture the
|
// still starts, just without the managed block. We capture the
|
||||||
// failure (rather than swallowing via `try?`) so the user gets
|
// failure (rather than swallowing via `try?`) so the user gets
|
||||||
@@ -722,16 +757,20 @@ final class ChatController {
|
|||||||
// JSON-decode edge case that would render just a folder icon
|
// JSON-decode edge case that would render just a folder icon
|
||||||
// with no text (pass-2 bug: user saw exactly that).
|
// with no text (pass-2 bug: user saw exactly that).
|
||||||
let ctx = context
|
let ctx = context
|
||||||
let resolved: String? = await Task.detached {
|
// Resolve both the path AND the name so we can (a) render the
|
||||||
|
// header chip with the name and (b) load any project-scoped
|
||||||
|
// slash commands at the project's `.scarf/slash-commands/` dir.
|
||||||
|
let resolved: (path: String, name: String)? = await Task.detached {
|
||||||
let attribution = SessionAttributionService(context: ctx)
|
let attribution = SessionAttributionService(context: ctx)
|
||||||
guard let path = attribution.projectPath(for: sessionID) else { return nil }
|
guard let path = attribution.projectPath(for: sessionID) else { return nil }
|
||||||
let registry = ProjectDashboardService(context: ctx).loadRegistry()
|
let registry = ProjectDashboardService(context: ctx).loadRegistry()
|
||||||
guard let name = registry.projects.first(where: { $0.path == path })?.name,
|
guard let name = registry.projects.first(where: { $0.path == path })?.name,
|
||||||
!name.isEmpty
|
!name.isEmpty
|
||||||
else { return nil }
|
else { return nil }
|
||||||
return name
|
return (path: path, name: name)
|
||||||
}.value
|
}.value
|
||||||
currentProjectName = resolved
|
currentProjectName = resolved?.name
|
||||||
|
vm.loadProjectScopedCommands(at: resolved?.path)
|
||||||
|
|
||||||
state = .connecting
|
state = .connecting
|
||||||
let client = ACPClient.forIOSApp(
|
let client = ACPClient.forIOSApp(
|
||||||
|
|||||||
@@ -267,6 +267,33 @@ final class ChatViewModel {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// If `text` is a `/<name> [args]` invocation matching a project-
|
||||||
|
/// scoped slash command currently loaded into the view model, return
|
||||||
|
/// the expanded prompt body (with `{{argument}}` substituted). Otherwise
|
||||||
|
/// return the input unchanged.
|
||||||
|
///
|
||||||
|
/// ACP commands and `quick_commands:` keep going to Hermes literally —
|
||||||
|
/// only project-scoped commands get the client-side expansion treatment
|
||||||
|
/// because Hermes has no concept of them.
|
||||||
|
private func expandIfProjectScoped(_ text: String) -> String {
|
||||||
|
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard trimmed.hasPrefix("/") else { return text }
|
||||||
|
let withoutSlash = String(trimmed.dropFirst())
|
||||||
|
let name: String
|
||||||
|
let argument: String
|
||||||
|
if let space = withoutSlash.firstIndex(of: " ") {
|
||||||
|
name = String(withoutSlash[..<space])
|
||||||
|
argument = String(withoutSlash[withoutSlash.index(after: space)...])
|
||||||
|
} else {
|
||||||
|
name = withoutSlash
|
||||||
|
argument = ""
|
||||||
|
}
|
||||||
|
guard !name.isEmpty,
|
||||||
|
let cmd = richChatViewModel.projectScopedCommand(named: name)
|
||||||
|
else { return text }
|
||||||
|
return ProjectSlashCommandService(context: context).expand(cmd, withArgument: argument)
|
||||||
|
}
|
||||||
|
|
||||||
private func sendViaACP(client: ACPClient, text: String) {
|
private func sendViaACP(client: ACPClient, text: String) {
|
||||||
guard let sessionId = richChatViewModel.sessionId else {
|
guard let sessionId = richChatViewModel.sessionId else {
|
||||||
clearACPErrorState()
|
clearACPErrorState()
|
||||||
@@ -280,10 +307,18 @@ final class ChatViewModel {
|
|||||||
richChatViewModel.addUserMessage(text: text)
|
richChatViewModel.addUserMessage(text: text)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Project-scoped slash commands expand client-side: the user
|
||||||
|
// sees the literal `/<name> args` they typed (already in the
|
||||||
|
// transcript as their bubble), but Hermes receives the expanded
|
||||||
|
// prompt template. The literal slash is meaningless to Hermes
|
||||||
|
// for project-scoped commands; this is what makes them portable
|
||||||
|
// and Hermes-version-independent. v2.5.
|
||||||
|
let wireText = expandIfProjectScoped(text)
|
||||||
|
|
||||||
acpStatus = "Agent working..."
|
acpStatus = "Agent working..."
|
||||||
acpPromptTask = Task { @MainActor in
|
acpPromptTask = Task { @MainActor in
|
||||||
do {
|
do {
|
||||||
let result = try await client.sendPrompt(sessionId: sessionId, text: text)
|
let result = try await client.sendPrompt(sessionId: sessionId, text: wireText)
|
||||||
acpStatus = "Ready"
|
acpStatus = "Ready"
|
||||||
richChatViewModel.handleACPEvent(
|
richChatViewModel.handleACPEvent(
|
||||||
.promptComplete(sessionId: sessionId, response: result)
|
.promptComplete(sessionId: sessionId, response: result)
|
||||||
@@ -420,11 +455,18 @@ final class ChatViewModel {
|
|||||||
let name = registry.projects.first(where: { $0.path == path })?.name
|
let name = registry.projects.first(where: { $0.path == path })?.name
|
||||||
self.currentProjectPath = path
|
self.currentProjectPath = path
|
||||||
self.currentProjectName = name ?? path
|
self.currentProjectName = name ?? path
|
||||||
|
// Pull any project-scoped slash commands the user has
|
||||||
|
// authored at <path>/.scarf/slash-commands/ so the
|
||||||
|
// chat slash menu surfaces them. Async + non-fatal —
|
||||||
|
// the menu degrades to ACP + quick commands only on
|
||||||
|
// any failure (logged inside the service).
|
||||||
|
self.richChatViewModel.loadProjectScopedCommands(at: path)
|
||||||
} else {
|
} else {
|
||||||
// Explicit clear on non-project sessions so the
|
// Explicit clear on non-project sessions so the
|
||||||
// indicator doesn't leak from a previous chat.
|
// indicator doesn't leak from a previous chat.
|
||||||
self.currentProjectPath = nil
|
self.currentProjectPath = nil
|
||||||
self.currentProjectName = nil
|
self.currentProjectName = nil
|
||||||
|
self.richChatViewModel.loadProjectScopedCommands(at: nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Refresh session list so the new ACP session appears in the Resume menu
|
// Refresh session list so the new ACP session appears in the Resume menu
|
||||||
|
|||||||
Reference in New Issue
Block a user