From 75b3e97cdb95162a57a9b170e2a1f114e5a08ab3 Mon Sep 17 00:00:00 2001 From: Alan Wizemann Date: Thu, 23 Apr 2026 15:11:19 +0200 Subject: [PATCH] feat(templates): install-time {{PROJECT_DIR}} substitution in cron prompts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../Services/ProjectTemplateInstaller.swift | 41 ++++++++++++- scarf/scarfTests/ProjectTemplateTests.swift | 58 +++++++++++++++++- templates/CONTRIBUTING.md | 5 +- .../site-status-checker.scarftemplate | Bin 6880 -> 7197 bytes .../site-status-checker/staging/AGENTS.md | 2 +- .../staging/cron/jobs.json | 2 +- templates/catalog.json | 4 +- 7 files changed, 103 insertions(+), 9 deletions(-) diff --git a/scarf/scarf/Core/Services/ProjectTemplateInstaller.swift b/scarf/scarf/Core/Services/ProjectTemplateInstaller.swift index c96e564..c39cf38 100644 --- a/scarf/scarf/Core/Services/ProjectTemplateInstaller.swift +++ b/scarf/scarf/Core/Services/ProjectTemplateInstaller.swift @@ -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( diff --git a/scarf/scarfTests/ProjectTemplateTests.swift b/scarf/scarfTests/ProjectTemplateTests.swift index c9dae32..2c871e9 100644 --- a/scarf/scarfTests/ProjectTemplateTests.swift +++ b/scarf/scarfTests/ProjectTemplateTests.swift @@ -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 diff --git a/templates/CONTRIBUTING.md b/templates/CONTRIBUTING.md index 49011bf..0bdc8da 100644 --- a/templates/CONTRIBUTING.md +++ b/templates/CONTRIBUTING.md @@ -73,7 +73,10 @@ Optional: - `instructions/CLAUDE.md`, `instructions/GEMINI.md`, `instructions/.cursorrules`, `instructions/.github/copilot-instructions.md` — agent-specific shims beyond `AGENTS.md`. - `skills//SKILL.md` — shipped skills, installed into `~/.hermes/skills/templates//` 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. ### 4. Build the bundle diff --git a/templates/awizemann/site-status-checker/site-status-checker.scarftemplate b/templates/awizemann/site-status-checker/site-status-checker.scarftemplate index 1846f604730bad6fe8772662370d946b09786c9d..42431a19dab9febb4653b0006ccd12b3117149b3 100644 GIT binary patch delta 3277 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 delta 2954 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