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
+43 -4
View File
@@ -533,8 +533,14 @@ final class ChatController {
guard !sessionId.isEmpty else { return }
draft = ""
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 {
_ = try await client.sendPrompt(sessionId: sessionId, text: text)
_ = try await client.sendPrompt(sessionId: sessionId, text: wireText)
} catch {
// The event task may already have surfaced a
// .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.
/// Idempotent.
func stop() async {
@@ -565,6 +593,9 @@ final class ChatController {
await stop()
vm.reset()
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()
}
@@ -577,6 +608,10 @@ final class ChatController {
await stop()
vm.reset()
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
// still starts, just without the managed block. We capture the
// 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
// with no text (pass-2 bug: user saw exactly that).
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)
guard let path = attribution.projectPath(for: sessionID) else { return nil }
let registry = ProjectDashboardService(context: ctx).loadRegistry()
guard let name = registry.projects.first(where: { $0.path == path })?.name,
!name.isEmpty
else { return nil }
return name
return (path: path, name: name)
}.value
currentProjectName = resolved
currentProjectName = resolved?.name
vm.loadProjectScopedCommands(at: resolved?.path)
state = .connecting
let client = ACPClient.forIOSApp(