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
+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):