// Package lockfile defines agent-lib lockfile schema v2: the deterministic // JSON document pinning vendored sources, their selection and renames. package lockfile import ( "encoding/json" "errors" "fmt" "os" "sort" ) const ( // Version is the schema version of lockfile v2. Version = 2 // FileName is the lockfile name inside the work repository root. FileName = "agent-lib.lock.json" ) // ErrNotExist reports that no lockfile exists yet. var ErrNotExist = errors.New("no lockfile found") // Content type names used across discovery, lockfile and the external area. const ( TypeSkill = "skills" TypeCommand = "commands" TypeAgent = "agents" TypeMcp = "mcp" ) // Types lists all content types in canonical order. var Types = []string{TypeSkill, TypeCommand, TypeAgent, TypeMcp} // Discovery configures where items are found inside a source repository. // Empty values select the standard layouts (skills/, commands/, agents/, mcp/ // relative to Root). type Discovery struct { Root string `json:"root,omitempty"` SkillsDir string `json:"skills_dir,omitempty"` CommandsDir string `json:"commands_dir,omitempty"` AgentsDir string `json:"agents_dir,omitempty"` McpDir string `json:"mcp_dir,omitempty"` } func (d Discovery) Resolve() Discovery { r := d if r.SkillsDir == "" { r.SkillsDir = TypeSkill } if r.CommandsDir == "" { r.CommandsDir = TypeCommand } if r.AgentsDir == "" { r.AgentsDir = TypeAgent } if r.McpDir == "" { r.McpDir = TypeMcp } return r } // SelectionMode is either "all" or "include". type SelectionMode string const ( ModeAll SelectionMode = "all" ModeInclude SelectionMode = "include" ) // Selection expresses what enters the work repository: everything minus // Exclude (mode all), or exactly Include (mode include). type Selection struct { Mode SelectionMode `json:"mode"` Exclude []string `json:"exclude,omitempty"` Include []string `json:"include,omitempty"` } // Source pins one vendored git source. type Source struct { URL string `json:"url"` Ref string `json:"ref"` Rev string `json:"rev"` Discovery *Discovery `json:"discovery,omitempty"` Selection Selection `json:"selection"` Renames map[string]string `json:"renames,omitempty"` Inventory map[string][]string `json:"inventory,omitempty"` Warnings []string `json:"warnings,omitempty"` } // Lockfile is the root document of schema v2. type Lockfile struct { Version int `json:"version"` Sources map[string]*Source `json:"sources"` } // New returns an empty lockfile v2. func New() *Lockfile { return &Lockfile{Version: Version, Sources: map[string]*Source{}} } // Load reads the lockfile at path. A missing file yields ErrNotExist. func Load(path string) (*Lockfile, error) { data, err := os.ReadFile(path) if err != nil { if errors.Is(err, os.ErrNotExist) { return nil, ErrNotExist } return nil, err } var l Lockfile if err := json.Unmarshal(data, &l); err != nil { return nil, fmt.Errorf("lockfile %s is not valid JSON: %w", path, err) } if l.Version != Version { return nil, fmt.Errorf("lockfile %s has schema version %d, want %d", path, l.Version, Version) } if l.Sources == nil { l.Sources = map[string]*Source{} } return &l, nil } // Save canonically orders all slices and maps, then writes deterministic JSON. func (l *Lockfile) Save(path string) error { for _, src := range l.Sources { sort.Strings(src.Selection.Exclude) sort.Strings(src.Selection.Include) sort.Strings(src.Warnings) for _, items := range src.Inventory { sort.Strings(items) } } data, err := Marshal(l) if err != nil { return err } return os.WriteFile(path, data, 0o644) } // Marshal renders the lockfile as deterministic bytes: fixed field order via // struct layout, sorted map keys, two-space indent, trailing newline. func Marshal(l *Lockfile) ([]byte, error) { data, err := json.MarshalIndent(l, "", " ") if err != nil { return nil, err } return append(data, '\n'), nil }