mirror of
https://github.com/awizemann/scarf.git
synced 2026-05-10 02:26:37 +00:00
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:
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user