From 9ecd89dcffc446f20d362ed30e59c7913fff17ab Mon Sep 17 00:00:00 2001 From: josegironn <30703536+josegironn@users.noreply.github.com> Date: Sat, 25 Jul 2026 18:19:43 -0700 Subject: [PATCH] feat(project): compile skill artifacts to tree hashes + bundle zips MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Skills are directories under /skills// containing SKILL.md. Compile emits { slug, treeHash } per skill (git tree hash of the dir at HEAD; not-a-repo / never-committed / dirty dir surface as issues) and, with the new --skill-bundles-dir flag, writes each skill's zip as .zip — raw bytes end to end (only SKILL.md is decoded, for frontmatter), no extension allow-list, 200-file cap, symlinks rejected. The bundle S3 location is injected by the platform compile job, never by the CLI. agents/skills are omitted from the compiled config when empty (the server contract treats both as optional); "skills" returns to the reserved agent.json fields. agent.schema.json re-vendored from mono-builder (drift check goes green once that side deploys). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01KgHMhia4ZHmppQ2EYoX1TT Co-authored-by: Orca --- cmd/project/compile.go | 4 +- projects/compile.go | 31 +- projects/compile_test.go | 27 ++ projects/load.go | 14 +- projects/load_test.go | 24 + projects/schemas/SCHEMAS.sha256 | 6 +- projects/schemas/agent.schema.json | 32 +- projects/skills.go | 441 ++++++++++++++++++ projects/skills_test.go | 385 +++++++++++++++ .../testdata/invalid-skill-slug/project.json | 3 + .../src/skills/Bad_Name/SKILL.md | 6 + .../skill-bad-frontmatter/project.json | 3 + .../src/skills/tools/SKILL.md | 3 + projects/testdata/with-skills/project.json | 4 + .../with-skills/src/agents/helper/agent.json | 5 + .../with-skills/src/skills/pdf-tools/SKILL.md | 9 + .../src/skills/pdf-tools/references/usage.md | 3 + .../src/skills/pdf-tools/scripts/run.js | 1 + projects/types.go | 33 +- 19 files changed, 1023 insertions(+), 11 deletions(-) create mode 100644 projects/skills.go create mode 100644 projects/skills_test.go create mode 100644 projects/testdata/invalid-skill-slug/project.json create mode 100644 projects/testdata/invalid-skill-slug/src/skills/Bad_Name/SKILL.md create mode 100644 projects/testdata/skill-bad-frontmatter/project.json create mode 100644 projects/testdata/skill-bad-frontmatter/src/skills/tools/SKILL.md create mode 100644 projects/testdata/with-skills/project.json create mode 100644 projects/testdata/with-skills/src/agents/helper/agent.json create mode 100644 projects/testdata/with-skills/src/skills/pdf-tools/SKILL.md create mode 100644 projects/testdata/with-skills/src/skills/pdf-tools/references/usage.md create mode 100644 projects/testdata/with-skills/src/skills/pdf-tools/scripts/run.js diff --git a/cmd/project/compile.go b/cmd/project/compile.go index 7cb500d..9ecdac5 100644 --- a/cmd/project/compile.go +++ b/cmd/project/compile.go @@ -29,13 +29,14 @@ func printCompileIssues(cmd *cobra.Command, issues []projects.Issue) { func newCompileCmd() *cobra.Command { var dir string var asJSON bool + var skillBundlesDir string cmd := &cobra.Command{ Use: "compile", Short: "Compile the project into its canonical config JSON", Long: `Validates and compiles the project directory. With --json, prints the canonical single-line config JSON to stdout; otherwise prints the pretty-printed config and its hash.`, RunE: func(cmd *cobra.Command, args []string) error { - result, issues := projects.Compile(dir) + result, issues := projects.CompileWithSkillBundles(dir, skillBundlesDir) if len(issues) > 0 { printCompileIssues(cmd, issues) @@ -59,6 +60,7 @@ func newCompileCmd() *cobra.Command { cmd.Flags().StringVar(&dir, "dir", ".", "Project directory to compile") cmd.Flags().BoolVar(&asJSON, "json", false, "Print the canonical single-line JSON only (machine-readable)") + cmd.Flags().StringVar(&skillBundlesDir, "skill-bundles-dir", "", "Write each skill's bundle to /.zip (unset: no zips are written)") return cmd } diff --git a/projects/compile.go b/projects/compile.go index dbfc3fe..b084db1 100644 --- a/projects/compile.go +++ b/projects/compile.go @@ -18,8 +18,22 @@ type CompileResult struct { // Compile loads, validates, and compiles a project directory. Returns the // result, or nil plus issues. ConfigJSON is deterministic for identical // inputs: struct field order is fixed, agents are sorted by slug, and Go -// marshals map keys in sorted order. +// marshals map keys in sorted order. Skill bundle zips are not written; use +// CompileWithSkillBundles for that. func Compile(dir string) (*CompileResult, []Issue) { + return compile(dir, "") +} + +// CompileWithSkillBundles is Compile plus writing each skill's zip bundle to +// /.zip (cmd/project/compile.go's +// --skill-bundles-dir). The CLI itself never uploads these to S3; the +// mono-owned compile job does that and injects CompiledSkill.Bundle before +// submitting. +func CompileWithSkillBundles(dir, skillBundlesDir string) (*CompileResult, []Issue) { + return compile(dir, skillBundlesDir) +} + +func compile(dir, skillBundlesDir string) (*CompileResult, []Issue) { absDir, err := filepath.Abs(dir) if err != nil { return nil, []Issue{{File: "project.json", Message: "cannot resolve project directory: " + err.Error()}} @@ -31,7 +45,7 @@ func Compile(dir string) (*CompileResult, []Issue) { return nil, issues } - agents := make([]CompiledAgent, 0, len(loaded.Agents)) + var agents []CompiledAgent for _, a := range loaded.Agents { prompt := a.SystemPrompt @@ -58,6 +72,18 @@ func Compile(dir string) (*CompileResult, []Issue) { }) } + var skills []CompiledSkill + + for _, s := range loaded.Skills { + compiled, skillIssues := readSkillBundle(dir, s, skillBundlesDir) + if len(skillIssues) > 0 { + issues = append(issues, skillIssues...) + continue + } + + skills = append(skills, *compiled) + } + if len(issues) > 0 { return nil, issues } @@ -66,6 +92,7 @@ func Compile(dir string) (*CompileResult, []Issue) { ConfigVersion: 1, Project: CompiledProject{Name: loaded.Definition.Name}, Agents: agents, + Skills: skills, } configJSON, err := json.Marshal(config) diff --git a/projects/compile_test.go b/projects/compile_test.go index b6b4a3d..0a79361 100644 --- a/projects/compile_test.go +++ b/projects/compile_test.go @@ -147,6 +147,33 @@ func TestCompileOversizePromptRejected(t *testing.T) { } } +// TestCompileEmptyProjectOmitsAgentsAndSkillsKeys checks that a project with +// no agents or skills directories compiles successfully and emits neither +// key in the compiled JSON, rather than "agents":[] / "skills":[]. +func TestCompileEmptyProjectOmitsAgentsAndSkillsKeys(t *testing.T) { + dir := t.TempDir() + writeFixtureProject(t, dir) + + res, issues := Compile(dir) + if len(issues) != 0 { + t.Fatalf("expected no issues, got: %+v", issues) + } + + if len(res.Config.Agents) != 0 { + t.Fatalf("expected no agents, got: %#v", res.Config.Agents) + } + if len(res.Config.Skills) != 0 { + t.Fatalf("expected no skills, got: %#v", res.Config.Skills) + } + + if strings.Contains(string(res.ConfigJSON), `"agents"`) { + t.Fatalf("expected no agents key in compiled JSON, got: %s", res.ConfigJSON) + } + if strings.Contains(string(res.ConfigJSON), `"skills"`) { + t.Fatalf("expected no skills key in compiled JSON, got: %s", res.ConfigJSON) + } +} + func TestCompileMissingPromptFile(t *testing.T) { dir := t.TempDir() writeFixtureProject(t, dir) diff --git a/projects/load.go b/projects/load.go index 5b82ec6..e86eb00 100644 --- a/projects/load.go +++ b/projects/load.go @@ -22,7 +22,7 @@ var schemaMessagePrinter = message.NewPrinter(language.English) // rejected with a dedicated message so v1 can introduce them without silent // behavior changes on old CLIs. var reservedAgentFields = []string{ - "schedules", "connectors", "apps", "skills", "toolPermissions", "tools", "hooks", + "schedules", "connectors", "apps", "toolPermissions", "tools", "hooks", "skills", } // rawSystemPrompt accepts either an inline string or {"file": "./x.md"}. @@ -209,11 +209,18 @@ func Load(dir string) (*LoadedProject, []Issue) { srcDir = "src/" } + skills, skillIssues := discoverSkills(dir, srcDir) + issues = append(issues, skillIssues...) + agentsDir := filepath.Join(dir, srcDir, "agents") entries, err := os.ReadDir(agentsDir) if err != nil { - // No agents directory means an empty (but valid) project. - return &LoadedProject{Definition: ProjectDefinition{Name: proj.Name, SrcDir: srcDir}}, nil + // No agents directory means an empty (but valid) project, unless + // skill discovery already surfaced issues. + if len(issues) > 0 { + return nil, issues + } + return &LoadedProject{Definition: ProjectDefinition{Name: proj.Name, SrcDir: srcDir}, Skills: skills}, nil } var agents []AgentDefinition @@ -285,5 +292,6 @@ func Load(dir string) (*LoadedProject, []Issue) { return &LoadedProject{ Definition: ProjectDefinition{Name: proj.Name, SrcDir: srcDir}, Agents: agents, + Skills: skills, }, nil } diff --git a/projects/load_test.go b/projects/load_test.go index c5bbbda..97fb98c 100644 --- a/projects/load_test.go +++ b/projects/load_test.go @@ -1,6 +1,8 @@ package projects import ( + "os" + "path/filepath" "strings" "testing" ) @@ -120,6 +122,28 @@ func TestLoadDuplicateSlugs(t *testing.T) { } } +// TestLoadAgentSkillsFieldReservedAgain checks that agent-skill attachment is +// gone: agent.json's "skills" field is reserved again (load.go), not parsed +// or cross-referenced against declared skills. +func TestLoadAgentSkillsFieldReservedAgain(t *testing.T) { + dir := t.TempDir() + writeFixtureProject(t, dir) + + agentDir := filepath.Join(dir, "src", "agents", "helper") + if err := os.MkdirAll(agentDir, 0755); err != nil { + t.Fatal(err) + } + agentJSON := `{"slug":"helper","name":"Helper","systemPrompt":"You help.","skills":["pdf-tools"]}` + if err := os.WriteFile(filepath.Join(agentDir, "agent.json"), []byte(agentJSON), 0644); err != nil { + t.Fatal(err) + } + + _, issues := Load(dir) + if !findIssue(issues, "reserved for a future version") || !findIssue(issues, "skills") { + t.Fatalf("expected \"skills\" to be reserved again, got: %+v", issues) + } +} + func TestLoadMissingProjectJSON(t *testing.T) { _, issues := Load(t.TempDir()) if !findIssue(issues, "project.json") { diff --git a/projects/schemas/SCHEMAS.sha256 b/projects/schemas/SCHEMAS.sha256 index 47e34a4..9597e31 100644 --- a/projects/schemas/SCHEMAS.sha256 +++ b/projects/schemas/SCHEMAS.sha256 @@ -1,4 +1,4 @@ -source: http://localhost:3301 -fetched: 2026-07-24T03:28:48Z +source: /Users/josegiron/orca/workspaces/mono-builder/project-skills (working tree, apps/api/src/shared/constants/schemas) +fetched: 2026-07-25T23:08:17Z 47002e01681b41a1030b88e395897008b370c4a42b465b541e77038f8b2509d1 projects/schemas/project.schema.json -cf84e4941f22d1a759594575154b80c1154e10ac685fa5597ffcc2f53eebe5ed projects/schemas/agent.schema.json +30f8146f88d12dcec76653f167b0843f0edb05fde6db6e74926b2f154747128b projects/schemas/agent.schema.json diff --git a/projects/schemas/agent.schema.json b/projects/schemas/agent.schema.json index 7b4f6d8..d937fdc 100644 --- a/projects/schemas/agent.schema.json +++ b/projects/schemas/agent.schema.json @@ -1 +1,31 @@ -{"$schema":"https://json-schema.org/draft/2020-12/schema","$id":"https://schemas.major.tech/agent.json","title":"Major agent definition","type":"object","properties":{"$schema":{"type":"string"},"slug":{"type":"string","maxLength":63,"pattern":"^[a-z0-9]+(-[a-z0-9]+)*$"},"name":{"type":"string","minLength":1,"maxLength":100},"description":{"type":"string","maxLength":1000},"model":{"type":"string","minLength":1},"systemPrompt":{"anyOf":[{"type":"string","minLength":1},{"type":"object","properties":{"file":{"type":"string","minLength":1}},"required":["file"],"additionalProperties":false}]},"env":{"type":"object","propertyNames":{"type":"string","pattern":"^[A-Z][A-Z0-9_]*$"},"additionalProperties":{"anyOf":[{"type":"string"},{"type":"null"}]}}},"required":["slug","name","systemPrompt"],"additionalProperties":false} \ No newline at end of file +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://schemas.major.tech/agent.json", + "title": "Major agent definition", + "type": "object", + "properties": { + "$schema": { "type": "string" }, + "slug": { "type": "string", "maxLength": 63, "pattern": "^[a-z0-9]+(-[a-z0-9]+)*$" }, + "name": { "type": "string", "minLength": 1, "maxLength": 100 }, + "description": { "type": "string", "maxLength": 1000 }, + "model": { "type": "string", "minLength": 1 }, + "systemPrompt": { + "anyOf": [ + { "type": "string", "minLength": 1 }, + { + "type": "object", + "properties": { "file": { "type": "string", "minLength": 1 } }, + "required": ["file"], + "additionalProperties": false + } + ] + }, + "env": { + "type": "object", + "propertyNames": { "type": "string", "pattern": "^[A-Z][A-Z0-9_]*$" }, + "additionalProperties": { "anyOf": [{ "type": "string" }, { "type": "null" }] } + } + }, + "required": ["slug", "name", "systemPrompt"], + "additionalProperties": false +} diff --git a/projects/skills.go b/projects/skills.go new file mode 100644 index 0000000..fb78a88 --- /dev/null +++ b/projects/skills.go @@ -0,0 +1,441 @@ +package projects + +// Skill discovery and bundle validation/publishing. Discovery here mirrors +// exactly how Load discovers agents under /agents (load.go): a +// directory is a skill only if it contains a marker file (SKILL.md instead of +// agent.json); directories without one are silently ignored. Bundle +// validation (file walk, size caps, frontmatter) is a Go port of the server's +// validator: packages/shared/src/utils/skill-validator.ts. Compiling never +// inlines file contents: a skill compiles down to the git tree hash of its +// directory at HEAD, plus an optional zip written to --skill-bundles-dir for +// the mono-owned compile job to upload to S3 and inject as the "bundle" +// field before submitting - the CLI itself never touches S3. + +import ( + "archive/zip" + "fmt" + "io/fs" + "os" + "os/exec" + "path/filepath" + "regexp" + "sort" + "strings" + "unicode/utf8" +) + +// SkillSlugPattern matches the server's skill slug contract: lowercase +// kebab-case, 1-63 characters (packages/api/src/schemas/projects.ts). +var SkillSlugPattern = regexp.MustCompile(`^[a-z0-9]+(-[a-z0-9]+)*$`) + +// Skill bundle caps, ported from SKILL_VALIDATION_LIMITS +// (packages/shared/src/constants/skill-validation.ts). The file extension +// allow-list the server used to enforce is gone: bundles may now contain +// arbitrary binary files (fonts, images, ...), so any extension is accepted. +const ( + MaxSkillSlugLen = 63 + MaxSkillFiles = 200 + MaxSkillFileBytes = 5 * 1024 * 1024 + MaxSkillBundleBytes = 25 * 1024 * 1024 + MaxSkillPathLen = 512 + MaxSkillNameLen = 120 + MaxSkillDescriptionLen = 2000 +) + +const skillGitkeep = ".gitkeep" + +// discoverSkills finds skill directories under /skills, mirroring +// how Load discovers agents under /agents: a directory is a skill +// only if it contains a SKILL.md; directories without one are ignored (same +// convention as agent directories missing agent.json). +func discoverSkills(dir, srcDir string) ([]SkillDefinition, []Issue) { + skillsDir := filepath.Join(dir, srcDir, "skills") + + entries, err := os.ReadDir(skillsDir) + if err != nil { + return nil, nil // no skills directory means no skills, not an error + } + + var skills []SkillDefinition + var issues []Issue + seenSlugs := map[string]string{} // slug -> skill dir that declared it + + for _, entry := range entries { + if !entry.IsDir() { + continue + } + + skillDir := filepath.Join(srcDir, "skills", entry.Name()) + skillMDFile := filepath.Join(skillDir, "SKILL.md") + + if _, err := os.Stat(filepath.Join(dir, skillMDFile)); err != nil { + continue // directories without SKILL.md are ignored + } + + slug := entry.Name() + if !SkillSlugPattern.MatchString(slug) || len(slug) > MaxSkillSlugLen { + issues = append(issues, Issue{ + File: skillMDFile, + Message: fmt.Sprintf("invalid skill slug %q: must match ^[a-z0-9]+(-[a-z0-9]+)*$ and be 1-%d characters", slug, MaxSkillSlugLen), + }) + continue + } + + if prevDir, dup := seenSlugs[slug]; dup { + issues = append(issues, Issue{ + File: skillMDFile, + Message: fmt.Sprintf("duplicate skill slug %q (already declared in %s)", slug, prevDir), + }) + continue + } + seenSlugs[slug] = skillDir + + skills = append(skills, SkillDefinition{Slug: slug, Dir: skillDir}) + } + + sort.Slice(skills, func(i, j int) bool { return skills[i].Slug < skills[j].Slug }) + + return skills, issues +} + +// skillFile is one file collected while walking a skill directory. content is +// only populated for SKILL.md (frontmatter needs it as text) and, when a +// bundle zip is being written, for every file - raw bytes, never decoded. +type skillFile struct { + relPath string + size int64 + content []byte +} + +// readSkillBundle walks a skill directory, validates every file against the +// server's skill-validator rules, and returns the compiled skill: its slug +// and the git tree hash of its directory at HEAD. When skillBundlesDir is +// non-empty, it also writes /.zip with every file's +// raw bytes. Symlinks (files or dirs) are rejected, mirroring readPromptFile's +// symlink handling in prompt.go. +func readSkillBundle(projectDir string, skill SkillDefinition, skillBundlesDir string) (*CompiledSkill, []Issue) { + root := filepath.Join(projectDir, skill.Dir) + needAllContent := skillBundlesDir != "" + + var files []skillFile + var issues []Issue + + walkErr := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if path == root { + return nil + } + + relPath := filepath.ToSlash(strings.TrimPrefix(path, root+string(filepath.Separator))) + fileID := filepath.Join(skill.Dir, filepath.FromSlash(relPath)) + + if d.Type()&fs.ModeSymlink != 0 { + issues = append(issues, Issue{ + File: fileID, + Message: fmt.Sprintf("skill %q: %q is a symlink; symlinks are not allowed", skill.Slug, relPath), + }) + return nil + } + + if d.IsDir() { + return nil + } + + issues = append(issues, validateSkillFilePath(skill.Slug, fileID, relPath)...) + + info, err := d.Info() + if err != nil { + return err + } + + if info.Size() > MaxSkillFileBytes { + issues = append(issues, Issue{ + File: fileID, + Message: fmt.Sprintf("skill %q: %q exceeds the per-file size cap of %d bytes", skill.Slug, relPath, MaxSkillFileBytes), + }) + } + + if utf8.RuneCountInString(relPath) > MaxSkillPathLen { + issues = append(issues, Issue{ + File: fileID, + Message: fmt.Sprintf("skill %q: path %q exceeds the max length of %d characters", skill.Slug, relPath, MaxSkillPathLen), + }) + } + + sf := skillFile{relPath: relPath, size: info.Size()} + + // Only SKILL.md is ever read as text (frontmatter parsing below); every + // other file's bytes are read only if a bundle zip needs them, and are + // never treated as UTF-8. + if relPath == "SKILL.md" || needAllContent { + content, err := os.ReadFile(path) + if err != nil { + issues = append(issues, Issue{File: fileID, Message: "cannot read file: " + err.Error()}) + return nil + } + sf.content = content + } + + files = append(files, sf) + + return nil + }) + if walkErr != nil { + return nil, []Issue{{File: skill.Dir, Message: fmt.Sprintf("cannot read skill %q: %s", skill.Slug, walkErr.Error())}} + } + + if len(files) > MaxSkillFiles { + issues = append(issues, Issue{ + File: skill.Dir, + Message: fmt.Sprintf("skill %q exceeds the max file count of %d", skill.Slug, MaxSkillFiles), + }) + } + + var totalSize int64 + var skillMD *skillFile + for i := range files { + totalSize += files[i].size + if files[i].relPath == "SKILL.md" { + skillMD = &files[i] + } + } + + if totalSize > MaxSkillBundleBytes { + issues = append(issues, Issue{ + File: skill.Dir, + Message: fmt.Sprintf("skill %q exceeds the max bundle size of %d bytes", skill.Slug, MaxSkillBundleBytes), + }) + } + + if skillMD == nil { + issues = append(issues, Issue{ + File: skill.Dir, + Message: fmt.Sprintf("skill %q must contain SKILL.md at its root", skill.Slug), + }) + } else { + issues = append(issues, validateSkillFrontmatter(skill.Slug, filepath.Join(skill.Dir, "SKILL.md"), skillMD.content)...) + } + + treeHash, hashIssues := resolveSkillTreeHash(projectDir, skill) + issues = append(issues, hashIssues...) + + if len(issues) > 0 { + return nil, issues + } + + sort.Slice(files, func(i, j int) bool { return files[i].relPath < files[j].relPath }) + + if needAllContent { + if err := writeSkillBundleZip(skillBundlesDir, treeHash, files); err != nil { + return nil, []Issue{{File: skill.Dir, Message: fmt.Sprintf("cannot write bundle zip for skill %q: %s", skill.Slug, err.Error())}} + } + } + + return &CompiledSkill{Slug: skill.Slug, TreeHash: treeHash}, nil +} + +// writeSkillBundleZip writes every file in files to +// /.zip: raw bytes, forward-slash paths relative to the +// skill directory, no transformation. Only called locally (--skill-bundles-dir); +// the authoritative compile job builds and uploads its own bundle from a +// clean clone. +func writeSkillBundleZip(bundlesDir, treeHash string, files []skillFile) error { + if err := os.MkdirAll(bundlesDir, 0755); err != nil { + return err + } + + f, err := os.Create(filepath.Join(bundlesDir, treeHash+".zip")) + if err != nil { + return err + } + defer f.Close() + + zw := zip.NewWriter(f) + for _, sf := range files { + w, err := zw.Create(sf.relPath) + if err != nil { + return err + } + if _, err := w.Write(sf.content); err != nil { + return err + } + } + + return zw.Close() +} + +// skillTreeHashPattern matches a git tree object hash: 40 hex characters for +// SHA-1 repositories, 64 for SHA-256 ones. +var skillTreeHashPattern = regexp.MustCompile(`^[0-9a-f]{40}$|^[0-9a-f]{64}$`) + +// runGit runs git with args in dir and returns trimmed stdout, or an error on +// failure (matching clients/git's style of shelling out per-call rather than +// keeping a long-lived process). +func runGit(dir string, args ...string) (string, error) { + cmd := exec.Command("git", args...) + cmd.Dir = dir + out, err := cmd.Output() + if err != nil { + return "", err + } + return strings.TrimSpace(string(out)), nil +} + +// resolveSkillTreeHash computes the compiled tree hash for a skill directory: +// the git tree object hash of the directory at HEAD (git rev-parse +// HEAD:). If the project isn't inside a git repository, +// or the skill directory has never been committed, that's a validation issue +// telling the user to commit it first. If the skill directory has uncommitted +// changes, the hash still resolves (it reflects HEAD), but that's reported as +// an issue too - Issue has no severity levels, so this is a hard issue, not +// merely a warning; the authoritative compile always runs on a clean clone at +// a fixed commit, so in practice this only affects local runs. +func resolveSkillTreeHash(projectDir string, skill SkillDefinition) (string, []Issue) { + absSkillDir := filepath.Join(projectDir, skill.Dir) + + repoRoot, err := runGit(absSkillDir, "rev-parse", "--show-toplevel") + if err != nil { + return "", []Issue{{ + File: skill.Dir, + Message: fmt.Sprintf("skill %q is not inside a git repository; commit the project, including the skill directory, before compiling", skill.Slug), + }} + } + + // git resolves symlinks in --show-toplevel; resolve absSkillDir the same + // way (macOS puts TMPDIR behind a /var -> /private/var symlink) so the two + // paths are comparable instead of producing a bogus "../../.." relative + // path that still resolves but never matches HEAD. + realSkillDir, err := filepath.EvalSymlinks(absSkillDir) + if err != nil { + return "", []Issue{{File: skill.Dir, Message: "cannot resolve skill directory path: " + err.Error()}} + } + + relToRepo, err := filepath.Rel(repoRoot, realSkillDir) + if err != nil { + return "", []Issue{{File: skill.Dir, Message: "cannot resolve skill directory relative to the git repository root: " + err.Error()}} + } + relToRepo = filepath.ToSlash(relToRepo) + + hash, err := runGit(repoRoot, "rev-parse", "HEAD:"+relToRepo) + if err != nil || !skillTreeHashPattern.MatchString(hash) { + return "", []Issue{{ + File: skill.Dir, + Message: fmt.Sprintf("skill %q has never been committed to git; commit the skill directory (%s) before compiling", skill.Slug, relToRepo), + }} + } + + var issues []Issue + if status, statusErr := runGit(repoRoot, "status", "--porcelain", "--", relToRepo); statusErr == nil && status != "" { + issues = append(issues, Issue{ + File: skill.Dir, + Message: fmt.Sprintf("skill %q has uncommitted changes; the compiled tree hash reflects the last commit (HEAD), not the working tree", skill.Slug), + }) + } + + return hash, issues +} + +// validateSkillFilePath ports the path-level checks from the server's +// validateSkillFilePath (packages/shared/src/utils/skill-validator.ts): +// backslashes and null bytes are defensive rejections, and dotfiles are +// rejected except .gitkeep. The extension allow-list this used to enforce is +// gone - any extension is accepted, since bundles may contain binary assets. +func validateSkillFilePath(slug, fileID, relPath string) []Issue { + var issues []Issue + + if strings.Contains(relPath, "\x00") { + issues = append(issues, Issue{File: fileID, Message: fmt.Sprintf("skill %q: path %q contains a null byte", slug, relPath)}) + } + + if strings.Contains(relPath, "\\") { + issues = append(issues, Issue{File: fileID, Message: fmt.Sprintf("skill %q: path %q contains a backslash; use forward slashes", slug, relPath)}) + } + + basename := relPath + if idx := strings.LastIndex(relPath, "/"); idx >= 0 { + basename = relPath[idx+1:] + } + + if strings.HasPrefix(basename, ".") && basename != skillGitkeep { + issues = append(issues, Issue{File: fileID, Message: fmt.Sprintf("skill %q: dotfile %q is not allowed (only %s is)", slug, basename, skillGitkeep)}) + } + + return issues +} + +// skillFrontmatterRe matches a leading --- delimited YAML block, mirroring +// the server's /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/. +var skillFrontmatterRe = regexp.MustCompile(`(?s)^---\r?\n(.*?)\r?\n---\r?\n?`) + +// skillFrontmatterFieldRe matches a "key: value" line. +var skillFrontmatterFieldRe = regexp.MustCompile(`^([A-Za-z_][A-Za-z0-9_-]*)\s*:\s*(.*)$`) + +var skillFrontmatterLineSplit = regexp.MustCompile(`\r?\n`) + +// validateSkillFrontmatter ports parseSkillFrontmatter +// (packages/shared/src/utils/skill-validator.ts): a lightweight line-based +// reader for the two scalar fields SKILL.md must declare, no YAML library. +// content is SKILL.md's bytes - the one file in a skill bundle ever decoded +// as text. +func validateSkillFrontmatter(slug, skillMDFile string, content []byte) []Issue { + match := skillFrontmatterRe.FindSubmatch(content) + if match == nil { + return []Issue{{ + File: skillMDFile, + Message: fmt.Sprintf("skill %q: SKILL.md must begin with a YAML frontmatter block delimited by --- lines", slug), + }} + } + + body := string(match[1]) + fields := map[string]string{} + + for _, line := range skillFrontmatterLineSplit.Split(body, -1) { + m := skillFrontmatterFieldRe.FindStringSubmatch(line) + if m == nil { + continue + } + + fields[m[1]] = unquoteSkillFrontmatterValue(strings.TrimSpace(m[2])) + } + + var issues []Issue + + name := strings.TrimSpace(fields["name"]) + if name == "" || utf8.RuneCountInString(name) > MaxSkillNameLen { + issues = append(issues, Issue{ + File: skillMDFile, + Message: fmt.Sprintf("skill %q: frontmatter \"name\" must be a non-empty string up to %d chars", slug, MaxSkillNameLen), + }) + } + + description := strings.TrimSpace(fields["description"]) + if description == "" || utf8.RuneCountInString(description) > MaxSkillDescriptionLen { + issues = append(issues, Issue{ + File: skillMDFile, + Message: fmt.Sprintf("skill %q: frontmatter \"description\" must be a non-empty string up to %d chars", slug, MaxSkillDescriptionLen), + }) + } + + return issues +} + +// unquoteSkillFrontmatterValue strips matching outer quotes and unescapes +// per-quote-style escapes, mirroring the server's parseSkillFrontmatter: +// double quotes unescape \" and \\, single quotes unescape ”. +func unquoteSkillFrontmatterValue(value string) string { + if len(value) >= 2 && strings.HasPrefix(value, `"`) && strings.HasSuffix(value, `"`) { + inner := value[1 : len(value)-1] + inner = strings.ReplaceAll(inner, `\"`, `"`) + inner = strings.ReplaceAll(inner, `\\`, `\`) + return inner + } + + if len(value) >= 2 && strings.HasPrefix(value, "'") && strings.HasSuffix(value, "'") { + inner := value[1 : len(value)-1] + return strings.ReplaceAll(inner, "''", "'") + } + + return value +} diff --git a/projects/skills_test.go b/projects/skills_test.go new file mode 100644 index 0000000..d185ca7 --- /dev/null +++ b/projects/skills_test.go @@ -0,0 +1,385 @@ +package projects + +import ( + "archive/zip" + "bytes" + "fmt" + "io" + "io/fs" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +// copyFixtureDir recursively copies src into dst, used to stage a testdata +// fixture (not itself a git repo) into a throwaway one. +func copyFixtureDir(t *testing.T, src, dst string) { + t.Helper() + + err := filepath.WalkDir(src, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + + rel, err := filepath.Rel(src, path) + if err != nil { + return err + } + + target := filepath.Join(dst, rel) + if d.IsDir() { + return os.MkdirAll(target, 0755) + } + + content, err := os.ReadFile(path) + if err != nil { + return err + } + + return os.WriteFile(target, content, 0644) + }) + if err != nil { + t.Fatalf("copy fixture %s -> %s: %v", src, dst, err) + } +} + +// runTestGit runs git in dir, failing the test on error. +func runTestGit(t *testing.T, dir string, args ...string) { + t.Helper() + + cmd := exec.Command("git", args...) + cmd.Dir = dir + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("git %v: %v\n%s", args, err, out) + } +} + +// initGitRepo git-inits dir and commits everything in it, so HEAD: +// tree-hash lookups resolve the way they would in a real project. +func initGitRepo(t *testing.T, dir string) { + t.Helper() + + runTestGit(t, dir, "init") + runTestGit(t, dir, "config", "user.email", "test@example.com") + runTestGit(t, dir, "config", "user.name", "Test") + runTestGit(t, dir, "add", "-A") + runTestGit(t, dir, "commit", "-m", "fixture") +} + +func TestCompileProjectWithSkills(t *testing.T) { + dir := t.TempDir() + copyFixtureDir(t, "testdata/with-skills", dir) + + // A binary file (non-UTF8 bytes) proves bundle files are never decoded as + // text and round-trip byte-identical through the zip. + binPath := filepath.Join(dir, "src", "skills", "pdf-tools", "assets", "icon.bin") + if err := os.MkdirAll(filepath.Dir(binPath), 0755); err != nil { + t.Fatal(err) + } + binContent := []byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0xFF, 0xDE, 0xAD, 0xBE, 0xEF} + if err := os.WriteFile(binPath, binContent, 0644); err != nil { + t.Fatal(err) + } + + initGitRepo(t, dir) + + bundlesDir := t.TempDir() + res, issues := CompileWithSkillBundles(dir, bundlesDir) + if len(issues) != 0 { + t.Fatalf("expected no issues, got: %+v", issues) + } + + if len(res.Config.Skills) != 1 { + t.Fatalf("expected 1 skill, got %d", len(res.Config.Skills)) + } + + skill := res.Config.Skills[0] + if skill.Slug != "pdf-tools" { + t.Fatalf("skill slug = %q, want pdf-tools", skill.Slug) + } + if !skillTreeHashPattern.MatchString(skill.TreeHash) { + t.Fatalf("tree hash %q does not look like a git tree hash", skill.TreeHash) + } + if skill.Bundle != nil { + t.Fatalf("CLI compile must never set bundle, got: %+v", skill.Bundle) + } + + // No file contents anywhere in the compiled config. + if strings.Contains(string(res.ConfigJSON), "console.log") { + t.Fatalf("compiled config must not inline file contents: %s", res.ConfigJSON) + } + + zipPath := filepath.Join(bundlesDir, skill.TreeHash+".zip") + zr, err := zip.OpenReader(zipPath) + if err != nil { + t.Fatalf("open bundle zip: %v", err) + } + defer zr.Close() + + got := map[string][]byte{} + for _, f := range zr.File { + rc, err := f.Open() + if err != nil { + t.Fatalf("open zip entry %s: %v", f.Name, err) + } + content, err := io.ReadAll(rc) + rc.Close() + if err != nil { + t.Fatalf("read zip entry %s: %v", f.Name, err) + } + got[f.Name] = content + } + + for _, name := range []string{"SKILL.md", "references/usage.md", "scripts/run.js", "assets/icon.bin"} { + if _, ok := got[name]; !ok { + t.Fatalf("expected %s in bundle zip, got: %v", name, got) + } + } + + if !bytes.Equal(got["assets/icon.bin"], binContent) { + t.Fatalf("binary file not byte-identical: got %v want %v", got["assets/icon.bin"], binContent) + } + + origSkillMD, err := os.ReadFile(filepath.Join(dir, "src", "skills", "pdf-tools", "SKILL.md")) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(got["SKILL.md"], origSkillMD) { + t.Fatalf("SKILL.md not byte-identical") + } + + if len(res.Config.Agents) != 1 { + t.Fatalf("expected 1 agent, got %d", len(res.Config.Agents)) + } +} + +func TestCompileWithoutBundlesDirWritesNoZips(t *testing.T) { + dir := t.TempDir() + copyFixtureDir(t, "testdata/with-skills", dir) + initGitRepo(t, dir) + + res, issues := Compile(dir) + if len(issues) != 0 { + t.Fatalf("expected no issues, got: %+v", issues) + } + + if len(res.Config.Skills) != 1 || res.Config.Skills[0].TreeHash == "" { + t.Fatalf("expected 1 skill with a tree hash, got: %+v", res.Config.Skills) + } +} + +func TestCompileNoSkillsDirOmitsSkillsKey(t *testing.T) { + res, issues := Compile("testdata/valid") + if len(issues) != 0 { + t.Fatalf("expected no issues, got: %+v", issues) + } + + if len(res.Config.Skills) != 0 { + t.Fatalf("expected no skills, got: %#v", res.Config.Skills) + } + + if strings.Contains(string(res.ConfigJSON), `"skills"`) { + t.Fatalf("expected no skills key in compiled JSON, got: %s", res.ConfigJSON) + } +} + +func TestCompileInvalidSkillSlugRejected(t *testing.T) { + _, issues := Compile("testdata/invalid-skill-slug") + if !findIssue(issues, "invalid skill slug") { + t.Fatalf("expected invalid-slug issue, got: %+v", issues) + } +} + +func TestCompileSkillBadFrontmatterRejected(t *testing.T) { + _, issues := Compile("testdata/skill-bad-frontmatter") + if !findIssue(issues, "frontmatter block") { + t.Fatalf("expected frontmatter issue, got: %+v", issues) + } +} + +// TestCompileDuplicateSkillSlugSkipped documents that two skills can't +// collide on slug via real directory scanning: the slug IS the directory +// name, and a filesystem cannot hold two entries of the same name in one +// directory. discoverSkills still guards against it defensively (skills.go), +// but there is no way to construct this on disk to exercise that path. +func TestCompileDuplicateSkillSlugSkipped(t *testing.T) { + t.Skip("duplicate skill slugs cannot be constructed on disk: slug == directory name") +} + +func TestCompileSkillSymlinkRejected(t *testing.T) { + dir := t.TempDir() + writeFixtureProject(t, dir) + + skillDir := filepath.Join(dir, "src", "skills", "linked") + if err := os.MkdirAll(skillDir, 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte("---\nname: Linked\ndescription: has a symlink\n---\nBody.\n"), 0644); err != nil { + t.Fatal(err) + } + + target := filepath.Join(dir, "real.md") + if err := os.WriteFile(target, []byte("real"), 0644); err != nil { + t.Fatal(err) + } + link := filepath.Join(skillDir, "linked.md") + if err := os.Symlink(target, link); err != nil { + t.Fatal(err) + } + + _, issues := Compile(dir) + if !findIssue(issues, "is a symlink") { + t.Fatalf("expected symlink issue, got: %+v", issues) + } +} + +func TestCompileSkillOversizeFileRejected(t *testing.T) { + dir := t.TempDir() + writeFixtureProject(t, dir) + + skillDir := filepath.Join(dir, "src", "skills", "big") + if err := os.MkdirAll(skillDir, 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte("---\nname: Big\ndescription: has an oversize file\n---\nBody.\n"), 0644); err != nil { + t.Fatal(err) + } + + big := make([]byte, MaxSkillFileBytes+1) + for i := range big { + big[i] = 'a' + } + if err := os.WriteFile(filepath.Join(skillDir, "notes.txt"), big, 0644); err != nil { + t.Fatal(err) + } + + _, issues := Compile(dir) + if !findIssue(issues, "per-file size cap") { + t.Fatalf("expected oversize-file issue, got: %+v", issues) + } +} + +// TestCompileSkillArbitraryExtensionAllowed checks that the old server-side +// extension allow-list is gone: binary asset types like .png/.woff2 compile +// cleanly now. +func TestCompileSkillArbitraryExtensionAllowed(t *testing.T) { + dir := t.TempDir() + writeFixtureProject(t, dir) + + skillDir := filepath.Join(dir, "src", "skills", "media") + if err := os.MkdirAll(skillDir, 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte("---\nname: Media\ndescription: Has binary assets.\n---\nBody.\n"), 0644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(skillDir, "logo.png"), []byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x01}, 0644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(skillDir, "font.woff2"), []byte{0x77, 0x4F, 0x46, 0x32, 0x00, 0xFF, 0x10}, 0644); err != nil { + t.Fatal(err) + } + + initGitRepo(t, dir) + + _, issues := Compile(dir) + if len(issues) != 0 { + t.Fatalf("expected .png/.woff2 files to be allowed now, got issues: %+v", issues) + } +} + +func TestCompileSkillFileCountCapRaisedTo200(t *testing.T) { + dir := t.TempDir() + writeFixtureProject(t, dir) + + skillDir := filepath.Join(dir, "src", "skills", "many") + if err := os.MkdirAll(skillDir, 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte("---\nname: Many\ndescription: has many files.\n---\nBody.\n"), 0644); err != nil { + t.Fatal(err) + } + + // SKILL.md plus MaxSkillFiles more is one over the cap. + for i := 0; i < MaxSkillFiles; i++ { + name := filepath.Join(skillDir, fmt.Sprintf("file-%d.txt", i)) + if err := os.WriteFile(name, []byte("x"), 0644); err != nil { + t.Fatal(err) + } + } + + initGitRepo(t, dir) + + _, issues := Compile(dir) + if !findIssue(issues, fmt.Sprintf("max file count of %d", MaxSkillFiles)) { + t.Fatalf("expected file-count cap issue, got: %+v", issues) + } +} + +func TestCompileSkillNotGitRepoRejected(t *testing.T) { + dir := t.TempDir() + writeFixtureProject(t, dir) + + skillDir := filepath.Join(dir, "src", "skills", "orphan") + if err := os.MkdirAll(skillDir, 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte("---\nname: Orphan\ndescription: never committed.\n---\nBody.\n"), 0644); err != nil { + t.Fatal(err) + } + + _, issues := Compile(dir) + if !findIssue(issues, "git repository") { + t.Fatalf("expected a not-a-git-repository issue, got: %+v", issues) + } +} + +func TestCompileSkillNeverCommittedRejected(t *testing.T) { + dir := t.TempDir() + writeFixtureProject(t, dir) + initGitRepo(t, dir) // commits project.json but not the skill created below + + skillDir := filepath.Join(dir, "src", "skills", "wip") + if err := os.MkdirAll(skillDir, 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte("---\nname: WIP\ndescription: never committed.\n---\nBody.\n"), 0644); err != nil { + t.Fatal(err) + } + + _, issues := Compile(dir) + if !findIssue(issues, "commit the skill directory") { + t.Fatalf("expected a never-committed issue, got: %+v", issues) + } +} + +func TestCompileSkillUncommittedChangesRejected(t *testing.T) { + dir := t.TempDir() + writeFixtureProject(t, dir) + + skillDir := filepath.Join(dir, "src", "skills", "dirty") + if err := os.MkdirAll(skillDir, 0755); err != nil { + t.Fatal(err) + } + skillMD := filepath.Join(skillDir, "SKILL.md") + if err := os.WriteFile(skillMD, []byte("---\nname: Dirty\ndescription: has local edits.\n---\nBody.\n"), 0644); err != nil { + t.Fatal(err) + } + + initGitRepo(t, dir) + + // Edit after committing: HEAD has the original content, the working tree + // does not - the compiled hash must still resolve (it reflects HEAD), but + // this must be reported. + if err := os.WriteFile(skillMD, []byte("---\nname: Dirty\ndescription: has local edits.\n---\nBody, edited.\n"), 0644); err != nil { + t.Fatal(err) + } + + _, issues := Compile(dir) + if !findIssue(issues, "uncommitted changes") { + t.Fatalf("expected an uncommitted-changes issue, got: %+v", issues) + } +} diff --git a/projects/testdata/invalid-skill-slug/project.json b/projects/testdata/invalid-skill-slug/project.json new file mode 100644 index 0000000..5d917fe --- /dev/null +++ b/projects/testdata/invalid-skill-slug/project.json @@ -0,0 +1,3 @@ +{ + "name": "Invalid Skill Slug" +} diff --git a/projects/testdata/invalid-skill-slug/src/skills/Bad_Name/SKILL.md b/projects/testdata/invalid-skill-slug/src/skills/Bad_Name/SKILL.md new file mode 100644 index 0000000..e1bdde3 --- /dev/null +++ b/projects/testdata/invalid-skill-slug/src/skills/Bad_Name/SKILL.md @@ -0,0 +1,6 @@ +--- +name: Bad Name +description: A skill with an invalid directory name. +--- + +Body. diff --git a/projects/testdata/skill-bad-frontmatter/project.json b/projects/testdata/skill-bad-frontmatter/project.json new file mode 100644 index 0000000..ea6e39e --- /dev/null +++ b/projects/testdata/skill-bad-frontmatter/project.json @@ -0,0 +1,3 @@ +{ + "name": "Skill Bad Frontmatter" +} diff --git a/projects/testdata/skill-bad-frontmatter/src/skills/tools/SKILL.md b/projects/testdata/skill-bad-frontmatter/src/skills/tools/SKILL.md new file mode 100644 index 0000000..0ab5cf9 --- /dev/null +++ b/projects/testdata/skill-bad-frontmatter/src/skills/tools/SKILL.md @@ -0,0 +1,3 @@ +# Tools + +This SKILL.md has no frontmatter block at all. diff --git a/projects/testdata/with-skills/project.json b/projects/testdata/with-skills/project.json new file mode 100644 index 0000000..10974c6 --- /dev/null +++ b/projects/testdata/with-skills/project.json @@ -0,0 +1,4 @@ +{ + "name": "With Skills", + "srcDir": "src/" +} diff --git a/projects/testdata/with-skills/src/agents/helper/agent.json b/projects/testdata/with-skills/src/agents/helper/agent.json new file mode 100644 index 0000000..cecca47 --- /dev/null +++ b/projects/testdata/with-skills/src/agents/helper/agent.json @@ -0,0 +1,5 @@ +{ + "slug": "helper", + "name": "Helper", + "systemPrompt": "You help with PDFs." +} diff --git a/projects/testdata/with-skills/src/skills/pdf-tools/SKILL.md b/projects/testdata/with-skills/src/skills/pdf-tools/SKILL.md new file mode 100644 index 0000000..212a5ba --- /dev/null +++ b/projects/testdata/with-skills/src/skills/pdf-tools/SKILL.md @@ -0,0 +1,9 @@ +--- +name: PDF Tools +description: Utilities for reading and manipulating PDF files. +--- + +# PDF Tools + +Use the scripts in `scripts/` to manipulate PDFs, and see `references/usage.md` +for details. diff --git a/projects/testdata/with-skills/src/skills/pdf-tools/references/usage.md b/projects/testdata/with-skills/src/skills/pdf-tools/references/usage.md new file mode 100644 index 0000000..6a1f91c --- /dev/null +++ b/projects/testdata/with-skills/src/skills/pdf-tools/references/usage.md @@ -0,0 +1,3 @@ +# Usage + +Call `scripts/run.js` with a path to a PDF file. diff --git a/projects/testdata/with-skills/src/skills/pdf-tools/scripts/run.js b/projects/testdata/with-skills/src/skills/pdf-tools/scripts/run.js new file mode 100644 index 0000000..f16dab1 --- /dev/null +++ b/projects/testdata/with-skills/src/skills/pdf-tools/scripts/run.js @@ -0,0 +1 @@ +console.log("run me on a pdf"); diff --git a/projects/types.go b/projects/types.go index 0bfad4c..f1731ba 100644 --- a/projects/types.go +++ b/projects/types.go @@ -26,10 +26,19 @@ type AgentDefinition struct { Dir string } +// SkillDefinition is one skill directory discovered under /skills. +// Load only records its slug and location; Compile reads and validates the +// full bundle (skills.go). +type SkillDefinition struct { + Slug string + Dir string // skill directory relative to the project root, like "src/skills/pdf-tools" +} + // LoadedProject is the fully parsed and validated project directory. type LoadedProject struct { Definition ProjectDefinition Agents []AgentDefinition + Skills []SkillDefinition } // CompiledProject is the project block of the compiled config. @@ -47,10 +56,32 @@ type CompiledAgent struct { Env map[string]*string `json:"env,omitempty"` } +// CompiledSkillBundle locates a skill's zipped bundle in S3. The CLI never +// sets this - it always emits Bundle nil. The mono-owned compile job uploads +// the bundle (built from the CLI's --skill-bundles-dir output, or its own +// clone of the same tree) and injects this field before submitting. +type CompiledSkillBundle struct { + Bucket string `json:"bucket"` + Key string `json:"key"` +} + +// CompiledSkill is one skill entry of the compiled config. It never inlines +// file contents: TreeHash is the git tree object hash of the skill directory +// at HEAD (skills.go), and the platform publishes skill versions from the S3 +// bundle located by Bundle once the compile job injects it. +type CompiledSkill struct { + Slug string `json:"slug"` + TreeHash string `json:"treeHash"` + Bundle *CompiledSkillBundle `json:"bundle,omitempty"` +} + // CompiledConfig is the canonical compile output. The platform stores this // JSON on project_versions.compiled_config and deploys read it verbatim. +// Agents and Skills are optional: an empty project omits both keys rather +// than emitting "[]". type CompiledConfig struct { ConfigVersion int `json:"configVersion"` Project CompiledProject `json:"project"` - Agents []CompiledAgent `json:"agents"` + Agents []CompiledAgent `json:"agents,omitempty"` + Skills []CompiledSkill `json:"skills,omitempty"` }