[object Object]

← back to Cli Printing Press

feat(cli): schema-version gate, doctor cache section, cache/share spec surface (#232)

e116a27c1fad3226fc9432da5cb972b169585efb · 2026-04-21 21:59:44 -0700 · Matt Van Horn

Foundations for the discrawl-inspired auto-refresh and snapshot-share
capabilities. No behavior change for existing published CLIs until a
spec opts into cache or share in a follow-up; this just ships the
generator plumbing.

Unit 0 (spec surface): adds Cache and Share blocks to APISpec with
Validate() rejecting share.enabled without snapshot_tables and rejecting
any table name matching auth_*, *_cache, or *_secrets. Parse, round-trip,
and validation tests (18 subtests).

Unit 1 (schema-version gate): store.go.tmpl now reads PRAGMA user_version
on every Open, fails fast when the on-disk schema exceeds the binary's
supported version, and stamps user_version on fresh or pre-gate databases.
Exports Store.SchemaVersion(), Store.DB(), Store.Path() for downstream
callers. A generated internal/store/schema_version_test.go ships in every
printed CLI that has a store.

Unit 4 (doctor cache section): doctor.go.tmpl emits a cache block with
schema_version, db_path, db_bytes, per-resource rows and last_synced_at,
and fresh/stale/unknown verdict when HasStore is true. Adds a --fail-on
flag for CI integration. Preserves the existing doctor output exactly
when no local store is emitted.

Ref: docs/plans/2026-04-21-001-feat-auto-refresh-and-share-plan.md

Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>

Files touched

Diff

commit e116a27c1fad3226fc9432da5cb972b169585efb
Author: Matt Van Horn <mvanhorn@users.noreply.github.com>
Date:   Tue Apr 21 21:59:44 2026 -0700

    feat(cli): schema-version gate, doctor cache section, cache/share spec surface (#232)
    
    Foundations for the discrawl-inspired auto-refresh and snapshot-share
    capabilities. No behavior change for existing published CLIs until a
    spec opts into cache or share in a follow-up; this just ships the
    generator plumbing.
    
    Unit 0 (spec surface): adds Cache and Share blocks to APISpec with
    Validate() rejecting share.enabled without snapshot_tables and rejecting
    any table name matching auth_*, *_cache, or *_secrets. Parse, round-trip,
    and validation tests (18 subtests).
    
    Unit 1 (schema-version gate): store.go.tmpl now reads PRAGMA user_version
    on every Open, fails fast when the on-disk schema exceeds the binary's
    supported version, and stamps user_version on fresh or pre-gate databases.
    Exports Store.SchemaVersion(), Store.DB(), Store.Path() for downstream
    callers. A generated internal/store/schema_version_test.go ships in every
    printed CLI that has a store.
    
    Unit 4 (doctor cache section): doctor.go.tmpl emits a cache block with
    schema_version, db_path, db_bytes, per-resource rows and last_synced_at,
    and fresh/stale/unknown verdict when HasStore is true. Adds a --fail-on
    flag for CI integration. Preserves the existing doctor output exactly
    when no local store is emitted.
    
    Ref: docs/plans/2026-04-21-001-feat-auto-refresh-and-share-plan.md
    
    Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
---
 internal/generator/generator.go                    |  17 ++
 internal/generator/generator_test.go               |   7 +-
 internal/generator/templates/doctor.go.tmpl        | 226 ++++++++++++++++++++-
 internal/generator/templates/store.go.tmpl         |  59 ++++++
 .../templates/store_schema_version_test.go.tmpl    | 116 +++++++++++
 internal/spec/spec.go                              |  93 +++++++++
 internal/spec/spec_test.go                         | 192 +++++++++++++++++
 7 files changed, 703 insertions(+), 7 deletions(-)

diff --git a/internal/generator/generator.go b/internal/generator/generator.go
index 72dd1d6d..d69d06d4 100644
--- a/internal/generator/generator.go
+++ b/internal/generator/generator.go
@@ -488,6 +488,15 @@ type helpersTemplateData struct {
 	HelperFlags
 }
 
+// doctorTemplateData wraps APISpec with a HasStore flag so the doctor
+// template can gate its cache-health section. Doctor is emitted for every
+// CLI — with or without a local store — so the template needs explicit
+// knowledge of whether internal/store exists.
+type doctorTemplateData struct {
+	*spec.APISpec
+	HasStore bool
+}
+
 // readmeTemplateData wraps APISpec with additional fields for README rendering.
 type readmeTemplateData struct {
 	*spec.APISpec
@@ -697,6 +706,11 @@ func (g *Generator) Generate() error {
 				APISpec:     g.Spec,
 				HelperFlags: hFlags,
 			}
+		case "doctor.go.tmpl":
+			data = &doctorTemplateData{
+				APISpec:  g.Spec,
+				HasStore: g.VisionSet.Store,
+			}
 		default:
 			data = g.Spec
 		}
@@ -953,6 +967,9 @@ func (g *Generator) Generate() error {
 		if err := g.renderTemplate("store.go.tmpl", filepath.Join("internal", "store", "store.go"), storeData); err != nil {
 			return fmt.Errorf("rendering store: %w", err)
 		}
+		if err := g.renderTemplate("store_schema_version_test.go.tmpl", filepath.Join("internal", "store", "schema_version_test.go"), storeData); err != nil {
+			return fmt.Errorf("rendering store schema version test: %w", err)
+		}
 	}
 
 	// Render vision CLI commands
diff --git a/internal/generator/generator_test.go b/internal/generator/generator_test.go
index b2188b4a..f7d0adfc 100644
--- a/internal/generator/generator_test.go
+++ b/internal/generator/generator_test.go
@@ -29,9 +29,10 @@ func TestGenerateProjectsCompile(t *testing.T) {
 		// +1 for internal/cli/profile.go (HeyGen-style named-profile system)
 		// +1 for internal/cli/deliver.go (HeyGen-style --deliver output routing)
 		// +1 for internal/cli/feedback.go (HeyGen-style in-band agent feedback channel)
-		{name: "stytch", specPath: filepath.Join("..", "..", "testdata", "stytch.yaml"), expectedFiles: 39},
-		{name: "clerk", specPath: filepath.Join("..", "..", "testdata", "clerk.yaml"), expectedFiles: 43},
-		{name: "loops", specPath: filepath.Join("..", "..", "testdata", "loops.yaml"), expectedFiles: 41},
+		// +1 for internal/store/schema_version_test.go (PRAGMA user_version gate, discrawl-inspired)
+		{name: "stytch", specPath: filepath.Join("..", "..", "testdata", "stytch.yaml"), expectedFiles: 40},
+		{name: "clerk", specPath: filepath.Join("..", "..", "testdata", "clerk.yaml"), expectedFiles: 44},
+		{name: "loops", specPath: filepath.Join("..", "..", "testdata", "loops.yaml"), expectedFiles: 42},
 	}
 
 	for _, tt := range tests {
diff --git a/internal/generator/templates/doctor.go.tmpl b/internal/generator/templates/doctor.go.tmpl
index 9d8edfc3..9d5b3791 100644
--- a/internal/generator/templates/doctor.go.tmpl
+++ b/internal/generator/templates/doctor.go.tmpl
@@ -4,9 +4,15 @@
 package cli
 
 import (
+{{- if .HasStore}}
+	"database/sql"
+{{- end}}
 	"fmt"
+{{- if .HasStore}}
+	"io"
+{{- end}}
 	"net/http"
-{{- if .Auth.EnvVars}}
+{{- if or .Auth.EnvVars .HasStore}}
 	"os"
 {{- end}}
 {{- if or (eq .Auth.Type "cookie") (eq .Auth.Type "composed")}}
@@ -16,11 +22,15 @@ import (
 	"time"
 
 	"{{modulePath}}/internal/config"
+{{- if .HasStore}}
+	"{{modulePath}}/internal/store"
+{{- end}}
 	"github.com/spf13/cobra"
 )
 
 func newDoctorCmd(flags *rootFlags) *cobra.Command {
-	return &cobra.Command{
+	var failOn string
+	cmd := &cobra.Command{
 		Use:   "doctor",
 		Short: "Check CLI health",
 		RunE: func(cmd *cobra.Command, args []string) error {
@@ -192,10 +202,21 @@ func newDoctorCmd(flags *rootFlags) *cobra.Command {
 				report["api"] = "not configured (set base_url in config file)"
 			}
 
+{{- if .HasStore}}
+			// Cache health: only reported when this CLI has a local store.
+			// Surfaces rows + last_synced_at per resource, schema version,
+			// and a fresh/stale/unknown verdict so agents can introspect
+			// whether to trust the cached data before issuing queries.
+			report["cache"] = collectCacheReport({{if .Cache.StaleAfter}}"{{.Cache.StaleAfter}}"{{else}}""{{end}})
+{{- end}}
+
 			report["version"] = version
 
 			if flags.asJSON {
-				return flags.printJSON(cmd, report)
+				if err := flags.printJSON(cmd, report); err != nil {
+					return err
+				}
+				return doctorExitForFailOn(failOn, report)
 			}
 
 			// Human-readable output with color
@@ -244,7 +265,204 @@ func newDoctorCmd(flags *rootFlags) *cobra.Command {
 				fmt.Fprintf(w, "  Get a key at: %v\n", keyURL)
 			}
 {{- end}}
-			return nil
+{{- if .HasStore}}
+			// Cache section: render after the primary health block so it
+			// sits next to version info, mirroring the JSON report layout.
+			if cacheAny, ok := report["cache"]; ok {
+				if cacheRep, ok := cacheAny.(map[string]any); ok {
+					renderCacheReport(w, cacheRep)
+				}
+			}
+{{- end}}
+			return doctorExitForFailOn(failOn, report)
 		},
 	}
+	cmd.Flags().StringVar(&failOn, "fail-on", "", "Exit non-zero when a health level is reached: stale, error. Default is never.")
+	return cmd
+}
+
+// doctorExitForFailOn returns a non-nil error when the report's worst
+// status meets or exceeds the --fail-on threshold. "error" always trips
+// when any section reports an error; "stale" also trips when the cache
+// section is stale. The default empty string means never fail on status.
+func doctorExitForFailOn(failOn string, report map[string]any) error {
+	if failOn == "" {
+		return nil
+	}
+	worstError := false
+	worstStale := false
+	for _, v := range report {
+		s, ok := v.(string)
+		if ok {
+			if strings.Contains(s, "error") || strings.Contains(s, "unreachable") || strings.Contains(s, "invalid") {
+				worstError = true
+			}
+		}
+		if m, ok := v.(map[string]any); ok {
+			if st, _ := m["status"].(string); st == "error" {
+				worstError = true
+			} else if st == "stale" {
+				worstStale = true
+			}
+		}
+	}
+	switch failOn {
+	case "error":
+		if worstError {
+			return fmt.Errorf("doctor: --fail-on=error triggered")
+		}
+	case "stale":
+		if worstError || worstStale {
+			return fmt.Errorf("doctor: --fail-on=stale triggered")
+		}
+	default:
+		return fmt.Errorf("doctor: unknown --fail-on value %q (valid: stale, error)", failOn)
+	}
+	return nil
 }
+{{- if .HasStore}}
+
+// collectCacheReport opens the local store, reads per-resource sync state,
+// and returns a map summarising cache health. Never panics on missing DB
+// or open failure; returns a map with status=unknown or status=error so the
+// caller can render and agents can interpret.
+//
+// staleAfterSpec is the CLI's configured threshold (e.g. "6h"); empty means
+// use the runtime default. The default is deliberately conservative (6h)
+// because the alternative is no freshness story at all.
+func collectCacheReport(staleAfterSpec string) map[string]any {
+	report := map[string]any{}
+	dbPath := defaultDBPath("{{.Name}}-pp-cli")
+	report["db_path"] = dbPath
+
+	fi, err := os.Stat(dbPath)
+	if err != nil {
+		if os.IsNotExist(err) {
+			report["status"] = "unknown"
+			report["hint"] = "Database not created yet; run '{{.Name}}-pp-cli sync' to hydrate."
+			return report
+		}
+		report["status"] = "error"
+		report["error"] = err.Error()
+		return report
+	}
+	report["db_bytes"] = fi.Size()
+
+	s, err := store.Open(dbPath)
+	if err != nil {
+		report["status"] = "error"
+		report["error"] = err.Error()
+		return report
+	}
+	defer s.Close()
+
+	if v, verr := s.SchemaVersion(); verr == nil {
+		report["schema_version"] = v
+	}
+
+	staleAfter := 6 * time.Hour
+	if staleAfterSpec != "" {
+		if d, derr := time.ParseDuration(staleAfterSpec); derr == nil {
+			staleAfter = d
+		}
+	}
+
+	rows, qerr := s.DB().Query(`SELECT resource_type, COALESCE(total_count, 0), last_synced_at FROM sync_state ORDER BY resource_type`)
+	if qerr != nil {
+		// sync_state may not exist on a fresh DB that has migrated but not
+		// yet had any sync runs — treat as unknown rather than error.
+		report["status"] = "unknown"
+		report["hint"] = "No sync state recorded; run '{{.Name}}-pp-cli sync' to populate."
+		return report
+	}
+	defer rows.Close()
+
+	var resources []map[string]any
+	fresh := true
+	haveAny := false
+	oldest := time.Duration(0)
+	for rows.Next() {
+		var rtype string
+		var count int64
+		var lastSynced sql.NullTime
+		if err := rows.Scan(&rtype, &count, &lastSynced); err != nil {
+			continue
+		}
+		r := map[string]any{"type": rtype, "rows": count}
+		if lastSynced.Valid {
+			haveAny = true
+			r["last_synced_at"] = lastSynced.Time.UTC().Format(time.RFC3339)
+			age := time.Since(lastSynced.Time)
+			r["staleness"] = age.Round(time.Minute).String()
+			if age > staleAfter {
+				fresh = false
+			}
+			if age > oldest {
+				oldest = age
+			}
+		} else {
+			r["staleness"] = "never"
+			fresh = false
+		}
+		resources = append(resources, r)
+	}
+	report["resources"] = resources
+	report["stale_after"] = staleAfter.String()
+
+	switch {
+	case !haveAny && len(resources) == 0:
+		report["status"] = "unknown"
+		report["hint"] = "sync_state is empty; run '{{.Name}}-pp-cli sync' to hydrate."
+	case fresh:
+		report["status"] = "fresh"
+	default:
+		report["status"] = "stale"
+		report["oldest_age"] = oldest.Round(time.Minute).String()
+		report["hint"] = "Some resources are older than stale_after; run '{{.Name}}-pp-cli sync' to refresh."
+	}
+	return report
+}
+
+func renderCacheReport(w io.Writer, rep map[string]any) {
+	status, _ := rep["status"].(string)
+	indicator := green("OK")
+	switch status {
+	case "stale":
+		indicator = yellow("WARN")
+	case "error":
+		indicator = red("FAIL")
+	case "unknown":
+		indicator = yellow("INFO")
+	}
+	fmt.Fprintf(w, "  %s Cache: %s\n", indicator, status)
+	if v, ok := rep["db_path"]; ok {
+		fmt.Fprintf(w, "    db_path: %v\n", v)
+	}
+	if v, ok := rep["schema_version"]; ok {
+		fmt.Fprintf(w, "    schema_version: %v\n", v)
+	}
+	if v, ok := rep["db_bytes"]; ok {
+		fmt.Fprintf(w, "    db_bytes: %v\n", v)
+	}
+	if v, ok := rep["stale_after"]; ok {
+		fmt.Fprintf(w, "    stale_after: %v\n", v)
+	}
+	if v, ok := rep["oldest_age"]; ok {
+		fmt.Fprintf(w, "    oldest_age: %v\n", v)
+	}
+	if resourcesAny, ok := rep["resources"]; ok {
+		if resources, ok := resourcesAny.([]map[string]any); ok && len(resources) > 0 {
+			fmt.Fprintf(w, "    resources:\n")
+			for _, r := range resources {
+				rtype, _ := r["type"].(string)
+				rows := r["rows"]
+				staleness, _ := r["staleness"].(string)
+				fmt.Fprintf(w, "      - %s: %v rows, %s\n", rtype, rows, staleness)
+			}
+		}
+	}
+	if hint, ok := rep["hint"]; ok {
+		fmt.Fprintf(w, "    hint: %v\n", hint)
+	}
+}
+{{- end}}
diff --git a/internal/generator/templates/store.go.tmpl b/internal/generator/templates/store.go.tmpl
index c93c4387..14b66792 100644
--- a/internal/generator/templates/store.go.tmpl
+++ b/internal/generator/templates/store.go.tmpl
@@ -26,6 +26,14 @@ func IsUUID(s string) bool {
 	return uuidPattern.MatchString(s)
 }
 
+// StoreSchemaVersion is the on-disk schema version this binary understands.
+// It is stamped into SQLite's PRAGMA user_version on fresh databases and
+// checked on every open. Bump this whenever a migration changes table
+// shape — adding columns, dropping indexes, changing FTS5 tokenizers —
+// so an older binary refuses to open a newer database rather than silently
+// producing wrong results against a schema it cannot read.
+const StoreSchemaVersion = 1
+
 type Store struct {
 	db   *sql.DB
 	path string
@@ -59,7 +67,50 @@ func (s *Store) Close() error {
 	return s.db.Close()
 }
 
+// Path returns the on-disk path of the backing SQLite file.
+func (s *Store) Path() string {
+	return s.path
+}
+
+// DB exposes the underlying *sql.DB for callers that need to run ad-hoc
+// queries (e.g., doctor's cache inspection, share snapshot import).
+// Callers must not call Close on the returned handle.
+func (s *Store) DB() *sql.DB {
+	return s.db
+}
+
+// SchemaVersion reads PRAGMA user_version, which is stamped by migrate().
+// A zero value means the database predates the schema-version gate — not
+// a bug, but the caller may want to warn.
+func (s *Store) SchemaVersion() (int, error) {
+	var v int
+	if err := s.db.QueryRow(`PRAGMA user_version`).Scan(&v); err != nil {
+		return 0, fmt.Errorf("read user_version: %w", err)
+	}
+	return v, nil
+}
+
+// setSchemaVersion stamps PRAGMA user_version. The value is not parameterizable
+// in SQLite's PRAGMA syntax, so it is formatted into the statement; migrate()
+// only calls this with the compile-time StoreSchemaVersion constant, so there
+// is no untrusted input.
+func (s *Store) setSchemaVersion(version int) error {
+	stmt := fmt.Sprintf(`PRAGMA user_version = %d`, version)
+	if _, err := s.db.Exec(stmt); err != nil {
+		return fmt.Errorf("stamp user_version: %w", err)
+	}
+	return nil
+}
+
 func (s *Store) migrate() error {
+	current, err := s.SchemaVersion()
+	if err != nil {
+		return fmt.Errorf("reading schema version: %w", err)
+	}
+	if current > StoreSchemaVersion {
+		return fmt.Errorf("database schema version %d is newer than supported version %d; upgrade the CLI binary or open an older database", current, StoreSchemaVersion)
+	}
+
 	migrations := []string{
 		`CREATE TABLE IF NOT EXISTS resources (
 			id TEXT PRIMARY KEY,
@@ -133,6 +184,14 @@ func (s *Store) migrate() error {
 			return fmt.Errorf("migration failed: %w", err)
 		}
 	}
+	// Stamp the schema version after successful migration. On a fresh DB
+	// this writes 1; on an already-stamped DB this is a no-op write of the
+	// same value. An older DB with user_version = 0 and pre-existing tables
+	// gets stamped here without any data rewrites because the migrations
+	// above are idempotent via CREATE TABLE IF NOT EXISTS.
+	if err := s.setSchemaVersion(StoreSchemaVersion); err != nil {
+		return err
+	}
 	return nil
 }
 
diff --git a/internal/generator/templates/store_schema_version_test.go.tmpl b/internal/generator/templates/store_schema_version_test.go.tmpl
new file mode 100644
index 00000000..7ddd5e04
--- /dev/null
+++ b/internal/generator/templates/store_schema_version_test.go.tmpl
@@ -0,0 +1,116 @@
+// Copyright {{currentYear}} {{.Owner}}. Licensed under Apache-2.0. See LICENSE.
+// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.
+
+package store
+
+import (
+	"database/sql"
+	"path/filepath"
+	"testing"
+
+	_ "modernc.org/sqlite"
+)
+
+// TestSchemaVersion_StampedOnFreshDB verifies that opening a brand-new
+// database stamps the current schema version. This is the contract that
+// makes StoreSchemaVersion upgrades safe: every freshly-created DB
+// records the version it was built under.
+func TestSchemaVersion_StampedOnFreshDB(t *testing.T) {
+	dbPath := filepath.Join(t.TempDir(), "data.db")
+	s, err := Open(dbPath)
+	if err != nil {
+		t.Fatalf("open fresh db: %v", err)
+	}
+	defer s.Close()
+
+	v, err := s.SchemaVersion()
+	if err != nil {
+		t.Fatalf("read schema version: %v", err)
+	}
+	if v != StoreSchemaVersion {
+		t.Fatalf("fresh db version = %d, want %d", v, StoreSchemaVersion)
+	}
+}
+
+// TestSchemaVersion_StampExistingZeroDB verifies the stamp-and-continue
+// rule for existing deployed databases. A DB that predates the gate has
+// user_version = 0; opening it with this binary should stamp the version
+// to 1 without touching any data.
+func TestSchemaVersion_StampExistingZeroDB(t *testing.T) {
+	dbPath := filepath.Join(t.TempDir(), "data.db")
+
+	// Pre-create the DB with user_version = 0 and no tables, simulating
+	// a database created by a pre-gate version of the binary before any
+	// migrations ran.
+	raw, err := sql.Open("sqlite", dbPath)
+	if err != nil {
+		t.Fatalf("open raw: %v", err)
+	}
+	if _, err := raw.Exec(`PRAGMA user_version = 0`); err != nil {
+		t.Fatalf("stamp zero: %v", err)
+	}
+	raw.Close()
+
+	s, err := Open(dbPath)
+	if err != nil {
+		t.Fatalf("open pre-gate db: %v", err)
+	}
+	defer s.Close()
+
+	v, err := s.SchemaVersion()
+	if err != nil {
+		t.Fatalf("read schema version: %v", err)
+	}
+	if v != StoreSchemaVersion {
+		t.Fatalf("post-stamp version = %d, want %d", v, StoreSchemaVersion)
+	}
+}
+
+// TestSchemaVersion_RefusesNewerDB verifies fail-fast when the on-disk
+// schema is newer than the binary supports. Without this gate, a user
+// who upgrades their library but not their binary would hit silent
+// "no such column" errors instead of a clear version mismatch.
+func TestSchemaVersion_RefusesNewerDB(t *testing.T) {
+	dbPath := filepath.Join(t.TempDir(), "data.db")
+
+	raw, err := sql.Open("sqlite", dbPath)
+	if err != nil {
+		t.Fatalf("open raw: %v", err)
+	}
+	if _, err := raw.Exec(`PRAGMA user_version = 999`); err != nil {
+		t.Fatalf("stamp future version: %v", err)
+	}
+	raw.Close()
+
+	_, err = Open(dbPath)
+	if err == nil {
+		t.Fatalf("expected open to fail on newer schema, got nil")
+	}
+}
+
+// TestSchemaVersion_ReopenIsIdempotent verifies that opening an already
+// correctly-stamped DB is a no-op — the second open reads the version
+// and the migrations are all idempotent (CREATE TABLE IF NOT EXISTS).
+func TestSchemaVersion_ReopenIsIdempotent(t *testing.T) {
+	dbPath := filepath.Join(t.TempDir(), "data.db")
+
+	s1, err := Open(dbPath)
+	if err != nil {
+		t.Fatalf("first open: %v", err)
+	}
+	s1.Close()
+
+	s2, err := Open(dbPath)
+	if err != nil {
+		t.Fatalf("second open: %v", err)
+	}
+	defer s2.Close()
+
+	v, err := s2.SchemaVersion()
+	if err != nil {
+		t.Fatalf("read schema version: %v", err)
+	}
+	if v != StoreSchemaVersion {
+		t.Fatalf("reopened version = %d, want %d", v, StoreSchemaVersion)
+	}
+}
diff --git a/internal/spec/spec.go b/internal/spec/spec.go
index 221132da..b733388a 100644
--- a/internal/spec/spec.go
+++ b/internal/spec/spec.go
@@ -38,6 +38,8 @@ type APISpec struct {
 	Resources       map[string]Resource `yaml:"resources" json:"resources"`
 	Types           map[string]TypeDef  `yaml:"types" json:"types"`
 	ExtraCommands   []ExtraCommand      `yaml:"extra_commands,omitempty" json:"extra_commands,omitempty"` // hand-written cobra commands declared so SKILL.md can document them; spec-only metadata, no code generated
+	Cache           CacheConfig         `yaml:"cache,omitempty" json:"cache,omitempty"`                   // cache freshness + auto-refresh config; when enabled, generated read commands auto-refresh stale local data before serving
+	Share           ShareConfig         `yaml:"share,omitempty" json:"share,omitempty"`                   // git-backed snapshot sharing config; when enabled, emits a `share` subcommand that publishes/subscribes to a git repo
 }
 
 // ExtraCommand declares a hand-written cobra command so the SKILL.md
@@ -106,6 +108,35 @@ type ConfigSpec struct {
 	Path   string `yaml:"path" json:"path"`
 }
 
+// CacheConfig gates the auto-refresh machinery emitted into a printed CLI.
+// Opt-in — CLIs whose local store is per-user state (carts, drafts) should leave
+// Enabled at its zero value so reads never silently replace the user's state
+// with a snapshot from a different session.
+//
+// StaleAfter and RefreshTimeout are strings parsed to time.Duration at CLI
+// runtime; keeping them as strings lets spec authors write "6h" or "30s" and
+// preserves the yaml-level representation for round-trip tooling.
+type CacheConfig struct {
+	Enabled        bool              `yaml:"enabled,omitempty" json:"enabled,omitempty"`                 // master switch; when false, freshness helpers and pre-run refresh hook are not emitted
+	StaleAfter     string            `yaml:"stale_after,omitempty" json:"stale_after,omitempty"`         // default duration after which any resource's last_synced_at is considered stale (e.g., "6h"). Blank means runtime default (6h).
+	RefreshTimeout string            `yaml:"refresh_timeout,omitempty" json:"refresh_timeout,omitempty"` // max wall-clock the pre-run refresh may block the command (e.g., "30s"). On timeout the command serves stale data with a stderr warning. Blank means runtime default (30s).
+	EnvOptOut      string            `yaml:"env_opt_out,omitempty" json:"env_opt_out,omitempty"`         // env var name that disables auto-refresh when set to "1" (e.g., LINEAR_NO_AUTO_REFRESH). Blank lets the template derive {{upper name}}_NO_AUTO_REFRESH.
+	Resources      map[string]string `yaml:"resources,omitempty" json:"resources,omitempty"`             // per-resource override of stale_after (e.g., quotes: "5m", channels: "24h"). Resources not listed inherit StaleAfter.
+}
+
+// ShareConfig gates the git-backed snapshot share surface emitted into a
+// printed CLI. When Enabled, the generator emits an internal/share package
+// plus a `share` cobra command (publish, subscribe, export, import). Share
+// is off by default because it is a multi-user feature and most CLIs are
+// single-user; enabling also requires an explicit SnapshotTables allowlist
+// to prevent accidental export of auth or per-user state.
+type ShareConfig struct {
+	Enabled        bool     `yaml:"enabled,omitempty" json:"enabled,omitempty"`                 // master switch; when false, the share package and command are not emitted
+	SnapshotTables []string `yaml:"snapshot_tables,omitempty" json:"snapshot_tables,omitempty"` // explicit allowlist of SQLite tables included in the snapshot. Required when Enabled. Names matching denylisted patterns (*_cache, *_secrets, auth_*) are rejected at parse time.
+	DefaultRepo    string   `yaml:"default_repo,omitempty" json:"default_repo,omitempty"`       // optional default git remote (e.g., "git@github.com:acme/linear-snapshots.git"); command-line --repo flag always wins
+	DefaultBranch  string   `yaml:"default_branch,omitempty" json:"default_branch,omitempty"`   // optional default branch for push/pull; blank means "main"
+}
+
 type Resource struct {
 	Description  string              `yaml:"description" json:"description"`
 	Path         string              `yaml:"path,omitempty" json:"path,omitempty"`             // base path for operations shorthand (e.g., /api/items)
@@ -335,6 +366,9 @@ func (s *APISpec) Validate() error {
 	if err := validateExtraCommands(s.ExtraCommands); err != nil {
 		return err
 	}
+	if err := validateCacheShare(s.Cache, s.Share); err != nil {
+		return err
+	}
 	for name, r := range s.Resources {
 		if len(r.Endpoints) == 0 && len(r.SubResources) == 0 {
 			return fmt.Errorf("resource %q has no endpoints", name)
@@ -392,6 +426,65 @@ func validateExtraCommands(cmds []ExtraCommand) error {
 	return nil
 }
 
+// shareTableDenyRe matches table names that must never appear in a share
+// snapshot: anything ending in _cache or _secrets, or starting with auth_.
+// These patterns catch the tables most likely to hold bearer tokens,
+// device fingerprints, or derived per-user state that should never travel
+// in a shared git repo.
+var shareTableDenyRe = regexp.MustCompile(`(?i)^auth_|_cache$|_secrets$`)
+
+// shareTableNameRe enforces SQLite-compatible lowercase identifiers for
+// snapshot table entries. Keeping this strict avoids surprises when the
+// generator later emits SELECT/DELETE statements against these names.
+var shareTableNameRe = regexp.MustCompile(`^[a-z][a-z0-9_]*$`)
+
+// durationLikeRe is a forgiving parse-time sanity check for CacheConfig
+// duration strings. The strict parse happens in the generated CLI at
+// runtime via time.ParseDuration; this check only rejects obviously
+// malformed values so typos surface at spec load, not at end-user runtime.
+var durationLikeRe = regexp.MustCompile(`^\d+(\.\d+)?(ns|us|µs|ms|s|m|h)(\d+(\.\d+)?(ns|us|µs|ms|s|m|h))*$`)
+
+func validateCacheShare(cache CacheConfig, share ShareConfig) error {
+	if cache.StaleAfter != "" && !durationLikeRe.MatchString(cache.StaleAfter) {
+		return fmt.Errorf("cache.stale_after %q is not a valid Go duration", cache.StaleAfter)
+	}
+	if cache.RefreshTimeout != "" && !durationLikeRe.MatchString(cache.RefreshTimeout) {
+		return fmt.Errorf("cache.refresh_timeout %q is not a valid Go duration", cache.RefreshTimeout)
+	}
+	for resource, dur := range cache.Resources {
+		if resource == "" {
+			return fmt.Errorf("cache.resources: resource name must not be empty")
+		}
+		if !durationLikeRe.MatchString(dur) {
+			return fmt.Errorf("cache.resources[%s] = %q is not a valid Go duration", resource, dur)
+		}
+	}
+
+	if !share.Enabled {
+		if len(share.SnapshotTables) > 0 {
+			return fmt.Errorf("share.snapshot_tables is set but share.enabled is false; either enable or remove")
+		}
+		return nil
+	}
+	if len(share.SnapshotTables) == 0 {
+		return fmt.Errorf("share.enabled requires a non-empty share.snapshot_tables allowlist")
+	}
+	seen := make(map[string]struct{}, len(share.SnapshotTables))
+	for i, t := range share.SnapshotTables {
+		if !shareTableNameRe.MatchString(t) {
+			return fmt.Errorf("share.snapshot_tables[%d]: %q must be a lowercase SQLite identifier (letters, digits, underscore)", i, t)
+		}
+		if shareTableDenyRe.MatchString(t) {
+			return fmt.Errorf("share.snapshot_tables[%d]: %q matches the denylist (auth_*, *_cache, *_secrets) and must not be shared", i, t)
+		}
+		if _, dup := seen[t]; dup {
+			return fmt.Errorf("share.snapshot_tables[%d]: %q appears more than once", i, t)
+		}
+		seen[t] = struct{}{}
+	}
+	return nil
+}
+
 // CountMCPTools counts total endpoints and public (NoAuth) endpoints across
 // all resources and sub-resources.
 func (s *APISpec) CountMCPTools() (total, public int) {
diff --git a/internal/spec/spec_test.go b/internal/spec/spec_test.go
index ada8e574..96ee7378 100644
--- a/internal/spec/spec_test.go
+++ b/internal/spec/spec_test.go
@@ -764,3 +764,195 @@ func TestExtraCommandsRoundTripYAML(t *testing.T) {
 	require.NoError(t, yaml.Unmarshal(data, &parsed))
 	assert.Equal(t, original.ExtraCommands, parsed.ExtraCommands)
 }
+
+func TestCacheShareParse(t *testing.T) {
+	input := `
+name: demo
+base_url: http://x
+auth:
+  type: none
+config:
+  format: toml
+  path: ~/.config/demo/config.toml
+resources:
+  items:
+    description: "Items"
+    endpoints:
+      list:
+        method: GET
+        path: /items
+cache:
+  enabled: true
+  stale_after: 6h
+  refresh_timeout: 30s
+  env_opt_out: DEMO_NO_AUTO_REFRESH
+  resources:
+    items: 5m
+share:
+  enabled: true
+  snapshot_tables:
+    - items
+    - sync_state
+  default_repo: git@github.com:acme/demo-snapshots.git
+  default_branch: main
+`
+	s, err := ParseBytes([]byte(input))
+	require.NoError(t, err)
+	assert.True(t, s.Cache.Enabled)
+	assert.Equal(t, "6h", s.Cache.StaleAfter)
+	assert.Equal(t, "30s", s.Cache.RefreshTimeout)
+	assert.Equal(t, "DEMO_NO_AUTO_REFRESH", s.Cache.EnvOptOut)
+	assert.Equal(t, "5m", s.Cache.Resources["items"])
+	assert.True(t, s.Share.Enabled)
+	assert.Equal(t, []string{"items", "sync_state"}, s.Share.SnapshotTables)
+	assert.Equal(t, "git@github.com:acme/demo-snapshots.git", s.Share.DefaultRepo)
+	assert.Equal(t, "main", s.Share.DefaultBranch)
+}
+
+func TestCacheShareAbsentIsBackwardCompatible(t *testing.T) {
+	input := `
+name: demo
+base_url: http://x
+auth:
+  type: none
+config:
+  format: toml
+  path: ~/.config/demo/config.toml
+resources:
+  items:
+    description: "Items"
+    endpoints:
+      list:
+        method: GET
+        path: /items
+`
+	s, err := ParseBytes([]byte(input))
+	require.NoError(t, err)
+	assert.False(t, s.Cache.Enabled)
+	assert.False(t, s.Share.Enabled)
+	assert.Empty(t, s.Cache.Resources)
+	assert.Empty(t, s.Share.SnapshotTables)
+}
+
+func TestCacheShareValidation(t *testing.T) {
+	base := func(cache CacheConfig, share ShareConfig) APISpec {
+		return APISpec{
+			Name:    "demo",
+			BaseURL: "http://x",
+			Resources: map[string]Resource{
+				"items": {Endpoints: map[string]Endpoint{"list": {Method: "GET", Path: "/items"}}},
+			},
+			Cache: cache,
+			Share: share,
+		}
+	}
+
+	tests := []struct {
+		name    string
+		cache   CacheConfig
+		share   ShareConfig
+		wantErr string
+	}{
+		{
+			name:    "share enabled without snapshot_tables",
+			share:   ShareConfig{Enabled: true},
+			wantErr: "non-empty share.snapshot_tables allowlist",
+		},
+		{
+			name:    "share snapshot_tables set but disabled",
+			share:   ShareConfig{Enabled: false, SnapshotTables: []string{"items"}},
+			wantErr: "snapshot_tables is set but share.enabled is false",
+		},
+		{
+			name:    "share table auth_tokens rejected",
+			share:   ShareConfig{Enabled: true, SnapshotTables: []string{"auth_tokens"}},
+			wantErr: "denylist",
+		},
+		{
+			name:    "share table oauth_cache rejected",
+			share:   ShareConfig{Enabled: true, SnapshotTables: []string{"oauth_cache"}},
+			wantErr: "denylist",
+		},
+		{
+			name:    "share table session_secrets rejected",
+			share:   ShareConfig{Enabled: true, SnapshotTables: []string{"session_secrets"}},
+			wantErr: "denylist",
+		},
+		{
+			name:    "share table uppercase rejected",
+			share:   ShareConfig{Enabled: true, SnapshotTables: []string{"Items"}},
+			wantErr: "lowercase SQLite identifier",
+		},
+		{
+			name:    "share table duplicate rejected",
+			share:   ShareConfig{Enabled: true, SnapshotTables: []string{"items", "items"}},
+			wantErr: "appears more than once",
+		},
+		{
+			name:    "cache stale_after invalid duration",
+			cache:   CacheConfig{Enabled: true, StaleAfter: "yesterday"},
+			wantErr: "not a valid Go duration",
+		},
+		{
+			name:    "cache refresh_timeout invalid duration",
+			cache:   CacheConfig{Enabled: true, RefreshTimeout: "soonish"},
+			wantErr: "not a valid Go duration",
+		},
+		{
+			name:    "cache per-resource invalid duration",
+			cache:   CacheConfig{Enabled: true, Resources: map[string]string{"items": "eh"}},
+			wantErr: "not a valid Go duration",
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			s := base(tt.cache, tt.share)
+			err := s.Validate()
+			require.Error(t, err)
+			assert.Contains(t, err.Error(), tt.wantErr)
+		})
+	}
+}
+
+func TestCacheShareAcceptsValidShapes(t *testing.T) {
+	tests := []struct {
+		name  string
+		cache CacheConfig
+		share ShareConfig
+	}{
+		{
+			name:  "cache only, no share",
+			cache: CacheConfig{Enabled: true, StaleAfter: "6h", RefreshTimeout: "30s"},
+		},
+		{
+			name:  "cache with per-resource overrides",
+			cache: CacheConfig{Enabled: true, StaleAfter: "6h", Resources: map[string]string{"items": "5m", "teams": "24h"}},
+		},
+		{
+			name:  "share only, no cache",
+			share: ShareConfig{Enabled: true, SnapshotTables: []string{"items", "teams", "sync_state"}},
+		},
+		{
+			name:  "cache and share both enabled",
+			cache: CacheConfig{Enabled: true, StaleAfter: "6h"},
+			share: ShareConfig{Enabled: true, SnapshotTables: []string{"items"}, DefaultBranch: "main"},
+		},
+		{
+			name:  "composite duration (90m, 1h30m) accepted",
+			cache: CacheConfig{Enabled: true, StaleAfter: "1h30m"},
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			s := APISpec{
+				Name:      "demo",
+				BaseURL:   "http://x",
+				Resources: map[string]Resource{"items": {Endpoints: map[string]Endpoint{"list": {Method: "GET", Path: "/items"}}}},
+				Cache:     tt.cache,
+				Share:     tt.share,
+			}
+			require.NoError(t, s.Validate())
+		})
+	}
+}

← ab7254b1 docs(cli): fix plugin install commands in README (#230)  ·  back to Cli Printing Press  ·  feat(cli): auto-refresh stale caches before read commands (# 4c05e1ee →