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"`
}