149 lines
4.0 KiB
Go
149 lines
4.0 KiB
Go
// 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
|
||
|
|
}
|