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 {