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
15 changes: 12 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,8 +57,8 @@ loadout sync # install into detected agents (asks first)
| `loadout source add\|list\|remove <url>` | Track skills from external git repos (pinned in `sources.lock.yaml`) |
| `loadout check` | Check sources for updates — `git ls-remote` only, nothing fetched or applied |
| `loadout update [source]` | Fetch updates, show changelog + artifact diff, apply to the store after confirmation |
| `loadout bundle export [--archive]` | Export the store as a shareable directory or `.loadout.tar.gz` — everything by default, or exactly `--artifacts`/`--profiles`/`--library` (repeatable) plus `--sources` |
| `loadout bundle import <path>` | Validate a bundle, show the merge plan (`--on-conflict skip\|overwrite`), apply after confirmation |
| `loadout bundle export [--archive\|--flat]` | Export the store as a shareable directory, `.loadout.tar.gz`, or a single self-contained `.loadout.yaml` document (`--flat`) — everything by default, or exactly `--artifacts`/`--profiles`/`--library` (repeatable) plus `--sources` |
| `loadout bundle import <path>` | Validate a bundle (directory, `.tar.gz`, or flat YAML — auto-detected), show the merge plan (`--on-conflict skip\|overwrite`), apply after confirmation |
| `loadout wizard` (alias `create`) | Guided builder: stack + engineering conventions + SDD → generated skills/instructions/commands (`--preset`, `--profile`, `--answers`, `--save-profile`, `--yes`) |
| `loadout profile list\|show\|rm` | Save, list, inspect and delete named wizard answer sets (see `--save-profile` above) |
| `loadout loadouts save\|list\|show\|switch\|rm` | Named, switchable sets of active artifacts — `switch` installs the set and removes whatever isn't in it (always asks before writing) |
Expand Down Expand Up @@ -230,7 +230,16 @@ loadout bundle export --archive --artifacts demo --profiles acme-backend # →

`--sources` additionally includes `sources.lock.yaml` in a selective export (it's always included in a full, unfiltered one).

The web UI covers the same import flow for `.loadout.tar.gz` archives: from the dashboard's "share" section, upload the file, review the plan (new / conflicting / identical artifacts, new sources), toggle skip-vs-overwrite for conflicts, and confirm — then run a sync to install into your agents. Directory bundles (the non-`--archive` export) are import-able from the CLI only. For picking exactly what to share instead of exporting everything, the dashboard's share section links to a dedicated **Export** page: chip-select artifacts (grouped by kind, with per-kind and select-all shortcuts), profiles, and library entries or whole groups, then download the resulting `.loadout.tar.gz`.
For sharing a small selection somewhere text-shaped — a gist, a doc, a chat message — `--flat` writes one self-contained YAML document (`apiVersion: loadout-config/v1`) instead of a directory or archive:

```bash
loadout bundle export --flat --artifacts demo --profiles acme-backend -o config.yaml
loadout bundle import config.yaml --yes # same merge plan/apply path as any other bundle
```

`bundle import` auto-detects the input shape (directory, `.tar.gz`, or flat YAML) from content, not the file extension, so there's no separate import command to remember.

The web UI covers the same import flow: from the dashboard's "share" section, either upload a `.loadout.tar.gz` file or paste a flat YAML document directly into a textarea, review the plan (new / conflicting / identical artifacts, new sources), toggle skip-vs-overwrite for conflicts, and confirm — then run a sync to install into your agents. Directory bundles (the non-`--archive` export) are import-able from the CLI only. For picking exactly what to share instead of exporting everything, the dashboard's share section links to a dedicated **Export** page: chip-select artifacts (grouped by kind, with per-kind and select-all shortcuts), profiles, and library entries or whole groups, then either download a `.loadout.tar.gz` or render a copyable/downloadable flat YAML document.

## Supported agents

Expand Down
91 changes: 91 additions & 0 deletions integration/m4_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -84,3 +84,94 @@ func TestBundleFlow_ExportWipeImport(t *testing.T) {
t.Fatalf("imported instruction not synced: %v\n%s", err, raw)
}
}

