Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion cmd/project/compile.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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 <dir>/<treeHash>.zip (unset: no zips are written)")

return cmd
}
31 changes: 29 additions & 2 deletions projects/compile.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
// <skillBundlesDir>/<treeHash>.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()}}
Expand All @@ -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
Expand All @@ -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
}
Expand All @@ -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)
Expand Down
27 changes: 27 additions & 0 deletions projects/compile_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
14 changes: 11 additions & 3 deletions projects/load.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"}.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -285,5 +292,6 @@ func Load(dir string) (*LoadedProject, []Issue) {
return &LoadedProject{
Definition: ProjectDefinition{Name: proj.Name, SrcDir: srcDir},
Agents: agents,
Skills: skills,
}, nil
}
24 changes: 24 additions & 0 deletions projects/load_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package projects

import (
"os"
"path/filepath"
"strings"
"testing"
)
Expand Down Expand Up @@ -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") {
Expand Down
6 changes: 3 additions & 3 deletions projects/schemas/SCHEMAS.sha256
Original file line number Diff line number Diff line change
@@ -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
32 changes: 31 additions & 1 deletion projects/schemas/agent.schema.json
Original file line number Diff line number Diff line change
@@ -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}
{
"$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
}
Loading
Loading