feat(slash-commands): .scarftemplate format extension + catalog validator (Phase 1.8-1.9)

Slash commands now travel with .scarftemplate bundles. Schema bumps
to v3 when a manifest declares contents.slashCommands; v1/v2 bundles
keep parsing unchanged.

Swift side:
- TemplateContents gains slashCommands: [String]? — names only.
  Bundle layout: slash-commands/<name>.md at the root.
- ProjectTemplateService.buildInstallPlan copies each claimed name
  into <projectDir>/.scarf/slash-commands/<name>.md.
- ProjectTemplateService.verifyClaims cross-checks: each name must
  pass ProjectSlashCommand.validateName, the file must exist, and
  the bundle can't contain unclaimed slash-commands/ files.
- TemplateLock gains slashCommandFiles: [String]? (relative to
  project root). The uninstaller's existing tracked-file logic
  removes them; user-authored slash commands in the same dir
  survive (they're not in the lock).
- ProjectTemplateExporter scans <project>/.scarf/slash-commands/ on
  export and copies each .md into the bundle root, populating the
  manifest contents claim. SchemaVersion bumps to 3 only when slash
  commands are present.

Python catalog validator (tools/build-catalog.py):
- SUPPORTED_SCHEMA_VERSIONS gains 3.
- SLASH_COMMAND_NAME_RE mirrors the Swift validation pattern.
- _validate_contents_claim picks up slashCommands: rejects malformed
  names, missing files, and unclaimed extras with the same error
  shapes the Swift verifier uses.

Tests:
- 4 new test_build_catalog cases. 28/28 catalog tests pass.
- ProjectTemplateTests literal updated for the new TemplateContents
  field.

Verified: Mac + iOS builds succeed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Alan Wizemann
2026-04-25 08:51:56 +02:00
parent 7f5ff1946e
commit b247942e1f
7 changed files with 274 additions and 9 deletions
@@ -63,6 +63,13 @@ struct TemplateContents: Codable, Sendable, Equatable {
/// validator so a bundle can't hide a schema from the preview.
/// `nil` or `0` means schema-less (v1-compatible behaviour).
let config: Int?
/// Names of project-scoped slash commands the template ships (added
/// in manifest schemaVersion 3). Each name `<n>` must correspond to
/// a `slash-commands/<n>.md` file at the bundle root with valid YAML
/// frontmatter (parsed by `ProjectSlashCommandService`). The
/// installer copies them to `<project>/.scarf/slash-commands/<n>.md`
/// on install. `nil` or `[]` means the template ships no commands.
let slashCommands: [String]?
}
struct TemplateMemoryClaim: Codable, Sendable, Equatable {
@@ -213,6 +220,13 @@ struct TemplateLock: Codable, Sendable {
/// Informational the actual removal of config.json rides on
/// `projectFiles`. Optional for back-compat.
let configFields: [String]?
/// Project-scoped slash command files the installer wrote, as paths
/// relative to the project root (e.g.
/// `.scarf/slash-commands/review.md`). The uninstaller removes
/// exactly these preserving any user-authored slash commands the
/// user added to `<project>/.scarf/slash-commands/` after install.
/// Optional for back-compat with pre-v2.5 lock files.
let slashCommandFiles: [String]?
enum CodingKeys: String, CodingKey {
case templateId = "template_id"
@@ -226,6 +240,7 @@ struct TemplateLock: Codable, Sendable {
case memoryBlockId = "memory_block_id"
case configKeychainItems = "config_keychain_items"
case configFields = "config_fields"
case slashCommandFiles = "slash_command_files"
}
}
@@ -40,6 +40,11 @@ struct ProjectTemplateExporter: Sendable {
let skillIds: [String]
let cronJobs: [HermesCronJob]
let memoryAppendix: String?
/// Names of slash commands that will be carried into the bundle
/// (read from `<project>/.scarf/slash-commands/<n>.md`). The
/// export sheet shows these in the preview so authors can see
/// what will travel with the bundle.
let slashCommandNames: [String]
}
/// Inputs collected by the export sheet.
@@ -78,6 +83,15 @@ struct ProjectTemplateExporter: Sendable {
}
let allJobs = HermesFileService(context: context).loadCronJobs()
let picked = allJobs.filter { inputs.includeCronJobIds.contains($0.id) }
// Pick up every project-scoped slash command at
// <project>/.scarf/slash-commands/. The exporter ships them
// unconditionally they're tied to the project, not to user
// identity, and the names go into the manifest's contents claim
// so installers see them in the preview sheet.
let slashCommandNames = ProjectSlashCommandService(context: context)
.loadCommands(at: dir)
.map(\.name)
.sorted()
return ExportPlan(
templateId: inputs.templateId,
templateName: inputs.templateName,
@@ -89,7 +103,8 @@ struct ProjectTemplateExporter: Sendable {
instructionFiles: instructions,
skillIds: inputs.includeSkillIds,
cronJobs: picked,
memoryAppendix: inputs.memoryAppendix
memoryAppendix: inputs.memoryAppendix,
slashCommandNames: slashCommandNames
)
}
@@ -183,6 +198,20 @@ struct ProjectTemplateExporter: Sendable {
try data.write(to: URL(fileURLWithPath: memDir + "/append.md"))
}
// Slash commands (manifest schemaVersion 3). Copy each from the
// project's `.scarf/slash-commands/<name>.md` into the bundle
// root's `slash-commands/<name>.md`. Read goes through the
// transport so remote projects work too.
if !plan.slashCommandNames.isEmpty {
let slashDir = stagingDir + "/slash-commands"
try FileManager.default.createDirectory(atPath: slashDir, withIntermediateDirectories: true)
for name in plan.slashCommandNames {
let source = plan.projectDir + "/.scarf/slash-commands/" + name + ".md"
let destination = slashDir + "/" + name + ".md"
try copyFromHermes(source, to: destination, transport: transport)
}
}
// If the source project was itself installed from a schemaful
// template, its `.scarf/manifest.json` carries the schema we
// want to forward to the exported bundle. We carry only the
@@ -194,10 +223,16 @@ struct ProjectTemplateExporter: Sendable {
from: plan.projectDir
)
// Bump schemaVersion to 2 when a schema is carried through;
// remain on 1 otherwise so schema-less exports stay
// byte-compatible with existing v2.2 catalog validators.
let schemaVersion = forwardedSchema == nil ? 1 : 2
// Bump schemaVersion based on the most-recent feature carried
// through:
// v3 bundle ships slashCommands (added v2.5).
// v2 bundle ships a config schema (added v2.3).
// v1 schema-less, byte-compatible with v2.2 catalog validators.
let schemaVersion: Int = {
if !plan.slashCommandNames.isEmpty { return 3 }
if forwardedSchema != nil { return 2 }
return 1
}()
// Manifest claims exactly what we just wrote
let manifest = ProjectTemplateManifest(
@@ -222,7 +257,8 @@ struct ProjectTemplateExporter: Sendable {
skills: plan.skillIds.isEmpty ? nil : plan.skillIds.compactMap { $0.split(separator: "/").last.map(String.init) },
cron: plan.cronJobs.isEmpty ? nil : plan.cronJobs.count,
memory: (inputs.memoryAppendix?.isEmpty == false) ? TemplateMemoryClaim(append: true) : nil,
config: forwardedSchema?.fields.count
config: forwardedSchema?.fields.count,
slashCommands: plan.slashCommandNames.isEmpty ? nil : plan.slashCommandNames
),
config: forwardedSchema
)
@@ -281,6 +281,16 @@ struct ProjectTemplateInstaller: Sendable {
guard let schema = plan.configSchema, !schema.isEmpty else { return nil }
return schema.fields.map(\.key)
}()
// Slash command file paths, RELATIVE to the project root, so the
// uninstaller can remove only what the template installed (not
// user-authored slash commands the user added later in the
// same dir). Source-relative-path identifies bundle slash commands
// because they live under `slash-commands/` in the unpacked tree.
let slashCommandFiles: [String]? = {
let names = plan.manifest.contents.slashCommands ?? []
guard !names.isEmpty else { return nil }
return names.sorted().map { ".scarf/slash-commands/\($0).md" }
}()
let lock = TemplateLock(
templateId: plan.manifest.id,
@@ -293,7 +303,8 @@ struct ProjectTemplateInstaller: Sendable {
cronJobNames: cronJobNames,
memoryBlockId: plan.memoryAppendix == nil ? nil : plan.manifest.id,
configKeychainItems: keychainItems,
configFields: configFields
configFields: configFields,
slashCommandFiles: slashCommandFiles
)
let encoder = JSONEncoder()
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
@@ -134,6 +134,24 @@ struct ProjectTemplateService: Sendable {
)
}
// Project-scoped slash commands (manifest schemaVersion 3+). Each
// claimed name `<n>` must correspond to a `slash-commands/<n>.md`
// file at the bundle root; copied into
// `<projectDir>/.scarf/slash-commands/<n>.md`. The chat layer
// picks them up automatically when the project chat starts.
for slashName in (manifest.contents.slashCommands ?? []) {
let source = "slash-commands/" + slashName + ".md"
guard inspection.files.contains(source) else {
throw ProjectTemplateError.requiredFileMissing(source)
}
projectFiles.append(
TemplateFileCopy(
sourceRelativePath: source,
destinationPath: projectDir + "/.scarf/slash-commands/" + slashName + ".md"
)
)
}
// Namespaced skills: copied wholesale from skills/<name>/** into
// ~/.hermes/skills/templates/<slug>/<name>/**.
var skillsFiles: [TemplateFileCopy] = []
@@ -456,6 +474,38 @@ struct ProjectTemplateService: Sendable {
)
}
// Slash commands (manifest schemaVersion 3+). Each claimed name
// must correspond to exactly one `slash-commands/<name>.md` file
// at the bundle root; extra files (not claimed) are rejected.
// Also reject malformed names so the on-disk shape stays
// round-trippable through `ProjectSlashCommandService.parse`.
if let claimed = manifest.contents.slashCommands {
for name in claimed {
if let reason = ProjectSlashCommand.validateName(name) {
throw ProjectTemplateError.contentClaimMismatch(
"manifest.contents.slashCommands lists \"\(name)\": \(reason)"
)
}
let path = "slash-commands/" + name + ".md"
if !fileSet.contains(path) {
throw ProjectTemplateError.contentClaimMismatch(
"manifest lists slash command \(name) but \(path) is missing from the bundle"
)
}
}
let presentSlash = fileSet.filter { $0.hasPrefix("slash-commands/") }
let claimedFull = Set(claimed.map { "slash-commands/" + $0 + ".md" })
if let extra = presentSlash.first(where: { !claimedFull.contains($0) }) {
throw ProjectTemplateError.contentClaimMismatch(
"bundle contains \(extra) but it's not listed in manifest.contents.slashCommands"
)
}
} else if fileSet.contains(where: { $0.hasPrefix("slash-commands/") }) {
throw ProjectTemplateError.contentClaimMismatch(
"bundle contains slash-commands/ but manifest.contents.slashCommands is missing"
)
}
let claimedCron = manifest.contents.cron ?? 0
if claimedCron != cronJobCount {
throw ProjectTemplateError.contentClaimMismatch(
+2 -1
View File
@@ -170,7 +170,8 @@ final class TestRegistryLock: @unchecked Sendable {
skills: skills,
cron: cron,
memory: nil,
config: configFieldCount ?? configSchema?.fields.count
config: configFieldCount ?? configSchema?.fields.count,
slashCommands: nil
),
config: configSchema
)
+46 -1
View File
@@ -47,7 +47,13 @@ from typing import Iterable
SCHEMA_VERSION_V1 = 1 # original v2.2 bundle
SCHEMA_VERSION_V2 = 2 # v2.3 — adds optional manifest.config block
SUPPORTED_SCHEMA_VERSIONS = {SCHEMA_VERSION_V1, SCHEMA_VERSION_V2}
SCHEMA_VERSION_V3 = 3 # v2.5 — adds optional contents.slashCommands block
SUPPORTED_SCHEMA_VERSIONS = {SCHEMA_VERSION_V1, SCHEMA_VERSION_V2, SCHEMA_VERSION_V3}
# Slash command names: lowercase letters, digits, hyphens; must start
# with a letter. Mirrors `ProjectSlashCommand.validNamePattern` on the
# Swift side so catalog ↔ installer round-trip is byte-clean.
SLASH_COMMAND_NAME_RE = re.compile(r"^[a-z][a-z0-9-]*$")
MAX_BUNDLE_BYTES = 5 * 1024 * 1024 # 5 MB cap on submissions; installer is 50 MB
REQUIRED_BUNDLE_FILES = ("template.json", "README.md", "AGENTS.md", "dashboard.json")
SUPPORTED_WIDGET_TYPES = {"stat", "progress", "text", "table", "chart", "list", "webview"}
@@ -258,6 +264,45 @@ def _validate_contents_claim(
f"contents.config={claimed_config} but config.schema has {schema_field_count} field(s)"
))
# Slash commands (schemaVersion 3+) — each claimed name must match
# SLASH_COMMAND_NAME_RE and have a corresponding `slash-commands/<n>.md`
# file at the bundle root. Extra slash-commands/ files not in the
# claim list are rejected. Mirrors Swift's verifyClaims slash-command
# check.
claimed_slash = contents.get("slashCommands")
if claimed_slash is not None:
if not isinstance(claimed_slash, list):
errors.append(ValidationError(
template_dir,
"contents.slashCommands must be a list of names"
))
claimed_slash = []
claimed_full = set()
for name in claimed_slash:
if not isinstance(name, str) or not SLASH_COMMAND_NAME_RE.match(name):
errors.append(ValidationError(
template_dir,
f"contents.slashCommands name {name!r} must match {SLASH_COMMAND_NAME_RE.pattern}"
))
continue
path = f"slash-commands/{name}.md"
claimed_full.add(path)
if path not in bundle_files:
errors.append(ValidationError(
template_dir,
f"contents.slashCommands claims {name!r} but {path} is missing from the bundle"
))
for present in {f for f in bundle_files if f.startswith("slash-commands/")} - claimed_full:
errors.append(ValidationError(
template_dir,
f"bundle has {present} but it's not listed in contents.slashCommands"
))
elif any(f.startswith("slash-commands/") for f in bundle_files):
errors.append(ValidationError(
template_dir,
"bundle contains slash-commands/ files but contents.slashCommands is missing"
))
def _validate_config_schema(manifest: dict, template_dir: Path, errors: list[ValidationError]) -> None:
"""Mirrors Swift `ProjectConfigService.validateSchema`. Structural
+107
View File
@@ -513,6 +513,113 @@ class ConfigSchemaValidationTests(unittest.TestCase):
errors = self._collect_errors()
self.assertEqual(errors, [])
# MARK: - Slash commands (schemaVersion 3, v2.5)
def test_accepts_template_with_slash_commands(self):
manifest = {
"schemaVersion": 3,
"id": "tester/slashes",
"name": "Slashes",
"version": "1.0.0",
"description": "ships slash commands",
"contents": {
"dashboard": True, "agentsMd": True,
"slashCommands": ["review", "deploy-staging"],
},
}
review_md = b"---\nname: review\ndescription: Code review\n---\nReview {{argument}}.\n"
deploy_md = b"---\nname: deploy-staging\ndescription: Deploy\n---\nDeploy now.\n"
make_template_dir(
self.repo, "tester", "slashes",
manifest=manifest,
bundle_files={
"template.json": json.dumps(manifest).encode("utf-8"),
"README.md": b"# r", "AGENTS.md": b"# a",
"dashboard.json": json.dumps(MINIMAL_DASHBOARD).encode("utf-8"),
"slash-commands/review.md": review_md,
"slash-commands/deploy-staging.md": deploy_md,
},
)
errors = self._collect_errors()
self.assertEqual(errors, [])
def test_rejects_unclaimed_slash_command_file(self):
manifest = {
"schemaVersion": 3,
"id": "tester/orphan",
"name": "Orphan",
"version": "1.0.0",
"description": "extra file",
"contents": {
"dashboard": True, "agentsMd": True,
"slashCommands": ["review"],
},
}
review_md = b"---\nname: review\ndescription: Code review\n---\nReview.\n"
rogue_md = b"---\nname: rogue\ndescription: Sneaky\n---\nrun.\n"
make_template_dir(
self.repo, "tester", "orphan",
manifest=manifest,
bundle_files={
"template.json": json.dumps(manifest).encode("utf-8"),
"README.md": b"# r", "AGENTS.md": b"# a",
"dashboard.json": json.dumps(MINIMAL_DASHBOARD).encode("utf-8"),
"slash-commands/review.md": review_md,
"slash-commands/rogue.md": rogue_md,
},
)
errors = self._collect_errors()
self.assertTrue(any("slash-commands/rogue.md" in str(e) for e in errors), errors)
def test_rejects_missing_slash_command_file(self):
manifest = {
"schemaVersion": 3,
"id": "tester/missing",
"name": "Missing",
"version": "1.0.0",
"description": "claim without file",
"contents": {
"dashboard": True, "agentsMd": True,
"slashCommands": ["review"],
},
}
make_template_dir(
self.repo, "tester", "missing",
manifest=manifest,
bundle_files={
"template.json": json.dumps(manifest).encode("utf-8"),
"README.md": b"# r", "AGENTS.md": b"# a",
"dashboard.json": json.dumps(MINIMAL_DASHBOARD).encode("utf-8"),
},
)
errors = self._collect_errors()
self.assertTrue(any("slash-commands/review.md" in str(e) for e in errors), errors)
def test_rejects_invalid_slash_command_name(self):
manifest = {
"schemaVersion": 3,
"id": "tester/bad-name",
"name": "BadName",
"version": "1.0.0",
"description": "bad slash name",
"contents": {
"dashboard": True, "agentsMd": True,
"slashCommands": ["BadName"], # uppercase rejected
},
}
make_template_dir(
self.repo, "tester", "bad-name",
manifest=manifest,
bundle_files={
"template.json": json.dumps(manifest).encode("utf-8"),
"README.md": b"# r", "AGENTS.md": b"# a",
"dashboard.json": json.dumps(MINIMAL_DASHBOARD).encode("utf-8"),
"slash-commands/BadName.md": b"---\nname: BadName\ndescription: x\n---\n",
},
)
errors = self._collect_errors()
self.assertTrue(any("BadName" in str(e) for e in errors), errors)
def _collect_errors(self):
errors = []
for tdir in build_catalog._iter_templates(self.repo):