mirror of
https://github.com/awizemann/scarf.git
synced 2026-05-10 10:36:35 +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:
@@ -63,6 +63,13 @@ struct TemplateContents: Codable, Sendable, Equatable {
|
|||||||
/// validator so a bundle can't hide a schema from the preview.
|
/// validator so a bundle can't hide a schema from the preview.
|
||||||
/// `nil` or `0` means schema-less (v1-compatible behaviour).
|
/// `nil` or `0` means schema-less (v1-compatible behaviour).
|
||||||
let config: Int?
|
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 {
|
struct TemplateMemoryClaim: Codable, Sendable, Equatable {
|
||||||
@@ -213,6 +220,13 @@ struct TemplateLock: Codable, Sendable {
|
|||||||
/// Informational — the actual removal of config.json rides on
|
/// Informational — the actual removal of config.json rides on
|
||||||
/// `projectFiles`. Optional for back-compat.
|
/// `projectFiles`. Optional for back-compat.
|
||||||
let configFields: [String]?
|
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 {
|
enum CodingKeys: String, CodingKey {
|
||||||
case templateId = "template_id"
|
case templateId = "template_id"
|
||||||
@@ -226,6 +240,7 @@ struct TemplateLock: Codable, Sendable {
|
|||||||
case memoryBlockId = "memory_block_id"
|
case memoryBlockId = "memory_block_id"
|
||||||
case configKeychainItems = "config_keychain_items"
|
case configKeychainItems = "config_keychain_items"
|
||||||
case configFields = "config_fields"
|
case configFields = "config_fields"
|
||||||
|
case slashCommandFiles = "slash_command_files"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -40,6 +40,11 @@ struct ProjectTemplateExporter: Sendable {
|
|||||||
let skillIds: [String]
|
let skillIds: [String]
|
||||||
let cronJobs: [HermesCronJob]
|
let cronJobs: [HermesCronJob]
|
||||||
let memoryAppendix: String?
|
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.
|
/// Inputs collected by the export sheet.
|
||||||
@@ -78,6 +83,15 @@ struct ProjectTemplateExporter: Sendable {
|
|||||||
}
|
}
|
||||||
let allJobs = HermesFileService(context: context).loadCronJobs()
|
let allJobs = HermesFileService(context: context).loadCronJobs()
|
||||||
let picked = allJobs.filter { inputs.includeCronJobIds.contains($0.id) }
|
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(
|
return ExportPlan(
|
||||||
templateId: inputs.templateId,
|
templateId: inputs.templateId,
|
||||||
templateName: inputs.templateName,
|
templateName: inputs.templateName,
|
||||||
@@ -89,7 +103,8 @@ struct ProjectTemplateExporter: Sendable {
|
|||||||
instructionFiles: instructions,
|
instructionFiles: instructions,
|
||||||
skillIds: inputs.includeSkillIds,
|
skillIds: inputs.includeSkillIds,
|
||||||
cronJobs: picked,
|
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"))
|
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
|
// If the source project was itself installed from a schemaful
|
||||||
// template, its `.scarf/manifest.json` carries the schema we
|
// template, its `.scarf/manifest.json` carries the schema we
|
||||||
// want to forward to the exported bundle. We carry only the
|
// want to forward to the exported bundle. We carry only the
|
||||||
@@ -194,10 +223,16 @@ struct ProjectTemplateExporter: Sendable {
|
|||||||
from: plan.projectDir
|
from: plan.projectDir
|
||||||
)
|
)
|
||||||
|
|
||||||
// Bump schemaVersion to 2 when a schema is carried through;
|
// Bump schemaVersion based on the most-recent feature carried
|
||||||
// remain on 1 otherwise so schema-less exports stay
|
// through:
|
||||||
// byte-compatible with existing v2.2 catalog validators.
|
// v3 — bundle ships slashCommands (added v2.5).
|
||||||
let schemaVersion = forwardedSchema == nil ? 1 : 2
|
// 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
|
// Manifest — claims exactly what we just wrote
|
||||||
let manifest = ProjectTemplateManifest(
|
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) },
|
skills: plan.skillIds.isEmpty ? nil : plan.skillIds.compactMap { $0.split(separator: "/").last.map(String.init) },
|
||||||
cron: plan.cronJobs.isEmpty ? nil : plan.cronJobs.count,
|
cron: plan.cronJobs.isEmpty ? nil : plan.cronJobs.count,
|
||||||
memory: (inputs.memoryAppendix?.isEmpty == false) ? TemplateMemoryClaim(append: true) : nil,
|
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
|
config: forwardedSchema
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -281,6 +281,16 @@ struct ProjectTemplateInstaller: Sendable {
|
|||||||
guard let schema = plan.configSchema, !schema.isEmpty else { return nil }
|
guard let schema = plan.configSchema, !schema.isEmpty else { return nil }
|
||||||
return schema.fields.map(\.key)
|
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(
|
let lock = TemplateLock(
|
||||||
templateId: plan.manifest.id,
|
templateId: plan.manifest.id,
|
||||||
@@ -293,7 +303,8 @@ struct ProjectTemplateInstaller: Sendable {
|
|||||||
cronJobNames: cronJobNames,
|
cronJobNames: cronJobNames,
|
||||||
memoryBlockId: plan.memoryAppendix == nil ? nil : plan.manifest.id,
|
memoryBlockId: plan.memoryAppendix == nil ? nil : plan.manifest.id,
|
||||||
configKeychainItems: keychainItems,
|
configKeychainItems: keychainItems,
|
||||||
configFields: configFields
|
configFields: configFields,
|
||||||
|
slashCommandFiles: slashCommandFiles
|
||||||
)
|
)
|
||||||
let encoder = JSONEncoder()
|
let encoder = JSONEncoder()
|
||||||
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
|
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
|
// Namespaced skills: copied wholesale from skills/<name>/** into
|
||||||
// ~/.hermes/skills/templates/<slug>/<name>/**.
|
// ~/.hermes/skills/templates/<slug>/<name>/**.
|
||||||
var skillsFiles: [TemplateFileCopy] = []
|
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
|
let claimedCron = manifest.contents.cron ?? 0
|
||||||
if claimedCron != cronJobCount {
|
if claimedCron != cronJobCount {
|
||||||
throw ProjectTemplateError.contentClaimMismatch(
|
throw ProjectTemplateError.contentClaimMismatch(
|
||||||
|
|||||||
@@ -170,7 +170,8 @@ final class TestRegistryLock: @unchecked Sendable {
|
|||||||
skills: skills,
|
skills: skills,
|
||||||
cron: cron,
|
cron: cron,
|
||||||
memory: nil,
|
memory: nil,
|
||||||
config: configFieldCount ?? configSchema?.fields.count
|
config: configFieldCount ?? configSchema?.fields.count,
|
||||||
|
slashCommands: nil
|
||||||
),
|
),
|
||||||
config: configSchema
|
config: configSchema
|
||||||
)
|
)
|
||||||
|
|||||||
+46
-1
@@ -47,7 +47,13 @@ from typing import Iterable
|
|||||||
|
|
||||||
SCHEMA_VERSION_V1 = 1 # original v2.2 bundle
|
SCHEMA_VERSION_V1 = 1 # original v2.2 bundle
|
||||||
SCHEMA_VERSION_V2 = 2 # v2.3 — adds optional manifest.config block
|
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
|
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")
|
REQUIRED_BUNDLE_FILES = ("template.json", "README.md", "AGENTS.md", "dashboard.json")
|
||||||
SUPPORTED_WIDGET_TYPES = {"stat", "progress", "text", "table", "chart", "list", "webview"}
|
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)"
|
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:
|
def _validate_config_schema(manifest: dict, template_dir: Path, errors: list[ValidationError]) -> None:
|
||||||
"""Mirrors Swift `ProjectConfigService.validateSchema`. Structural
|
"""Mirrors Swift `ProjectConfigService.validateSchema`. Structural
|
||||||
|
|||||||
@@ -513,6 +513,113 @@ class ConfigSchemaValidationTests(unittest.TestCase):
|
|||||||
errors = self._collect_errors()
|
errors = self._collect_errors()
|
||||||
self.assertEqual(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):
|
def _collect_errors(self):
|
||||||
errors = []
|
errors = []
|
||||||
for tdir in build_catalog._iter_templates(self.repo):
|
for tdir in build_catalog._iter_templates(self.repo):
|
||||||
|
|||||||
Reference in New Issue
Block a user