mirror of
https://github.com/awizemann/scarf.git
synced 2026-05-10 10:36:35 +00:00
feat(projects): Scarf-managed project-context block in AGENTS.md
Hermes has no native "project" concept and the ACP wire protocol drops extra params at `session/new`. But Hermes DOES auto-read AGENTS.md from the session's cwd at startup (research confirmed: priority order `.hermes.md` → `HERMES.md` → AGENTS.md → CLAUDE.md → .cursorrules; 20KB cap; first match wins). So the agent- awareness path is file-based, not protocol-based. This commit adds `ProjectAgentContextService` — a one-job service that writes a Scarf-managed block into `<project>/AGENTS.md` between `<!-- scarf-project:begin -->` and `<!-- scarf-project:end -->` markers. Same pattern as the v2.2 memory-block appendix: bounded, self-declaring, re-generable, safe on hand-authored content outside the markers. ## Block contents - Project name (from registry) - Project directory path - Dashboard.json path - Template id + version (when template-installed) - Configuration field NAMES with type hints — never VALUES. Secrets always render as `field_key (secret — name only, value stored in Keychain)`. Config.json values never appear in the block, so the injected context is safe to drop into any agent regardless of what's in Keychain. - Registered cron jobs attributed to this project (matched via the `[tmpl:<id>] …` prefix convention) - Uninstall manifest reference (when `.scarf/template.lock.json` exists) - A note to the agent: cwd is the project dir, respect template content below the block. ## Integration point `ChatViewModel.startACPSession(resume:projectPath:)` refreshes the block BEFORE `client.start()` — Hermes reads AGENTS.md during session boot, so it has to land on disk first. `try?` with a warning log: a failed refresh doesn't block the chat, the session just starts without the extra context. ## Idempotency + safety - Two consecutive refreshes produce byte-identical output - Hand-edits outside the markers survive every refresh - Empty project dir → AGENTS.md created with just the block - Existing AGENTS.md without markers → block prepended; rest preserved below - Orphaned begin-marker (no end) → treated as "no block present," new block prepended, orphan left in place (likely hand-typed, not a Scarf corruption) ## Tests 13 new tests in ProjectAgentContextServiceTests: - applyBlock pure-text transform: prepend / replace / idempotency / empty input / orphaned-marker fallback - renderBlock content: identity fields, template presence, config field names (and CRITICALLY: no values leak for secret fields) - refresh end-to-end on isolated temp dirs: file creation, user content preservation, idempotency across runs, stale-block rewrite 93/93 Swift tests pass (was 80; +13 new). ## Deferred TERMINAL_CWD env-var plumbing in ACPClient was scoped in the plan but skipped — ACPClient.start() doesn't know the cwd at launch (it's per-session), and plumbing it would restructure the actor's lifecycle. Hermes already receives the cwd via ACP's `session/new` params and uses it for context-file discovery there, so TERMINAL_CWD is belt-and-suspenders we can add later without breaking anything. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,293 @@
|
||||
import Foundation
|
||||
import os
|
||||
|
||||
/// Writes a Scarf-managed marker block into `<project>/AGENTS.md` so
|
||||
/// that Hermes — which auto-reads `AGENTS.md` from the session's cwd
|
||||
/// at startup — has consistent project identity and metadata in every
|
||||
/// project-scoped chat.
|
||||
///
|
||||
/// **Why this exists.** Hermes has no native "project" concept and ACP
|
||||
/// passes only `(cwd, mcpServers)` at session create — extra params
|
||||
/// are silently dropped on Hermes's side. The documented hook for
|
||||
/// giving the agent context when cwd is set programmatically is the
|
||||
/// auto-load of `AGENTS.md` (or `.hermes.md` / `CLAUDE.md` /
|
||||
/// `.cursorrules`, in that priority) from the cwd. Scarf owns a
|
||||
/// managed region of the project's AGENTS.md; template-author content
|
||||
/// lives outside that region and is preserved.
|
||||
///
|
||||
/// **Marker contract.** The region sits between:
|
||||
///
|
||||
/// ```
|
||||
/// <!-- scarf-project:begin -->
|
||||
/// …Scarf-managed content…
|
||||
/// <!-- scarf-project:end -->
|
||||
/// ```
|
||||
///
|
||||
/// Same pattern as the v2.2 memory-block appendix — bounded, self-
|
||||
/// declaring, safe to re-generate. Everything outside the markers is
|
||||
/// left byte-identical across refreshes.
|
||||
///
|
||||
/// **Secret-safe.** The block surfaces field NAMES from `config.json`
|
||||
/// (via the cached manifest's schema) but never VALUES. A rendered
|
||||
/// block contains no secrets even for a project whose config.json
|
||||
/// has Keychain-ref URIs.
|
||||
///
|
||||
/// **Refresh timing.** `ChatViewModel.startACPSession(resume:projectPath:)`
|
||||
/// calls `refresh(for:)` immediately before Hermes opens the session.
|
||||
/// Hermes reads AGENTS.md during session boot, so the marker block
|
||||
/// must have landed on disk first. Non-blocking on failure — a
|
||||
/// failed refresh logs and the chat proceeds without the block.
|
||||
struct ProjectAgentContextService: Sendable {
|
||||
private static let logger = Logger(subsystem: "com.scarf", category: "ProjectAgentContextService")
|
||||
|
||||
/// Marker strings. Load-bearing: the format must stay stable
|
||||
/// across releases so existing project AGENTS.md files continue
|
||||
/// to be recognized and rewritten cleanly.
|
||||
static let beginMarker = "<!-- scarf-project:begin -->"
|
||||
static let endMarker = "<!-- scarf-project:end -->"
|
||||
|
||||
let context: ServerContext
|
||||
|
||||
nonisolated init(context: ServerContext = .local) {
|
||||
self.context = context
|
||||
}
|
||||
|
||||
// MARK: - Public
|
||||
|
||||
/// Refresh (or create) the Scarf-managed block in the project's
|
||||
/// AGENTS.md. Reads current project state — template manifest,
|
||||
/// config schema, registered cron jobs — and produces a block
|
||||
/// reflecting today's truth. Idempotent: two consecutive calls
|
||||
/// with no intervening state change produce byte-identical
|
||||
/// output.
|
||||
nonisolated func refresh(for project: ProjectEntry) throws {
|
||||
let block = renderBlock(for: project)
|
||||
let path = agentsMdPath(for: project)
|
||||
let transport = context.makeTransport()
|
||||
|
||||
// Ensure the project directory exists — this service is the
|
||||
// first thing that touches the project dir when the user
|
||||
// scaffolds a bare project via `+` + starts a chat. Normally
|
||||
// the dir exists (registered project = dir exists); belt-
|
||||
// and-suspenders for edge cases.
|
||||
if !transport.fileExists(project.path) {
|
||||
try transport.createDirectory(project.path)
|
||||
}
|
||||
|
||||
if !transport.fileExists(path) {
|
||||
// Fresh AGENTS.md with just our block + a trailing
|
||||
// newline so editors render it cleanly.
|
||||
let data = (block + "\n").data(using: .utf8) ?? Data()
|
||||
try transport.writeFile(path, data: data)
|
||||
Self.logger.info("created AGENTS.md with Scarf block for \(project.name, privacy: .public)")
|
||||
return
|
||||
}
|
||||
|
||||
// Read existing, splice in the new block.
|
||||
let existingData = try transport.readFile(path)
|
||||
let existing = String(data: existingData, encoding: .utf8) ?? ""
|
||||
let rewritten = Self.applyBlock(block: block, to: existing)
|
||||
guard let outData = rewritten.data(using: .utf8) else {
|
||||
throw ProjectAgentContextError.encodingFailed
|
||||
}
|
||||
// Skip the write when nothing changed — avoids unnecessary
|
||||
// file-watcher churn. Matches what disk snapshot shows.
|
||||
guard outData != existingData else { return }
|
||||
try transport.writeFile(path, data: outData)
|
||||
Self.logger.info("refreshed Scarf block in AGENTS.md for \(project.name, privacy: .public)")
|
||||
}
|
||||
|
||||
// MARK: - Marker splice (testable in isolation)
|
||||
|
||||
/// Core text transform: given an existing file and a freshly-
|
||||
/// rendered block, return the file with the block spliced in.
|
||||
///
|
||||
/// Three cases handled:
|
||||
/// 1. Existing file has both markers → replace the inclusive
|
||||
/// region, preserve everything outside untouched.
|
||||
/// 2. Existing file has no markers → prepend the block followed
|
||||
/// by a two-newline separator so it reads as its own section.
|
||||
/// 3. Existing file has a begin marker but no end → we DON'T try
|
||||
/// to be clever; treat as "no markers present" and prepend.
|
||||
/// User intervention or a later refresh can restore shape.
|
||||
/// The stray begin-marker is left in the file; we don't
|
||||
/// truncate to EOF (as the memory-block installer does)
|
||||
/// because an orphaned begin on this file is more likely
|
||||
/// hand-typed than a corrupt Scarf write.
|
||||
nonisolated static func applyBlock(block: String, to existing: String) -> String {
|
||||
guard let beginRange = existing.range(of: beginMarker),
|
||||
let endRange = existing.range(
|
||||
of: endMarker,
|
||||
range: beginRange.upperBound..<existing.endIndex
|
||||
)
|
||||
else {
|
||||
// No well-formed Scarf block present — prepend.
|
||||
let trimmedExisting = existing.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if trimmedExisting.isEmpty {
|
||||
return block + "\n"
|
||||
}
|
||||
return block + "\n\n" + existing
|
||||
}
|
||||
// Full span: from the begin marker through the end marker
|
||||
// (inclusive). Consumes any trailing whitespace/newlines
|
||||
// immediately following the end marker so a re-render of a
|
||||
// shorter block doesn't leave a dangling blank line.
|
||||
var upperBound = endRange.upperBound
|
||||
while upperBound < existing.endIndex,
|
||||
existing[upperBound].isNewline {
|
||||
upperBound = existing.index(after: upperBound)
|
||||
}
|
||||
let before = String(existing[existing.startIndex..<beginRange.lowerBound])
|
||||
let after = String(existing[upperBound..<existing.endIndex])
|
||||
// Preserve the leading whitespace / content structure of
|
||||
// `before` but ensure exactly one blank line separates it
|
||||
// from the new block when there IS prior content.
|
||||
let prefix = before.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
? ""
|
||||
: before.trimmingRightNewlines() + "\n\n"
|
||||
// Suffix: a blank line BEFORE the remaining content, ensuring
|
||||
// the template/user content is visually separated from the
|
||||
// Scarf block.
|
||||
let suffix = after.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
? "\n"
|
||||
: "\n\n" + after.trimmingLeftNewlines()
|
||||
return prefix + block + suffix
|
||||
}
|
||||
|
||||
// MARK: - Block rendering
|
||||
|
||||
/// Build the Markdown block for a given project. Pure function of
|
||||
/// project state — exposed for tests that want to assert on
|
||||
/// rendered content without touching disk.
|
||||
nonisolated func renderBlock(for project: ProjectEntry) -> String {
|
||||
let templateInfo = readTemplateInfo(for: project)
|
||||
let configFieldsLine = renderConfigFieldsLine(for: project)
|
||||
let cronLines = renderCronLines(for: project, templateId: templateInfo?.id)
|
||||
let lockFilePresent = context.makeTransport().fileExists(
|
||||
project.path + "/.scarf/template.lock.json"
|
||||
)
|
||||
|
||||
var lines: [String] = []
|
||||
lines.append(Self.beginMarker)
|
||||
lines.append("## Scarf project context")
|
||||
lines.append("")
|
||||
lines.append("_Auto-generated by Scarf — do not edit between the begin/end markers._")
|
||||
lines.append("")
|
||||
lines.append("You are operating inside a Scarf project named **\"\(project.name)\"**. Scarf is a macOS GUI for Hermes; the user is working with this project through it. This chat session's working directory is the project's directory — path-relative tool calls resolve inside the project.")
|
||||
lines.append("")
|
||||
lines.append("- **Project directory:** `\(project.path)`")
|
||||
lines.append("- **Dashboard:** `\(project.path)/.scarf/dashboard.json`")
|
||||
|
||||
if let tpl = templateInfo {
|
||||
lines.append("- **Template:** `\(tpl.id)` v\(tpl.version)")
|
||||
}
|
||||
lines.append("- **Configuration fields:** \(configFieldsLine)")
|
||||
|
||||
if cronLines.isEmpty {
|
||||
lines.append("- **Registered cron jobs:** (none attributed to this project)")
|
||||
} else {
|
||||
lines.append("- **Registered cron jobs:**")
|
||||
for line in cronLines {
|
||||
lines.append(" - \(line)")
|
||||
}
|
||||
}
|
||||
|
||||
if lockFilePresent {
|
||||
lines.append("- **Uninstall manifest:** `\(project.path)/.scarf/template.lock.json` (tracks files written by template install)")
|
||||
}
|
||||
|
||||
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(Self.endMarker)
|
||||
|
||||
return lines.joined(separator: "\n")
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
nonisolated private func agentsMdPath(for project: ProjectEntry) -> String {
|
||||
project.path + "/AGENTS.md"
|
||||
}
|
||||
|
||||
/// Read `<project>/.scarf/manifest.json` for template id + version.
|
||||
/// Nil when not present (bare project) or when the file is
|
||||
/// unparseable — the block still renders cleanly without the
|
||||
/// template line.
|
||||
nonisolated private func readTemplateInfo(for project: ProjectEntry) -> (id: String, version: String)? {
|
||||
let manifestPath = project.path + "/.scarf/manifest.json"
|
||||
let transport = context.makeTransport()
|
||||
guard transport.fileExists(manifestPath) else { return nil }
|
||||
guard let data = try? transport.readFile(manifestPath) else { return nil }
|
||||
guard let manifest = try? JSONDecoder().decode(ProjectTemplateManifest.self, from: data) else { return nil }
|
||||
return (id: manifest.id, version: manifest.version)
|
||||
}
|
||||
|
||||
/// Build the "Configuration fields" bullet's tail. Returns a
|
||||
/// comma-joined list of backticked field names with inline type
|
||||
/// hints (`(secret)`), or the literal string "(none)" when the
|
||||
/// project has no config schema. **Never** includes values.
|
||||
nonisolated private func renderConfigFieldsLine(for project: ProjectEntry) -> String {
|
||||
let manifestPath = project.path + "/.scarf/manifest.json"
|
||||
let transport = context.makeTransport()
|
||||
guard transport.fileExists(manifestPath),
|
||||
let data = try? transport.readFile(manifestPath),
|
||||
let manifest = try? JSONDecoder().decode(ProjectTemplateManifest.self, from: data),
|
||||
let schema = manifest.config,
|
||||
!schema.fields.isEmpty
|
||||
else {
|
||||
return "(none)"
|
||||
}
|
||||
let fieldList = schema.fields.map { field -> String in
|
||||
let secretTag = field.type == .secret ? " (secret — name only, value stored in Keychain)" : ""
|
||||
return "`\(field.key)`\(secretTag)"
|
||||
}
|
||||
return fieldList.joined(separator: ", ")
|
||||
}
|
||||
|
||||
/// Return a list of human-readable cron-job descriptions for jobs
|
||||
/// attributed to this project via the `[tmpl:<id>] …` name prefix.
|
||||
/// Empty array when no jobs match (either the project has no
|
||||
/// template or no jobs carry the tag).
|
||||
nonisolated private func renderCronLines(for project: ProjectEntry, templateId: String?) -> [String] {
|
||||
guard let templateId else { return [] }
|
||||
let prefix = "[tmpl:\(templateId)]"
|
||||
let jobs = HermesFileService(context: context).loadCronJobs()
|
||||
return jobs
|
||||
.filter { $0.name.hasPrefix(prefix) }
|
||||
.map { job in
|
||||
let scheduleDesc = job.schedule.display
|
||||
?? job.schedule.expression
|
||||
?? job.schedule.kind
|
||||
let pausedDesc = job.enabled ? "enabled" : "paused"
|
||||
return "`\(job.name)` — schedule `\(scheduleDesc)`, currently \(pausedDesc)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum ProjectAgentContextError: Error {
|
||||
case encodingFailed
|
||||
}
|
||||
|
||||
// MARK: - String helpers (file-scoped)
|
||||
|
||||
private extension String {
|
||||
/// Drop trailing newlines + CRs but preserve other trailing
|
||||
/// whitespace (tabs, non-breaking spaces) that might be
|
||||
/// meaningful in some edge case.
|
||||
func trimmingRightNewlines() -> String {
|
||||
var result = self
|
||||
while let last = result.last, last.isNewline {
|
||||
result.removeLast()
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/// Symmetric counterpart: strip leading newlines / CRs.
|
||||
func trimmingLeftNewlines() -> String {
|
||||
var result = self
|
||||
while let first = result.first, first.isNewline {
|
||||
result.removeFirst()
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
@@ -317,6 +317,25 @@ final class ChatViewModel {
|
||||
self.acpClient = client
|
||||
let attribution = SessionAttributionService(context: context)
|
||||
|
||||
// If the caller passed a project path, refresh the Scarf-
|
||||
// managed block in the project's AGENTS.md BEFORE starting
|
||||
// ACP — Hermes auto-reads AGENTS.md at session boot, so the
|
||||
// block has to land on disk first. Non-blocking on failure:
|
||||
// we log and proceed without the block. Safe on bare
|
||||
// projects (creates AGENTS.md with just the block); safe on
|
||||
// template-installed projects (splices the block into
|
||||
// existing AGENTS.md without touching template content).
|
||||
if let projectPath {
|
||||
let registry = ProjectDashboardService(context: context).loadRegistry()
|
||||
if let project = registry.projects.first(where: { $0.path == projectPath }) {
|
||||
do {
|
||||
try ProjectAgentContextService(context: context).refresh(for: project)
|
||||
} catch {
|
||||
logger.warning("couldn't refresh project context block for \(project.name): \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Task { @MainActor in
|
||||
do {
|
||||
// Start ACP process and event loop FIRST
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
import Testing
|
||||
import Foundation
|
||||
@testable import scarf
|
||||
|
||||
/// Exercises the Scarf-managed AGENTS.md marker block logic added in
|
||||
/// v2.3. Tests operate on isolated temp directories — no dependency
|
||||
/// on ~/.hermes contents, no cross-suite lock needed.
|
||||
@Suite struct ProjectAgentContextServiceTests {
|
||||
|
||||
// MARK: - applyBlock pure-text transform
|
||||
|
||||
@Test func applyBlockPrependsWhenNoMarkersPresent() {
|
||||
let existing = "# My Template\n\nSome instructions.\n"
|
||||
let block = "<!-- scarf-project:begin -->\nhello\n<!-- scarf-project:end -->"
|
||||
let result = ProjectAgentContextService.applyBlock(block: block, to: existing)
|
||||
#expect(result.hasPrefix("<!-- scarf-project:begin -->"))
|
||||
#expect(result.contains("<!-- scarf-project:end -->"))
|
||||
#expect(result.contains("# My Template"))
|
||||
#expect(result.contains("Some instructions."))
|
||||
// Exactly one blank line between block and original content.
|
||||
#expect(result.contains("<!-- scarf-project:end -->\n\n# My Template"))
|
||||
}
|
||||
|
||||
@Test func applyBlockWritesFreshFileWhenEmpty() {
|
||||
let block = "<!-- scarf-project:begin -->\nhello\n<!-- scarf-project:end -->"
|
||||
let result = ProjectAgentContextService.applyBlock(block: block, to: "")
|
||||
// Empty input → just the block + trailing newline; no weird
|
||||
// leading whitespace.
|
||||
#expect(result == block + "\n")
|
||||
}
|
||||
|
||||
@Test func applyBlockReplacesExistingMarkerRegion() {
|
||||
let existing = """
|
||||
<!-- scarf-project:begin -->
|
||||
old content line 1
|
||||
old content line 2
|
||||
<!-- scarf-project:end -->
|
||||
|
||||
# Template docs preserved
|
||||
|
||||
Template behavior.
|
||||
"""
|
||||
let newBlock = "<!-- scarf-project:begin -->\nfresh content\n<!-- scarf-project:end -->"
|
||||
let result = ProjectAgentContextService.applyBlock(block: newBlock, to: existing)
|
||||
|
||||
#expect(result.contains("fresh content"))
|
||||
// Old content is gone.
|
||||
#expect(!result.contains("old content line 1"))
|
||||
#expect(!result.contains("old content line 2"))
|
||||
// Template content outside markers is preserved.
|
||||
#expect(result.contains("# Template docs preserved"))
|
||||
#expect(result.contains("Template behavior."))
|
||||
}
|
||||
|
||||
@Test func applyBlockIsIdempotent() {
|
||||
let existing = "# Project\n\nContent.\n"
|
||||
let block = "<!-- scarf-project:begin -->\nv1\n<!-- scarf-project:end -->"
|
||||
let once = ProjectAgentContextService.applyBlock(block: block, to: existing)
|
||||
let twice = ProjectAgentContextService.applyBlock(block: block, to: once)
|
||||
#expect(once == twice)
|
||||
}
|
||||
|
||||
@Test func applyBlockOrphanedBeginMarkerFallsBackToPrepend() {
|
||||
// Stray begin with no end: treat as "no well-formed block,"
|
||||
// prepend. Leaves the orphan in place — it was probably
|
||||
// hand-typed, not a corrupt Scarf write. Conservative.
|
||||
let existing = "<!-- scarf-project:begin -->\nstray text with no end marker\n"
|
||||
let block = "<!-- scarf-project:begin -->\nnew\n<!-- scarf-project:end -->"
|
||||
let result = ProjectAgentContextService.applyBlock(block: block, to: existing)
|
||||
#expect(result.hasPrefix("<!-- scarf-project:begin -->\nnew\n<!-- scarf-project:end -->"))
|
||||
#expect(result.contains("stray text with no end marker"))
|
||||
}
|
||||
|
||||
// MARK: - renderBlock content
|
||||
|
||||
@Test func renderBlockIncludesProjectIdentity() throws {
|
||||
let dir = try Self.makeTempDir()
|
||||
defer { try? FileManager.default.removeItem(atPath: dir) }
|
||||
let project = ProjectEntry(name: "My Project", path: dir)
|
||||
let svc = ProjectAgentContextService(context: .local)
|
||||
let block = svc.renderBlock(for: project)
|
||||
|
||||
#expect(block.contains(ProjectAgentContextService.beginMarker))
|
||||
#expect(block.contains(ProjectAgentContextService.endMarker))
|
||||
#expect(block.contains("\"My Project\""))
|
||||
#expect(block.contains(dir))
|
||||
#expect(block.contains("dashboard.json"))
|
||||
}
|
||||
|
||||
@Test func renderBlockOmitsTemplateSectionForBareProject() throws {
|
||||
let dir = try Self.makeTempDir()
|
||||
defer { try? FileManager.default.removeItem(atPath: dir) }
|
||||
let project = ProjectEntry(name: "Bare", path: dir)
|
||||
let svc = ProjectAgentContextService(context: .local)
|
||||
let block = svc.renderBlock(for: project)
|
||||
#expect(!block.contains("**Template:**"))
|
||||
#expect(block.contains("**Configuration fields:** (none)"))
|
||||
}
|
||||
|
||||
@Test func renderBlockIncludesTemplateWhenManifestPresent() throws {
|
||||
let dir = try Self.makeTempDir()
|
||||
defer { try? FileManager.default.removeItem(atPath: dir) }
|
||||
let scarfDir = dir + "/.scarf"
|
||||
try FileManager.default.createDirectory(atPath: scarfDir, withIntermediateDirectories: true)
|
||||
// Minimal valid v1 manifest — no config schema.
|
||||
let manifest = """
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"id": "author/example",
|
||||
"name": "Example",
|
||||
"version": "1.2.3",
|
||||
"description": "…",
|
||||
"contents": { "dashboard": true, "agentsMd": true }
|
||||
}
|
||||
"""
|
||||
try manifest.data(using: .utf8)!.write(to: URL(fileURLWithPath: scarfDir + "/manifest.json"))
|
||||
|
||||
let project = ProjectEntry(name: "Example", path: dir)
|
||||
let svc = ProjectAgentContextService(context: .local)
|
||||
let block = svc.renderBlock(for: project)
|
||||
#expect(block.contains("**Template:** `author/example` v1.2.3"))
|
||||
}
|
||||
|
||||
@Test func renderBlockListsConfigFieldNamesNotValues() throws {
|
||||
let dir = try Self.makeTempDir()
|
||||
defer { try? FileManager.default.removeItem(atPath: dir) }
|
||||
let scarfDir = dir + "/.scarf"
|
||||
try FileManager.default.createDirectory(atPath: scarfDir, withIntermediateDirectories: true)
|
||||
// Schema-bearing manifest with one string field and one secret.
|
||||
let manifest = """
|
||||
{
|
||||
"schemaVersion": 2,
|
||||
"id": "x/y",
|
||||
"name": "Y",
|
||||
"version": "1.0.0",
|
||||
"description": "…",
|
||||
"contents": { "dashboard": true, "agentsMd": true, "config": 2 },
|
||||
"config": {
|
||||
"schema": [
|
||||
{ "key": "site_url", "type": "string", "label": "Site URL", "required": true },
|
||||
{ "key": "api_token", "type": "secret", "label": "API Token", "required": true }
|
||||
]
|
||||
}
|
||||
}
|
||||
"""
|
||||
try manifest.data(using: .utf8)!.write(to: URL(fileURLWithPath: scarfDir + "/manifest.json"))
|
||||
|
||||
// A config.json with a "secret" VALUE — the block must NOT
|
||||
// echo this value. If it does, secrets leak into an agent-
|
||||
// readable file, which is exactly the thing to avoid.
|
||||
let configJSON = """
|
||||
{
|
||||
"schemaVersion": 2,
|
||||
"templateId": "x/y",
|
||||
"values": {
|
||||
"site_url": { "type": "string", "value": "https://example.com" },
|
||||
"api_token": { "type": "keychainRef", "uri": "keychain://com.scarf.template.x-y/api_token:abc123" }
|
||||
},
|
||||
"updatedAt": "2026-04-24T00:00:00Z"
|
||||
}
|
||||
"""
|
||||
try configJSON.data(using: .utf8)!.write(to: URL(fileURLWithPath: scarfDir + "/config.json"))
|
||||
|
||||
let project = ProjectEntry(name: "Y", path: dir)
|
||||
let svc = ProjectAgentContextService(context: .local)
|
||||
let block = svc.renderBlock(for: project)
|
||||
|
||||
// Field names present with type hints.
|
||||
#expect(block.contains("`site_url`"))
|
||||
#expect(block.contains("`api_token`"))
|
||||
#expect(block.contains("(secret — name only, value stored in Keychain)"))
|
||||
// CRITICAL: no VALUES appear — not the site URL, not the
|
||||
// keychain ref. The block is safe to drop into an agent
|
||||
// context.
|
||||
#expect(!block.contains("https://example.com"))
|
||||
#expect(!block.contains("keychain://"))
|
||||
#expect(!block.contains("abc123"))
|
||||
}
|
||||
|
||||
// MARK: - refresh end-to-end (temp dir on local filesystem)
|
||||
|
||||
@Test func refreshCreatesAGENTSMdWhenMissing() throws {
|
||||
let dir = try Self.makeTempDir()
|
||||
defer { try? FileManager.default.removeItem(atPath: dir) }
|
||||
let project = ProjectEntry(name: "Fresh", path: dir)
|
||||
|
||||
try ProjectAgentContextService(context: .local).refresh(for: project)
|
||||
|
||||
let agentsMd = dir + "/AGENTS.md"
|
||||
#expect(FileManager.default.fileExists(atPath: agentsMd))
|
||||
let contents = try String(contentsOf: URL(fileURLWithPath: agentsMd))
|
||||
#expect(contents.contains(ProjectAgentContextService.beginMarker))
|
||||
#expect(contents.contains(ProjectAgentContextService.endMarker))
|
||||
#expect(contents.contains("\"Fresh\""))
|
||||
}
|
||||
|
||||
@Test func refreshPreservesUserContentBelow() throws {
|
||||
let dir = try Self.makeTempDir()
|
||||
defer { try? FileManager.default.removeItem(atPath: dir) }
|
||||
let agentsMd = dir + "/AGENTS.md"
|
||||
let userContent = "# Template\n\nDo the thing.\n"
|
||||
try userContent.data(using: .utf8)!.write(to: URL(fileURLWithPath: agentsMd))
|
||||
|
||||
let project = ProjectEntry(name: "Preserved", path: dir)
|
||||
try ProjectAgentContextService(context: .local).refresh(for: project)
|
||||
|
||||
let after = try String(contentsOf: URL(fileURLWithPath: agentsMd))
|
||||
#expect(after.contains(ProjectAgentContextService.beginMarker))
|
||||
#expect(after.contains("# Template"))
|
||||
#expect(after.contains("Do the thing."))
|
||||
// Block goes FIRST; user content follows.
|
||||
let beginIdx = after.range(of: ProjectAgentContextService.beginMarker)!.lowerBound
|
||||
let userIdx = after.range(of: "# Template")!.lowerBound
|
||||
#expect(beginIdx < userIdx)
|
||||
}
|
||||
|
||||
@Test func refreshIsFullyIdempotent() throws {
|
||||
let dir = try Self.makeTempDir()
|
||||
defer { try? FileManager.default.removeItem(atPath: dir) }
|
||||
let project = ProjectEntry(name: "Twice", path: dir)
|
||||
let svc = ProjectAgentContextService(context: .local)
|
||||
try svc.refresh(for: project)
|
||||
let first = try Data(contentsOf: URL(fileURLWithPath: dir + "/AGENTS.md"))
|
||||
try svc.refresh(for: project)
|
||||
let second = try Data(contentsOf: URL(fileURLWithPath: dir + "/AGENTS.md"))
|
||||
#expect(first == second)
|
||||
}
|
||||
|
||||
@Test func refreshRewritesStaleBlock() throws {
|
||||
let dir = try Self.makeTempDir()
|
||||
defer { try? FileManager.default.removeItem(atPath: dir) }
|
||||
let agentsMd = dir + "/AGENTS.md"
|
||||
// Pre-seed a stale Scarf block with a different project name
|
||||
// and a user section below.
|
||||
let seed = """
|
||||
<!-- scarf-project:begin -->
|
||||
Old stale content — project was called "Something Else".
|
||||
<!-- scarf-project:end -->
|
||||
|
||||
# Template
|
||||
"""
|
||||
try seed.data(using: .utf8)!.write(to: URL(fileURLWithPath: agentsMd))
|
||||
|
||||
let project = ProjectEntry(name: "Current Name", path: dir)
|
||||
try ProjectAgentContextService(context: .local).refresh(for: project)
|
||||
|
||||
let after = try String(contentsOf: URL(fileURLWithPath: agentsMd))
|
||||
#expect(after.contains("\"Current Name\""))
|
||||
#expect(!after.contains("Something Else"))
|
||||
#expect(after.contains("# Template"))
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
nonisolated static func makeTempDir() throws -> String {
|
||||
let dir = NSTemporaryDirectory() + "scarf-project-context-test-" + UUID().uuidString
|
||||
try FileManager.default.createDirectory(atPath: dir, withIntermediateDirectories: true)
|
||||
return dir
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user