[object Object]

← back to Cli Printing Press

feat(cli): git-backed snapshot share + cache_freshness scorecard dimension (#234)

5e2ed6a8a43205eb9c408171be467cc1806b9e75 · 2026-04-21 22:24:30 -0700 · Matt Van Horn

Phase 3 of the discrawl-inspired rollout. A teammate with API credentials
runs 'share publish' on a cadence; everyone else runs 'share subscribe
<remote>' once and reads from the cache without credentials. Auto-refresh
picks up new snapshots on the next read.

Unit 5 (share package): new internal/share/share.go emitted when
share.enabled. Ports discrawl's manifest + per-table JSONL format,
ImportIfChanged warm-import optimization, fail-fast on schema version
mismatch, transactional import with fast pragmas, FTS rebuild after
bulk reinsert. Tables larger than 40 MB are sharded. 6 generated
round-trip tests including warm no-op and manifest-version rejection.

Unit 6 (share commands): share export, share import, share publish,
share subscribe wired into root.go as a parent cobra command. Publish
sequences EnsureRepo -> Pull -> Export -> Commit -> Push (skips push
when nothing changed). Subscribe is the credential-free onboarding
flow. README and help text describe the team workflow.

Unit 8 (scorecard dimension): new cache_freshness Tier 1 dimension
(0-10). Sub-criteria: schema-version gate (+3), doctor cache section
(+2), auto-refresh wired (+3), share surface (+2). Marked N/A when the
CLI has no local store so CLIs without a cache story aren't penalized.
tier1Max adjusts to 160 / 150 / 140 based on scored dimensions. 4
generated table tests cover full-stack, partial, missing-store cases.

Gated on VisionSet.Store + Share.Enabled so opted-out CLIs carry none
of the share emission surface. Full test suite and golangci-lint clean.

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 5e2ed6a8a43205eb9c408171be467cc1806b9e75
Author: Matt Van Horn <mvanhorn@users.noreply.github.com>
Date:   Tue Apr 21 22:24:30 2026 -0700

    feat(cli): git-backed snapshot share + cache_freshness scorecard dimension (#234)
    
    Phase 3 of the discrawl-inspired rollout. A teammate with API credentials
    runs 'share publish' on a cadence; everyone else runs 'share subscribe
    <remote>' once and reads from the cache without credentials. Auto-refresh
    picks up new snapshots on the next read.
    
    Unit 5 (share package): new internal/share/share.go emitted when
    share.enabled. Ports discrawl's manifest + per-table JSONL format,
    ImportIfChanged warm-import optimization, fail-fast on schema version
    mismatch, transactional import with fast pragmas, FTS rebuild after
    bulk reinsert. Tables larger than 40 MB are sharded. 6 generated
    round-trip tests including warm no-op and manifest-version rejection.
    
    Unit 6 (share commands): share export, share import, share publish,
    share subscribe wired into root.go as a parent cobra command. Publish
    sequences EnsureRepo -> Pull -> Export -> Commit -> Push (skips push
    when nothing changed). Subscribe is the credential-free onboarding
    flow. README and help text describe the team workflow.
    
    Unit 8 (scorecard dimension): new cache_freshness Tier 1 dimension
    (0-10). Sub-criteria: schema-version gate (+3), doctor cache section
    (+2), auto-refresh wired (+3), share surface (+2). Marked N/A when the
    CLI has no local store so CLIs without a cache story aren't penalized.
    tier1Max adjusts to 160 / 150 / 140 based on scored dimensions. 4
    generated table tests cover full-stack, partial, missing-store cases.
    
    Gated on VisionSet.Store + Share.Enabled so opted-out CLIs carry none
    of the share emission surface. Full test suite and golangci-lint clean.
    
    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                    |  19 +
 internal/generator/generator_test.go               |  76 +++
 internal/generator/templates/root.go.tmpl          |   3 +
 internal/generator/templates/share.go.tmpl         | 723 +++++++++++++++++++++
 .../generator/templates/share_commands.go.tmpl     | 259 ++++++++
 internal/generator/templates/share_test.go.tmpl    | 185 ++++++
 internal/pipeline/scorecard.go                     | 106 ++-
 internal/pipeline/scorecard_tier2_test.go          |  70 +-
 8 files changed, 1421 insertions(+), 20 deletions(-)

diff --git a/internal/generator/generator.go b/internal/generator/generator.go
index e2070757..87e58e61 100644
--- a/internal/generator/generator.go
+++ b/internal/generator/generator.go
@@ -751,6 +751,25 @@ func (g *Generator) Generate() error {
 		}
 	}
 
+	// Emit the git-backed share package only when explicitly enabled and
+	// the CLI has a local store. Share requires a SnapshotTables allowlist;
+	// spec.Validate has already rejected a missing allowlist with a clear
+	// error before we reach this point.
+	if g.VisionSet.Store && g.Spec.Share.Enabled {
+		if err := os.MkdirAll(filepath.Join(g.OutputDir, "internal", "share"), 0o755); err != nil {
+			return fmt.Errorf("creating share dir: %w", err)
+		}
+		if err := g.renderTemplate("share.go.tmpl", filepath.Join("internal", "share", "share.go"), g.Spec); err != nil {
+			return fmt.Errorf("rendering share: %w", err)
+		}
+		if err := g.renderTemplate("share_test.go.tmpl", filepath.Join("internal", "share", "share_test.go"), g.Spec); err != nil {
+			return fmt.Errorf("rendering share test: %w", err)
+		}
+		if err := g.renderTemplate("share_commands.go.tmpl", filepath.Join("internal", "cli", "share_commands.go"), g.Spec); err != nil {
+			return fmt.Errorf("rendering share commands: %w", err)
+		}
+	}
+
 	if g.FixtureSet != nil {
 		if err := g.renderTemplate("captured_test.go.tmpl", filepath.Join("internal", "client", "client_captured_test.go"), g.FixtureSet); err != nil {
 			return fmt.Errorf("rendering captured fixture tests: %w", err)
diff --git a/internal/generator/generator_test.go b/internal/generator/generator_test.go
index 9fc9edff..0790493d 100644
--- a/internal/generator/generator_test.go
+++ b/internal/generator/generator_test.go
@@ -158,6 +158,82 @@ func TestGenerateFreshnessHelperEmitted(t *testing.T) {
 	runGoCommand(t, outputDir, "test", "./internal/cliutil/...")
 }
 
+// TestGenerateShareEmittedWhenEnabled verifies the end-to-end share
+// surface: share package, share commands, and the share subcommand
+// registered on the root command. Exercises the generated share_test.go
+// to confirm the round-trip export → import contract holds.
+func TestGenerateShareEmittedWhenEnabled(t *testing.T) {
+	t.Parallel()
+
+	apiSpec, err := spec.Parse(filepath.Join("..", "..", "testdata", "stytch.yaml"))
+	require.NoError(t, err)
+	apiSpec.Cache = spec.CacheConfig{Enabled: true, StaleAfter: "6h"}
+	apiSpec.Share = spec.ShareConfig{
+		Enabled:        true,
+		SnapshotTables: []string{"users", "sync_state"},
+	}
+
+	outputDir := filepath.Join(t.TempDir(), naming.CLI(apiSpec.Name))
+	gen := New(apiSpec, outputDir)
+	require.NoError(t, gen.Generate())
+
+	// share package is emitted under internal/share.
+	for _, name := range []string{"share.go", "share_test.go"} {
+		_, err := os.Stat(filepath.Join(outputDir, "internal", "share", name))
+		require.NoError(t, err, "expected internal/share/%s to be emitted when share is enabled", name)
+	}
+
+	// share cobra commands are emitted under internal/cli.
+	shareCmdsPath := filepath.Join(outputDir, "internal", "cli", "share_commands.go")
+	data, err := os.ReadFile(shareCmdsPath)
+	require.NoError(t, err)
+	for _, snippet := range []string{
+		"func newShareCmd",
+		"func newShareExportCmd",
+		"func newShareImportCmd",
+		"func newSharePublishCmd",
+		"func newShareSubscribeCmd",
+	} {
+		assert.Contains(t, string(data), snippet, "share_commands.go missing %q", snippet)
+	}
+
+	// root.go registers the share parent command.
+	rootSrc, err := os.ReadFile(filepath.Join(outputDir, "internal", "cli", "root.go"))
+	require.NoError(t, err)
+	assert.Contains(t, string(rootSrc), "rootCmd.AddCommand(newShareCmd(&flags))",
+		"root.go must register newShareCmd when share is enabled")
+
+	// The generated share package tests must compile and pass; this is
+	// the round-trip safety net for the Unit 5 contract.
+	runGoCommand(t, outputDir, "mod", "tidy")
+	runGoCommand(t, outputDir, "build", "./...")
+	runGoCommand(t, outputDir, "test", "./internal/share/...")
+}
+
+// TestGenerateShareSkippedWhenDisabled confirms share is not emitted for
+// CLIs that don't opt in. Matters because share.go imports git via
+// os/exec and pulls in a new emission path; absent specs should carry
+// none of that overhead.
+func TestGenerateShareSkippedWhenDisabled(t *testing.T) {
+	t.Parallel()
+
+	apiSpec, err := spec.Parse(filepath.Join("..", "..", "testdata", "stytch.yaml"))
+	require.NoError(t, err)
+	require.False(t, apiSpec.Share.Enabled)
+
+	outputDir := filepath.Join(t.TempDir(), naming.CLI(apiSpec.Name))
+	gen := New(apiSpec, outputDir)
+	require.NoError(t, gen.Generate())
+
+	for _, name := range []string{
+		filepath.Join("internal", "share", "share.go"),
+		filepath.Join("internal", "cli", "share_commands.go"),
+	} {
+		_, err := os.Stat(filepath.Join(outputDir, name))
+		assert.True(t, os.IsNotExist(err), "%s must not be emitted when share is disabled", name)
+	}
+}
+
 // TestGenerateFreshnessHelperSkippedWhenCacheOff verifies that a spec
 // without cache or share does not receive the freshness helper.
 // CLIs without a cache story should not carry dead code.
diff --git a/internal/generator/templates/root.go.tmpl b/internal/generator/templates/root.go.tmpl
index 73848c4f..67c0871a 100644
--- a/internal/generator/templates/root.go.tmpl
+++ b/internal/generator/templates/root.go.tmpl
@@ -217,6 +217,9 @@ Run '{{.Name}}-pp-cli doctor' to verify auth and connectivity.{{backtick}},
 {{- if .VisionSet.Store}}
 	rootCmd.AddCommand(newWorkflowCmd(&flags))
 {{- end}}
+{{- if .Share.Enabled}}
+	rootCmd.AddCommand(newShareCmd(&flags))
+{{- end}}
 {{- range .WorkflowConstructors}}
 	rootCmd.AddCommand(new{{.}}Cmd(&flags))
 {{- end}}
diff --git a/internal/generator/templates/share.go.tmpl b/internal/generator/templates/share.go.tmpl
new file mode 100644
index 00000000..90b06c1b
--- /dev/null
+++ b/internal/generator/templates/share.go.tmpl
@@ -0,0 +1,723 @@
+// 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 share implements git-backed snapshot sharing for {{.Name}}-pp-cli.
+// The publish side dumps the local SQLite store to per-table JSONL files
+// under a git repo, commits, and pushes. The subscribe side clones/pulls
+// the repo and re-hydrates the local store from the snapshot — no API
+// credentials required. Discrawl v0.3.0 is the reference implementation.
+package share
+
+import (
+	"bufio"
+	"context"
+	"database/sql"
+	"encoding/json"
+	"errors"
+	"fmt"
+	"os"
+	"os/exec"
+	"path/filepath"
+	"strings"
+	"time"
+
+	"{{modulePath}}/internal/store"
+)
+
+const (
+	// ManifestName is the manifest file at the root of a share repo.
+	ManifestName = "manifest.json"
+	// ManifestVersion is the current manifest schema version. Incompatible
+	// shape changes bump this; subscribers that don't recognise the version
+	// refuse to import rather than corrupt the local DB.
+	ManifestVersion = 1
+	// LastImportSyncScope is the sync_state key recording when the
+	// subscriber last applied any import. Used to decide whether to
+	// run Pull + Import on the next auto-refresh tick.
+	LastImportSyncScope = "share:last_import_at"
+	// LastImportManifestSyncScope records the generated_at of the last
+	// imported manifest, enabling warm no-op imports when the remote
+	// hasn't advanced.
+	LastImportManifestSyncScope = "share:last_import_manifest_generated_at"
+)
+
+// ErrNoManifest is returned when the share repo has no manifest.json,
+// meaning the remote has nothing to import (typical for a freshly
+// subscribed repo before the first publish).
+var ErrNoManifest = errors.New("share manifest not found")
+
+// maxShardBytes bounds a single JSONL file. Tables larger than this are
+// split into numbered shards so git diffs stay tractable. Matches
+// discrawl's empirical threshold.
+const maxShardBytes int64 = 40 * 1024 * 1024
+
+// SnapshotTables is the spec-defined allowlist of tables included in a
+// snapshot. Validated at generation time against the auth_*/_cache/_secrets
+// denylist so tokens and per-user state never travel in a shared repo.
+var SnapshotTables = []string{
+{{- range .Share.SnapshotTables}}
+	"{{.}}",
+{{- end}}
+}
+
+// Options configures a share operation.
+type Options struct {
+	// RepoPath is the local checkout of the share repo.
+	RepoPath string
+	// Remote is the git remote URL. Empty means local-only (init in place).
+	Remote string
+	// Branch is the git branch to publish to or subscribe from; empty → main.
+	Branch string
+}
+
+// Manifest describes a snapshot in a deterministic on-disk format. Every
+// field is present for warm-import discrimination and forward/backward
+// compatibility — subscribers at an older SchemaVersion refuse to import
+// a newer snapshot rather than crash mid-ingest.
+type Manifest struct {
+	Version          int             `json:"version"`
+	CLIName          string          `json:"cli_name"`
+	SchemaVersion    int             `json:"schema_version"`
+	GeneratorVersion string          `json:"generator_version,omitempty"`
+	GeneratedAt      time.Time       `json:"generated_at"`
+	Tables           []TableManifest `json:"tables"`
+}
+
+// TableManifest captures one table's shards, column ordering, and row
+// count. Column ordering in the manifest is the authoritative order for
+// JSONL entries — importers must not rely on column order from the
+// current schema, which may drift.
+type TableManifest struct {
+	Name    string   `json:"name"`
+	File    string   `json:"file,omitempty"`
+	Files   []string `json:"files,omitempty"`
+	Columns []string `json:"columns"`
+	Rows    int      `json:"rows"`
+}
+
+// EnsureRepo creates the share repo on disk if it does not yet exist.
+// When Remote is set, it clones; otherwise it `git init`s in place so a
+// local-only publish workflow works without any upstream.
+func EnsureRepo(ctx context.Context, opts Options) error {
+	if strings.TrimSpace(opts.RepoPath) == "" {
+		return fmt.Errorf("share repo path is empty")
+	}
+	if _, err := os.Stat(filepath.Join(opts.RepoPath, ".git")); err == nil {
+		return nil
+	}
+	if strings.TrimSpace(opts.Remote) != "" {
+		if err := os.MkdirAll(filepath.Dir(opts.RepoPath), 0o755); err != nil {
+			return fmt.Errorf("mkdir share parent: %w", err)
+		}
+		if err := runGit(ctx, "", "clone", opts.Remote, opts.RepoPath); err != nil {
+			return err
+		}
+		if strings.TrimSpace(opts.Branch) != "" {
+			if err := runGit(ctx, opts.RepoPath, "checkout", "-B", opts.Branch); err != nil {
+				return err
+			}
+		}
+		return nil
+	}
+	if err := os.MkdirAll(opts.RepoPath, 0o755); err != nil {
+		return fmt.Errorf("mkdir share repo: %w", err)
+	}
+	if err := runGit(ctx, opts.RepoPath, "init"); err != nil {
+		return err
+	}
+	if strings.TrimSpace(opts.Branch) != "" {
+		if err := runGit(ctx, opts.RepoPath, "checkout", "-B", opts.Branch); err != nil {
+			return err
+		}
+	}
+	return nil
+}
+
+// Pull fetches the configured branch from origin and fast-forwards the
+// local checkout. A missing remote ref is treated as an empty branch
+// (first publish hasn't happened yet) rather than an error.
+func Pull(ctx context.Context, opts Options) error {
+	if strings.TrimSpace(opts.Remote) == "" {
+		return nil
+	}
+	if err := EnsureRepo(ctx, opts); err != nil {
+		return err
+	}
+	if err := runGit(ctx, opts.RepoPath, "fetch", "--prune", "origin"); err != nil {
+		return err
+	}
+	branch := opts.Branch
+	if strings.TrimSpace(branch) == "" {
+		branch = "main"
+	}
+	remoteRef := "refs/remotes/origin/" + branch
+	if _, err := gitOutput(ctx, opts.RepoPath, "rev-parse", "--verify", remoteRef); err != nil {
+		return runGit(ctx, opts.RepoPath, "checkout", "-B", branch)
+	}
+	if err := runGit(ctx, opts.RepoPath, "checkout", "-B", branch, "origin/"+branch); err != nil {
+		return err
+	}
+	return runGit(ctx, opts.RepoPath, "pull", "--ff-only", "origin", branch)
+}
+
+// Commit stages everything under the repo and commits with the given
+// message. Returns false (no error) if nothing changed — avoids empty
+// commits when the snapshot hasn't moved.
+func Commit(ctx context.Context, opts Options, message string) (bool, error) {
+	if err := runGit(ctx, opts.RepoPath, "add", "."); err != nil {
+		return false, err
+	}
+	out, err := gitOutput(ctx, opts.RepoPath, "status", "--porcelain")
+	if err != nil {
+		return false, err
+	}
+	if strings.TrimSpace(out) == "" {
+		return false, nil
+	}
+	if strings.TrimSpace(message) == "" {
+		message = "sync: {{.Name}}-pp-cli snapshot"
+	}
+	if err := runGit(ctx, opts.RepoPath, "commit", "-m", message); err != nil {
+		return false, err
+	}
+	return true, nil
+}
+
+// Push sends the local branch to origin. On a non-fast-forward refusal
+// it auto-rebases against origin and retries once, mirroring discrawl's
+// policy for serialised team publishes.
+func Push(ctx context.Context, opts Options) error {
+	branch := opts.Branch
+	if strings.TrimSpace(branch) == "" {
+		branch = "main"
+	}
+	out, err := gitOutput(ctx, opts.RepoPath, "push", "-u", "origin", branch)
+	if err == nil {
+		return nil
+	}
+	if !isNonFastForwardPush(out) {
+		return fmt.Errorf("git push -u origin %s: %w\n%s", branch, err, strings.TrimSpace(out))
+	}
+	if pullErr := runGit(ctx, opts.RepoPath, "pull", "--rebase", "--autostash", "origin", branch); pullErr != nil {
+		return fmt.Errorf("rebase before push retry: %w", pullErr)
+	}
+	return runGit(ctx, opts.RepoPath, "push", "-u", "origin", branch)
+}
+
+// Export reads each SnapshotTable from the store and writes per-table
+// JSONL plus the manifest. Large tables are sharded under maxShardBytes
+// so git diffs remain tractable.
+func Export(ctx context.Context, s *store.Store, opts Options) (Manifest, error) {
+	if err := EnsureRepo(ctx, opts); err != nil {
+		return Manifest{}, err
+	}
+	dataDir := filepath.Join(opts.RepoPath, "tables")
+	if err := os.RemoveAll(dataDir); err != nil {
+		return Manifest{}, fmt.Errorf("reset tables dir: %w", err)
+	}
+	if err := os.MkdirAll(dataDir, 0o755); err != nil {
+		return Manifest{}, fmt.Errorf("mkdir tables dir: %w", err)
+	}
+	schemaVersion, err := s.SchemaVersion()
+	if err != nil {
+		return Manifest{}, fmt.Errorf("read schema version: %w", err)
+	}
+	manifest := Manifest{
+		Version:       ManifestVersion,
+		CLIName:       "{{.Name}}-pp-cli",
+		SchemaVersion: schemaVersion,
+		GeneratedAt:   time.Now().UTC(),
+	}
+	for _, table := range SnapshotTables {
+		entry, err := exportTable(ctx, s.DB(), dataDir, table)
+		if err != nil {
+			return Manifest{}, err
+		}
+		manifest.Tables = append(manifest.Tables, entry)
+	}
+	body, err := json.MarshalIndent(manifest, "", "  ")
+	if err != nil {
+		return Manifest{}, err
+	}
+	body = append(body, '\n')
+	if err := os.WriteFile(filepath.Join(opts.RepoPath, ManifestName), body, 0o600); err != nil {
+		return Manifest{}, fmt.Errorf("write manifest: %w", err)
+	}
+	return manifest, nil
+}
+
+// Import reads the manifest and re-hydrates the local store. It runs
+// inside a single transaction — either the full snapshot lands or the
+// old cache is preserved. Fast pragmas are applied during the bulk
+// insert and restored before commit.
+func Import(ctx context.Context, s *store.Store, opts Options) (Manifest, error) {
+	manifest, err := ReadManifest(opts.RepoPath)
+	if err != nil {
+		return Manifest{}, err
+	}
+	if manifest.SchemaVersion > store.StoreSchemaVersion {
+		return Manifest{}, fmt.Errorf("snapshot schema version %d newer than binary supports (%d); upgrade {{.Name}}-pp-cli", manifest.SchemaVersion, store.StoreSchemaVersion)
+	}
+	restore, err := applyImportPragmas(ctx, s.DB())
+	if err != nil {
+		return Manifest{}, err
+	}
+	restored := false
+	defer func() {
+		if !restored {
+			_ = restore(ctx)
+		}
+	}()
+	tx, err := s.DB().BeginTx(ctx, nil)
+	if err != nil {
+		return Manifest{}, err
+	}
+	committed := false
+	defer func() {
+		if !committed {
+			_ = tx.Rollback()
+		}
+	}()
+	// Truncate snapshot tables in reverse order — SnapshotTables lists
+	// dependent/child tables after their parents, so this DELETE order
+	// is safe against foreign keys emitted by the store.
+	for i := len(SnapshotTables) - 1; i >= 0; i-- {
+		table := SnapshotTables[i]
+		if _, err := tx.ExecContext(ctx, "DELETE FROM "+quoteIdent(table)); err != nil {
+			return Manifest{}, fmt.Errorf("clear %s: %w", table, err)
+		}
+	}
+	for _, table := range manifest.Tables {
+		if err := importTable(ctx, tx, opts.RepoPath, table); err != nil {
+			return Manifest{}, err
+		}
+	}
+	if err := tx.Commit(); err != nil {
+		return Manifest{}, err
+	}
+	committed = true
+	if err := rebuildFTS(ctx, s.DB()); err != nil {
+		return Manifest{}, err
+	}
+	if err := MarkImported(ctx, s, manifest); err != nil {
+		return Manifest{}, err
+	}
+	if err := restore(ctx); err != nil {
+		return Manifest{}, err
+	}
+	restored = true
+	return manifest, nil
+}
+
+// ImportIfChanged is the warm-import path. When the local
+// LastImportManifestSyncScope equals the remote manifest.GeneratedAt, no
+// rows are touched — just the LastImportSyncScope timestamp is refreshed
+// so auto-refresh does not re-check for another stale_after window.
+func ImportIfChanged(ctx context.Context, s *store.Store, opts Options) (Manifest, bool, error) {
+	manifest, err := ReadManifest(opts.RepoPath)
+	if err != nil {
+		return Manifest{}, false, err
+	}
+	if ManifestAlreadyImported(ctx, s, manifest) {
+		if err := MarkImported(ctx, s, manifest); err != nil {
+			return Manifest{}, false, err
+		}
+		return manifest, false, nil
+	}
+	imported, err := Import(ctx, s, opts)
+	if err != nil {
+		return Manifest{}, false, err
+	}
+	return imported, true, nil
+}
+
+// ManifestAlreadyImported reports whether this manifest's GeneratedAt
+// already matches the subscriber's last import.
+func ManifestAlreadyImported(ctx context.Context, s *store.Store, manifest Manifest) bool {
+	if manifest.GeneratedAt.IsZero() {
+		return false
+	}
+	last, err := getSyncState(ctx, s, LastImportManifestSyncScope)
+	if err != nil || strings.TrimSpace(last) == "" {
+		return false
+	}
+	t, err := time.Parse(time.RFC3339Nano, last)
+	if err != nil {
+		return false
+	}
+	return t.Equal(manifest.GeneratedAt)
+}
+
+// MarkImported records both the wall-clock of this import run and the
+// manifest's GeneratedAt. Auto-refresh reads the wall-clock; warm-import
+// reads GeneratedAt.
+func MarkImported(ctx context.Context, s *store.Store, manifest Manifest) error {
+	if err := setSyncState(ctx, s, LastImportSyncScope, time.Now().UTC().Format(time.RFC3339Nano)); err != nil {
+		return err
+	}
+	if manifest.GeneratedAt.IsZero() {
+		return nil
+	}
+	return setSyncState(ctx, s, LastImportManifestSyncScope, manifest.GeneratedAt.Format(time.RFC3339Nano))
+}
+
+// ReadManifest parses manifest.json from the repo root. Returns
+// ErrNoManifest when the file does not exist (subscriber on a fresh repo
+// that hasn't been published to yet).
+func ReadManifest(repoPath string) (Manifest, error) {
+	data, err := os.ReadFile(filepath.Join(repoPath, ManifestName))
+	if err != nil {
+		if os.IsNotExist(err) {
+			return Manifest{}, ErrNoManifest
+		}
+		return Manifest{}, fmt.Errorf("read share manifest: %w", err)
+	}
+	var manifest Manifest
+	if err := json.Unmarshal(data, &manifest); err != nil {
+		return Manifest{}, fmt.Errorf("parse share manifest: %w", err)
+	}
+	if manifest.Version != ManifestVersion {
+		return Manifest{}, fmt.Errorf("unsupported share manifest version %d (this binary understands v%d)", manifest.Version, ManifestVersion)
+	}
+	return manifest, nil
+}
+
+// NeedsImport reports whether the last import is older than staleAfter.
+// Zero or negative staleAfter defaults to 15 minutes.
+func NeedsImport(ctx context.Context, s *store.Store, staleAfter time.Duration) bool {
+	if staleAfter <= 0 {
+		staleAfter = 15 * time.Minute
+	}
+	last, err := getSyncState(ctx, s, LastImportSyncScope)
+	if err != nil || strings.TrimSpace(last) == "" {
+		return true
+	}
+	t, err := time.Parse(time.RFC3339Nano, last)
+	if err != nil {
+		return true
+	}
+	return time.Since(t) >= staleAfter
+}
+
+// exportTable dumps one table to JSONL. Columns are discovered from the
+// DB metadata so additive schema changes (new columns) travel in the
+// snapshot without any spec update.
+func exportTable(ctx context.Context, db *sql.DB, dataDir, table string) (TableManifest, error) {
+	rows, err := db.QueryContext(ctx, "SELECT * FROM "+quoteIdent(table))
+	if err != nil {
+		return TableManifest{}, fmt.Errorf("select %s: %w", table, err)
+	}
+	defer rows.Close()
+	cols, err := rows.Columns()
+	if err != nil {
+		return TableManifest{}, err
+	}
+
+	entry := TableManifest{Name: table, Columns: cols}
+	var (
+		rowCount   int
+		shardIndex int
+		currentW   *shardWriter
+	)
+	openNewShard := func() error {
+		if currentW != nil {
+			if err := currentW.Close(); err != nil {
+				return err
+			}
+			entry.Files = append(entry.Files, currentW.rel)
+		}
+		shardIndex++
+		name := fmt.Sprintf("%s-%03d.jsonl", table, shardIndex)
+		w, err := newShardWriter(dataDir, name)
+		if err != nil {
+			return err
+		}
+		currentW = w
+		return nil
+	}
+	if err := openNewShard(); err != nil {
+		return TableManifest{}, err
+	}
+
+	values := make([]any, len(cols))
+	pointers := make([]any, len(cols))
+	for i := range values {
+		pointers[i] = &values[i]
+	}
+	for rows.Next() {
+		if err := rows.Scan(pointers...); err != nil {
+			return TableManifest{}, err
+		}
+		record := make(map[string]any, len(cols))
+		for i, name := range cols {
+			record[name] = normalizeForJSON(values[i])
+		}
+		line, err := json.Marshal(record)
+		if err != nil {
+			return TableManifest{}, err
+		}
+		line = append(line, '\n')
+		if currentW.bytes+int64(len(line)) > maxShardBytes && currentW.bytes > 0 {
+			if err := openNewShard(); err != nil {
+				return TableManifest{}, err
+			}
+		}
+		if _, err := currentW.w.Write(line); err != nil {
+			return TableManifest{}, err
+		}
+		currentW.bytes += int64(len(line))
+		rowCount++
+	}
+	if err := rows.Err(); err != nil {
+		return TableManifest{}, err
+	}
+	if err := currentW.Close(); err != nil {
+		return TableManifest{}, err
+	}
+	entry.Files = append(entry.Files, currentW.rel)
+	if len(entry.Files) == 1 {
+		entry.File = entry.Files[0]
+		entry.Files = nil
+	}
+	entry.Rows = rowCount
+	return entry, nil
+}
+
+// importTable reads one table's JSONL shards and inserts each row using
+// the manifest's column order. The INSERT uses the manifest columns, not
+// the live schema columns, so a subscriber running an older binary
+// tolerates a snapshot that includes new columns the subscriber doesn't
+// have — SQLite's tolerant SQL rejects unknown columns at prepare time,
+// surfacing a clear error.
+func importTable(ctx context.Context, tx *sql.Tx, repoPath string, entry TableManifest) error {
+	files := entry.Files
+	if entry.File != "" {
+		files = append([]string{entry.File}, files...)
+	}
+	if len(files) == 0 {
+		return nil
+	}
+	cols := entry.Columns
+	if len(cols) == 0 {
+		return fmt.Errorf("manifest for %s has no columns", entry.Name)
+	}
+	placeholders := make([]string, len(cols))
+	for i := range placeholders {
+		placeholders[i] = "?"
+	}
+	colList := make([]string, len(cols))
+	for i, c := range cols {
+		colList[i] = quoteIdent(c)
+	}
+	query := fmt.Sprintf(`INSERT INTO %s (%s) VALUES (%s)`,
+		quoteIdent(entry.Name),
+		strings.Join(colList, ","),
+		strings.Join(placeholders, ","),
+	)
+	stmt, err := tx.PrepareContext(ctx, query)
+	if err != nil {
+		return fmt.Errorf("prepare insert %s: %w", entry.Name, err)
+	}
+	defer stmt.Close()
+
+	for _, relPath := range files {
+		path := filepath.Join(repoPath, relPath)
+		f, err := os.Open(path)
+		if err != nil {
+			return fmt.Errorf("open %s: %w", relPath, err)
+		}
+		scanner := bufio.NewScanner(f)
+		scanner.Buffer(make([]byte, 1024*1024), 64*1024*1024)
+		for scanner.Scan() {
+			var record map[string]any
+			if err := json.Unmarshal(scanner.Bytes(), &record); err != nil {
+				f.Close()
+				return fmt.Errorf("decode %s: %w", relPath, err)
+			}
+			args := make([]any, len(cols))
+			for i, c := range cols {
+				args[i] = record[c]
+			}
+			if _, err := stmt.ExecContext(ctx, args...); err != nil {
+				f.Close()
+				return fmt.Errorf("insert %s: %w", entry.Name, err)
+			}
+		}
+		if err := scanner.Err(); err != nil {
+			f.Close()
+			return err
+		}
+		f.Close()
+	}
+	return nil
+}
+
+// applyImportPragmas disables durability costs during bulk insert and
+// returns a restore function the caller defers. Matches discrawl's
+// fast-import pattern; crash during import rolls back the transaction
+// so the unsafe pragmas never leak into a persisted state.
+func applyImportPragmas(ctx context.Context, db *sql.DB) (func(context.Context) error, error) {
+	for _, stmt := range []string{
+		`PRAGMA temp_store = memory`,
+		`PRAGMA cache_size = -262144`,
+		`PRAGMA synchronous = off`,
+		`PRAGMA journal_mode = off`,
+	} {
+		if _, err := db.ExecContext(ctx, stmt); err != nil {
+			return nil, fmt.Errorf("apply import pragma %q: %w", stmt, err)
+		}
+	}
+	return func(ctx context.Context) error {
+		for _, stmt := range []string{
+			`PRAGMA journal_mode = wal`,
+			`PRAGMA synchronous = normal`,
+		} {
+			if _, err := db.ExecContext(ctx, stmt); err != nil {
+				return fmt.Errorf("restore import pragma %q: %w", stmt, err)
+			}
+		}
+		return nil
+	}, nil
+}
+
+// rebuildFTS asks each FTS5 virtual table to rebuild itself from the
+// content tables. Required after a bulk truncate+reinsert because the
+// content-backed FTS tables shed their rowids when their parent table
+// was emptied.
+func rebuildFTS(ctx context.Context, db *sql.DB) error {
+	rows, err := db.QueryContext(ctx, `SELECT name FROM sqlite_master WHERE type = 'table' AND name LIKE '%_fts'`)
+	if err != nil {
+		return fmt.Errorf("list fts tables: %w", err)
+	}
+	defer rows.Close()
+	var names []string
+	for rows.Next() {
+		var name string
+		if err := rows.Scan(&name); err != nil {
+			return err
+		}
+		names = append(names, name)
+	}
+	if err := rows.Err(); err != nil {
+		return err
+	}
+	for _, name := range names {
+		stmt := fmt.Sprintf(`INSERT INTO %s(%s) VALUES('rebuild')`, quoteIdent(name), quoteIdent(name))
+		if _, err := db.ExecContext(ctx, stmt); err != nil {
+			// Some FTS setups (external content) may reject rebuild;
+			// log and continue rather than aborting the whole import.
+			// A stderr warning would go to the caller; at this layer
+			// we stay silent and trust callers' own telemetry.
+			_ = err
+		}
+	}
+	return nil
+}
+
+type shardWriter struct {
+	f     *os.File
+	w     *bufio.Writer
+	rel   string
+	bytes int64
+}
+
+func newShardWriter(dataDir, name string) (*shardWriter, error) {
+	path := filepath.Join(dataDir, name)
+	f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600)
+	if err != nil {
+		return nil, fmt.Errorf("open shard %s: %w", name, err)
+	}
+	return &shardWriter{f: f, w: bufio.NewWriter(f), rel: filepath.ToSlash(filepath.Join("tables", name))}, nil
+}
+
+func (sw *shardWriter) Close() error {
+	if err := sw.w.Flush(); err != nil {
+		sw.f.Close()
+		return err
+	}
+	return sw.f.Close()
+}
+
+// normalizeForJSON maps SQL scan values to JSON-friendly forms. Most
+// scalars round-trip as-is; []byte becomes a string (SQLite stores
+// most blobs as TEXT anyway); time.Time marshals fine via encoding/json.
+func normalizeForJSON(v any) any {
+	switch x := v.(type) {
+	case []byte:
+		return string(x)
+	default:
+		return x
+	}
+}
+
+// quoteIdent returns a safely-quoted SQLite identifier. Used only on
+// names sourced from the generator at generation time or the manifest,
+// which itself was produced by the generator — there is no user input
+// reaching this function.
+func quoteIdent(name string) string {
+	return `"` + strings.ReplaceAll(name, `"`, `""`) + `"`
+}
+
+// getSyncState fetches one sync_state value via a direct SQL query so
+// share.go stays independent of store's own helpers (which it cannot
+// call from here because store imports share indirectly through the
+// schema layer in some paths). The sync_state table shape is stable.
+func getSyncState(ctx context.Context, s *store.Store, scope string) (string, error) {
+	var v sql.NullString
+	err := s.DB().QueryRowContext(ctx, `SELECT last_cursor FROM sync_state WHERE resource_type = ?`, scope).Scan(&v)
+	if err == sql.ErrNoRows {
+		return "", nil
+	}
+	if err != nil {
+		return "", fmt.Errorf("read sync_state %q: %w", scope, err)
+	}
+	if !v.Valid {
+		return "", nil
+	}
+	return v.String, nil
+}
+
+func setSyncState(ctx context.Context, s *store.Store, scope, value string) error {
+	_, err := s.DB().ExecContext(ctx,
+		`INSERT INTO sync_state(resource_type, last_cursor, last_synced_at) VALUES(?, ?, ?)
+		 ON CONFLICT(resource_type) DO UPDATE SET last_cursor = excluded.last_cursor, last_synced_at = excluded.last_synced_at`,
+		scope, value, time.Now().UTC(),
+	)
+	if err != nil {
+		return fmt.Errorf("write sync_state %q: %w", scope, err)
+	}
+	return nil
+}
+
+// runGit invokes git with args in workDir. Stderr is captured and
+// surfaced via the returned error on failure so callers can present a
+// useful diagnostic without leaking command-line construction.
+func runGit(ctx context.Context, workDir string, args ...string) error {
+	out, err := gitOutput(ctx, workDir, args...)
+	if err != nil {
+		return fmt.Errorf("git %s: %w\n%s", strings.Join(args, " "), err, strings.TrimSpace(out))
+	}
+	return nil
+}
+
+func gitOutput(ctx context.Context, workDir string, args ...string) (string, error) {
+	cmd := exec.CommandContext(ctx, "git", args...)
+	if workDir != "" {
+		cmd.Dir = workDir
+	}
+	var stdout, stderr strings.Builder
+	cmd.Stdout = &stdout
+	cmd.Stderr = &stderr
+	if err := cmd.Run(); err != nil {
+		return stderr.String(), err
+	}
+	return stdout.String(), nil
+}
+
+// isNonFastForwardPush inspects git push stderr for the canonical
+// non-FF marker. Keeping this string-matched (rather than exit-code)
+// matches discrawl's behaviour and tolerates the small differences in
+// git output across versions.
+func isNonFastForwardPush(stderr string) bool {
+	return strings.Contains(stderr, "non-fast-forward") || strings.Contains(stderr, "rejected")
+}
diff --git a/internal/generator/templates/share_commands.go.tmpl b/internal/generator/templates/share_commands.go.tmpl
new file mode 100644
index 00000000..b26b6089
--- /dev/null
+++ b/internal/generator/templates/share_commands.go.tmpl
@@ -0,0 +1,259 @@
+// 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 cli
+
+import (
+	"errors"
+	"fmt"
+	"os"
+	"path/filepath"
+
+	"{{modulePath}}/internal/share"
+	"{{modulePath}}/internal/store"
+	"github.com/spf13/cobra"
+)
+
+// newShareCmd groups the git-backed snapshot sharing commands into one
+// parent. Discrawl v0.3.0 established the `share publish` / `share subscribe`
+// vocabulary; we keep it for recognisability.
+func newShareCmd(flags *rootFlags) *cobra.Command {
+	cmd := &cobra.Command{
+		Use:   "share",
+		Short: "Publish or subscribe to a git-backed snapshot of the local cache",
+		Long: `Share the local {{.Name}}-pp-cli cache across teammates via a git repo.
+One teammate with API credentials runs 'share publish' on a cadence; the
+rest run 'share subscribe <remote>' once, then read from the cache
+without needing credentials. Auto-refresh picks up new snapshots on the
+next read command.`,
+	}
+	cmd.AddCommand(newShareExportCmd(flags))
+	cmd.AddCommand(newShareImportCmd(flags))
+	cmd.AddCommand(newSharePublishCmd(flags))
+	cmd.AddCommand(newShareSubscribeCmd(flags))
+	return cmd
+}
+
+func newShareExportCmd(flags *rootFlags) *cobra.Command {
+	var repoPath string
+	cmd := &cobra.Command{
+		Use:   "export",
+		Short: "Write a snapshot of the local cache to a directory",
+		Long: `Export snapshots the configured tables to a local directory in the
+share format (manifest.json + per-table JSONL). The directory is not
+required to be a git repo — use this for offline handoffs or CI staging.`,
+		Example: `  {{.Name}}-pp-cli share export --repo ./snapshot`,
+		RunE: func(cmd *cobra.Command, args []string) error {
+			if repoPath == "" {
+				return fmt.Errorf("--repo is required")
+			}
+			s, err := openShareStore()
+			if err != nil {
+				return err
+			}
+			defer s.Close()
+			manifest, err := share.Export(cmd.Context(), s, share.Options{RepoPath: repoPath})
+			if err != nil {
+				return err
+			}
+			return flags.printJSON(cmd, map[string]any{
+				"event":         "share_export",
+				"repo":          repoPath,
+				"generated_at":  manifest.GeneratedAt,
+				"tables":        len(manifest.Tables),
+				"cli_name":      manifest.CLIName,
+				"schema_version": manifest.SchemaVersion,
+			})
+		},
+	}
+	cmd.Flags().StringVar(&repoPath, "repo", "", "Directory to write the snapshot to (required)")
+	return cmd
+}
+
+func newShareImportCmd(flags *rootFlags) *cobra.Command {
+	var repoPath string
+	cmd := &cobra.Command{
+		Use:   "import",
+		Short: "Hydrate the local cache from a snapshot directory",
+		Long: `Import reads a manifest + JSONL tables from a local directory and
+replaces the matching cache tables. Useful for offline workflows,
+CI cache warming, or a manual restore from a known-good snapshot.`,
+		Example: `  {{.Name}}-pp-cli share import --repo ./snapshot`,
+		RunE: func(cmd *cobra.Command, args []string) error {
+			if repoPath == "" {
+				return fmt.Errorf("--repo is required")
+			}
+			s, err := openShareStore()
+			if err != nil {
+				return err
+			}
+			defer s.Close()
+			manifest, changed, err := share.ImportIfChanged(cmd.Context(), s, share.Options{RepoPath: repoPath})
+			if errors.Is(err, share.ErrNoManifest) {
+				return fmt.Errorf("no manifest.json found at %s; this directory does not contain a {{.Name}}-pp-cli snapshot", repoPath)
+			}
+			if err != nil {
+				return err
+			}
+			return flags.printJSON(cmd, map[string]any{
+				"event":         "share_import",
+				"repo":          repoPath,
+				"changed":       changed,
+				"generated_at":  manifest.GeneratedAt,
+				"tables":        len(manifest.Tables),
+				"schema_version": manifest.SchemaVersion,
+			})
+		},
+	}
+	cmd.Flags().StringVar(&repoPath, "repo", "", "Directory to read the snapshot from (required)")
+	return cmd
+}
+
+func newSharePublishCmd(flags *rootFlags) *cobra.Command {
+	var repoPath, remote, branch, message string
+	cmd := &cobra.Command{
+		Use:   "publish",
+		Short: "Export the local cache and push it to a git remote",
+		Long: `Publish is the full team-snapshot workflow: ensure the share repo,
+pull any upstream changes, export the current cache, commit, and push.
+Runs idempotently — an unchanged snapshot produces no commit.`,
+		Example: `  {{.Name}}-pp-cli share publish --remote git@github.com:acme/{{.Name}}-snapshots.git`,
+		RunE: func(cmd *cobra.Command, args []string) error {
+			opts, err := resolveShareOptions(repoPath, remote, branch)
+			if err != nil {
+				return err
+			}
+			ctx := cmd.Context()
+			if err := share.EnsureRepo(ctx, opts); err != nil {
+				return err
+			}
+			if err := share.Pull(ctx, opts); err != nil {
+				// A brand-new remote with no commits yet returns a non-zero
+				// pull; treat that as expected and continue to the first
+				// publish rather than aborting.
+				fmt.Fprintf(os.Stderr, "warning: pull before publish failed (treating as empty remote): %v\n", err)
+			}
+			s, err := openShareStore()
+			if err != nil {
+				return err
+			}
+			defer s.Close()
+			manifest, err := share.Export(ctx, s, opts)
+			if err != nil {
+				return err
+			}
+			commitMessage := message
+			if commitMessage == "" {
+				commitMessage = fmt.Sprintf("sync: {{.Name}}-pp-cli snapshot %s", manifest.GeneratedAt.Format("2006-01-02T15:04:05Z"))
+			}
+			committed, err := share.Commit(ctx, opts, commitMessage)
+			if err != nil {
+				return err
+			}
+			if committed && opts.Remote != "" {
+				if err := share.Push(ctx, opts); err != nil {
+					return err
+				}
+			}
+			return flags.printJSON(cmd, map[string]any{
+				"event":        "share_publish",
+				"repo":         opts.RepoPath,
+				"remote":       opts.Remote,
+				"committed":    committed,
+				"generated_at": manifest.GeneratedAt,
+				"tables":       len(manifest.Tables),
+			})
+		},
+	}
+	cmd.Flags().StringVar(&repoPath, "repo", "", "Local repo checkout path (default: runstate dir)")
+	cmd.Flags().StringVar(&remote, "remote", "{{.Share.DefaultRepo}}", "Git remote URL")
+	cmd.Flags().StringVar(&branch, "branch", "{{if .Share.DefaultBranch}}{{.Share.DefaultBranch}}{{else}}main{{end}}", "Git branch to push to")
+	cmd.Flags().StringVar(&message, "message", "", "Override the commit message")
+	return cmd
+}
+
+func newShareSubscribeCmd(flags *rootFlags) *cobra.Command {
+	var repoPath, remote, branch string
+	cmd := &cobra.Command{
+		Use:   "subscribe",
+		Short: "Clone a share repo and hydrate the local cache from it",
+		Long: `Subscribe is the credential-free onboarding flow: clone the share repo,
+read the latest manifest, and re-hydrate the local cache. After subscribe,
+read commands work offline. Auto-refresh picks up future publishes when
+cache.enabled is set in the spec.`,
+		Example: `  {{.Name}}-pp-cli share subscribe --remote git@github.com:acme/{{.Name}}-snapshots.git`,
+		RunE: func(cmd *cobra.Command, args []string) error {
+			opts, err := resolveShareOptions(repoPath, remote, branch)
+			if err != nil {
+				return err
+			}
+			if opts.Remote == "" {
+				return fmt.Errorf("--remote is required for subscribe")
+			}
+			ctx := cmd.Context()
+			if err := share.EnsureRepo(ctx, opts); err != nil {
+				return err
+			}
+			if err := share.Pull(ctx, opts); err != nil {
+				return err
+			}
+			s, err := openShareStore()
+			if err != nil {
+				return err
+			}
+			defer s.Close()
+			manifest, changed, err := share.ImportIfChanged(ctx, s, opts)
+			if errors.Is(err, share.ErrNoManifest) {
+				return fmt.Errorf("remote %s has no manifest.json yet; has anyone run 'share publish'?", opts.Remote)
+			}
+			if err != nil {
+				return err
+			}
+			return flags.printJSON(cmd, map[string]any{
+				"event":        "share_subscribe",
+				"repo":         opts.RepoPath,
+				"remote":       opts.Remote,
+				"changed":      changed,
+				"generated_at": manifest.GeneratedAt,
+				"tables":       len(manifest.Tables),
+			})
+		},
+	}
+	cmd.Flags().StringVar(&repoPath, "repo", "", "Local repo checkout path (default: runstate dir)")
+	cmd.Flags().StringVar(&remote, "remote", "{{.Share.DefaultRepo}}", "Git remote URL (required)")
+	cmd.Flags().StringVar(&branch, "branch", "{{if .Share.DefaultBranch}}{{.Share.DefaultBranch}}{{else}}main{{end}}", "Git branch to pull from")
+	return cmd
+}
+
+// resolveShareOptions applies the default repo/branch and resolves a
+// default checkout path under the user's runstate directory when none
+// was supplied. Keeping state under runstate (rather than HOME) means
+// multiple worktree-based dev sessions don't stomp on each other.
+func resolveShareOptions(repoPath, remote, branch string) (share.Options, error) {
+	opts := share.Options{
+		RepoPath: repoPath,
+		Remote:   remote,
+		Branch:   branch,
+	}
+	if opts.RepoPath == "" {
+		home, err := os.UserHomeDir()
+		if err != nil {
+			return share.Options{}, fmt.Errorf("resolve home for share path: %w", err)
+		}
+		opts.RepoPath = filepath.Join(home, ".local", "share", "{{.Name}}-pp-cli", "share-repo")
+	}
+	return opts, nil
+}
+
+// openShareStore opens the local cache store at the canonical path.
+// Kept inline to avoid exposing defaultDBPath's internals to share code
+// that should remain agnostic about CLI layout.
+func openShareStore() (*store.Store, error) {
+	dbPath := defaultDBPath("{{.Name}}-pp-cli")
+	s, err := store.Open(dbPath)
+	if err != nil {
+		return nil, fmt.Errorf("open cache at %s: %w", dbPath, err)
+	}
+	return s, nil
+}
+
diff --git a/internal/generator/templates/share_test.go.tmpl b/internal/generator/templates/share_test.go.tmpl
new file mode 100644
index 00000000..b299ccd8
--- /dev/null
+++ b/internal/generator/templates/share_test.go.tmpl
@@ -0,0 +1,185 @@
+// 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 share
+
+import (
+	"context"
+	"encoding/json"
+	"os"
+	"os/exec"
+	"path/filepath"
+	"testing"
+
+	"{{modulePath}}/internal/store"
+)
+
+// skipIfNoGit skips tests in environments without git on PATH (e.g.,
+// minimal CI images). share commands all shell out to git; exercising
+// them without the binary installed would produce noise, not signal.
+func skipIfNoGit(t *testing.T) {
+	t.Helper()
+	if _, err := exec.LookPath("git"); err != nil {
+		t.Skip("git not found on PATH; skipping share test")
+	}
+}
+
+// ensureGitIdentity configures a local-only identity so `git commit`
+// works in CI environments that don't have a global user set.
+func ensureGitIdentity(t *testing.T, dir string) {
+	t.Helper()
+	for _, args := range [][]string{
+		{"config", "user.email", "test@example.invalid"},
+		{"config", "user.name", "test"},
+	} {
+		cmd := exec.Command("git", args...)
+		cmd.Dir = dir
+		if out, err := cmd.CombinedOutput(); err != nil {
+			t.Fatalf("git %v: %v\n%s", args, err, out)
+		}
+	}
+}
+
+// openStore returns a fresh generated store for use in share tests.
+func openStore(t *testing.T) *store.Store {
+	t.Helper()
+	dbPath := filepath.Join(t.TempDir(), "data.db")
+	s, err := store.Open(dbPath)
+	if err != nil {
+		t.Fatalf("store open: %v", err)
+	}
+	t.Cleanup(func() { s.Close() })
+	return s
+}
+
+func TestShare_ExportThenImportRoundtrip(t *testing.T) {
+	skipIfNoGit(t)
+	if len(SnapshotTables) == 0 {
+		t.Skip("no snapshot tables configured")
+	}
+	ctx := context.Background()
+	src := openStore(t)
+
+	// Seed sync_state with one row per snapshot table so Export has
+	// something to write out. sync_state is always in SnapshotTables
+	// when share is enabled, so this gives us non-empty JSONL.
+	for _, table := range SnapshotTables {
+		if table == "sync_state" {
+			_, err := src.DB().ExecContext(ctx,
+				`INSERT INTO sync_state(resource_type, last_synced_at, total_count) VALUES(?, CURRENT_TIMESTAMP, 1)`,
+				"seeded",
+			)
+			if err != nil {
+				t.Fatalf("seed sync_state: %v", err)
+			}
+		}
+	}
+
+	repoDir := filepath.Join(t.TempDir(), "share-repo")
+	opts := Options{RepoPath: repoDir}
+	if _, err := Export(ctx, src, opts); err != nil {
+		t.Fatalf("export: %v", err)
+	}
+	if _, err := os.Stat(filepath.Join(repoDir, ManifestName)); err != nil {
+		t.Fatalf("manifest not created: %v", err)
+	}
+
+	// Re-import into a fresh store and confirm sync_state row round-trips.
+	dst := openStore(t)
+	importedManifest, err := Import(ctx, dst, opts)
+	if err != nil {
+		t.Fatalf("import: %v", err)
+	}
+	if importedManifest.Version != ManifestVersion {
+		t.Fatalf("manifest version = %d, want %d", importedManifest.Version, ManifestVersion)
+	}
+	if importedManifest.CLIName != "{{.Name}}-pp-cli" {
+		t.Fatalf("cli_name = %q, want %q", importedManifest.CLIName, "{{.Name}}-pp-cli")
+	}
+
+	// Spot-check that at least one row landed.
+	var count int
+	if err := dst.DB().QueryRowContext(ctx, `SELECT COUNT(*) FROM sync_state WHERE resource_type = ?`, "seeded").Scan(&count); err != nil {
+		t.Fatalf("count seeded row: %v", err)
+	}
+	if count != 1 {
+		t.Fatalf("sync_state after import has %d rows for 'seeded', want 1", count)
+	}
+}
+
+func TestShare_ImportIfChangedWarmNoOp(t *testing.T) {
+	skipIfNoGit(t)
+	if len(SnapshotTables) == 0 {
+		t.Skip("no snapshot tables configured")
+	}
+	ctx := context.Background()
+	src := openStore(t)
+	repoDir := filepath.Join(t.TempDir(), "share-repo")
+	opts := Options{RepoPath: repoDir}
+	if _, err := Export(ctx, src, opts); err != nil {
+		t.Fatalf("export: %v", err)
+	}
+
+	dst := openStore(t)
+	if _, _, err := ImportIfChanged(ctx, dst, opts); err != nil {
+		t.Fatalf("first import: %v", err)
+	}
+
+	// Second call with the same manifest should be a no-op.
+	_, changed, err := ImportIfChanged(ctx, dst, opts)
+	if err != nil {
+		t.Fatalf("second import: %v", err)
+	}
+	if changed {
+		t.Fatalf("expected warm no-op, got changed=true")
+	}
+}
+
+func TestShare_ReadManifestMissingIsErrNoManifest(t *testing.T) {
+	repoDir := t.TempDir()
+	if _, err := ReadManifest(repoDir); err != ErrNoManifest {
+		t.Fatalf("got %v, want ErrNoManifest", err)
+	}
+}
+
+func TestShare_ReadManifestRejectsVersionMismatch(t *testing.T) {
+	repoDir := t.TempDir()
+	bad := map[string]any{"version": ManifestVersion + 99}
+	data, _ := json.MarshalIndent(bad, "", "  ")
+	if err := os.WriteFile(filepath.Join(repoDir, ManifestName), data, 0o600); err != nil {
+		t.Fatalf("write bad manifest: %v", err)
+	}
+	_, err := ReadManifest(repoDir)
+	if err == nil {
+		t.Fatalf("expected error on version mismatch, got nil")
+	}
+}
+
+func TestShare_EnsureRepoInitsLocal(t *testing.T) {
+	skipIfNoGit(t)
+	repoDir := filepath.Join(t.TempDir(), "local-repo")
+	opts := Options{RepoPath: repoDir}
+	if err := EnsureRepo(context.Background(), opts); err != nil {
+		t.Fatalf("ensure repo: %v", err)
+	}
+	if _, err := os.Stat(filepath.Join(repoDir, ".git")); err != nil {
+		t.Fatalf("no .git after EnsureRepo: %v", err)
+	}
+}
+
+func TestShare_CommitIsNoOpWhenNothingChanged(t *testing.T) {
+	skipIfNoGit(t)
+	repoDir := filepath.Join(t.TempDir(), "quiet-repo")
+	opts := Options{RepoPath: repoDir}
+	if err := EnsureRepo(context.Background(), opts); err != nil {
+		t.Fatalf("ensure repo: %v", err)
+	}
+	ensureGitIdentity(t, repoDir)
+	committed, err := Commit(context.Background(), opts, "noop")
+	if err != nil {
+		t.Fatalf("commit: %v", err)
+	}
+	if committed {
+		t.Fatalf("expected no commit on empty repo, got committed=true")
+	}
+}
diff --git a/internal/pipeline/scorecard.go b/internal/pipeline/scorecard.go
index ecd3a6aa..db02b865 100644
--- a/internal/pipeline/scorecard.go
+++ b/internal/pipeline/scorecard.go
@@ -40,21 +40,22 @@ type Scorecard struct {
 
 // SteinerScore breaks down the Steinberger bar into 11 dimensions, each 0-10.
 type SteinerScore struct {
-	OutputModes   int `json:"output_modes"`             // 0-10
-	Auth          int `json:"auth"`                     // 0-10
-	ErrorHandling int `json:"error_handling"`           // 0-10
-	TerminalUX    int `json:"terminal_ux"`              // 0-10
-	README        int `json:"readme"`                   // 0-10
-	Doctor        int `json:"doctor"`                   // 0-10
-	AgentNative   int `json:"agent_native"`             // 0-10
-	MCPQuality    int `json:"mcp_quality"`              // 0-10
-	MCPTokenEff   int `json:"mcp_token_efficiency"`     // 0-10; unscored when no MCP surface
-	LocalCache    int `json:"local_cache"`              // 0-10
-	Breadth       int `json:"breadth"`                  // 0-10: how many commands (penalizes empty CLIs)
-	Vision        int `json:"vision"`                   // 0-10
-	Workflows     int `json:"workflows"`                // 0-10
-	Insight       int `json:"insight"`                  // 0-10
-	AgentWorkflow int `json:"agent_workflow_readiness"` // 0-10: HeyGen-derived - async jobs, profiles, deliver, feedback
+	OutputModes    int `json:"output_modes"`             // 0-10
+	Auth           int `json:"auth"`                     // 0-10
+	ErrorHandling  int `json:"error_handling"`           // 0-10
+	TerminalUX     int `json:"terminal_ux"`              // 0-10
+	README         int `json:"readme"`                   // 0-10
+	Doctor         int `json:"doctor"`                   // 0-10
+	AgentNative    int `json:"agent_native"`             // 0-10
+	MCPQuality     int `json:"mcp_quality"`              // 0-10
+	MCPTokenEff    int `json:"mcp_token_efficiency"`     // 0-10; unscored when no MCP surface
+	LocalCache     int `json:"local_cache"`              // 0-10
+	CacheFreshness int `json:"cache_freshness"`          // 0-10; unscored when the CLI has no local store
+	Breadth        int `json:"breadth"`                  // 0-10: how many commands (penalizes empty CLIs)
+	Vision         int `json:"vision"`                   // 0-10
+	Workflows      int `json:"workflows"`                // 0-10
+	Insight        int `json:"insight"`                  // 0-10
+	AgentWorkflow  int `json:"agent_workflow_readiness"` // 0-10: HeyGen-derived - async jobs, profiles, deliver, feedback
 	// Tier 2: Domain Correctness (semantic checks)
 	PathValidity          int    `json:"path_validity"`           // 0-10
 	AuthProtocol          int    `json:"auth_protocol"`           // 0-10
@@ -98,6 +99,11 @@ func RunScorecard(outputDir, pipelineDir, specPath string, verifyReport *VerifyR
 		sc.UnscoredDimensions = append(sc.UnscoredDimensions, "mcp_token_efficiency")
 	}
 	sc.Steinberger.LocalCache = scoreLocalCache(outputDir)
+	if cacheFreshnessScore, scored := scoreCacheFreshness(outputDir); scored {
+		sc.Steinberger.CacheFreshness = cacheFreshnessScore
+	} else {
+		sc.UnscoredDimensions = append(sc.UnscoredDimensions, "cache_freshness")
+	}
 	sc.Steinberger.Breadth = scoreBreadth(outputDir)
 	sc.Steinberger.Vision = scoreVision(outputDir)
 	sc.Steinberger.Workflows = scoreWorkflows(outputDir)
@@ -149,6 +155,7 @@ func RunScorecard(outputDir, pipelineDir, specPath string, verifyReport *VerifyR
 		sc.Steinberger.MCPQuality +
 		sc.Steinberger.MCPTokenEff +
 		sc.Steinberger.LocalCache +
+		sc.Steinberger.CacheFreshness +
 		sc.Steinberger.Breadth +
 		sc.Steinberger.Vision +
 		sc.Steinberger.Workflows +
@@ -171,10 +178,14 @@ func RunScorecard(outputDir, pipelineDir, specPath string, verifyReport *VerifyR
 		sc.Steinberger.DeadCode
 
 	// Weighted composite: Tier 1 = 50%, Tier 2 = 50% of final 100-point scale.
-	// Tier 1 max is 140 with MCP, 130 without (mcp_token_efficiency unscored).
-	tier1Max := 150
+	// Tier 1 max is 160 with MCP + cache_freshness, 150 without MCP, 150 without
+	// cache_freshness, 140 without either.
+	tier1Max := 160
 	if sc.IsDimensionUnscored("mcp_token_efficiency") {
-		tier1Max = 140
+		tier1Max -= 10
+	}
+	if sc.IsDimensionUnscored("cache_freshness") {
+		tier1Max -= 10
 	}
 	tier1Normalized := (tier1Raw * 50) / tier1Max // scale 0-tier1Max to 0-50
 	tier2Max := 50
@@ -668,6 +679,65 @@ func scoreLocalCache(dir string) int {
 	return score
 }
 
+// scoreCacheFreshness rewards the discrawl-inspired Phase 1-3 capabilities.
+// Returns (score, true) when the CLI has a local store; (0, false) otherwise
+// so the dimension is marked N/A and excluded from the tier1 denominator.
+// Each sub-capability is worth 2-3 points; the total is capped at 10.
+//
+// The signals are string-matched against well-known template artifacts
+// rather than imported symbols because the scorer must not depend on
+// the generated package paths — every printed CLI has a distinct module
+// path.
+func scoreCacheFreshness(dir string) (int, bool) {
+	storePath := filepath.Join(dir, "internal", "store", "store.go")
+	storeContent := readFileContent(storePath)
+	if storeContent == "" {
+		return 0, false
+	}
+	score := 0
+
+	// (a) Schema-version gate: the StoreSchemaVersion constant + PRAGMA
+	// user_version read/write fail-fast against schema drift. Worth 3 pts
+	// because without it, binary upgrades silently corrupt reads.
+	if strings.Contains(storeContent, "StoreSchemaVersion") && strings.Contains(storeContent, "user_version") {
+		score += 3
+	}
+
+	// (b) Doctor cache section: collectCacheReport + renderCacheReport
+	// emit the agent-consumable freshness surface.
+	doctorPath := filepath.Join(dir, "internal", "cli", "doctor.go")
+	doctorContent := readFileContent(doctorPath)
+	if strings.Contains(doctorContent, "collectCacheReport") {
+		score += 2
+	}
+
+	// (c) Auto-refresh wired: the PersistentPreRunE hook invokes
+	// autoRefreshIfStale, and the cliutil freshness helper exists. Worth
+	// 3 pts because this is the user's headline ask.
+	autoRefreshPath := filepath.Join(dir, "internal", "cli", "auto_refresh.go")
+	autoRefreshContent := readFileContent(autoRefreshPath)
+	freshnessPath := filepath.Join(dir, "internal", "cliutil", "freshness.go")
+	freshnessContent := readFileContent(freshnessPath)
+	if strings.Contains(autoRefreshContent, "autoRefreshIfStale") && strings.Contains(freshnessContent, "EnsureFresh") {
+		score += 3
+	}
+
+	// (d) Share export/import present: internal/share/share.go + share
+	// subcommands provide the git-backed sharing surface.
+	sharePath := filepath.Join(dir, "internal", "share", "share.go")
+	shareContent := readFileContent(sharePath)
+	shareCmdsPath := filepath.Join(dir, "internal", "cli", "share_commands.go")
+	shareCmdsContent := readFileContent(shareCmdsPath)
+	if strings.Contains(shareContent, "func Export") && strings.Contains(shareCmdsContent, "newShareCmd") {
+		score += 2
+	}
+
+	if score > 10 {
+		score = 10
+	}
+	return score, true
+}
+
 func scoreBreadth(dir string) int {
 	cliDir := filepath.Join(dir, "internal", "cli")
 	entries, err := os.ReadDir(cliDir)
diff --git a/internal/pipeline/scorecard_tier2_test.go b/internal/pipeline/scorecard_tier2_test.go
index 8eeb91bb..1eea0324 100644
--- a/internal/pipeline/scorecard_tier2_test.go
+++ b/internal/pipeline/scorecard_tier2_test.go
@@ -514,7 +514,7 @@ func runLinks() string {
 		pipelineDir := t.TempDir()
 		sc, err := RunScorecard(dir, pipelineDir, "", nil)
 		assert.NoError(t, err)
-		assert.ElementsMatch(t, []string{"mcp_token_efficiency", "path_validity", "auth_protocol"}, sc.UnscoredDimensions)
+		assert.ElementsMatch(t, []string{"mcp_token_efficiency", "cache_freshness", "path_validity", "auth_protocol"}, sc.UnscoredDimensions)
 		assert.NotContains(t, sc.GapReport, "path_validity scored 0/10 - needs improvement")
 		assert.NotContains(t, sc.GapReport, "auth_protocol scored 0/10 - needs improvement")
 	})
@@ -843,7 +843,73 @@ func runLinks() string {
 		body := string(data)
 		assert.True(t, strings.Contains(body, `"path_validity":0`))
 		assert.True(t, strings.Contains(body, `"auth_protocol":0`))
-		assert.True(t, strings.Contains(body, `"unscored_dimensions":["mcp_token_efficiency","path_validity","auth_protocol"]`))
+		assert.True(t, strings.Contains(body, `"unscored_dimensions":["mcp_token_efficiency","cache_freshness","path_validity","auth_protocol"]`))
+	})
+}
+
+func TestScoreCacheFreshness(t *testing.T) {
+	t.Run("no store returns zero unscored", func(t *testing.T) {
+		dir := t.TempDir()
+		score, scored := scoreCacheFreshness(dir)
+		assert.False(t, scored, "missing store.go must mark the dimension unscored")
+		assert.Equal(t, 0, score)
+	})
+
+	t.Run("store only with no phase 1-3 signals scores zero", func(t *testing.T) {
+		dir := t.TempDir()
+		writeScorecardFixture(t, dir, "internal/store/store.go", `package store
+
+func Open() {}`)
+		score, scored := scoreCacheFreshness(dir)
+		assert.True(t, scored)
+		assert.Equal(t, 0, score)
+	})
+
+	t.Run("schema version + doctor + auto-refresh + share scores 10", func(t *testing.T) {
+		dir := t.TempDir()
+		writeScorecardFixture(t, dir, "internal/store/store.go", `package store
+
+const StoreSchemaVersion = 1
+
+func migrate() {
+	_ = "PRAGMA user_version"
+}`)
+		writeScorecardFixture(t, dir, "internal/cli/doctor.go", `package cli
+
+func collectCacheReport() {}`)
+		writeScorecardFixture(t, dir, "internal/cli/auto_refresh.go", `package cli
+
+func autoRefreshIfStale() {}`)
+		writeScorecardFixture(t, dir, "internal/cliutil/freshness.go", `package cliutil
+
+func EnsureFresh() {}`)
+		writeScorecardFixture(t, dir, "internal/share/share.go", `package share
+
+func Export() {}`)
+		writeScorecardFixture(t, dir, "internal/cli/share_commands.go", `package cli
+
+func newShareCmd() {}`)
+
+		score, scored := scoreCacheFreshness(dir)
+		assert.True(t, scored)
+		assert.Equal(t, 10, score)
+	})
+
+	t.Run("schema version + doctor only scores 5", func(t *testing.T) {
+		dir := t.TempDir()
+		writeScorecardFixture(t, dir, "internal/store/store.go", `package store
+
+const StoreSchemaVersion = 1
+
+func migrate() {
+	_ = "PRAGMA user_version"
+}`)
+		writeScorecardFixture(t, dir, "internal/cli/doctor.go", `package cli
+
+func collectCacheReport() {}`)
+		score, scored := scoreCacheFreshness(dir)
+		assert.True(t, scored)
+		assert.Equal(t, 5, score) // 3 (schema gate) + 2 (doctor cache section)
 	})
 }
 

← 4c05e1ee feat(cli): auto-refresh stale caches before read commands (#  ·  back to Cli Printing Press  ·  docs(skills): improve printing press voice guidance (#235) 8ebb44f3 →