From 8a87ff19224e38e9f4fabea70857390dc545da0f Mon Sep 17 00:00:00 2001 From: Alan Wizemann Date: Sat, 25 Apr 2026 08:40:15 +0200 Subject: [PATCH] feat(slash-commands): list project commands in AGENTS.md block (Phase 1.5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The chat layer client-side-expands / args, but the agent still needs to know what commands exist so it can answer "what slash commands does this project have?" and recognise the marker prepended to expanded prompts. ProjectContextBlock.renderMinimalBlock(...) gains an optional slashCommandNames parameter; when non-empty, a new "Project slash commands" bullet lists the names as backticked / entries. Mac's ProjectAgentContextService.renderBlock(for:) reads the names via ProjectSlashCommandService.loadCommands(at:).map(\.name) and emits the same bullet, keeping Mac and iOS block output aligned where the content overlaps. iOS chat resetAndStartInProject splits the slash-command load into a synchronous read on a detached task BEFORE writing the block — needed because the block has to land on disk before `hermes acp` boots, and the async load that populates the chat menu would lose the race. Verified: ScarfCore, Mac, iOS all build clean. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Services/ProjectContextBlock.swift | 16 +++++++++++++++- scarf/Scarf iOS/Chat/ChatView.swift | 18 +++++++++++++++--- .../Services/ProjectAgentContextService.swift | 17 +++++++++++++++++ 3 files changed, 47 insertions(+), 4 deletions(-) diff --git a/scarf/Packages/ScarfCore/Sources/ScarfCore/Services/ProjectContextBlock.swift b/scarf/Packages/ScarfCore/Sources/ScarfCore/Services/ProjectContextBlock.swift index 6106916..6e8b92c 100644 --- a/scarf/Packages/ScarfCore/Sources/ScarfCore/Services/ProjectContextBlock.swift +++ b/scarf/Packages/ScarfCore/Sources/ScarfCore/Services/ProjectContextBlock.swift @@ -107,7 +107,17 @@ public enum ProjectContextBlock { /// yet. The marker + identity headers match the Mac output byte- /// for-byte where the content overlaps, so a project scaffolded /// on iOS round-trips cleanly through the Mac. - public static func renderMinimalBlock(projectName: String, projectPath: String) -> String { + /// + /// `slashCommandNames` populates the v2.5 "Project slash commands" + /// line — pass nil/empty to omit that line entirely. The names + /// flow into the agent's context so it can answer "what commands + /// do I have available?" and recognise the `` + /// marker the chat layer prepends to expanded prompts. + public static func renderMinimalBlock( + projectName: String, + projectPath: String, + slashCommandNames: [String]? = nil + ) -> String { var lines: [String] = [] lines.append(beginMarker) lines.append("## Scarf project context") @@ -118,6 +128,10 @@ public enum ProjectContextBlock { lines.append("") lines.append("- **Project directory:** `\(projectPath)`") lines.append("- **Dashboard:** `\(projectPath)/.scarf/dashboard.json`") + if let names = slashCommandNames, !names.isEmpty { + let formatted = names.sorted().map { "`/\($0)`" }.joined(separator: ", ") + lines.append("- **Project slash commands:** \(formatted). The user invokes these via the chat slash menu; you'll see the expanded prompt as a normal user message preceded by ``.") + } lines.append("") lines.append("Any content below this block is template- or user-authored; preserve and defer to it for project-specific behavior. Do NOT modify content inside these markers — Scarf rewrites this block on every project-scoped chat start.") lines.append(endMarker) diff --git a/scarf/Scarf iOS/Chat/ChatView.swift b/scarf/Scarf iOS/Chat/ChatView.swift index c76e14b..f197b18 100644 --- a/scarf/Scarf iOS/Chat/ChatView.swift +++ b/scarf/Scarf iOS/Chat/ChatView.swift @@ -612,6 +612,19 @@ final class ChatController { // /.scarf/slash-commands/ into the chat menu. // Async + non-fatal — degrades cleanly on SFTP failures (logged). vm.loadProjectScopedCommands(at: project.path) + // Synchronously load the slash command NAMES so we can list them + // in the AGENTS.md block (the agent needs to know what commands + // are available). This is a separate read from the async one + // above because the block has to land on disk BEFORE `hermes acp` + // boots — async loads might lose the race. Blocking load on a + // detached task to keep the MainActor responsive. + let ctx = context + let projectPath = project.path + let slashNames: [String] = await Task.detached { + ProjectSlashCommandService(context: ctx) + .loadCommands(at: projectPath) + .map(\.name) + }.value // 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 @@ -619,10 +632,9 @@ final class ChatController { // for this session, with the underlying error in "Show details". let block = ProjectContextBlock.renderMinimalBlock( projectName: project.name, - projectPath: project.path + projectPath: project.path, + slashCommandNames: slashNames ) - let ctx = context - let projectPath = project.path let writeResult: Result = await Task.detached { do { try ProjectContextBlock.writeBlock( diff --git a/scarf/scarf/Core/Services/ProjectAgentContextService.swift b/scarf/scarf/Core/Services/ProjectAgentContextService.swift index 72ebe94..5a83cdc 100644 --- a/scarf/scarf/Core/Services/ProjectAgentContextService.swift +++ b/scarf/scarf/Core/Services/ProjectAgentContextService.swift @@ -129,6 +129,7 @@ struct ProjectAgentContextService: Sendable { let templateInfo = readTemplateInfo(for: project) let configFieldsLine = renderConfigFieldsLine(for: project) let cronLines = renderCronLines(for: project, templateId: templateInfo?.id) + let slashCommandNames = readSlashCommandNames(for: project) let lockFilePresent = context.makeTransport().fileExists( project.path + "/.scarf/template.lock.json" ) @@ -158,6 +159,11 @@ struct ProjectAgentContextService: Sendable { } } + if !slashCommandNames.isEmpty { + let formatted = slashCommandNames.sorted().map { "`/\($0)`" }.joined(separator: ", ") + lines.append("- **Project slash commands:** \(formatted). The user invokes these via the chat slash menu; you'll see the expanded prompt as a normal user message preceded by ``.") + } + if lockFilePresent { lines.append("- **Uninstall manifest:** `\(project.path)/.scarf/template.lock.json` (tracks files written by template install)") } @@ -169,6 +175,17 @@ struct ProjectAgentContextService: Sendable { return lines.joined(separator: "\n") } + /// Read the names of every project-scoped slash command at + /// `/.scarf/slash-commands/`. Empty array when the dir + /// is absent or no `.md` files parse cleanly. Used by `renderBlock` + /// to surface the available commands to the agent so it knows what + /// `` markers to expect on user prompts. + nonisolated private func readSlashCommandNames(for project: ProjectEntry) -> [String] { + ProjectSlashCommandService(context: context) + .loadCommands(at: project.path) + .map(\.name) + } + // MARK: - Helpers nonisolated private func agentsMdPath(for project: ProjectEntry) -> String {