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:
Alan Wizemann
2026-04-23 15:11:19 +02:00
parent 60820f9cfb
commit 75b3e97cdb
7 changed files with 103 additions and 9 deletions
@@ -179,7 +179,17 @@ struct ProjectTemplateInstaller: Sendable {
} }
args.append(job.schedule) args.append(job.schedule)
if let prompt = job.prompt, !prompt.isEmpty { 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) let (output, exit) = context.runHermes(args)
@@ -221,6 +231,35 @@ struct ProjectTemplateInstaller: Sendable {
return entry 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 // MARK: - Lock file
nonisolated private func writeLockFile( nonisolated private func writeLockFile(
+55 -3
View File
@@ -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 // MARK: - Registry snapshot helpers
/// Read the raw bytes of the current projects.json so we can restore /// 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")) #expect(statTitles.contains("Last Checked"))
// Cron prompt references .scarf/config.json (where values.sites // Cron prompt references .scarf/config.json (where values.sites
// + values.timeout_seconds live) and the dashboard/log it writes. // + values.timeout_seconds live), the dashboard/log it writes,
// If either stops being referenced, the cron wouldn't know which // and the {{PROJECT_DIR}} placeholder the installer resolves
// data to read or where to write results. // 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 ?? "" let cronPrompt = inspection.cronJobs.first?.prompt ?? ""
#expect(cronPrompt.contains("config.json")) #expect(cronPrompt.contains("config.json"))
#expect(cronPrompt.contains("values.sites")) #expect(cronPrompt.contains("values.sites"))
#expect(cronPrompt.contains("dashboard.json")) #expect(cronPrompt.contains("dashboard.json"))
#expect(cronPrompt.contains("status-log.md")) #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 /// Resolve the example bundle path robustly. Unit-test working dirs
+4 -1
View File
@@ -73,7 +73,10 @@ Optional:
- `instructions/CLAUDE.md`, `instructions/GEMINI.md`, `instructions/.cursorrules`, `instructions/.github/copilot-instructions.md` — agent-specific shims beyond `AGENTS.md`. - `instructions/CLAUDE.md`, `instructions/GEMINI.md`, `instructions/.cursorrules`, `instructions/.github/copilot-instructions.md` — agent-specific shims beyond `AGENTS.md`.
- `skills/<skill-name>/SKILL.md` — shipped skills, installed into `~/.hermes/skills/templates/<slug>/` on the user's side. - `skills/<skill-name>/SKILL.md` — shipped skills, installed into `~/.hermes/skills/templates/<slug>/` on the user's side.
- `cron/jobs.json` — an array of cron job specs. Each has `name`, `schedule` (e.g. `0 9 * * *` or `every 2h`), `prompt`, optional `deliver`, `skills[]`, `repeat`. - `cron/jobs.json` — an array of cron job specs. Each has `name`, `schedule` (e.g. `0 9 * * *` or `every 2h`), `prompt`, optional `deliver`, `skills[]`, `repeat`. The prompt may use these install-time placeholders — the installer substitutes them before registering the cron job with Hermes:
- `{{PROJECT_DIR}}` — absolute path of the newly-installed project dir. **Required for any cron prompt that reads or writes project files** — Hermes doesn't set a CWD when firing cron jobs, so relative paths (`.scarf/config.json`) won't resolve. Write `{{PROJECT_DIR}}/.scarf/config.json` instead.
- `{{TEMPLATE_ID}}` — the `owner/name` id from your manifest.
- `{{TEMPLATE_SLUG}}` — the sanitised slug used for the project dir name + skills namespace.
- `memory/append.md` — markdown appended to the user's `MEMORY.md` between template-specific markers. Use sparingly — most templates don't need this. - `memory/append.md` — markdown appended to the user's `MEMORY.md` between template-specific markers. Use sparingly — most templates don't need this.
### 4. Build the bundle ### 4. Build the bundle
@@ -37,7 +37,7 @@ No `sites.txt` anymore — sites come from `.scarf/config.json`.
## What to do when the cron job fires ## What to do when the cron job fires
The cron job runs this project's "Check site status" prompt. When invoked: The cron prompt Scarf registers for this project carries **absolute paths** (the installer substitutes `{{PROJECT_DIR}}` at install time) — you don't need to figure out the project's location yourself. Use whatever absolute paths appear in the prompt you received; if you're working in the project's interactive chat instead, the paths below are relative to the project root.
1. Read `.scarf/config.json`. Extract `values.sites` (array of URLs) and `values.timeout_seconds` (number). If `sites` is empty or missing, write a `status-log.md` entry noting "no sites configured — open Configuration to add some" and leave the dashboard untouched. 1. Read `.scarf/config.json`. Extract `values.sites` (array of URLs) and `values.timeout_seconds` (number). If `sites` is empty or missing, write a `status-log.md` entry noting "no sites configured — open Configuration to add some" and leave the dashboard untouched.
2. For each URL in `sites`, make an HTTP GET request with the configured timeout. Follow up to 3 redirects. Treat any 2xx or 3xx response as **up**, anything else (including timeouts and DNS failures) as **down**. 2. For each URL in `sites`, make an HTTP GET request with the configured timeout. Follow up to 3 redirects. Treat any 2xx or 3xx response as **up**, anything else (including timeouts and DNS failures) as **down**.
@@ -2,6 +2,6 @@
{ {
"name": "Check site status", "name": "Check site status",
"schedule": "0 9 * * *", "schedule": "0 9 * * *",
"prompt": "Run the site status check for this project. Follow the instructions in AGENTS.md: read .scarf/config.json to get values.sites (the URL list) and values.timeout_seconds, HTTP GET each URL with the configured timeout, prepend a results section to status-log.md (creating it with the stub header if it doesn't exist yet), and update the three stat widgets plus the Watched Sites list items in .scarf/dashboard.json. When done, reply with a one-line summary like '3 up, 1 down — example.com timed out'." "prompt": "Run the site status check for the Scarf project at {{PROJECT_DIR}}. Read {{PROJECT_DIR}}/.scarf/config.json to get `values.sites` (the URL list) and `values.timeout_seconds` (the per-URL HTTP timeout). HTTP GET each URL with that timeout, following up to 3 redirects; treat 2xx/3xx as up and anything else (including timeouts and DNS failures) as down. Prepend a new timestamped results section to {{PROJECT_DIR}}/status-log.md create the file with a one-line header if it doesn't exist yet. Update {{PROJECT_DIR}}/.scarf/dashboard.json: set the Sites Up / Sites Down / Last Checked stat widgets' `value` fields, and replace the 'Watched Sites' list widget's `items` array with one entry per URL (text = URL, status = \"up\" or \"down\"). Preserve every other field in dashboard.json as-is. Reply with a one-line summary like '3 up, 1 down — example.com timed out'."
} }
] ]
+2 -2
View File
@@ -7,8 +7,8 @@
"name": "Alan Wizemann", "name": "Alan Wizemann",
"url": "https://github.com/awizemann/scarf" "url": "https://github.com/awizemann/scarf"
}, },
"bundleSha256": "3274964418b7cb1e4df2980936e16f9afdf3d4e90c91ae76dead6e5065306818", "bundleSha256": "a3c3a3b1cd1799443fa32ac5f1f643bf28b2e1b30c1b7786a1fa93ef227b0c7e",
"bundleSize": 6880, "bundleSize": 7197,
"category": "monitoring", "category": "monitoring",
"config": { "config": {
"modelRecommendation": { "modelRecommendation": {