// TestBundleFlow_FlatYAML mirrors TestBundleFlow_ExportWipeImport but uses
// the single-file --flat format instead of a .tar.gz archive — the same
// "new machine" story, paste-into-a-gist shaped.
func TestBundleFlow_FlatYAML(t *testing.T) {
machineA := newEnv(t)
machineA.mustRun("init", "--name", "team-kit")
machineA.mustRun("new", "skill", "review")
machineA.mustRun("new", "instruction", "style")

if out, err := machineA.run("", "wizard", "--preset", "go-cli", "--save-profile", "acme-backend"); err != nil {
t.Fatalf("save-profile errored: %v\n%s", err, out)
}
storeRootA := filepath.Join(machineA.home, ".local", "share", "loadout", "store")
libraryDir := filepath.Join(storeRootA, "library")
if err := os.MkdirAll(libraryDir, 0o755); err != nil {
t.Fatal(err)
}
// Entries are content-addressed (sha256(trimmed text)[:12]) — a group
// referencing this entry must use the real derived ID, not a made-up
// one, or SaveGroup's membership validation on import rejects it.
const entryID = "13465ce6d578" // sha256("always write tests first")[:12]
libraryYAML := "instructions:\n - id: " + entryID + "\n text: always write tests first\n createdAt: 2026-01-01T00:00:00Z\n"
if err := os.WriteFile(filepath.Join(libraryDir, "instructions.yaml"), []byte(libraryYAML), 0o644); err != nil {
t.Fatal(err)
}
groupsYAML := "groups:\n - name: testing-rules\n entryIds: [" + entryID + "]\n createdAt: 2026-01-01T00:00:00Z\n"
if err := os.WriteFile(filepath.Join(libraryDir, "groups.yaml"), []byte(groupsYAML), 0o644); err != nil {
t.Fatal(err)
}

flatFile := filepath.Join(machineA.home, "team.loadout.yaml")
out := machineA.mustRun("bundle", "export", "--flat", "--out", flatFile)
if !strings.Contains(out, flatFile) {
t.Fatalf("export output:\n%s", out)
}
rawFlat, err := os.ReadFile(flatFile)
if err != nil {
t.Fatal(err)
}
if !strings.HasPrefix(string(rawFlat), "apiVersion: loadout-config/v1") {
t.Fatalf("flat export missing expected apiVersion header:\n%s", rawFlat)
}

machineB := newEnv(t)
machineB.mustRun("init", "--name", "mine")

if out, err := machineB.run("", "bundle", "import", flatFile); err != nil {
t.Fatalf("declined import errored: %v\n%s", err, out)
}
storeSkills := filepath.Join(machineB.home, ".local", "share", "loadout", "store", "skills", "review")
if _, err := os.Stat(storeSkills); !os.IsNotExist(err) {
t.Fatal("import wrote without confirmation")
}

out = machineB.mustRun("bundle", "import", flatFile, "--yes")
if !strings.Contains(out, "review") || !strings.Contains(out, "style") {
t.Fatalf("import output:\n%s", out)
}
if !strings.Contains(out, "acme-backend") {
t.Fatalf("import output missing the profile:\n%s", out)
}
if !strings.Contains(out, "library entry") {
t.Fatalf("import output missing the library entry:\n%s", out)
}
if !strings.Contains(out, "library group testing-rules") {
t.Fatalf("import output missing the library group:\n%s", out)
}

storeRootB := filepath.Join(machineB.home, ".local", "share", "loadout", "store")
if _, err := os.Stat(filepath.Join(storeRootB, "profiles", "acme-backend.yaml")); err != nil {
t.Fatalf("profile not imported into machine B's store: %v", err)
}
libRaw, err := os.ReadFile(filepath.Join(storeRootB, "library", "instructions.yaml"))
if err != nil || !strings.Contains(string(libRaw), "always write tests first") {
t.Fatalf("library entry not imported into machine B's store: %v\n%s", err, libRaw)
}
groupsRaw, err := os.ReadFile(filepath.Join(storeRootB, "library", "groups.yaml"))
if err != nil || !strings.Contains(string(groupsRaw), "testing-rules") {
t.Fatalf("library group not imported into machine B's store: %v\n%s", err, groupsRaw)
}

machineB.mustRun("sync", "--yes")
if _, err := os.Stat(machineB.claude("skills", "review", "SKILL.md")); err != nil {
t.Fatalf("imported skill not synced on machine B: %v", err)
}
rawInstr, err := os.ReadFile(machineB.claude("CLAUDE.md"))
if err != nil || !strings.Contains(string(rawInstr), "loadout:begin style") {
t.Fatalf("imported instruction not synced: %v\n%s", err, rawInstr)
}
}
35 changes: 29 additions & 6 deletions internal/bundle/archive.go
Original file line number Diff line number Diff line change
Expand Up @@ -115,8 +115,14 @@ func untarGz(archive, dir string) error {
}
}

