feat(slash-commands): list project commands in AGENTS.md block (Phase 1.5)

The chat layer client-side-expands /<name> 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
<!-- scarf-slash:<name> --> 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 /<name> 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) <noreply@anthropic.com>
This commit is contained in:
Alan Wizemann
2026-04-25 08:40:15 +02:00
parent 6808adfa98
commit 8a87ff1922
3 changed files with 47 additions and 4 deletions
@@ -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 `<!-- scarf-slash:<name> -->`
/// 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 `<!-- scarf-slash:<name> -->`.")
}
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)
+15 -3
View File
@@ -612,6 +612,19 @@ final class ChatController {
// <project.path>/.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<Void, Error> = await Task.detached {
do {
try ProjectContextBlock.writeBlock(
@@ -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 `<!-- scarf-slash:<name> -->`.")
}
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
/// `<project>/.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
/// `<!-- scarf-slash:<name> -->` 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 {