mirror of
https://github.com/awizemann/scarf.git
synced 2026-05-08 02:14: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:
@@ -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(
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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