Repository: danielgtaylor/restish
Pull request #156 (2023-01-04 06:25:44)

Bulk Resource Management
This PR adds support for client-side bulk resource management via a `git`-like interface behind the new `restish bulk` subcommand. Typical usage:

```sh
$ mkdir books && cd books
$ restish bulk init api.rest.sh/books

# ... Make changes ...

$ restish bulk status
$ restish bulk diff

$ restish bulk push
```

This works for any API which returns a list of versioned resources which either include links to fetch each resource, or have enough information to generate links from a template. Lots of docs are included so I'll let those stand on their own and not repeat everything here!


Patch:
diff --git a/bulk/commands.go b/bulk/commands.go
new file mode 100644
index 0000000..23a65bd
--- /dev/null
+++ b/bulk/commands.go
@@ -0,0 +1,444 @@
+package bulk
+
+import (
+	"encoding/json"
+	"fmt"
+	"io"
+	"io/fs"
+	"net/http"
+	"os"
+	"sort"
+	"strings"
+
+	"github.com/danielgtaylor/mexpr"
+	"github.com/danielgtaylor/restish/cli"
+	"github.com/danielgtaylor/restish/openapi"
+	"github.com/hexops/gotextdiff"
+	"github.com/hexops/gotextdiff/myers"
+	"github.com/hexops/gotextdiff/span"
+	"github.com/pb33f/libopenapi/datamodel/high/base"
+	"github.com/pb33f/libopenapi/datamodel/low"
+	lowbase "github.com/pb33f/libopenapi/datamodel/low/base"
+	"github.com/pb33f/libopenapi/index"
+	"github.com/spf13/afero"
+	"github.com/spf13/cobra"
+	"github.com/spf13/viper"
+	"gopkg.in/yaml.v3"
+)
+
+var afs afero.Fs = afero.NewOsFs()
+
+// panicOnErr panics if an error is passed, otherwise does nothing.
+func panicOnErr(err error) {
+	if err != nil {
+		panic(err)
+	}
+}
+
+// isFalsey returns if a value is falsey, such as `0`, `""`, `[]any{}`, etc.
+// Empty slices and maps are considered falsey.
+func isFalsey(v any) bool {
+	switch t := v.(type) {
+	case bool:
+		return !t
+	case int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64:
+		return t == 0
+	case float32, float64:
+		return t == 0.0
+	case string:
+		return len(t) == 0
+	case []byte:
+		return len(t) == 0
+	case []any:
+		return len(t) == 0
+	case map[string]any:
+		return len(t) == 0
+	case map[any]any:
+		return len(t) == 0
+	}
+	return false
+}
+
+// newInterpreter creates a new mexpr interpreter, optionally with type
+// checking if a JSON Schema is available to describe the structure of the
+// input. Parse errors are logged as warnings since there could be false
+// positives - the idea is to provide help to the user for debugging.
+func newInterpreter(expression, schemaURL string) mexpr.Interpreter {
+	var example map[string]any
+
+	if schemaURL != "" {
+		// We have a schema which might be a JSON Schema we can understand. Let's
+		// try to download, parse, and generate an example for the type checker.
+		// Note: JSON Schema supports a superset of the mexpr types, for example
+		// one-ofs and if/then/else. Some schemas will result in warnings that
+		// may be false positives, but this is still a useful feature worth
+		// keeping in my opinion.
+		req, _ := http.NewRequest(http.MethodGet, schemaURL, nil)
+		if resp, err := cli.MakeRequest(req); err == nil && resp.StatusCode < 300 {
+			cli.DecodeResponse(resp)
+			defer resp.Body.Close()
+			if body, err := io.ReadAll(resp.Body); err == nil {
+
+				var rootNode yaml.Node
+				var ls lowbase.Schema
+
+				if err := yaml.Unmarshal(body, &rootNode); err == nil {
+					if err := low.BuildModel(rootNode.Content[0], &ls); err == nil {
+						if err := ls.Build(rootNode.Content[0], index.NewSpecIndex(&rootNode)); err == nil {
+							s := base.NewSchema(&ls)
+							result := openapi.GenExample(s, 0)
+							if asMap, ok := result.(map[string]any); ok {
+								example = asMap
+							}
+						}
+					}
+				}
+			}
+		}
+	}
+
+	ast, err := mexpr.Parse(expression, example, mexpr.UnquotedStrings)
+	if err != nil {
+		cli.LogWarning(err.Pretty(expression))
+		// Just return a falsey value to filter these files out.
+		ast = &mexpr.Node{
+			Type:  mexpr.NodeLiteral,
+			Value: 0,
+		}
+	}
+
+	return mexpr.NewInterpreter(ast, mexpr.UnquotedStrings)
+}
+
+// collectFiles gets a list of files to manipulate for a given command, taking
+// into account what was passed on the commandline, any filter matching options,
+// and whether to include files which have been deleted on disk but are still
+// present in the metadata index.
+func collectFiles(meta *Meta, args []string, match string, includeDeleted bool) []string {
+	if len(args) == 0 {
+		// No files passed in, so let's find them!
+		seen := map[string]bool{}
+		afero.Walk(afs, ".", func(path string, f fs.FileInfo, err error) error {
+			if f.IsDir() || strings.HasPrefix(path, ".") {
+				return nil
+			}
+
+			args = append(args, path)
+			seen[path] = true
+			return nil
+		})
+
+		if includeDeleted {
+			for _, f := range meta.Files {
+				if !seen[f.Path] {
+					args = append(args, f.Path)
+				}
+			}
+		}
+	}
+
+	if match != "" {
+		// We want to filter by an experession.
+		newArgs := []string{}
+
+		interpreters := map[string]mexpr.Interpreter{}
+		for _, path := range args {
+			schema := ""
+			if f := meta.Files[path]; f != nil {
+				schema = f.Schema
+			}
+
+			// Individual resource types could have their own schemas. We build a
+			// registry of one interpreter for each distinct type that has a schema.
+			i := interpreters[schema]
+			if i == nil {
+				interpreters[schema] = newInterpreter(match, schema)
+				i = interpreters[schema]
+			}
+
+			var v any
+			b, _ := afero.ReadFile(afs, path)
+			json.Unmarshal(b, &v)
+			result, err := i.Run(v)
+			if err != nil || result == nil || isFalsey(result) {
+				// Skip!
+				continue
+			}
+			newArgs = append(newArgs, path)
+		}
+
+		args = newArgs
+	}
+
+	sort.Strings(args)
+
+	return args
+}
+
+// loadMeta loads the Restish bulk metadata file from disk if possible.
+func loadMeta(meta *Meta) error {
+	b, err := afero.ReadFile(afs, metaFile)
+	if err != nil {
+		return err
+	}
+	return json.Unmarshal(b, meta)
+}
+
+// mustLoadMeta loads the metadata file or panics.
+func mustLoadMeta() *Meta {
+	var m Meta
+	panicOnErr(loadMeta(&m))
+	return &m
+}
+
+// getStatus displays the current status of the checkout, including both
+// remote and local changes.
+func getStatus() error {
+	meta := mustLoadMeta()
+	local, remote, err := meta.GetChanged(collectFiles(meta, []string{}, "", false))
+	if err != nil {
+		return err
+	}
+
+	if len(remote) > 0 {
+		fmt.Fprintf(cli.Stdout, "Remote changes on %s\n  (use \"%s bulk pull\" to update)\n", meta.URL, os.Args[0])
+		for _, changed := range remote {
+			fmt.Fprintln(cli.Stdout, changed)
+		}
+	} else {
+		fmt.Fprintln(cli.Stdout, "You are up to date with "+meta.URL)
+	}
+
+	if len(local) == 0 {
+		fmt.Fprintln(cli.Stdout, "No local changes")
+		return nil
+	}
+
+	fmt.Fprintf(cli.Stdout, "Local changes:\n  (use \"%s bulk reset [file]...\" to undo)\n  (use \"%s bulk diff [file]...\" to view changes)\n", os.Args[0], os.Args[0])
+	for _, changed := range local {
+		fmt.Fprintln(cli.Stdout, changed)
+	}
+
+	return nil
+}
+
+// diff a single file
+func diff(originalPath, modifiedPath string, original, modified []byte) {
+	var parsedOrig, parsedMod any
+	var err error
+
+	if len(original) > 0 {
+		json.Unmarshal(original, &parsedOrig)
+		original, err = cli.MarshalShort("json", true, parsedOrig)
+		panicOnErr(err)
+	}
+
+	if len(modified) > 0 {
+		json.Unmarshal(modified, &parsedMod)
+		modified, err = cli.MarshalShort("json", true, parsedMod)
+		panicOnErr(err)
+	}
+
+	edits := myers.ComputeEdits(span.URIFromPath("remote"), string(original), string(modified))
+
+	if len(edits) == 0 {
+		fmt.Fprintln(cli.Stdout, "No changes made.")
+		return
+	} else {
+		diff := fmt.Sprint(gotextdiff.ToUnified(originalPath, modifiedPath, string(original), edits))
+		if viper.GetBool("color") {
+			d, _ := cli.Highlight("diff", []byte(diff))
+			diff = string(d)
+		}
+		fmt.Fprintln(cli.Stdout, diff)
+	}
+}
+
+// getLocalDiffs for the given set of file paths. Displays one diff per file
+// without any separators.
+func getLocalDiffs(meta *Meta, files []string) error {
+	changed := false
+	for _, path := range files {
+		var orig []byte
+		if f, ok := meta.Files[path]; ok {
+			if !f.IsChangedLocal(false) {
+				continue
+			}
+			orig, _ = f.Fetch()
+		}
+		changed = true
+		modified, _ := afero.ReadFile(afs, path)
+		diff("remote "+meta.Base+strings.TrimSuffix(path, ".json"), "local "+path, orig, modified)
+	}
+
+	if !changed {
+		fmt.Fprintln(cli.Stdout, "No local changes")
+	}
+
+	return nil
+}
+
+// getRemoteDiffs shows a diff for all the changed remote files.
+func getRemoteDiffs(meta *Meta) error {
+	_, remote, err := meta.GetChanged(collectFiles(meta, []string{}, "", true))
+	if err != nil {
+		return err
+	}
+
+	if len(remote) == 0 {
+		fmt.Fprintln(cli.Stdout, "No remote changes")
+		return nil
+	}
+
+	for _, f := range remote {
+		path := f.File.Path
+		modified, _ := f.File.Fetch()
+		orig, _ := afero.ReadFile(afs, path)
+		diff("local "+path, "remote "+meta.Base+strings.TrimSuffix(path, ".json"), orig, modified)
+	}
+
+	return nil
+}
+
+// Init the bulk commands given a parent command.
+func Init(cmd *cobra.Command) {
+	bulk := cobra.Command{
+		GroupID: "generic",
+		Use:     "bulk",
+		Short:   "Client-side bulk resource management https://rest.sh/#/bulk",
+		Example: "  " + os.Args[0] + " bulk init api.rest.sh/books\n  " + os.Args[0] + " bulk list -m 'rating_average >= 4.8'\n  " + os.Args[0] + " bulk status",
+	}
+
+	bulk.AddGroup(
+		&cobra.Group{ID: "init", Title: "Start Here:"},
+		&cobra.Group{ID: "info", Title: "Informational Commands:"},
+		&cobra.Group{ID: "local", Title: "Local Edit Commands:"},
+		&cobra.Group{ID: "remote", Title: "Remote Sync Commands:"},
+	)
+
+	init := cobra.Command{
+		GroupID:    "init",
+		Use:        "init URL [-f filter] [--url-template tmpl]",
+		Aliases:    []string{"i"},
+		SuggestFor: []string{"checkout", "co", "clone", "cl"},
+		Short:      "Initialize a new bulk checkout. Start here.",
+		Long: "Initialize a new bulk checkout via a URL that returns a list that contains a link and version for each resource. Use a `-f` filter to massage the response and the `--url-template` option to create a URL from a resource ID if no link is available. The response should look something like:\n\n```json" + `
+[
+  {
+    "url": "...",
+    "version": "..."
+  },
+  {
+    "url": "...",
+    "version": "..."
+  }
+]
+` + "```\n\nThe following fields will automatically be found and used:\n\n- Resource URL: `url`, `uri`, `self`, `link`\n- Resource version: `version`, `etag`, `last_modified`, `lastModified`, `modified`.\n\nFiltering (if used) runs *before* URL template rendering.\n\nRestish assumes resources have client-generated IDs and use HTTP `PUT`, but if that's not the case then you can still create new resources manually with `restish post ...`.",
+		Args:    cobra.ExactArgs(1),
+		Example: "  " + os.Args[0] + " bulk init api.example.com/users -f 'body.{url, version: last_login}'\n  " + os.Args[0] + " bulk init api.example.com/users -f 'body.{id, version: last_login}' --url-template='/users/{id}'",
+		Run: func(cmd *cobra.Command, args []string) {
+			var m Meta
+			loadMeta(&m)
+			template, _ := cmd.Flags().GetString("url-template")
+			panicOnErr(m.Init(args[0], template))
+		},
+	}
+	init.Flags().String("url-template", "", "URL template to build links (e.g. from item IDs)")
+
+	list := cobra.Command{
+		GroupID: "info",
+		Use:     "list [--match expr]",
+		Aliases: []string{"ls"},
+		Short:   "List checked out files",
+		Args:    cobra.NoArgs,
+		Example: "  " + os.Args[0] + " bulk list -m 'id contains abc'\n  " + os.Args[0] + " bulk list -m 'reviews where rating > 4'",
+		Run: func(cmd *cobra.Command, args []string) {
+			match, _ := cmd.Flags().GetString("match")
+			for _, path := range collectFiles(mustLoadMeta(), args, match, false) {
+				fmt.Fprintln(cli.Stdout, path)
+			}
+		},
+	}
+	list.Flags().StringP("match", "m", "", "Expression to match")
+
+	pull := cobra.Command{
+		GroupID: "remote",
+		Use:     "pull",
+		Aliases: []string{"pl"},
+		Short:   "Pull remote updates. Does not overwrite local changes.",
+		Args:    cobra.NoArgs,
+		Run: func(cmd *cobra.Command, args []string) {
+			panicOnErr(mustLoadMeta().Pull())
+		},
+	}
+
+	status := cobra.Command{
+		GroupID: "info",
+		Use:     "status",
+		Aliases: []string{"st"},
+		Short:   "Show the local & remote added/changed/removed files",
+		Args:    cobra.NoArgs,
+		RunE: func(cmd *cobra.Command, args []string) error {
+			return getStatus()
+		},
+	}
+
+	diff := cobra.Command{
+		GroupID: "info",
+		Use:     "diff [file... | --match expr | --remote]",
+		Aliases: []string{"di"},
+		Short:   "Show a diff of local or remote changed files",
+		Run: func(cmd *cobra.Command, args []string) {
+			match, _ := cmd.Flags().GetString("match")
+			remote, _ := cmd.Flags().GetBool("remote")
+			meta := mustLoadMeta()
+			if remote {
+				panicOnErr(getRemoteDiffs(meta))
+			} else {
+				panicOnErr(getLocalDiffs(meta, collectFiles(meta, args, match, true)))
+			}
+		},
+	}
+	diff.Flags().StringP("match", "m", "", "Expression to match")
+	diff.Flags().Bool("remote", false, "Show remote diffs instead of local")
+
+	reset := cobra.Command{
+		GroupID: "local",
+		Use:     "reset [file... | --match expr]",
+		Aliases: []string{"re"},
+		Short:   "Undo local changes to files",
+		Run: func(cmd *cobra.Command, args []string) {
+			meta := mustLoadMeta()
+			match, _ := cmd.Flags().GetString("match")
+			for _, name := range collectFiles(meta, args, match, true) {
+				if f, ok := meta.Files[name]; ok {
+					panicOnErr(f.Reset())
+				}
+			}
+			panicOnErr(meta.Save())
+		},
+	}
+	reset.Flags().StringP("match", "m", "", "Expression to match")
+
+	push := cobra.Command{
+		GroupID: "remote",
+		Use:     "push",
+		Aliases: []string{"ps"},
+		Short:   "Upload local changes to the remote server",
+		Args:    cobra.NoArgs,
+		Run: func(cmd *cobra.Command, args []string) {
+			// TODO: limit, pause-every, wait-between, concurrent, etc to control uploads?
+			panicOnErr(mustLoadMeta().Push())
+		},
+	}
+
+	bulk.AddCommand(&init)
+	bulk.AddCommand(&list)
+	bulk.AddCommand(&pull)
+	bulk.AddCommand(&status)
+	bulk.AddCommand(&diff)
+	bulk.AddCommand(&reset)
+	bulk.AddCommand(&push)
+
+	cmd.AddCommand(&bulk)
+}
diff --git a/bulk/file.go b/bulk/file.go
new file mode 100644
index 0000000..27ecb44
--- /dev/null
+++ b/bulk/file.go
@@ -0,0 +1,154 @@
+package bulk
+
+import (
+	"bytes"
+	"encoding/json"
+	"fmt"
+	"net/http"
+	"net/url"
+	"path"
+	"path/filepath"
+	"reflect"
+
+	"github.com/danielgtaylor/restish/cli"
+	"github.com/spf13/afero"
+	"github.com/zeebo/xxh3"
+)
+
+// hash returns a new fast 128-bit hash of the given bytes.
+func hash(b []byte) []byte {
+	tmp := xxh3.Hash128(b).Bytes()
+	return tmp[:]
+}
+
+// File represents a checked out file with metadata about the remote and local
+// version(s) of the file.
+type File struct {
+	// Path is the relative path to the local file
+	Path string `json:"path"`
+	// URL to the remote file
+	URL string `json:"url"`
+
+	// ETag header used for conditional updates
+	ETag string `json:"etag,omitempty"`
+	// LastModified header used for conditional updates
+	LastModified string `json:"last_modified,omitempty"`
+
+	// VersionRemote used to compare when listing
+	VersionRemote string `json:"version_remote,omitempty"`
+	// VersionLocal tracks the local copy of the file
+	VersionLocal string `json:"version_local,omitempty"`
+
+	// Schema is used to describe the type of the resource, if available.
+	Schema string `json:"schema,omitempty"`
+
+	// Hash is used for detecting local changes
+	Hash []byte `json:"hash,omitempty"`
+}
+
+// GetData returns the file contents.
+func (f *File) GetData() ([]byte, error) {
+	return afero.ReadFile(afs, f.Path)
+}
+
+// IsChangedLocal returns whether a file has been modified locally. The
+// `ignoreDeleted` parameter sets whether deleted files are considered to be
+// changed or not.
+func (f *File) IsChangedLocal(ignoreDeleted bool) bool {
+	if len(f.Hash) == 0 {
+		return false
+	}
+	b, err := f.GetData()
+	if err != nil {
+		return !ignoreDeleted
+	}
+
+	// Round-trip to get consistent formatting. This is inefficient but a much
+	// nicer experience for people with auto-formatters set up in their editor
+	// or who may try to undo changes and get the formatting slightly off.
+	var tmp any
+	json.Unmarshal(b, &tmp)
+	b, _ = cli.MarshalShort("json", true, tmp)
+
+	return !bytes.Equal(hash(b), f.Hash)
+}
+
+// IsChangedRemote returns whether the local and remote versions mismatch.
+func (f *File) IsChangedRemote() bool {
+	return f.VersionLocal != f.VersionRemote
+}
+
+// Fetch pulls the remote file and updates the metadata.
+func (f *File) Fetch() ([]byte, error) {
+	req, _ := http.NewRequest(http.MethodGet, f.URL, nil)
+	// TODO: conditional fetch?
+	resp, err := cli.GetParsedResponse(req)
+	if err != nil {
+		return nil, err
+	}
+
+	if resp.Status >= http.StatusBadRequest {
+		cli.LogError("Error fetching %s from %s\n", f.Path, f.URL)
+		cli.Formatter.Format(resp)
+		return nil, fmt.Errorf("error fetching %s", f.URL)
+	}
+
+	if etag := resp.Headers["Etag"]; etag != "" {
+		f.ETag = etag
+	}
+
+	if lastModified := resp.Headers["Last-Modified"]; lastModified != "" {
+		f.LastModified = lastModified
+	}
+
+	if db := resp.Links["describedby"]; len(db) > 0 {
+		baseURL, _ := url.Parse(f.URL)
+		u, _ := url.Parse(db[0].URI)
+		f.Schema = baseURL.ResolveReference(u).String()
+	} else {
+		v := reflect.ValueOf(resp.Body)
+		if v.Kind() == reflect.Map && !v.IsNil() {
+			if s := v.MapIndex(reflect.ValueOf("$schema")); s.Kind() == reflect.String {
+				// Assume this is not a relative URL as it lives within the doc.
+				f.Schema = v.String()
+			}
+		}
+	}
+
+	b, err := cli.MarshalShort("json", true, resp.Body)
+	if err != nil {
+		return nil, err
+	}
+
+	f.VersionLocal = f.VersionRemote
+
+	if err := f.WriteCached(b); err != nil {
+		return nil, err
+	}
+
+	return b, nil
+}
+
+// WriteCached writes the file to disk in the special cache directory.
+func (f *File) WriteCached(b []byte) error {
+	fp := path.Join(".rshbulk", f.Path)
+	afs.MkdirAll(filepath.Dir(fp), 0700)
+	return afero.WriteFile(afs, fp, b, 0600)
+}
+
+// Write writes the file to disk. This also updates the local file hash
+// used to determine if the file has been modified.
+func (f *File) Write(b []byte) error {
+	f.Hash = hash(b)
+	afs.MkdirAll(filepath.Dir(f.Path), 0700)
+	return afero.WriteFile(afs, f.Path, b, 0600)
+}
+
+// Reset overwrites the local file with the remote contents.
+func (f *File) Reset() error {
+	cached, err := afero.ReadFile(afs, path.Join(metaDir, f.Path))
+	if err != nil {
+		return err
+	}
+	return f.Write(cached)
+}
diff --git a/bulk/metadata.go b/bulk/metadata.go
new file mode 100644
index 0000000..a19ec6f
--- /dev/null
+++ b/bulk/metadata.go
@@ -0,0 +1,482 @@
+package bulk
+
+import (
+	"bytes"
+	"fmt"
+	"net/http"
+	"net/url"
+	"os"
+	"path/filepath"
+	"regexp"
+	"sort"
+	"strings"
+	"time"
+
+	"github.com/danielgtaylor/restish/cli"
+	"github.com/danielgtaylor/shorthand/v2"
+	"github.com/logrusorgru/aurora"
+	"github.com/schollz/progressbar/v3"
+	"github.com/spf13/afero"
+	"github.com/spf13/viper"
+)
+
+const (
+	metaDir  = ".rshbulk"
+	metaFile = ".rshbulk" + string(os.PathSeparator) + "meta"
+)
+
+// commonPrefix finds the longest common directory prefix of a given set
+// of URLs. The set of all strings after the prefix is guaranteed to be
+// unique.
+func commonPrefix(urls []listEntry) string {
+	if len(urls) == 0 {
+		return ""
+	}
+
+	prefix := strings.Split(urls[0].URL, "/")
+
+	for _, entry := range urls[1:] {
+		parts := strings.Split(entry.URL, "/")
+		for i, part := range parts {
+			if len(prefix) == i || prefix[i] != part {
+				prefix = prefix[:i]
+				break
+			}
+		}
+	}
+
+	return strings.Join(prefix, "/") + "/"
+}
+
+// getFirstKey returns the first found string key value for the given keys
+// which are searched in order if item is a map. Returns an empty string if
+// none are found.
+func getFirstKey(item any, keys ...string) string {
+	if m, ok := item.(map[string]any); ok {
+		for _, k := range keys {
+			if m[k] != nil {
+				return fmt.Sprintf("%v", m[k])
+			}
+		}
+	}
+	if m, ok := item.(map[any]any); ok {
+		for _, k := range keys {
+			if m[k] != nil {
+				return fmt.Sprintf("%v", m[k])
+			}
+		}
+	}
+	return ""
+}
+
+// listEntry represents a response from a list resources call.
+type listEntry struct {
+	URL     string `json:"url"`
+	Version string `json:"version"`
+}
+
+type fileStatus uint8
+
+const (
+	// Terminal color codes
+	statusAdded    = 150
+	statusModified = 172
+	statusRemoved  = 204
+)
+
+// changedFile represents a file with a changed status (add/modify/remove)
+type changedFile struct {
+	Status fileStatus
+	File   *File
+}
+
+func (c changedFile) String() string {
+	au := aurora.NewAurora(viper.GetBool("color"))
+	label := map[fileStatus]string{
+		statusAdded:    "added",
+		statusModified: "modified",
+		statusRemoved:  "removed",
+	}[c.Status]
+	return fmt.Sprintf("\t%8s:  %s", au.Index(uint8(c.Status), label), c.File.Path)
+}
+
+// Meta represents metadata about the remote and local status of the checkout.
+type Meta struct {
+	URL         string           `json:"url"`
+	Filter      string           `json:"filter,omitempty"`
+	Base        string           `json:"base,omitempty"`
+	Schema      string           `json:"schema,omitempty"`
+	URLTemplate string           `json:"url_template,omitempty"`
+	Files       map[string]*File `json:"files,omitempty"`
+}
+
+// Save the metadata file to disk.
+func (m *Meta) Save() error {
+	b, err := cli.MarshalShort("json", true, m)
+	if err != nil {
+		return err
+	}
+	afs.MkdirAll(metaDir, 0700)
+	return afero.WriteFile(afs, metaFile, b, 0600)
+}
+
+// Init initializes the metadata file, saves it to disk, and then performs
+// the initial pull to fetch each file.
+func (m *Meta) Init(url, template string) error {
+	m.URL = cli.FixAddress(url)
+	m.Filter = viper.GetString("rsh-filter")
+	m.URLTemplate = template
+	m.Files = map[string]*File{}
+
+	if err := m.Save(); err != nil {
+		return err
+	}
+
+	return m.Pull()
+}
+
+// PullIndex updates the index of remote files and their versions. It does not
+// save the metadata file.
+func (m *Meta) PullIndex() error {
+	bar := progressbar.NewOptions(-1,
+		progressbar.OptionSetWriter(cli.Stdout),
+		progressbar.OptionEnableColorCodes(true),
+		progressbar.OptionSetDescription("Refreshing index..."),
+		progressbar.OptionSpinnerType(14),
+		progressbar.OptionSetRenderBlankState(false),
+	)
+
+	done := make(chan bool)
+
+	go func() {
+		// Don't draw the spinner until the request has taken a short while already
+		// to prevent a flash of text that immediately disappears.
+		time.Sleep(250 * time.Millisecond)
+		for {
+			select {
+			case <-done:
+				bar.Clear()
+				return
+			default:
+				bar.Add(1)
+				time.Sleep(250 * time.Millisecond)
+			}
+		}
+	}()
+
+	defer func() {
+		done <- true
+	}()
+
+	req, _ := http.NewRequest(http.MethodGet, m.URL, nil)
+	parsed, err := cli.GetParsedResponse(req)
+	if err != nil {
+		panic(err)
+	}
+
+	if parsed.Status >= http.StatusBadRequest {
+		cli.LogError("Error fetching resource list %s\n", m.URL)
+		cli.Formatter.Format(parsed)
+		return fmt.Errorf("error fetching %s", m.URL)
+	}
+
+	var data any
+	if m.Filter == "" {
+		data = parsed.Body
+	} else {
+		opts := shorthand.GetOptions{}
+		if viper.GetBool("rsh-verbose") {
+			opts.DebugLogger = cli.LogDebug
+		}
+
+		result, _, err := shorthand.GetPath(m.Filter, parsed.Map(), opts)
+		if err != nil {
+			return err
+		}
+
+		data = result
+	}
+
+	if _, ok := data.([]any); !ok {
+		panic("resource list response is not a list")
+	}
+
+	var entries []listEntry
+
+	for _, entry := range data.([]any) {
+		// Try to get a {url, version} tuple from various possible common key names.
+		url := getFirstKey(entry, "url", "uri", "self", "link")
+		if url == "" && m.URLTemplate != "" {
+			// We have a way to build the URL from other fields in the response.
+			re := regexp.MustCompile(`\{[^}]+\}`)
+			url = re.ReplaceAllStringFunc(m.URLTemplate, func(match string) string {
+				match = strings.Trim(match, "{}")
+				if m, ok := entry.(map[string]any); ok {
+					return fmt.Sprintf("%v", m[match])
+				}
+				if m, ok := entry.(map[any]any); ok {
+					return fmt.Sprintf("%v", m[match])
+				}
+				return ""
+			})
+		}
+
+		version := getFirstKey(entry, "version", "etag", "last_modified", "lastModified", "modified")
+
+		if (url == "") || (version == "") {
+			return fmt.Errorf("list response must contain a URL and version for each resource")
+		}
+		entries = append(entries, listEntry{url, version})
+	}
+
+	baseURL, _ := url.Parse(m.URL)
+	prefix, _ := url.Parse(commonPrefix(entries))
+	m.Base = baseURL.ResolveReference(prefix).String()
+
+	for _, f := range m.Files {
+		// Clear all the remote versions, we will set them for files that exist
+		// in the next step.
+		f.VersionRemote = ""
+	}
+
+	for _, entry := range entries {
+		u, _ := url.Parse(entry.URL)
+		resolved := baseURL.ResolveReference(u).String()
+		path := resolved[len(m.Base):] + ".json"
+		f := m.Files[path]
+		if f == nil {
+			// Remote file was added.
+			f = &File{
+				Path: path,
+				URL:  resolved,
+			}
+			m.Files[path] = f
+		}
+		f.VersionRemote = entry.Version
+	}
+
+	return nil
+}
+
+// Pull files from the remote. In the case of local changes this will update
+// the index but *not* overwrite the local file containing the edits. When
+// the pull completes, the metadata file is saved.
+func (m *Meta) Pull() error {
+	if err := m.PullIndex(); err != nil {
+		return err
+	}
+
+	updates := []*File{}
+	for _, f := range m.Files {
+		if f.VersionLocal == f.VersionRemote {
+			// No need to redownload this.
+			continue
+		}
+
+		updates = append(updates, f)
+	}
+
+	if len(updates) == 0 {
+		fmt.Fprintln(cli.Stdout, "Already up to date.")
+		return nil
+	}
+
+	bar := progressbar.NewOptions(len(updates),
+		progressbar.OptionSetWriter(cli.Stdout),
+		progressbar.OptionEnableColorCodes(true),
+		progressbar.OptionSetDescription("Pulling resources..."),
+	)
+
+	for _, f := range updates {
+		if f.VersionRemote == "" {
+			// This was removed on the remote!
+			delete(m.Files, f.Path)
+			if !f.IsChangedLocal(true) {
+				afs.Remove(f.Path)
+			}
+			bar.Add(1)
+			continue
+		}
+
+		b, err := f.Fetch()
+		if err != nil {
+			return err
+		}
+
+		// Don't overwrite local edits!
+		if f.IsChangedLocal(true) {
+			bar.Clear()
+			fmt.Fprintln(cli.Stdout, "Skipping due to local edits:", f.Path)
+			bar.Add(1)
+			continue
+		}
+
+		if err := f.Write(b); err != nil {
+			return err
+		}
+
+		bar.Add(1)
+	}
+
+	fmt.Fprintln(cli.Stdout)
+
+	return m.Save()
+}
+
+// GetChanged calculates all the changed local and remote files using the
+// following rules after refreshing the index:
+// Remote:
+// - Added: No local version or file
+// - Changed: Local version != remote version
+// - Removed: No remote version
+// Local:
+// - Added: Local file with no metadata entry
+// - Changed: Local file hash != remote file hash
+// - Removed: Metadata entry without local file
+func (m *Meta) GetChanged(files []string) ([]changedFile, []changedFile, error) {
+	if err := m.PullIndex(); err != nil {
+		return nil, nil, err
+	}
+
+	filesMap := map[string]bool{}
+	for _, path := range files {
+		filesMap[path] = true
+	}
+
+	local := []changedFile{}
+	remote := []changedFile{}
+
+	for _, path := range files {
+		if strings.HasPrefix(path, ".") {
+			// Skip hidden dotfiles.
+			continue
+		}
+		if f, ok := m.Files[path]; ok {
+			if f.IsChangedLocal(true) {
+				local = append(local, changedFile{statusModified, f})
+			}
+			if f.VersionRemote == "" {
+				remote = append(remote, changedFile{statusRemoved, f})
+			} else if f.VersionLocal != f.VersionRemote {
+				remote = append(remote, changedFile{statusModified, f})
+			}
+		} else {
+			local = append(local, changedFile{
+				statusAdded, &File{
+					Path: path,
+					URL:  m.Base + strings.TrimSuffix(path, filepath.Ext(path)),
+				},
+			})
+		}
+	}
+
+	for _, f := range m.Files {
+		if f.VersionLocal == "" {
+			remote = append(remote, changedFile{statusAdded, f})
+		} else {
+			if !filesMap[f.Path] {
+				local = append(local, changedFile{statusRemoved, f})
+			}
+		}
+	}
+
+	// Because deleted files would be appended, we need to sort!
+	sort.Slice(local, func(i, j int) bool {
+		return local[i].File.Path < local[j].File.Path
+	})
+
+	return local, remote, nil
+}
+
+// Push uploads changed files to the server, using conditional updates when
+// possible.
+func (m *Meta) Push() error {
+	local, _, err := m.GetChanged(collectFiles(m, []string{}, "", false))
+	if err != nil {
+		return err
+	}
+
+	bar := progressbar.NewOptions(len(local),
+		progressbar.OptionSetWriter(cli.Stdout),
+		progressbar.OptionEnableColorCodes(true),
+		progressbar.OptionSetDescription("Pushing resources..."),
+	)
+
+	for _, changed := range local {
+		f := changed.File
+		if changed.Status == statusModified || changed.Status == statusAdded {
+			body, _ := afero.ReadFile(afs, f.Path)
+			req, _ := http.NewRequest(http.MethodPut, f.URL, bytes.NewReader(body))
+
+			if f.ETag != "" {
+				req.Header.Set("If-Match", f.ETag)
+			} else if f.LastModified != "" {
+				req.Header.Set("If-Unmodified-Since", f.LastModified)
+			}
+
+			resp, err := cli.GetParsedResponse(req)
+			if err != nil {
+				return err
+			}
+			if resp.Status >= 400 {
+				fmt.Fprintf(cli.Stdout, "Error uploading %s to %s\n", f.Path, f.URL)
+				cli.Formatter.Format(resp)
+				return err
+			}
+
+			if changed.Status == statusAdded {
+				// Add the file to the metadata
+				m.Files[changed.File.Path] = changed.File
+			}
+
+			// Fetch and write the updated metadata/file to disk.
+			b, err := f.Fetch()
+			if err != nil {
+				return err
+			}
+			if err := f.Write(b); err != nil {
+				return err
+			}
+		} else {
+			req, _ := http.NewRequest(http.MethodDelete, f.URL, nil)
+
+			if f.ETag != "" {
+				req.Header.Set("If-Match", f.ETag)
+			} else if f.LastModified != "" {
+				req.Header.Set("If-Unmodified-Since", f.LastModified)
+			}
+
+			resp, err := cli.GetParsedResponse(req)
+			if err != nil {
+				return err
+			}
+			if resp.Status >= 400 {
+				fmt.Fprintf(cli.Stdout, "Error uploading %s to %s\n", f.Path, f.URL)
+				cli.Formatter.Format(resp)
+				return err
+			}
+			delete(m.Files, f.Path)
+		}
+		bar.Add(1)
+	}
+
+	fmt.Fprintln(cli.Stdout)
+
+	if err := m.PullIndex(); err != nil {
+		return err
+	}
+
+	for _, changed := range local {
+		// Mark all the changed files as matching the new remote version. The
+		// file contents were already updated above. This code can't be run until
+		// after we pull the index again to get the updated remote versions.
+		changed.File.VersionLocal = changed.File.VersionRemote
+	}
+
+	if err := m.Save(); err != nil {
+		return err
+	}
+
+	fmt.Fprintln(cli.Stdout, "Push complete.")
+	return nil
+}
diff --git a/cli/cli.go b/cli/cli.go
index 0f5c470..813c7bd 100644
--- a/cli/cli.go
+++ b/cli/cli.go
@@ -199,6 +199,7 @@ func Init(name string, version string) {
 	tty := false
 	if isatty.IsTerminal(os.Stdout.Fd()) || isatty.IsCygwinTerminal(os.Stdout.Fd()) || viper.GetBool("tty") {
 		tty = true
+		viper.Set("tty", true)
 	}
 
 	useColor = false
@@ -210,6 +211,8 @@ func Init(name string, version string) {
 		// Support colored output across operating systems.
 		Stdout = colorable.NewColorableStdout()
 		Stderr = colorable.NewColorableStderr()
+
+		viper.Set("color", useColor)
 	}
 
 	au = aurora.NewAurora(useColor)
@@ -671,7 +674,7 @@ func Defaults() {
 }
 
 // Run the CLI! Parse arguments, make requests, print responses.
-func Run() {
+func Run() error {
 	// We need to register new commands at runtime based on the selected API
 	// so that we don't have to potentially refresh and parse every single
 	// registered API just to run. So this is a little hacky, but we hijack
@@ -782,15 +785,23 @@ func Run() {
 		}
 	}
 
+	var returnErr error
+
 	// Phew, we made it. Execute the command now that everything is loaded
 	// and all the relevant sub-commands are registered.
 	defer func() {
 		if err := recover(); err != nil {
 			LogError("Caught error: %v", err)
 			LogDebug("%s", string(debug.Stack()))
+			if e, ok := err.(error); ok {
+				returnErr = e
+			}
 		}
 	}()
 	if err := Root.Execute(); err != nil {
 		LogError("Error: %v", err)
+		returnErr = err
 	}
+
+	return returnErr
 }
diff --git a/cli/request.go b/cli/request.go
index d610058..3ad304a 100644
--- a/cli/request.go
+++ b/cli/request.go
@@ -16,6 +16,11 @@ import (
 	"github.com/spf13/viper"
 )
 
+// FixAddress can convert `:8000` or `example.com` to a full URL.
+func FixAddress(addr string) string {
+	return fixAddress(addr)
+}
+
 // fixAddress can convert `:8000` or `example.com` to a full URL.
 func fixAddress(addr string) string {
 	if strings.HasPrefix(addr, ":") {
diff --git a/docs/README.md b/docs/README.md
index 23cd912..1ef56dc 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -40,6 +40,7 @@
   - [Terrifically Simple JSON](https://github.com/mpnally/Terrifically-Simple-JSON)
   - [JSON:API](https://jsonapi.org/)
 - Local caching that respects [RFC 7234](https://tools.ietf.org/html/rfc7234) `Cache-Control` and `Expires` headers
+- Client-side bulk resource management (like git for API resources)
 - CLI [shorthand](https://github.com/danielgtaylor/openapi-cli-generator/tree/master/shorthand#cli-shorthand-syntax) for structured data input (e.g. for JSON)
 - [Shorthand query](https://github.com/danielgtaylor/shorthand#querying) response filtering & projection
 - Colorized prettified readable output
diff --git a/docs/_sidebar.md b/docs/_sidebar.md
index e5a2c41..42e98c6 100644
--- a/docs/_sidebar.md
+++ b/docs/_sidebar.md
@@ -7,3 +7,4 @@
 - [CLI Shorthand](shorthand.md "CLI Shorthand")
 - [Output](output.md "Restish Output")
 - [Hypermedia](hypermedia.md "Hypermedia Linking in Restish")
+- [Bulk Management](bulk.md "Bulk Resource Management")
diff --git a/docs/bulk.md b/docs/bulk.md
new file mode 100644
index 0000000..2cc800d
--- /dev/null
+++ b/docs/bulk.md
@@ -0,0 +1,245 @@
+# Bulk Resource Management
+
+Restish includes support for a `git`-like client-side bulk resource management interface, enabling you to pull resources onto disk and track both remote and local changes, get diffs, batch submit updates, etc.
+
+## Prerequisites
+
+Any API that supports versioned resources and provides that version in the list response can work with Restish's bulk resource management.
+
+If common response field names and standard conditional update headers are used, things should just work out of the box. If not, then there are arguments to massage the response data into a format Restish can understand, described in more detail in the reference below.
+
+For example, you might have a simple CRUD-style API like this, which fully supports bulk operations:
+
+```
+GET    /books           List books returns [{url: "...", version: "..."}]
+GET    /books/{book-id} Get book
+PUT    /books/{book-id} Create/update book
+DELETE /books/{book-id} Delete book
+```
+
+?> Resource creation via Restish `bulk` requires the use of client-generated identifiers and HTTP `PUT` requests. Use plain old `restish post ...` otherwise.
+
+## Getting Started & Demo
+
+Let's see how it works in action by using an example CRUD API:
+
+```bash
+# First, let's create a simple alias for running bulk commands.
+$ alias rb="restish bulk"
+
+# Initialize your first checkout
+$ mkdir books && cd books
+$ rb init api.rest.sh/books
+```
+
+You will see the book resources get downloaded and saved on disk. You can list all the resources, or filter them by matching specific criteria using [mexpr](https://github.com/danielgtaylor/mexpr) expressions, which show any resource whose expression result is "truthy" (meaning a non-zero scalar or non-empty map/slice).
+
+```bash
+# List all the resources
+$ rb list
+letters-from-an-astrophysicist.json
+sapiens.json
+the-demon-haunted-world.json
+the-fabric-of-the-cosmos.json
+the-food-lab.json
+
+# List books with high average ratings
+$ rb list --match='rating_average >= 4.8'
+letters-from-an-astrophysicist.json
+the-food-lab.json
+
+# List books with recent ratings
+$ rb list --match='recent_ratings where date after "2022-01-01"'
+sapiens.json
+
+# List books written by Brian
+$ rb list --match='author.lower contains brian'
+the-fabric-of-the-cosmos.json
+```
+
+Restish also understands JSON Schema, so if the resources advertise a schema (e.g. via a `describedby` link relation of a `$schema` property) then it can provide useful errors when filtering. Since the example books advertise a schema at https://api.rest.sh/schemas/Book.json we can get warnings about potential expression problems:
+
+```bash
+$ rb list --match='recent_ratings > 5'
+WARN: cannot compare array with number
+recent_ratings > 5
+...............^^^^
+```
+
+Next, let's make some changes!
+
+```bash
+# Delete a book locally!
+$ rm the-food-lab.json
+
+# Create a new book with minimal config
+$ echo '{"title": "my-new-book"}' >my-new-book.json
+
+# Get the status (note, the server updates sapiens frequently)
+$ rb status
+Remote changes on https://api.rest.sh/books
+  (use "restish bulk pull" to update)
+	modified:  sapiens.json
+Local changes:
+  (use "restish bulk reset [file]..." to undo)
+  (use "restish bulk diff [file]..." to view changes)
+	   added:  my-new-book.json
+	 removed:  the-food-lab.json
+
+# Reset the deletion so we don't delete the book on the server!
+$ rb reset the-food-lab.json
+
+# Push your changes
+$ rb push
+Pushing resources... 100% |████████████████████████████████████████|
+Push complete.
+```
+
+The server now has a `my-new-book` resource. In the future, you can update to the latest changes that others make via:
+
+```bash
+# Pull the latest
+$ rb pull
+Pulling resources... 100% |████████████████████████████████████████|
+
+# See no changes
+$ rb status
+You are up to date with https://api.rest.sh/books
+No local changes
+```
+
+## Reference
+
+### Init
+
+```bash
+restish bulk init URL [-f filter] [--url-template tmpl]
+```
+
+Initialize a new bulk checkout. The response should be a list of resources which contain a link URL and version, or optionally you can pass a filter or URL template to build the link URL and/or version needed to fetch listed resources.
+
+Alias: `i`
+
+| Param / Option      | Description & Example                                                                                                                                                          |
+| ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
+| `URL`               | The URL to list resources<br/>Example: `api.rest.sh/books`                                                                                                                     |
+| `--rsh-filter`/`-f` | Filter the response via [Shorthand Query](./shorthand.md#querying)<br/>Example: `-f 'body.{id, version: last_modified_dt}'`                                                    |
+| `--url-template`    | Template string to build URLs from list response items. If a filter is passed, it is processed _before_ rendering the URL template.<br/>Example: `--url-template='/items/{id}` |
+
+#### Automatically Recognized Fields
+
+The following fields are automatically recognized and used when available in the list response items, allowing bulk resource management to just work out of the box with a large number of APIs. Fields are checked in the order listed below and the first that is found will be used.
+
+| Description      | Field Names                                                    |
+| ---------------- | -------------------------------------------------------------- |
+| Resource URL     | `url`, `uri`, `self`, `link`                                   |
+| Resource version | `version`, `etag`, `last_modified`, `lastModified`, `modified` |
+
+#### Complex Example
+
+For a more complex example, let's assume you have an API at `example.com/items` which returns resources for multiple people via a list operation like this:
+
+```json
+{
+	"items": [
+		{
+			"owner": "alice",
+			"id": "abc123",
+			"unique_hash": "a1b1"
+		},
+		{
+			"owner": "bob",
+			"id": "def456",
+			"unique_hash": "a1b2"
+		},
+		...
+	]
+}
+```
+
+Each individual item is available under the owner's name, e.g. the first item would be fetched via `example.com/items/alice/abc123`. You can initialize this bulk checkout via Restish like so:
+
+```bash
+$ rb init example.com/items \
+	-f 'body.items.{owner, id, version: unique_hash}' \
+	--url-template='/items/{owner}/{id}'
+```
+
+### List
+
+```bash
+restish bulk list [--match expr]
+```
+
+List checked out resources, optionally with filtering via expressions.
+
+Alias: `ls`
+
+| Param / Option  | Description & Example                                                                                                       |
+| --------------- | --------------------------------------------------------------------------------------------------------------------------- |
+| `-m`, `--match` | Match resources using [mexpr](https://github.com/danielgtaylor/mexpr) expressions<br/>Example: `-m 'rating_average >= 4.8'` |
+
+?> Match expressions show any resource whose expression result is "truthy" (meaning a non-zero scalar or non-empty map/slice). `false`, `0`, `""`, `[]`, and `{}` are considered "falsey".
+
+### Status
+
+```bash
+restish bulk status
+```
+
+Show the local & remote added/changed/removed files.
+
+Alias: `st`
+
+### Diff
+
+```bash
+restish bulk diff [FILE... | --match expr | --remote]
+```
+
+Show a diff of local or remote changed files.
+
+Alias: `di`
+
+| Param / Option  | Description & Example                                                                                                       |
+| --------------- | --------------------------------------------------------------------------------------------------------------------------- |
+| `-m`, `--match` | Match resources using [mexpr](https://github.com/danielgtaylor/mexpr) expressions<br/>Example: `-m 'rating_average >= 4.8'` |
+| `--remote`      | Show remote diffs instead of local                                                                                          |
+
+?> Remote diffs can be useful to see changes before doing a `rb pull`!
+
+### Reset
+
+```bash
+restish bulk reset [FILE... | --match expr]
+```
+
+Undo local changes to files.
+
+Alias: `re`
+
+| Param / Option  | Description & Example                                                                                                       |
+| --------------- | --------------------------------------------------------------------------------------------------------------------------- |
+| `-m`, `--match` | Match resources using [mexpr](https://github.com/danielgtaylor/mexpr) expressions<br/>Example: `-m 'rating_average >= 4.8'` |
+
+### Pull
+
+```bash
+restish bulk pull
+```
+
+Pull remote updates. Use `restish bulk status` to see if there are remote updates to pull.
+
+Pulling does not overwrite local changes. Use `restish bulk reset FILE` to overwrite local changes after a pull.
+
+Alias: `pl`
+
+### Push
+
+```bash
+restish bulk push
+```
+
+Upload local changes to the remote server. Resources are updated sequentially (one after the other).
+
+Alias: `ps`
diff --git a/docs/guide.md b/docs/guide.md
index 33c756c..3b9a0c8 100644
--- a/docs/guide.md
+++ b/docs/guide.md
@@ -196,11 +196,11 @@ Accept-Ranges: bytes
 
 APIs can be registered in order to provide API description auto-discovery (e.g. OpenAPI 3) with convenience commands and authentication. The following API description formats and versions are supported:
 
-| Format  | Version | Notes                              |
-| ------- | ------- | ---------------------------------- |
-| Swagger | 2.0     | :x: Not supported                  |
-| OpenAPI | 3.0     | :white_check_mark: Fully supported |
-| OpenAPI | 3.1     | :white_check_mark: Fully supported |
+| Format  | Version | Notes              |
+| ------- | ------- | ------------------ |
+| Swagger | 2.0     | ❌ Not supported   |
+| OpenAPI | 3.0     | ✅ Fully supported |
+| OpenAPI | 3.1     | ✅ Fully supported |
 
 APIs are registered with a short nickname. For example the GitHub v3 API might be called `github` or the Digital Ocean API might be called `do`.
 
diff --git a/docs/index.html b/docs/index.html
index fd82178..d92f0ab 100644
--- a/docs/index.html
+++ b/docs/index.html
@@ -159,6 +159,16 @@
         color: #d6ffb7;
         font-style: italic;
       }
+
+      .token.diffAdded {
+        color: #afd787;
+      }
+      .token.diffModified {
+        color: #d78700;
+      }
+      .token.diffRemoved {
+        color: #ff5f87;
+      }
     </style>
   </head>
   <body>
@@ -262,17 +272,20 @@
           },
         },
         string: {
-          // pattern: /("(?:\\.|[^\\"\r\n])*"(?!\s*:))|('(?:\\.|[^\\'\r\n])*'(?!\s*:))/,
-          pattern: /("(?:\\.|[^\\"\r\n])*"(?!\s*:))/,
+          pattern: /("(?:\\.|[^\\"\r\n])*"(?!\s*:))|('(?:\\.|[^\\'\r\n])*'(?!\s*:))/,
+          // pattern: /("(?:\\.|[^\\"\r\n])*"(?!\s*:))/,
           greedy: true,
         },
         keypress: /<\S+>/,
+        diffAdded: /\s+added: /,
+        diffModified: /\s+modified: /,
+        diffRemoved: /\s+removed: /,
         property: /[A-Za-z0-9.-]+(?=[:[{][a-z0-9.^ _-\}[\]])/,
         number: /\b[0-9]+(\.[0-9]+)?/,
         boolean: /\b(?:true|false)\b/i,
         null: /\bnull\b/i,
         function: /contains|startsWith/,
-        keyword: /restish|<|[|]|\b(for|do|done)(?!\/)\b/,
+        keyword: /restish|rb|<|>|[|]|\b(for|do|done)(?!\/)\b/,
       };
     </script>
   </body>
diff --git a/go.mod b/go.mod
index 6ff25ff..5eeba6e 100644
--- a/go.mod
+++ b/go.mod
@@ -10,7 +10,8 @@ require (
 	github.com/andybalholm/brotli v1.0.4
 	github.com/charmbracelet/glamour v0.6.0
 	github.com/danielgtaylor/casing v0.0.0-20210126043903-4e55e6373ac3
-	github.com/danielgtaylor/shorthand/v2 v2.0.1
+	github.com/danielgtaylor/mexpr v1.7.3
+	github.com/danielgtaylor/shorthand/v2 v2.0.2
 	github.com/eliukblau/pixterm v1.3.1
 	github.com/fxamacker/cbor/v2 v2.4.0
 	github.com/gbl08ma/httpcache v1.0.2
@@ -23,16 +24,20 @@ require (
 	github.com/mattn/go-colorable v0.1.13
 	github.com/mattn/go-isatty v0.0.16
 	github.com/mitchellh/mapstructure v1.5.0
+	github.com/nmrshll/go-cp v0.0.0-20180115193924-61436d3b7cfa
 	github.com/pb33f/libopenapi v0.3.7
+	github.com/schollz/progressbar/v3 v3.12.2
 	github.com/shamaton/msgpack/v2 v2.1.1
+	github.com/spf13/afero v1.9.3
 	github.com/spf13/cobra v1.6.1
 	github.com/spf13/pflag v1.0.5
 	github.com/spf13/viper v1.14.0
 	github.com/stretchr/testify v1.8.1
 	github.com/tent/http-link-go v0.0.0-20130702225549-ac974c61c2f9
+	github.com/zeebo/xxh3 v1.0.2
 	golang.org/x/exp v0.0.0-20221204150635-6dcec336b2bb
 	golang.org/x/oauth2 v0.2.0
-	golang.org/x/term v0.2.0
+	golang.org/x/term v0.3.0
 	golang.org/x/text v0.5.0
 	gopkg.in/h2non/gock.v1 v1.1.2
 	gopkg.in/yaml.v2 v2.4.0
@@ -42,7 +47,6 @@ require (
 require (
 	github.com/aymanbagabas/go-osc52 v1.2.1 // indirect
 	github.com/aymerick/douceur v0.2.0 // indirect
-	github.com/danielgtaylor/mexpr v1.7.2 // indirect
 	github.com/davecgh/go-spew v1.1.1 // indirect
 	github.com/disintegration/imaging v1.6.2 // indirect
 	github.com/dlclark/regexp2 v1.7.0 // indirect
@@ -56,11 +60,13 @@ require (
 	github.com/hashicorp/hcl v1.0.0 // indirect
 	github.com/inconshreveable/mousetrap v1.1.0 // indirect
 	github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
+	github.com/klauspost/cpuid/v2 v2.0.9 // indirect
 	github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
 	github.com/magiconair/properties v1.8.6 // indirect
 	github.com/mattn/go-runewidth v0.0.14 // indirect
 	github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d // indirect
 	github.com/microcosm-cc/bluemonday v1.0.21 // indirect
+	github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db // indirect
 	github.com/muesli/reflow v0.3.0 // indirect
 	github.com/muesli/termenv v0.13.0 // indirect
 	github.com/olekukonko/tablewriter v0.0.5 // indirect
@@ -69,7 +75,6 @@ require (
 	github.com/peterbourgon/diskv v2.0.1+incompatible // indirect
 	github.com/pmezard/go-difflib v1.0.0 // indirect
 	github.com/rivo/uniseg v0.4.3 // indirect
-	github.com/spf13/afero v1.9.3 // indirect
 	github.com/spf13/cast v1.5.0 // indirect
 	github.com/spf13/jwalterweatherman v1.1.0 // indirect
 	github.com/subosito/gotenv v1.4.1 // indirect
diff --git a/go.sum b/go.sum
index b2072ed..fc36a36 100644
--- a/go.sum
+++ b/go.sum
@@ -71,10 +71,10 @@ github.com/creack/pty v1.1.17 h1:QeVUsEDNrLBW4tMgZHvxy18sKtr6VI492kBhUfhDJNI=
 github.com/creack/pty v1.1.17/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4=
 github.com/danielgtaylor/casing v0.0.0-20210126043903-4e55e6373ac3 h1:qDsADtCM9A6UfvHje3eD91dufI9nVSwHWEqqhAvh28U=
 github.com/danielgtaylor/casing v0.0.0-20210126043903-4e55e6373ac3/go.mod h1:eFdYmNxcuLDrRNW0efVoxSaApmvGXfHZ9k2CT/RSUF0=
-github.com/danielgtaylor/mexpr v1.7.2 h1:NDAIvh3BIE8jDSjJEO/EqTNxuguaNbNEH/LPTDbM4bQ=
-github.com/danielgtaylor/mexpr v1.7.2/go.mod h1:Jcg0kPd2IPMV8ZxnpOj/UbNwMbM9As23hi8yxKsrI0U=
-github.com/danielgtaylor/shorthand/v2 v2.0.1 h1:RUKQzqt8mH57IP7gfo90MajH+Sm2HjjxWRAnSybRByw=
-github.com/danielgtaylor/shorthand/v2 v2.0.1/go.mod h1:iWZyWz+QrYscKroB6hdrK01C2u6qIg/dE3ttmsrUZfE=
+github.com/danielgtaylor/mexpr v1.7.3 h1:mEW3GKsrywh/KERIDgb13xr/fgAN+063jXOw54C7Sj4=
+github.com/danielgtaylor/mexpr v1.7.3/go.mod h1:Jcg0kPd2IPMV8ZxnpOj/UbNwMbM9As23hi8yxKsrI0U=
+github.com/danielgtaylor/shorthand/v2 v2.0.2 h1:J9QESJmFf1t1mD1uNviyLhZ94RKSuLZJHaMjXV393vY=
+github.com/danielgtaylor/shorthand/v2 v2.0.2/go.mod h1:LXOb4jEUfX1PB+S57k1bi98o0PDWLvaK0OnhHpKsilM=
 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
 github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
 github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -199,9 +199,12 @@ github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2
 github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
 github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU=
 github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk=
+github.com/k0kubun/go-ansi v0.0.0-20180517002512-3bf9e2903213/go.mod h1:vNUNkEQ1e29fT/6vq2aBdFsgNPmy8qMdSay1npru+Sw=
 github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs=
 github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8=
 github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
+github.com/klauspost/cpuid/v2 v2.0.9 h1:lgaqFMSdTdQYdZ04uHyN2d/eKdOMyi2YLSvlQIBFYa4=
+github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
 github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg=
 github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
 github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
@@ -232,6 +235,8 @@ github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d h1:5PJl274Y63IEHC+7izoQ
 github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE=
 github.com/microcosm-cc/bluemonday v1.0.21 h1:dNH3e4PSyE4vNX+KlRGHT5KrSvjeUkoNPwEORjffHJg=
 github.com/microcosm-cc/bluemonday v1.0.21/go.mod h1:ytNkv4RrDrLJ2pqlsSI46O6IVXmZOBBD4SaJyDwwTkM=
+github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db h1:62I3jR2EmQ4l5rM/4FEfDWcRD+abF5XlKShorW5LRoQ=
+github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db/go.mod h1:l0dey0ia/Uv7NcFFVbCLtqEBQbrT4OCwCSKTEv6enCw=
 github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
 github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
 github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s=
@@ -240,6 +245,8 @@ github.com/muesli/termenv v0.13.0 h1:wK20DRpJdDX8b7Ek2QfhvqhRQFZ237RGRO0RQ/Iqdy0
 github.com/muesli/termenv v0.13.0/go.mod h1:sP1+uffeLaEYpyOTb8pLCUctGcGLnoFjSn4YJK5e2bc=
 github.com/nbio/st v0.0.0-20140626010706-e9e8d9816f32 h1:W6apQkHrMkS0Muv8G/TipAy/FJl/rCYT0+EuS8+Z0z4=
 github.com/nbio/st v0.0.0-20140626010706-e9e8d9816f32/go.mod h1:9wM+0iRr9ahx58uYLpLIr5fm8diHn0JbqRycJi6w0Ms=
+github.com/nmrshll/go-cp v0.0.0-20180115193924-61436d3b7cfa h1:/SRdH7jdcIW6WBtZO1BlpI+6TxqpOiMqLZJ10Uhk0k0=
+github.com/nmrshll/go-cp v0.0.0-20180115193924-61436d3b7cfa/go.mod h1:/Uh/WFiWYXoTKVsM302U10XnogAldY7up/xErXmt1FA=
 github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
 github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE=
 github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU=
@@ -279,6 +286,8 @@ github.com/rivo/uniseg v0.4.3/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUc
 github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
 github.com/rogpeppe/go-internal v1.6.1 h1:/FiVV8dS/e+YqF2JvO3yXRFbBLTIuSDkuC7aBOAvL+k=
 github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
+github.com/schollz/progressbar/v3 v3.12.2 h1:yLqqqpQNMxGxHY8uEshRihaHWwa0rf0yb7/Zrpgq2C0=
+github.com/schollz/progressbar/v3 v3.12.2/go.mod h1:HFJYIYQQJX32UJdyoigUl19xoV6aMwZt6iX/C30RWfg=
 github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0=
 github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM=
 github.com/shamaton/msgpack/v2 v2.1.1 h1:gAMxOtVJz93R0EwewwUc8tx30n34aV6BzJuwHE8ogAk=
@@ -299,6 +308,7 @@ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+
 github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
 github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
 github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
+github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
 github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
 github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
 github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
@@ -328,6 +338,9 @@ github.com/yuin/goldmark v1.5.3 h1:3HUJmBFbQW9fhQOzMgseU134xfi6hU+mjWywx5Ty+/M=
 github.com/yuin/goldmark v1.5.3/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
 github.com/yuin/goldmark-emoji v1.0.1 h1:ctuWEyzGBwiucEqxzwe0SOYDXPAucOrE9NQC18Wa1os=
 github.com/yuin/goldmark-emoji v1.0.1/go.mod h1:2w1E6FEWLcDQkoTE+7HU6QF1F6SLlNGjRIBbIZQFqkQ=
+github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ=
+github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0=
+github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA=
 go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU=
 go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8=
 go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
@@ -497,8 +510,8 @@ golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
 golang.org/x/term v0.0.0-20210503060354-a79de5458b56/go.mod h1:tfny5GFUkzUvx4ps4ajbZsCe5lw1metzhBm9T3x7oIY=
 golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
-golang.org/x/term v0.2.0 h1:z85xZCsEl7bi/KwbNADeBYoOP0++7W1ipu+aGnpwzRM=
-golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc=
+golang.org/x/term v0.3.0 h1:qoo4akIqOcDME5bhc/NgxUdovd6BSS2uMsVjB56q1xI=
+golang.org/x/term v0.3.0/go.mod h1:q750SLmJuPmVoN1blW3UFBPREJfb1KmY3vwxfr+nFDA=
 golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
 golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
 golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
diff --git a/main.go b/main.go
index e121a71..f9bec00 100644
--- a/main.go
+++ b/main.go
@@ -3,6 +3,7 @@ package main
 import (
 	"os"
 
+	"github.com/danielgtaylor/restish/bulk"
 	"github.com/danielgtaylor/restish/cli"
 	"github.com/danielgtaylor/restish/oauth"
 	"github.com/danielgtaylor/restish/openapi"
@@ -26,6 +27,8 @@ func main() {
 	// Register default encodings, content type handlers, and link parsers.
 	cli.Defaults()
 
+	bulk.Init(cli.Root)
+
 	// Register format loaders to auto-discover API descriptions
 	cli.AddLoader(openapi.New())
 
@@ -34,5 +37,7 @@ func main() {
 	cli.AddAuth("oauth-authorization-code", &oauth.AuthorizationCodeHandler{})
 
 	// Run the CLI, parsing arguments, making requests, and printing responses.
-	cli.Run()
+	if err := cli.Run(); err != nil {
+		os.Exit(1)
+	}
 }
diff --git a/openapi/example.go b/openapi/example.go
index c494166..7c99ab5 100644
--- a/openapi/example.go
+++ b/openapi/example.go
@@ -9,8 +9,8 @@ import (
 	"golang.org/x/exp/maps"
 )
 
-// genExample creates a dummy example from a given schema.
-func genExample(schema *base.Schema, mode schemaMode) interface{} {
+// GenExample creates a dummy example from a given schema.
+func GenExample(schema *base.Schema, mode schemaMode) interface{} {
 	return genExampleInternal(schema, mode, map[[32]byte]bool{})
 }
 
diff --git a/openapi/openapi.go b/openapi/openapi.go
index 7305cfa..a80aa6b 100644
--- a/openapi/openapi.go
+++ b/openapi/openapi.go
@@ -179,7 +179,7 @@ func getRequestInfo(op *v3.Operation) (string, *base.Schema, []interface{}) {
 			}
 
 			if schema != nil && len(examples) == 0 {
-				examples = append(examples, genExample(schema, modeWrite))
+				examples = append(examples, GenExample(schema, modeWrite))
 			}
 
 			mts[mt] = []any{schema, examples}


Test patch:
diff --git a/bulk/commands_test.go b/bulk/commands_test.go
new file mode 100644
index 0000000..6e8958c
--- /dev/null
+++ b/bulk/commands_test.go
@@ -0,0 +1,452 @@
+package bulk
+
+import (
+	"fmt"
+	"io/fs"
+	"net/http"
+	"os"
+	"strings"
+	"testing"
+	"time"
+
+	"github.com/danielgtaylor/restish/cli"
+	"github.com/spf13/afero"
+	"github.com/stretchr/testify/require"
+	"gopkg.in/h2non/gock.v1"
+)
+
+func run(cmd ...string) (string, error) {
+	capture := &strings.Builder{}
+	cli.Stdout = capture
+	cli.Stderr = capture
+	cli.Root.SetOut(capture)
+	os.Args = append([]string{"restish"}, cmd...)
+	err := cli.Run()
+
+	return capture.String(), err
+}
+
+func mustExist(t *testing.T, path string) {
+	_, err := afs.Stat(path)
+	if err != nil {
+		paths := []string{}
+		afero.Walk(afs, "", func(path string, info fs.FileInfo, err error) error {
+			paths = append(paths, path)
+			return nil
+		})
+		t.Fatalf("Expected path %s not found in fs: %v", path, paths)
+	}
+}
+
+func mustEqualJSON(t *testing.T, path string, contents string) {
+	mustExist(t, path)
+	b, err := afero.ReadFile(afs, path)
+	require.NoError(t, err)
+	require.JSONEq(t, string(b), contents)
+}
+
+func mustContain(t *testing.T, path string, contents string) {
+	mustExist(t, path)
+	b, err := afero.ReadFile(afs, path)
+	require.NoError(t, err)
+	require.Contains(t, string(b), contents)
+}
+
+func mustHaveCalledAllHTTPMocks(t *testing.T) {
+	if !gock.IsDone() {
+		requests := []string{}
+		for _, mock := range gock.Pending() {
+			requests = append(requests, mock.Request().Method+" "+mock.Request().URLStruct.String())
+		}
+		require.True(t, gock.IsDone(), "Not all HTTP mocks were called:\n%s", strings.Join(requests, "\n"))
+	}
+}
+
+type remoteFile struct {
+	User    string `json:"user"`
+	ID      string `json:"id"`
+	Version string `json:"version"`
+	body    string
+	fetch   bool
+}
+
+func expectRemote(files []remoteFile) {
+	gock.New("https://example.com").
+		Get("/all-items").
+		Reply(http.StatusOK).
+		JSON(files)
+
+	for _, f := range files {
+		if f.fetch {
+			expectRemoteFile(f)
+		}
+	}
+}
+
+func expectRemoteFile(f remoteFile) {
+	body := f.body
+	if body == "" {
+		body = fmt.Sprintf(`{"id": "%s"}`, f.ID)
+	}
+
+	gock.New("https://example.com").
+		Get("/users/"+f.User+"/items/"+f.ID).
+		Reply(http.StatusOK).
+		SetHeader("Content-Type", "application/json").
+		SetHeader("Etag", string(hash([]byte(body)))).
+		SetHeader("Last-Modified", time.Now().Format(http.TimeFormat)).
+		BodyString(body)
+}
+
+/*
+Test workflow:
+- init (pull-index, pull)
+- list / list with filter
+- edit some files remotely
+- status
+- diff remote
+- pull
+- edit some files locally
+- reset a file
+- status
+- diff
+- push
+*/
+func TestWorkflow(t *testing.T) {
+	defer gock.Off()
+
+	expectRemote([]remoteFile{
+		{User: "a", ID: "a1", Version: "a11", fetch: true},
+		{User: "a", ID: "a2", Version: "a21", fetch: true},
+		{User: "b", ID: "b1", Version: "b11", fetch: true},
+		{User: "c", ID: "c1", Version: "c11", fetch: true},
+	})
+
+	afs = afero.NewMemMapFs()
+
+	cli.Init("test", "1.0.0")
+	cli.Defaults()
+	Init(cli.Root)
+
+	// Init
+	// ====
+	run("bulk", "init", "example.com/all-items", "--url-template=/users/{user}/items/{id}")
+
+	mustExist(t, ".rshbulk")
+	mustExist(t, ".rshbulk/meta")
+	mustEqualJSON(t, "a/items/a1.json", `{"id": "a1"}`)
+	mustEqualJSON(t, "a/items/a2.json", `{"id": "a2"}`)
+	mustEqualJSON(t, "b/items/b1.json", `{"id": "b1"}`)
+	mustEqualJSON(t, "c/items/c1.json", `{"id": "c1"}`)
+	mustHaveCalledAllHTTPMocks(t)
+
+	// List
+	// ----
+	gock.Flush()
+	out, err := run("bulk", "list")
+	require.NoError(t, err)
+	require.Contains(t, out, "a/items/a1.json")
+	require.Contains(t, out, "a/items/a2.json")
+	require.Contains(t, out, "b/items/b1.json")
+	require.Contains(t, out, "c/items/c1.json")
+
+	// List with match query
+	// ---------------------
+	gock.Flush()
+	out, err = run("bulk", "list", "-m", "id contains 1")
+	require.NoError(t, err)
+	require.Contains(t, out, "a/items/a1.json")
+	require.NotContains(t, out, "a/items/a2.json")
+	require.Contains(t, out, "b/items/b1.json")
+	require.Contains(t, out, "c/items/c1.json")
+
+	// Remote files changed
+	// --------------------
+	gock.Flush()
+
+	expectRemote([]remoteFile{
+		{User: "a", ID: "a1", Version: "a11"},
+		{User: "a", ID: "a2", Version: "a21"},
+		{User: "b", ID: "b1", Version: "b12"},
+		{User: "d", ID: "d1", Version: "d11"},
+	})
+
+	metaFileContents, err := afero.ReadFile(afs, ".rshbulk/meta")
+	require.NoError(t, err)
+
+	out, err = run("bulk", "status")
+	require.NoError(t, err)
+	require.Contains(t, out, "Remote changes")
+	require.Contains(t, out, "modified:  b/items/b1.json")
+	require.Contains(t, out, "removed:  c/items/c1.json")
+	require.Contains(t, out, "added:  d/items/d1.json")
+	mustHaveCalledAllHTTPMocks(t)
+
+	// The status command should never change the metadata!
+	mfc2, _ := afero.ReadFile(afs, ".rshbulk/meta")
+	require.Equal(t, string(metaFileContents), string(mfc2))
+
+	// Diff remote changes
+	// -------------------
+	gock.Flush()
+
+	expectRemote([]remoteFile{
+		{User: "a", ID: "a1", Version: "a11"},
+		{User: "a", ID: "a2", Version: "a21"},
+		{User: "b", ID: "b1", Version: "b12", body: `{"id": "b1", "foo": 1}`, fetch: true},
+		{User: "d", ID: "d1", Version: "d11", fetch: true},
+	})
+
+	out, err = run("bulk", "diff", "--remote")
+	require.NoError(t, err)
+	require.Contains(t, out, "+  \"foo\": 1")
+	require.Contains(t, out, "-{\n-  \"id\": \"c1\"\n-}")
+	require.Contains(t, out, "+{\n+  \"id\": \"d1\"\n+}")
+	mustHaveCalledAllHTTPMocks(t)
+
+	// Pull changes
+	// ------------
+	gock.Flush()
+
+	expectRemote([]remoteFile{
+		{User: "a", ID: "a1", Version: "a11"},
+		{User: "a", ID: "a2", Version: "a21"},
+		{User: "b", ID: "b1", Version: "b12", body: `{"id": "b1", "foo": "bar"}`, fetch: true},
+		{User: "d", ID: "d1", Version: "d11", fetch: true},
+	})
+
+	_, err = run("-v", "bulk", "pull")
+	require.NoError(t, err)
+	mustContain(t, ".rshbulk/meta", "a21")
+	mustContain(t, ".rshbulk/meta", "b12")
+	mustContain(t, ".rshbulk/meta", "d11")
+	mustExist(t, ".rshbulk/d/items/d1.json")
+	mustEqualJSON(t, "b/items/b1.json", `{"id": "b1", "foo": "bar"}`)
+	mustEqualJSON(t, "d/items/d1.json", `{"id": "d1"}`)
+	_, err = afs.Stat("c/items/c1.json")
+	require.Error(t, err)
+	mustHaveCalledAllHTTPMocks(t)
+
+	gock.Flush()
+	expectRemote([]remoteFile{
+		{User: "a", ID: "a1", Version: "a11"},
+		{User: "a", ID: "a2", Version: "a21"},
+		{User: "b", ID: "b1", Version: "b12", body: `{"id": "b1", "foo": "bar"}`},
+		{User: "d", ID: "d1", Version: "d11"},
+	})
+
+	out, err = run("bulk", "status")
+	require.NoError(t, err)
+	require.Contains(t, out, "You are up to date with https://example.com")
+	require.Contains(t, out, "No local changes")
+	mustHaveCalledAllHTTPMocks(t)
+
+	// Edit local files
+	// ----------------
+	gock.Flush()
+
+	expectRemote([]remoteFile{
+		{User: "a", ID: "a1", Version: "a11"},
+		{User: "a", ID: "a2", Version: "a21"},
+		{User: "b", ID: "b1", Version: "b12", body: `{"id": "b1", "foo": "bar"}`},
+		{User: "d", ID: "d1", Version: "d11"},
+	})
+
+	afero.WriteFile(afs, "a/items/a1.json", []byte(`{"id": "a1", "labels": ["one"]}`), 0600)
+	afs.Remove("a/items/a2.json")
+	afs.Remove("d/items/d1.json")
+	afero.WriteFile(afs, "a/items/a3.json", []byte(`{"id": "a3"}`), 0600)
+
+	// Whoops, let's reset one of the files before getting the status!
+	_, err = run("bulk", "reset", "a/items/a2.json")
+	require.NoError(t, err)
+
+	out, err = run("bulk", "status")
+	require.NoError(t, err)
+	require.Contains(t, out, "Local changes")
+	require.Contains(t, out, "modified:  a/items/a1.json")
+	require.Contains(t, out, "removed:  d/items/d1.json")
+	require.Contains(t, out, "added:  a/items/a3.json")
+	mustHaveCalledAllHTTPMocks(t)
+
+	// Show diff
+	// ---------
+	gock.Flush()
+
+	expectRemoteFile(remoteFile{User: "a", ID: "a1", Version: "a11"})
+
+	// Ugh, flag state is saved between runs, so explicitly set to false here.
+	out, err = run("bulk", "diff", "--remote=false")
+	require.NoError(t, err)
+	require.Contains(t, out, "--- remote https://example.com/users/a/items/a1")
+	require.Contains(t, out, "+++ local a/items/a1.json")
+	require.Contains(t, out, "+  \"labels\": [\n+    \"one\"\n+  ]")
+
+	// Push changes
+	// ------------
+	gock.Flush()
+
+	expectRemote([]remoteFile{
+		{User: "a", ID: "a1", Version: "a11"},
+		{User: "a", ID: "a2", Version: "a21"},
+		{User: "b", ID: "b1", Version: "b12", body: `{"id": "b1", "foo": "bar"}`},
+		{User: "d", ID: "d1", Version: "d11"},
+	})
+
+	gock.New("https://example.com").
+		Put("/users/a/items/a1").
+		Reply(http.StatusOK)
+
+	gock.New("https://example.com").
+		Put("/users/a/items/a3").
+		Reply(http.StatusOK)
+
+	gock.New("https://example.com").
+		Delete("/users/d/items/d1").
+		Reply(http.StatusNoContent)
+
+	// Remote has changed after push!
+	expectRemote([]remoteFile{
+		{User: "a", ID: "a1", Version: "a12", fetch: true},
+		{User: "a", ID: "a2", Version: "a21"},
+		{User: "a", ID: "a3", Version: "a31", fetch: true},
+		{User: "b", ID: "b1", Version: "b12", body: `{"id": "b1", "foo": "bar"}`},
+	})
+
+	out, err = run("bulk", "push")
+	require.NoError(t, err)
+	require.Contains(t, out, "Push complete")
+	mustHaveCalledAllHTTPMocks(t)
+
+	// Status should be empty
+	// ----------------------
+	gock.Flush()
+
+	expectRemote([]remoteFile{
+		{User: "a", ID: "a1", Version: "a12"},
+		{User: "a", ID: "a2", Version: "a21"},
+		{User: "a", ID: "a3", Version: "a31"},
+		{User: "b", ID: "b1", Version: "b12", body: `{"id": "b1", "foo": "bar"}`},
+	})
+
+	out, err = run("bulk", "status")
+	require.NoError(t, err)
+	require.Contains(t, out, "You are up to date with https://example.com")
+	require.Contains(t, out, "No local changes")
+	mustHaveCalledAllHTTPMocks(t)
+
+	// Diff should be empty
+	// --------------------
+	gock.Flush()
+
+	out, err = run("bulk", "diff")
+	require.NoError(t, err)
+	require.Contains(t, out, "No local changes")
+	mustHaveCalledAllHTTPMocks(t)
+}
+
+func TestFalsey(t *testing.T) {
+	for _, item := range []any{false, 0, 0.0, "", []byte{}, []any{}, map[string]any{}, map[any]any{}} {
+		t.Run(fmt.Sprintf("%T-%+v", item, item), func(t *testing.T) {
+			require.True(t, isFalsey(item))
+		})
+	}
+}
+
+func TestInterpreterWithSchema(t *testing.T) {
+	defer gock.Off()
+
+	gock.New("https://example.com").
+		Get("/schemas/user.json").
+		Reply(http.StatusOK).
+		SetHeader("Content-Type", "application/json").
+		BodyString(`{
+			"type": "object",
+			"properties": {
+				"name": {
+					"type": "string"
+				},
+				"trinkets": {
+					"type": "array",
+					"items": {
+						"type": "object",
+						"properties": {
+							"age": {
+								"type": "number"
+							}
+						}
+					}
+				}
+			}
+		}`)
+
+	capture := &strings.Builder{}
+	cli.Stdout = capture
+	cli.Stderr = capture
+	newInterpreter("trinkets where age > 5", "https://example.com/schemas/user.json")
+	require.NotContains(t, capture.String(), "WARN")
+}
+
+func TestInterpreterWithSchemaWarning(t *testing.T) {
+	defer gock.Off()
+
+	gock.New("https://example.com").
+		Get("/schemas/user.json").
+		Reply(http.StatusOK).
+		SetHeader("Content-Type", "application/json").
+		BodyString(`{
+			"type": "object",
+			"properties": {
+				"name": {
+					"type": "string"
+				},
+				"trinkets": {
+					"type": "array",
+					"items": {
+						"type": "object",
+						"properties": {
+							"age": {
+								"type": "number"
+							}
+						}
+					}
+				}
+			}
+		}`)
+
+	capture := &strings.Builder{}
+	cli.Stdout = capture
+	cli.Stderr = capture
+	newInterpreter("name > 5", "https://example.com/schemas/user.json")
+	require.Contains(t, capture.String(), "WARN: cannot compare string with number")
+}
+
+func TestInterpreterWithSchema404(t *testing.T) {
+	defer gock.Off()
+
+	gock.New("https://example.com").
+		Get("/schemas/user.json").
+		Reply(http.StatusNotFound)
+
+	capture := &strings.Builder{}
+	cli.Stdout = capture
+	cli.Stderr = capture
+	newInterpreter("name contains foo", "https://example.com/schemas/user.json")
+	require.NotContains(t, capture.String(), "WARN")
+}
+
+func TestInterpreterWithSchemaInvalid(t *testing.T) {
+	defer gock.Off()
+
+	gock.New("https://example.com").
+		Get("/schemas/user.json").
+		Reply(http.StatusOK).
+		SetHeader("Content-Type", "application/json").
+		BodyString(`{bad json}`)
+
+	capture := &strings.Builder{}
+	cli.Stdout = capture
+	cli.Stderr = capture
+	newInterpreter("name contains foo", "https://example.com/schemas/user.json")
+	require.NotContains(t, capture.String(), "WARN")
+}
diff --git a/openapi/example_test.go b/openapi/example_test.go
index 993aca4..3564a2f 100644
--- a/openapi/example_test.go
+++ b/openapi/example_test.go
@@ -306,7 +306,7 @@ func TestExample(t *testing.T) {
 			// spew.Dump(ls)
 
 			s := base.NewSchema(&ls)
-			assert.EqualValues(t, example.out, genExample(s, example.mode))
+			assert.EqualValues(t, example.out, GenExample(s, example.mode))
 		})
 	}
 }