// openBundle returns the bundle root for a directory or extracts a .tar.gz
// into a temp dir (cleanup returned).
// gzipMagic is the two-byte header every gzip stream starts with.
var gzipMagic = []byte{0x1f, 0x8b}

// openBundle returns the bundle root for a directory, extracts a .tar.gz
// into a temp dir, or unpacks a single flat YAML document into a temp dir
// shaped like a bundle — detected by content, not file extension, so it
// works the same whether the path ends in .tar.gz, .yaml, or nothing at
// all. cleanup is returned for the two temp-dir cases.
func openBundle(src string) (root string, cleanup func(), err error) {
info, err := os.Stat(src)
if err != nil {
Expand All @@ -125,13 +131,30 @@ func openBundle(src string) (root string, cleanup func(), err error) {
if info.IsDir() {
return src, nil, nil
}
tmp, err := os.MkdirTemp("", "loadout-import-*")

f, err := os.Open(src)
if err != nil {
return "", nil, err
}
if err := untarGz(src, tmp); err != nil {
os.RemoveAll(tmp)
magic := make([]byte, 2)
n, _ := io.ReadFull(f, magic)
f.Close()

if n == 2 && magic[0] == gzipMagic[0] && magic[1] == gzipMagic[1] {
tmp, err := os.MkdirTemp("", "loadout-import-*")
if err != nil {
return "", nil, err
}
if err := untarGz(src, tmp); err != nil {
os.RemoveAll(tmp)
return "", nil, err
}
return tmp, func() { os.RemoveAll(tmp) }, nil
}

raw, err := os.ReadFile(src)
if err != nil {
return "", nil, err
}
return tmp, func() { os.RemoveAll(tmp) }, nil
return unpackFlat(raw)
}
70 changes: 68 additions & 2 deletions internal/bundle/bundle.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
"io/fs"
"os"
"path/filepath"
"slices"
"sort"
"strings"
"time"
Expand Down Expand Up @@ -156,6 +157,12 @@ type ImportPlan struct {
// content-addressed (see wizard.AddToLibrary), so merging them is
// always safe — there is no conflict concept for the library.
NewLibraryEntries []wizard.LibraryEntry
// NewGroups and ConflictGroups are library groups (library/groups.yaml)
// diffed by name — same shape as profiles: a name the local store
// doesn't have is New, the same name with different member entries is
// a Conflict.
NewGroups []string
ConflictGroups []string

bundleRoot string
cleanup func()
Expand All @@ -171,9 +178,9 @@ func (p *ImportPlan) Close() {
// Changes reports how many artifacts Apply would write with the given
// overwrite policy.
func (p *ImportPlan) Changes(overwrite bool) int {
n := len(p.New) + len(p.Sources) + len(p.NewProfiles) + len(p.NewLibraryEntries)
n := len(p.New) + len(p.Sources) + len(p.NewProfiles) + len(p.NewLibraryEntries) + len(p.NewGroups)
if overwrite {
n += len(p.Conflicts) + len(p.ConflictProfiles)
n += len(p.Conflicts) + len(p.ConflictProfiles) + len(p.ConflictGroups)
}
return n
}
Expand Down Expand Up @@ -293,6 +300,30 @@ func PlanImport(s *store.Store, src string) (*ImportPlan, error) {
}
}

localGroups, err := wizard.ListGroups(s)
if err != nil {
plan.Close()
return nil, err
}
bundleGroups, err := wizard.ListGroups(bundleStore)
if err != nil {
plan.Close()
return nil, err
}
localGroupByName := make(map[string]wizard.LibraryGroup, len(localGroups))
for _, g := range localGroups {
localGroupByName[g.Name] = g
}
for _, g := range bundleGroups {
local, exists := localGroupByName[g.Name]
switch {
case !exists:
plan.NewGroups = append(plan.NewGroups, g.Name)
case !slices.Equal(local.EntryIDs, g.EntryIDs):
plan.ConflictGroups = append(plan.ConflictGroups, g.Name)
}
}

sort.Slice(plan.New, func(i, j int) bool { return plan.New[i].ID < plan.New[j].ID })
sort.Slice(plan.Conflicts, func(i, j int) bool { return plan.Conflicts[i].ID < plan.Conflicts[j].ID })
sort.Strings(plan.NewProfiles)
Expand Down Expand Up @@ -410,6 +441,41 @@ func Apply(s *store.Store, p *ImportPlan, overwrite bool) ([]string, error) {
}
}

if len(p.NewGroups) > 0 || (overwrite && len(p.ConflictGroups) > 0) {
bundleStore, err := store.Open(p.bundleRoot)
if err != nil {
return applied, err
}
bundleGroups, err := wizard.ListGroups(bundleStore)
if err != nil {
return applied, err
}
byName := make(map[string]wizard.LibraryGroup, len(bundleGroups))
for _, g := range bundleGroups {
byName[g.Name] = g
}
applyGroup := func(name string) error {
g, ok := byName[name]
if !ok {
return fmt.Errorf("group %q not found in bundle", name)
}
_, err := wizard.SaveGroup(s, g.Name, g.EntryIDs)
return err
}
for _, name := range p.NewGroups {
if err := applyGroup(name); err != nil {
return applied, err
}
}
if overwrite {
for _, name := range p.ConflictGroups {
if err := applyGroup(name); err != nil {
return applied, err
}
}
}
}

return applied, nil
}

Expand Down
97 changes: 97 additions & 0 deletions internal/bundle/bundle_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -269,6 +269,103 @@ func TestApply_SkipsConflictingProfileWithoutOverwrite(t *testing.T) {
}
}

func TestPlanImport_IncludesGroups(t *testing.T) {
src := testStore(t, "team-kit")
a, err := wizard.AddToLibrary(src, "always write tests first")
if err != nil {
t.Fatal(err)
}
if _, err := wizard.SaveGroup(src, "testing-rules", []string{a.ID}); err != nil {
t.Fatal(err)
}
dest := filepath.Join(t.TempDir(), "exported")
if _, err := Export(src, dest, "test"); err != nil {
t.Fatal(err)
}

fresh := testStore(t, "mine")
plan, err := PlanImport(fresh, dest)
if err != nil {
t.Fatal(err)
}
defer plan.Close()
if len(plan.NewGroups) != 1 || plan.NewGroups[0] != "testing-rules" {
t.Fatalf("NewGroups = %v, want [testing-rules]", plan.NewGroups)
}

if _, err := Apply(fresh, plan, false); err != nil {
t.Fatal(err)
}
groups, err := wizard.ListGroups(fresh)
if err != nil {
t.Fatal(err)
}
if len(groups) != 1 || groups[0].Name != "testing-rules" || len(groups[0].EntryIDs) != 1 {
t.Fatalf("imported groups = %v", groups)
}
}

func TestApply_SkipsConflictingGroupWithoutOverwrite(t *testing.T) {
src := testStore(t, "team-kit")
a, err := wizard.AddToLibrary(src, "entry a")
if err != nil {
t.Fatal(err)
}
if _, err := wizard.SaveGroup(src, "rules", []string{a.ID}); err != nil {
t.Fatal(err)
}
dest := filepath.Join(t.TempDir(), "exported")
if _, err := Export(src, dest, "test"); err != nil {
t.Fatal(err)
}

local := testStore(t, "mine")
b, err := wizard.AddToLibrary(local, "entry b")
if err != nil {
t.Fatal(err)
}
if _, err := wizard.SaveGroup(local, "rules", []string{b.ID}); err != nil {
t.Fatal(err)
}

plan, err := PlanImport(local, dest)
if err != nil {
t.Fatal(err)
}
defer plan.Close()
if len(plan.ConflictGroups) != 1 || plan.ConflictGroups[0] != "rules" {
t.Fatalf("ConflictGroups = %v", plan.ConflictGroups)
}

// Skip policy: local group untouched. Apply also needs entry "a" to
// exist locally before an overwrite could succeed.
if _, err := wizard.AddToLibrary(local, "entry a"); err != nil {
t.Fatal(err)
}
if _, err := Apply(local, plan, false); err != nil {
t.Fatal(err)
}
groups, err := wizard.ListGroups(local)
if err != nil {
t.Fatal(err)
}
if len(groups) != 1 || groups[0].EntryIDs[0] != b.ID {
t.Fatalf("skip policy should leave the local group untouched, got %v", groups)
}

// Overwrite policy replaces membership with the bundle's version.
if _, err := Apply(local, plan, true); err != nil {
t.Fatal(err)
}
groups, err = wizard.ListGroups(local)
if err != nil {
t.Fatal(err)
}
if len(groups) != 1 || groups[0].EntryIDs[0] != a.ID {
t.Fatalf("overwrite policy should replace membership with the bundle's, got %v", groups)
}
}

func TestUntarGz_RejectsPathTraversal(t *testing.T) {
// Hand-craft a malicious archive.
dir := t.TempDir()
Expand Down
Loading
Loading