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 1846f60..42431a1 100644 Binary files a/templates/awizemann/site-status-checker/site-status-checker.scarftemplate and b/templates/awizemann/site-status-checker/site-status-checker.scarftemplate differ diff --git a/templates/awizemann/site-status-checker/staging/AGENTS.md b/templates/awizemann/site-status-checker/staging/AGENTS.md index a827465..693e72f 100644 --- a/templates/awizemann/site-status-checker/staging/AGENTS.md +++ b/templates/awizemann/site-status-checker/staging/AGENTS.md @@ -37,7 +37,7 @@ No `sites.txt` anymore — sites come from `.scarf/config.json`. ## 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. 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**. diff --git a/templates/awizemann/site-status-checker/staging/cron/jobs.json b/templates/awizemann/site-status-checker/staging/cron/jobs.json index cad1290..9ae3cd2 100644 --- a/templates/awizemann/site-status-checker/staging/cron/jobs.json +++ b/templates/awizemann/site-status-checker/staging/cron/jobs.json @@ -2,6 +2,6 @@ { "name": "Check site status", "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'." } ] diff --git a/templates/catalog.json b/templates/catalog.json index 59ca326..7334a81 100644 --- a/templates/catalog.json +++ b/templates/catalog.json @@ -7,8 +7,8 @@ "name": "Alan Wizemann", "url": "https://github.com/awizemann/scarf" }, - "bundleSha256": "3274964418b7cb1e4df2980936e16f9afdf3d4e90c91ae76dead6e5065306818", - "bundleSize": 6880, + "bundleSha256": "a3c3a3b1cd1799443fa32ac5f1f643bf28b2e1b30c1b7786a1fa93ef227b0c7e", + "bundleSize": 7197, "category": "monitoring", "config": { "modelRecommendation": {