mirror of
https://github.com/awizemann/scarf.git
synced 2026-05-10 10:36:35 +00:00
feat(templates): install-time {{PROJECT_DIR}} substitution in cron prompts
Hermes doesn't set a working directory when firing cron jobs, so any
relative path in a template's cron prompt (`.scarf/config.json`,
`status-log.md`, etc.) resolves against whatever dir Hermes happens
to be in — NOT the installed project. Practical effect: site-status-
checker's cron job fires, agent runs with relative paths, finds
nothing to read, silently bails. User sees "Run now" complete with
zero output and nothing updated on disk.
Fix: the installer now substitutes template-author placeholders in
cron prompts at install time, before calling `hermes cron create`.
The registered cron job carries a fully-qualified, CWD-independent
prompt.
Supported tokens (deliberately few — each is part of the template
format contract from now on):
- `{{PROJECT_DIR}}` — absolute path of the installed project dir.
The one that was motivating this fix; required for any cron prompt
that reads or writes project files.
- `{{TEMPLATE_ID}}` — the `owner/name` from the manifest, for
templates that want to tag delivery payloads or log lines.
- `{{TEMPLATE_SLUG}}` — the sanitised slug used by the installer for
dir name + skills namespace, for templates that want to reference
their skills install path.
Implemented as a static `ProjectTemplateInstaller.substituteCronTokens`
so it's testable as a pure function. Unsupported placeholders pass
through verbatim — template authors notice in testing that their
token didn't get replaced and either use a supported one or file
a request.
Site Status Checker v1.1.0 updated to use the tokens:
- cron/jobs.json prompt now opens with "Run the site status check
for the Scarf project at {{PROJECT_DIR}}" and references
{{PROJECT_DIR}}/.scarf/config.json, {{PROJECT_DIR}}/status-log.md,
and {{PROJECT_DIR}}/.scarf/dashboard.json explicitly.
- AGENTS.md gains a note explaining that the cron-registered prompt
carries absolute paths (installer substitutes at install time),
while interactive-chat agents can keep using relative paths.
- bundle rebuilt, catalog regenerated.
templates/CONTRIBUTING.md documents the three supported tokens under
the cron/jobs.json bullet so future authors don't have to discover
this by hitting the same CWD bug.
Tests:
- ProjectTemplateExampleTemplateTests.siteStatusCheckerParsesAndPlans
extended to assert the bundled prompt contains {{PROJECT_DIR}}
UNRESOLVED. If someone accidentally bakes an absolute path into
the template (their install dir), every user of that template
would get the wrong path — this test catches that.
- Four new substitution tests in ProjectTemplateInstallerTests:
resolves PROJECT_DIR / resolves ID + SLUG / leaves unknown tokens
untouched / substitutes repeated occurrences. All go through the
static helper directly; no install round-trip needed.
57/57 Swift tests + 24/24 Python tests pass. Catalog check clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -179,7 +179,17 @@ struct ProjectTemplateInstaller: Sendable {
|
||||
}
|
||||
args.append(job.schedule)
|
||||
if let prompt = job.prompt, !prompt.isEmpty {
|
||||
args.append(prompt)
|
||||
// Substitute template-author tokens with install-time
|
||||
// values. Hermes doesn't set a CWD for cron runs — when
|
||||
// the agent fires the prompt, any relative path
|
||||
// (`.scarf/config.json`, `status-log.md`, etc.) resolves
|
||||
// against the agent's own dir, not the project. Templates
|
||||
// use `{{PROJECT_DIR}}` as a placeholder for the absolute
|
||||
// path; we swap in the real project dir here so the
|
||||
// registered cron job carries a fully-qualified prompt
|
||||
// that works regardless of CWD.
|
||||
let resolvedPrompt = Self.substituteCronTokens(prompt, plan: plan)
|
||||
args.append(resolvedPrompt)
|
||||
}
|
||||
|
||||
let (output, exit) = context.runHermes(args)
|
||||
@@ -221,6 +231,35 @@ struct ProjectTemplateInstaller: Sendable {
|
||||
return entry
|
||||
}
|
||||
|
||||
// MARK: - Token substitution (install-time placeholder resolution)
|
||||
|
||||
/// Supported placeholders for template-author prompts. Keep the set
|
||||
/// intentionally small — every token here becomes a load-bearing
|
||||
/// part of the template format that we can't rename without
|
||||
/// breaking existing bundles.
|
||||
///
|
||||
/// - `{{PROJECT_DIR}}`: absolute path of the newly-created project
|
||||
/// directory. Required for cron prompts because Hermes doesn't
|
||||
/// establish a CWD when firing cron jobs; relative paths would
|
||||
/// resolve against whatever dir Hermes happens to be in.
|
||||
///
|
||||
/// - `{{TEMPLATE_ID}}`: the `owner/name` id from the manifest.
|
||||
/// Less load-bearing; occasionally useful for tagging or
|
||||
/// delivery targets that reference the template.
|
||||
///
|
||||
/// - `{{TEMPLATE_SLUG}}`: the sanitised slug the installer used
|
||||
/// for the skills namespace and project dir name.
|
||||
nonisolated static func substituteCronTokens(
|
||||
_ prompt: String,
|
||||
plan: TemplateInstallPlan
|
||||
) -> String {
|
||||
var out = prompt
|
||||
out = out.replacingOccurrences(of: "{{PROJECT_DIR}}", with: plan.projectDir)
|
||||
out = out.replacingOccurrences(of: "{{TEMPLATE_ID}}", with: plan.manifest.id)
|
||||
out = out.replacingOccurrences(of: "{{TEMPLATE_SLUG}}", with: plan.manifest.slug)
|
||||
return out
|
||||
}
|
||||
|
||||
// MARK: - Lock file
|
||||
|
||||
nonisolated private func writeLockFile(
|
||||
|
||||
@@ -382,6 +382,52 @@ final class TestRegistryLock: @unchecked Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Cron prompt token substitution
|
||||
|
||||
@Test func substituteCronTokensResolvesProjectDir() throws {
|
||||
let plan = try TemplateInstallerViewModelTests.makePlanWithConfigSchema()
|
||||
let raw = "Read {{PROJECT_DIR}}/.scarf/config.json"
|
||||
let resolved = ProjectTemplateInstaller.substituteCronTokens(raw, plan: plan)
|
||||
#expect(resolved == "Read \(plan.projectDir)/.scarf/config.json")
|
||||
// Original placeholder must be fully replaced — a lingering
|
||||
// {{PROJECT_DIR}} would leave the cron job trying to read a
|
||||
// literal file named `{{PROJECT_DIR}}` which doesn't exist.
|
||||
#expect(resolved.contains("{{PROJECT_DIR}}") == false)
|
||||
}
|
||||
|
||||
@Test func substituteCronTokensResolvesIdAndSlug() throws {
|
||||
let plan = try TemplateInstallerViewModelTests.makePlanWithConfigSchema()
|
||||
let raw = "Log as {{TEMPLATE_ID}} (slug {{TEMPLATE_SLUG}})"
|
||||
let resolved = ProjectTemplateInstaller.substituteCronTokens(raw, plan: plan)
|
||||
#expect(resolved.contains(plan.manifest.id))
|
||||
#expect(resolved.contains(plan.manifest.slug))
|
||||
#expect(resolved.contains("{{TEMPLATE_ID}}") == false)
|
||||
#expect(resolved.contains("{{TEMPLATE_SLUG}}") == false)
|
||||
}
|
||||
|
||||
@Test func substituteCronTokensLeavesUnknownTokensUntouched() throws {
|
||||
let plan = try TemplateInstallerViewModelTests.makePlanWithConfigSchema()
|
||||
let raw = "{{PROJECT_DIR}} but keep {{UNSUPPORTED}} literal"
|
||||
let resolved = ProjectTemplateInstaller.substituteCronTokens(raw, plan: plan)
|
||||
#expect(resolved.contains(plan.projectDir))
|
||||
// Unsupported placeholders pass through verbatim — template
|
||||
// authors will notice in testing that their token didn't get
|
||||
// replaced and either use a supported one or request a new one.
|
||||
#expect(resolved.contains("{{UNSUPPORTED}}"))
|
||||
}
|
||||
|
||||
@Test func substituteCronTokensRepeatsWithinString() throws {
|
||||
let plan = try TemplateInstallerViewModelTests.makePlanWithConfigSchema()
|
||||
let raw = "Read {{PROJECT_DIR}}/a and write {{PROJECT_DIR}}/b"
|
||||
let resolved = ProjectTemplateInstaller.substituteCronTokens(raw, plan: plan)
|
||||
// Both occurrences should be replaced — not just the first.
|
||||
// A single-replace bug here would leave the second relative,
|
||||
// causing the same CWD issue this whole feature was meant to
|
||||
// fix.
|
||||
let count = resolved.components(separatedBy: plan.projectDir).count - 1
|
||||
#expect(count == 2)
|
||||
}
|
||||
|
||||
// MARK: - Registry snapshot helpers
|
||||
|
||||
/// Read the raw bytes of the current projects.json so we can restore
|
||||
@@ -986,14 +1032,20 @@ final class TestRegistryLock: @unchecked Sendable {
|
||||
#expect(statTitles.contains("Last Checked"))
|
||||
|
||||
// Cron prompt references .scarf/config.json (where values.sites
|
||||
// + values.timeout_seconds live) and the dashboard/log it writes.
|
||||
// If either stops being referenced, the cron wouldn't know which
|
||||
// data to read or where to write results.
|
||||
// + values.timeout_seconds live), the dashboard/log it writes,
|
||||
// and the {{PROJECT_DIR}} placeholder the installer resolves
|
||||
// at install time. If either stops being referenced, the cron
|
||||
// wouldn't know which data to read or where to write results.
|
||||
let cronPrompt = inspection.cronJobs.first?.prompt ?? ""
|
||||
#expect(cronPrompt.contains("config.json"))
|
||||
#expect(cronPrompt.contains("values.sites"))
|
||||
#expect(cronPrompt.contains("dashboard.json"))
|
||||
#expect(cronPrompt.contains("status-log.md"))
|
||||
// {{PROJECT_DIR}} must remain UNRESOLVED in the bundle — the
|
||||
// installer substitutes it at install time. If someone
|
||||
// accidentally baked an absolute path into the template, that
|
||||
// path would follow every install to every user's machine.
|
||||
#expect(cronPrompt.contains("{{PROJECT_DIR}}"))
|
||||
}
|
||||
|
||||
/// Resolve the example bundle path robustly. Unit-test working dirs
|
||||
|
||||
Reference in New Issue
Block a user