diff --git a/scarf/scarf/Core/Models/ProjectTemplate.swift b/scarf/scarf/Core/Models/ProjectTemplate.swift index 9dc4a8a..a01fd24 100644 --- a/scarf/scarf/Core/Models/ProjectTemplate.swift +++ b/scarf/scarf/Core/Models/ProjectTemplate.swift @@ -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 `` must correspond to + /// a `slash-commands/.md` file at the bundle root with valid YAML + /// frontmatter (parsed by `ProjectSlashCommandService`). The + /// installer copies them to `/.scarf/slash-commands/.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 `/.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" } } diff --git a/scarf/scarf/Core/Services/ProjectTemplateExporter.swift b/scarf/scarf/Core/Services/ProjectTemplateExporter.swift index 6b06b81..5ec675c 100644 --- a/scarf/scarf/Core/Services/ProjectTemplateExporter.swift +++ b/scarf/scarf/Core/Services/ProjectTemplateExporter.swift @@ -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 `/.scarf/slash-commands/.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 + // /.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/.md` into the bundle + // root's `slash-commands/.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 ) diff --git a/scarf/scarf/Core/Services/ProjectTemplateInstaller.swift b/scarf/scarf/Core/Services/ProjectTemplateInstaller.swift index ed3c68e..49ecda5 100644 --- a/scarf/scarf/Core/Services/ProjectTemplateInstaller.swift +++ b/scarf/scarf/Core/Services/ProjectTemplateInstaller.swift @@ -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] diff --git a/scarf/scarf/Core/Services/ProjectTemplateService.swift b/scarf/scarf/Core/Services/ProjectTemplateService.swift index d2a7b58..cbd71b4 100644 --- a/scarf/scarf/Core/Services/ProjectTemplateService.swift +++ b/scarf/scarf/Core/Services/ProjectTemplateService.swift @@ -134,6 +134,24 @@ struct ProjectTemplateService: Sendable { ) } + // Project-scoped slash commands (manifest schemaVersion 3+). Each + // claimed name `` must correspond to a `slash-commands/.md` + // file at the bundle root; copied into + // `/.scarf/slash-commands/.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//** into // ~/.hermes/skills/templates///**. 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/.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( diff --git a/scarf/scarfTests/ProjectTemplateTests.swift b/scarf/scarfTests/ProjectTemplateTests.swift index 4cdf8e4..80799b5 100644 --- a/scarf/scarfTests/ProjectTemplateTests.swift +++ b/scarf/scarfTests/ProjectTemplateTests.swift @@ -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 ) diff --git a/tools/build-catalog.py b/tools/build-catalog.py index ff1b2ef..c8b94ff 100755 --- a/tools/build-catalog.py +++ b/tools/build-catalog.py @@ -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/.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 diff --git a/tools/test_build_catalog.py b/tools/test_build_catalog.py index f3f855d..623d91c 100644 --- a/tools/test_build_catalog.py +++ b/tools/test_build_catalog.py @@ -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):