Files
agent-lib/internal/lockfile/lockfile.go
T

164 lines
4.4 KiB
Go
Raw Permalink Normal View History

// 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{}}
}
// ParseBytes unmarshals and validates lockfile JSON content.
func ParseBytes(data []byte) (*Lockfile, error) {
var l Lockfile
if err := json.Unmarshal(data, &l); err != nil {
return nil, fmt.Errorf("lockfile is not valid JSON: %w", err)
}
if l.Version != Version {
return nil, fmt.Errorf("lockfile has schema version %d, want %d", l.Version, Version)
}
if l.Sources == nil {
l.Sources = map[string]*Source{}
}
return &l, nil
}
// 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
}