[object Object]

← back to Cli Printing Press

fix(cli): dry-run narrative examples in strict validation (#550)

e4e66bd3d726936797541cf43c074c8264eaf67e · 2026-05-03 19:05:07 -0700 · Trevin Chow

Files touched

Diff

commit e4e66bd3d726936797541cf43c074c8264eaf67e
Author: Trevin Chow <trevin@trevinchow.com>
Date:   Sun May 3 19:05:07 2026 -0700

    fix(cli): dry-run narrative examples in strict validation (#550)
---
 internal/cli/validate_narrative.go             |  30 ++++--
 internal/narrativecheck/narrativecheck.go      | 124 +++++++++++++++++++++++--
 internal/narrativecheck/narrativecheck_test.go |  61 ++++++++++++
 internal/pipeline/live_check.go                |  42 +--------
 internal/pipeline/live_check_test.go           |  19 ----
 internal/shellargs/shellargs.go                |  81 ++++++++++++++++
 internal/shellargs/shellargs_test.go           |  48 ++++++++++
 skills/printing-press/SKILL.md                 |  31 +++++--
 8 files changed, 354 insertions(+), 82 deletions(-)

diff --git a/internal/cli/validate_narrative.go b/internal/cli/validate_narrative.go
index 26026ddd..a1f615bd 100644
--- a/internal/cli/validate_narrative.go
+++ b/internal/cli/validate_narrative.go
@@ -18,18 +18,19 @@ func newValidateNarrativeCmd() *cobra.Command {
 		researchPath string
 		binaryPath   string
 		strict       bool
+		fullExamples bool
 		asJSON       bool
 	)
 
 	cmd := &cobra.Command{
 		Use:           "validate-narrative",
-		Short:         "Verify research.json narrative commands resolve in a built CLI's Cobra tree",
+		Short:         "Verify research.json narrative commands against a built CLI",
 		SilenceUsage:  true,
 		SilenceErrors: true,
 		Long: `Walks every narrative.quickstart[].command and narrative.recipes[].command
 in research.json and runs '<binary> <words> --help' to confirm each command
-path exists. Replaces the bash recipe in skills/printing-press/SKILL.md so
-the same check is testable, scriptable, and reusable from dogfood/scorecard.
+path exists. With --full-examples, also runs each complete example under
+PRINTING_PRESS_VERIFY=1, appending --dry-run when the command advertises it.
 
 Without this check, broken commands ship to the README's Quick Start and
 the SKILL's recipes; users hit "unknown command" on copy-paste.`,
@@ -43,6 +44,11 @@ the SKILL's recipes; users hit "unknown command" on copy-paste.`,
     --research $API_RUN_DIR/research.json \
     --binary $CLI_WORK_DIR/myapi-pp-cli
 
+  # Stronger check: also dry-run full examples to catch bad flags/args
+  printing-press validate-narrative --strict --full-examples \
+    --research $API_RUN_DIR/research.json \
+    --binary $CLI_WORK_DIR/myapi-pp-cli
+
   # JSON output for downstream tooling
   printing-press validate-narrative --json \
     --research $API_RUN_DIR/research.json \
@@ -60,7 +66,9 @@ the SKILL's recipes; users hit "unknown command" on copy-paste.`,
 			ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt)
 			defer cancel()
 
-			report, err := narrativecheck.Validate(ctx, researchPath, binaryPath)
+			report, err := narrativecheck.ValidateWithOptions(ctx, researchPath, binaryPath, narrativecheck.Options{
+				FullExamples: fullExamples,
+			})
 			if err != nil {
 				return &ExitError{Code: ExitInputError, Err: err}
 			}
@@ -83,6 +91,7 @@ the SKILL's recipes; users hit "unknown command" on copy-paste.`,
 	cmd.Flags().StringVar(&researchPath, "research", "", "Path to research.json (required)")
 	cmd.Flags().StringVar(&binaryPath, "binary", "", "Path to the built CLI binary to walk (required)")
 	cmd.Flags().BoolVar(&strict, "strict", false, "Exit non-zero on any missing command or empty narrative (default: warn-only)")
+	cmd.Flags().BoolVar(&fullExamples, "full-examples", false, "Also run full narrative examples safely with PRINTING_PRESS_VERIFY=1 and --dry-run where supported")
 	cmd.Flags().BoolVar(&asJSON, "json", false, "Emit machine-readable JSON instead of the human report")
 	return cmd
 }
@@ -97,11 +106,20 @@ func printHumanReport(w io.Writer, report *narrativecheck.Report) {
 			fmt.Fprintf(w, "MISSING [%s]: %s → %s\n", r.Section, r.Command, r.Words)
 		case narrativecheck.StatusEmptyWords:
 			fmt.Fprintf(w, "EMPTY [%s]: %s has no subcommand words to verify\n", r.Section, r.Command)
+		case narrativecheck.StatusExampleFailed:
+			fmt.Fprintf(w, "FAILED [%s]: %s → %s\n", r.Section, r.Command, r.Error)
+		case narrativecheck.StatusUnsupported:
+			fmt.Fprintf(w, "UNSUPPORTED [%s]: %s → %s\n", r.Section, r.Command, r.Error)
 		}
 	}
-	if report.Missing+report.Empty == 0 && !report.ResearchEmpty {
+	if !report.HasFailures() && !report.ResearchEmpty {
+		if report.FullExamples {
+			fmt.Fprintf(w, "OK: %d narrative commands resolved and full examples passed\n", report.Walked)
+			return
+		}
 		fmt.Fprintf(w, "OK: %d narrative commands resolved against the CLI tree\n", report.Walked)
 		return
 	}
-	fmt.Fprintf(w, "DONE: %d ok, %d missing, %d empty-words\n", report.Walked, report.Missing, report.Empty)
+	fmt.Fprintf(w, "DONE: %d ok, %d missing, %d empty-words, %d failed-examples, %d unsupported\n",
+		report.Walked, report.Missing, report.Empty, report.ExampleFailed, report.Unsupported)
 }
diff --git a/internal/narrativecheck/narrativecheck.go b/internal/narrativecheck/narrativecheck.go
index dc860fd2..ea15068b 100644
--- a/internal/narrativecheck/narrativecheck.go
+++ b/internal/narrativecheck/narrativecheck.go
@@ -19,6 +19,8 @@ import (
 	"os"
 	"os/exec"
 	"strings"
+
+	"github.com/mvanhorn/cli-printing-press/v3/internal/shellargs"
 )
 
 // Section names the narrative section a command lives in. Matches the
@@ -38,6 +40,12 @@ const (
 	StatusOK         Status = "ok"
 	StatusMissing    Status = "missing"
 	StatusEmptyWords Status = "empty-words"
+	// StatusExampleFailed means the command path resolved, but the full
+	// narrative example failed when executed under the verify environment.
+	StatusExampleFailed Status = "example-failed"
+	// StatusUnsupported means full-example validation could not safely run
+	// because the command does not advertise --dry-run.
+	StatusUnsupported Status = "unsupported"
 )
 
 type Result struct {
@@ -52,10 +60,13 @@ type Result struct {
 }
 
 type Report struct {
-	Walked  int      `json:"walked"`
-	Missing int      `json:"missing"`
-	Empty   int      `json:"empty"`
-	Results []Result `json:"results"`
+	Walked        int      `json:"walked"`
+	Missing       int      `json:"missing"`
+	Empty         int      `json:"empty"`
+	ExampleFailed int      `json:"example_failed,omitempty"`
+	Unsupported   int      `json:"unsupported,omitempty"`
+	Results       []Result `json:"results"`
+	FullExamples  bool     `json:"full_examples,omitempty"`
 	// ResearchEmpty is true when neither narrative.quickstart nor
 	// narrative.recipes contained any entries. The LLM may have
 	// omitted both sections by mistake; the caller's --strict flag
@@ -63,11 +74,25 @@ type Report struct {
 	ResearchEmpty bool `json:"research_empty,omitempty"`
 }
 
+// Options controls optional narrative validation checks.
+type Options struct {
+	// FullExamples validates each full narrative command, not just its
+	// Cobra path. The example is run with PRINTING_PRESS_VERIFY=1 and
+	// --dry-run appended when the command advertises --dry-run.
+	FullExamples bool
+}
+
 // Validate parses researchPath, walks every narrative.quickstart and
 // narrative.recipes command, and resolves it against the binary's
 // Cobra tree by running `<binary> <words> --help`. ctx scopes every
 // subprocess so callers can interrupt cleanly.
 func Validate(ctx context.Context, researchPath, binaryPath string) (*Report, error) {
+	return ValidateWithOptions(ctx, researchPath, binaryPath, Options{})
+}
+
+// ValidateWithOptions parses researchPath and validates every narrative
+// command according to opts. The default behavior matches Validate.
+func ValidateWithOptions(ctx context.Context, researchPath, binaryPath string, opts Options) (*Report, error) {
 	commands, err := loadCommands(researchPath)
 	if err != nil {
 		return nil, err
@@ -75,10 +100,11 @@ func Validate(ctx context.Context, researchPath, binaryPath string) (*Report, er
 
 	report := &Report{
 		Results:       make([]Result, 0, len(commands)),
+		FullExamples:  opts.FullExamples,
 		ResearchEmpty: len(commands) == 0,
 	}
 	for _, sc := range commands {
-		r := classify(ctx, binaryPath, sc.Section, sc.Command)
+		r := classify(ctx, binaryPath, sc.Section, sc.Command, opts)
 		switch r.Status {
 		case StatusOK:
 			report.Walked++
@@ -86,6 +112,10 @@ func Validate(ctx context.Context, researchPath, binaryPath string) (*Report, er
 			report.Missing++
 		case StatusEmptyWords:
 			report.Empty++
+		case StatusExampleFailed:
+			report.ExampleFailed++
+		case StatusUnsupported:
+			report.Unsupported++
 		}
 		report.Results = append(report.Results, r)
 	}
@@ -95,7 +125,7 @@ func Validate(ctx context.Context, researchPath, binaryPath string) (*Report, er
 // HasFailures reports whether the run found any missing or empty-words
 // entries. Callers gate --strict exit codes on this.
 func (r *Report) HasFailures() bool {
-	return r.Missing > 0 || r.Empty > 0
+	return r.Missing > 0 || r.Empty > 0 || r.ExampleFailed > 0 || r.Unsupported > 0
 }
 
 type sectionCommand struct {
@@ -146,7 +176,7 @@ func loadCommands(researchPath string) ([]sectionCommand, error) {
 // binary name, keep words until the first flag (starts with `-`) or
 // non-identifier character. Hyphens stay because Cobra subcommands use
 // them (`list-projects`).
-func classify(ctx context.Context, binaryPath string, section Section, command string) Result {
+func classify(ctx context.Context, binaryPath string, section Section, command string, opts Options) Result {
 	words := extractSubcommandWords(command)
 	r := Result{Section: section, Command: command, Words: strings.Join(words, " ")}
 
@@ -156,17 +186,93 @@ func classify(ctx context.Context, binaryPath string, section Section, command s
 		return r
 	}
 
-	args := append(words, "--help")
-	if err := exec.CommandContext(ctx, binaryPath, args...).Run(); err != nil {
+	helpArgs := append(words, "--help")
+	if !opts.FullExamples {
+		if err := exec.CommandContext(ctx, binaryPath, helpArgs...).Run(); err != nil {
+			r.Status = StatusMissing
+			r.Error = fmt.Sprintf("%s %s --help failed: %v", binaryPath, r.Words, err)
+			return r
+		}
+
+		r.Status = StatusOK
+		return r
+	}
+
+	helpOut, err := exec.CommandContext(ctx, binaryPath, helpArgs...).CombinedOutput()
+	if err != nil {
 		r.Status = StatusMissing
 		r.Error = fmt.Sprintf("%s %s --help failed: %v", binaryPath, r.Words, err)
 		return r
 	}
+	return classifyFullExample(ctx, binaryPath, command, helpOut, r)
+}
+
+func classifyFullExample(ctx context.Context, binaryPath, command string, helpOut []byte, r Result) Result {
+	tokens, err := shellargs.Split(command)
+	if err != nil {
+		r.Status = StatusExampleFailed
+		r.Error = err.Error()
+		return r
+	}
+	if len(tokens) <= 1 {
+		r.Status = StatusEmptyWords
+		r.Error = "command has no arguments to execute after the binary name"
+		return r
+	}
+
+	args := append([]string(nil), tokens[1:]...)
+	if !hasEnabledBoolFlag(args, "--dry-run") {
+		if !helpAdvertisesDryRun(helpOut) {
+			r.Status = StatusUnsupported
+			r.Error = "full-example validation skipped: command help does not advertise --dry-run"
+			return r
+		}
+		args = append(args, "--dry-run")
+	}
+
+	cmd := exec.CommandContext(ctx, binaryPath, args...)
+	cmd.Env = append(os.Environ(), "PRINTING_PRESS_VERIFY=1")
+	out, err := cmd.CombinedOutput()
+	if err != nil {
+		r.Status = StatusExampleFailed
+		r.Error = fmt.Sprintf("full example failed: %s %s: %v%s",
+			binaryPath,
+			strings.Join(args, " "),
+			err,
+			formatOutputSuffix(out),
+		)
+		return r
+	}
 
 	r.Status = StatusOK
 	return r
 }
 
+func hasEnabledBoolFlag(args []string, flag string) bool {
+	for _, arg := range args {
+		if arg == flag || arg == flag+"=true" {
+			return true
+		}
+	}
+	return false
+}
+
+func helpAdvertisesDryRun(out []byte) bool {
+	return strings.Contains(string(out), "--dry-run")
+}
+
+func formatOutputSuffix(out []byte) string {
+	trimmed := strings.TrimSpace(string(out))
+	if trimmed == "" {
+		return ""
+	}
+	const max = 500
+	if len(trimmed) > max {
+		trimmed = trimmed[:max] + "..."
+	}
+	return ": " + trimmed
+}
+
 // extractSubcommandWords replicates the bash recipe's awk wordlist
 // extraction so the Go and bash implementations classify identically:
 //
diff --git a/internal/narrativecheck/narrativecheck_test.go b/internal/narrativecheck/narrativecheck_test.go
index 8d874598..11d1fc60 100644
--- a/internal/narrativecheck/narrativecheck_test.go
+++ b/internal/narrativecheck/narrativecheck_test.go
@@ -178,6 +178,60 @@ func TestValidate_EndToEnd(t *testing.T) {
 	}
 }
 
+func TestValidateWithOptions_FullExamplesCatchesInvalidFlag(t *testing.T) {
+	t.Parallel()
+
+	binary := buildStubBinary(t)
+	research := writeFile(t, `{"narrative":{
+		"quickstart":[
+			{"command":"stub widgets list --bad-flag"}
+		]
+	}}`)
+
+	report, err := ValidateWithOptions(context.Background(), research, binary, Options{FullExamples: true})
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	if report.Walked != 0 {
+		t.Errorf("Walked = %d, want 0", report.Walked)
+	}
+	if report.ExampleFailed != 1 {
+		t.Errorf("ExampleFailed = %d, want 1", report.ExampleFailed)
+	}
+	if !report.HasFailures() {
+		t.Error("HasFailures should be true when a full narrative example fails")
+	}
+	if len(report.Results) != 1 {
+		t.Fatalf("expected 1 result, got %d", len(report.Results))
+	}
+	got := report.Results[0]
+	if got.Status != StatusExampleFailed {
+		t.Fatalf("Status = %q, want %q", got.Status, StatusExampleFailed)
+	}
+	if !strings.Contains(got.Error, "--bad-flag") {
+		t.Errorf("Error %q should mention the invalid flag", got.Error)
+	}
+}
+
+func TestClassifyFullExample_ReportsUnsupportedWhenDryRunUnavailable(t *testing.T) {
+	t.Parallel()
+
+	got := classifyFullExample(
+		context.Background(),
+		"/not/invoked",
+		"stub widgets list",
+		[]byte("Usage: stub widgets list"),
+		Result{Section: SectionQuickstart, Command: "stub widgets list", Words: "widgets list"},
+	)
+	if got.Status != StatusUnsupported {
+		t.Fatalf("Status = %q, want %q", got.Status, StatusUnsupported)
+	}
+	if !strings.Contains(got.Error, "does not advertise --dry-run") {
+		t.Errorf("Error %q should explain why the full example was not run", got.Error)
+	}
+}
+
 // TestValidate_EmptyResearchFlagsResearchEmpty covers the LLM-omitted-
 // both-sections case.
 func TestValidate_EmptyResearchFlagsResearchEmpty(t *testing.T) {
@@ -239,6 +293,12 @@ var validPathPrefixes = []string{
 
 func main() {
 	args := os.Args[1:]
+	for _, a := range args {
+		if a == "--bad-flag" {
+			fmt.Fprintln(os.Stderr, "unknown flag: --bad-flag")
+			os.Exit(1)
+		}
+	}
 	var path []string
 	for _, a := range args {
 		if strings.HasPrefix(a, "-") {
@@ -250,6 +310,7 @@ func main() {
 	for _, prefix := range validPathPrefixes {
 		if joined == prefix || strings.HasPrefix(joined, prefix+" ") {
 			fmt.Println("usage stub:", prefix)
+			fmt.Println("      --dry-run   Show request without sending")
 			return
 		}
 	}
diff --git a/internal/pipeline/live_check.go b/internal/pipeline/live_check.go
index 4ec6920c..98e2af54 100644
--- a/internal/pipeline/live_check.go
+++ b/internal/pipeline/live_check.go
@@ -15,6 +15,8 @@ import (
 	"sync"
 	"time"
 	"unicode"
+
+	"github.com/mvanhorn/cli-printing-press/v3/internal/shellargs"
 )
 
 // LiveStatus is the outcome of one feature's live check.
@@ -465,45 +467,7 @@ func (lw *limitedWriter) Write(p []byte) (int, error) {
 // and returns the subcommand arguments (everything after the binary name).
 // Respects double-quoted tokens so queries with spaces stay intact.
 func parseExampleArgs(example string) ([]string, error) {
-	tokens, err := shellSplit(example)
-	if err != nil {
-		return nil, err
-	}
-	if len(tokens) < 2 {
-		return nil, fmt.Errorf("example has no subcommand: %q", example)
-	}
-	return tokens[1:], nil
-}
-
-// shellSplit handles double-quoted tokens — enough for the Example formats
-// the absorb phase produces. No shell metacharacter interpolation is done.
-// Single quotes, escaped characters, and backslashes are not recognized;
-// Examples using those will need updating.
-func shellSplit(s string) ([]string, error) {
-	var tokens []string
-	var current strings.Builder
-	inQuote := false
-	for i := 0; i < len(s); i++ {
-		c := s[i]
-		switch {
-		case c == '"':
-			inQuote = !inQuote
-		case c == ' ' && !inQuote:
-			if current.Len() > 0 {
-				tokens = append(tokens, current.String())
-				current.Reset()
-			}
-		default:
-			current.WriteByte(c)
-		}
-	}
-	if inQuote {
-		return nil, fmt.Errorf("unclosed quote in %q", s)
-	}
-	if current.Len() > 0 {
-		tokens = append(tokens, current.String())
-	}
-	return tokens, nil
+	return shellargs.ArgsAfterBinary(example)
 }
 
 // extractQueryToken returns a positional argument that looks like a human-
diff --git a/internal/pipeline/live_check_test.go b/internal/pipeline/live_check_test.go
index 17d7b6f5..dae67d40 100644
--- a/internal/pipeline/live_check_test.go
+++ b/internal/pipeline/live_check_test.go
@@ -247,25 +247,6 @@ func TestInsightCap(t *testing.T) {
 	}
 }
 
-// TestShellSplit covers the quoted-query parsing used for Example commands.
-func TestShellSplit(t *testing.T) {
-	cases := []struct {
-		in   string
-		want []string
-	}{
-		{`cli goat brownies`, []string{"cli", "goat", "brownies"}},
-		{`cli goat "chicken tikka masala" --limit 5`, []string{"cli", "goat", "chicken tikka masala", "--limit", "5"}},
-		{`cli  multiple   spaces`, []string{"cli", "multiple", "spaces"}},
-	}
-	for _, tc := range cases {
-		got, err := shellSplit(tc.in)
-		require.NoError(t, err)
-		require.Equal(t, tc.want, got, "input=%q", tc.in)
-	}
-	_, err := shellSplit(`cli "unclosed`)
-	require.Error(t, err)
-}
-
 // TestExtractQueryToken covers the query detection used for relevance checks.
 // The extractor is deliberately simple: it returns the last positional
 // argument before the first flag, after excluding URLs and numeric IDs
diff --git a/internal/shellargs/shellargs.go b/internal/shellargs/shellargs.go
new file mode 100644
index 00000000..28664507
--- /dev/null
+++ b/internal/shellargs/shellargs.go
@@ -0,0 +1,81 @@
+package shellargs
+
+import (
+	"fmt"
+	"strings"
+)
+
+// Split tokenizes the simple command examples the Printing Press emits in
+// README/SKILL narrative. It preserves double-quoted tokens and backslash
+// escapes, but intentionally does not perform shell expansion.
+func Split(s string) ([]string, error) {
+	var tokens []string
+	var current strings.Builder
+	inQuote := false
+	tokenStarted := false
+	escaped := false
+
+	flush := func() {
+		tokens = append(tokens, current.String())
+		current.Reset()
+		tokenStarted = false
+	}
+
+	for _, r := range s {
+		if escaped {
+			current.WriteRune(r)
+			tokenStarted = true
+			escaped = false
+			continue
+		}
+		if r == '\\' {
+			escaped = true
+			tokenStarted = true
+			continue
+		}
+		if inQuote {
+			if r == '"' {
+				inQuote = false
+				tokenStarted = true
+				continue
+			}
+			current.WriteRune(r)
+			tokenStarted = true
+			continue
+		}
+		switch r {
+		case '"':
+			inQuote = true
+			tokenStarted = true
+		case ' ', '\t', '\n', '\r':
+			if tokenStarted {
+				flush()
+			}
+		default:
+			current.WriteRune(r)
+			tokenStarted = true
+		}
+	}
+	if escaped {
+		current.WriteRune('\\')
+	}
+	if inQuote {
+		return nil, fmt.Errorf("unclosed quote in %q", s)
+	}
+	if tokenStarted {
+		flush()
+	}
+	return tokens, nil
+}
+
+// ArgsAfterBinary returns every token after the leading binary name.
+func ArgsAfterBinary(example string) ([]string, error) {
+	tokens, err := Split(example)
+	if err != nil {
+		return nil, err
+	}
+	if len(tokens) < 2 {
+		return nil, fmt.Errorf("example has no subcommand: %q", example)
+	}
+	return tokens[1:], nil
+}
diff --git a/internal/shellargs/shellargs_test.go b/internal/shellargs/shellargs_test.go
new file mode 100644
index 00000000..a938488d
--- /dev/null
+++ b/internal/shellargs/shellargs_test.go
@@ -0,0 +1,48 @@
+package shellargs
+
+import (
+	"reflect"
+	"testing"
+)
+
+func TestSplit(t *testing.T) {
+	cases := []struct {
+		in   string
+		want []string
+	}{
+		{`cli goat brownies`, []string{"cli", "goat", "brownies"}},
+		{`cli goat "chicken tikka masala" --limit 5`, []string{"cli", "goat", "chicken tikka masala", "--limit", "5"}},
+		{`cli  multiple   spaces`, []string{"cli", "multiple", "spaces"}},
+		{`cli query \"literal\"`, []string{"cli", "query", `"literal"`}},
+	}
+	for _, tc := range cases {
+		got, err := Split(tc.in)
+		if err != nil {
+			t.Fatalf("Split(%q): %v", tc.in, err)
+		}
+		if !reflect.DeepEqual(got, tc.want) {
+			t.Fatalf("Split(%q) = %#v, want %#v", tc.in, got, tc.want)
+		}
+	}
+}
+
+func TestSplitUnclosedQuote(t *testing.T) {
+	if _, err := Split(`cli "unclosed`); err == nil {
+		t.Fatal("expected unclosed quote error")
+	}
+}
+
+func TestArgsAfterBinary(t *testing.T) {
+	got, err := ArgsAfterBinary(`cli goat "chicken tikka masala"`)
+	if err != nil {
+		t.Fatal(err)
+	}
+	want := []string{"goat", "chicken tikka masala"}
+	if !reflect.DeepEqual(got, want) {
+		t.Fatalf("ArgsAfterBinary() = %#v, want %#v", got, want)
+	}
+
+	if _, err := ArgsAfterBinary("cli"); err == nil {
+		t.Fatal("expected missing subcommand error")
+	}
+}
diff --git a/skills/printing-press/SKILL.md b/skills/printing-press/SKILL.md
index 3f40c500..415e5399 100644
--- a/skills/printing-press/SKILL.md
+++ b/skills/printing-press/SKILL.md
@@ -1755,31 +1755,36 @@ Phase 1 research brief for auth requirements and manually add env var support to
 `config.go` using the pattern: add `APIKey`/`APIKeySource` fields to the Config struct,
 and `os.Getenv("<API>_API_KEY")` in the Load function.
 
-**REQUIRED: Validate narrative `command` strings resolve in the CLI tree.**
+**REQUIRED: Validate narrative `command` strings before saving/publishing examples.**
 The LLM (or human) authoring `research.json` can name commands that don't actually
 exist in the generated CLI — `<cli> stats` when the real shape is `<cli> reports stats`,
-or a command that was dropped because its endpoint had a complex body. Without a check,
-the broken commands ship to the README's Quick Start (`narrative.quickstart`) and the
-SKILL's recipes (`narrative.recipes`); users copy-paste them and hit `unknown command`
-on the very first invocation.
+or a command that was dropped because its endpoint had a complex body. It can also
+write a real command path with a bogus flag or positional shape. Without a check, the
+broken commands ship to the README's Quick Start (`narrative.quickstart`) and the
+SKILL's recipes (`narrative.recipes`); users copy-paste them and hit failures on the
+very first invocation.
 
 Build the CLI binary, then run `printing-press validate-narrative` against it. The
 subcommand walks every `narrative.quickstart[].command` and `narrative.recipes[].command`,
 strips the binary name and trailing arguments, and runs `<binary> <words> --help` for
-each. The check is offline — no live API access needed.
+each. With `--full-examples`, it also runs the complete example under
+`PRINTING_PRESS_VERIFY=1`, appending `--dry-run` when the command advertises it. This
+catches bad flags and argument shapes without making live API calls.
 
 ```bash
 QUICKSTART_BINARY="$CLI_WORK_DIR/<api>-pp-cli"
 go build -o "$QUICKSTART_BINARY" "$CLI_WORK_DIR/cmd/<api>-pp-cli"
 
-printing-press validate-narrative --strict \
+printing-press validate-narrative --strict --full-examples \
   --research "$API_RUN_DIR/research.json" \
   --binary "$QUICKSTART_BINARY"
 ```
 
 `--strict` exits non-zero on any missing command, empty subcommand-words entry, or
-empty narrative (both sections omitted). Drop `--strict` to get a warn-only report,
-or add `--json` for machine-readable output.
+empty narrative (both sections omitted). With `--full-examples`, it also fails on full
+examples that cannot dry-run or whose full invocation fails. Drop `--strict` to get a
+warn-only report, omit `--full-examples` only when you intentionally want the old
+offline path check, or add `--json` for machine-readable output.
 
 If any commands are reported missing, fix them in `research.json` before continuing.
 Common causes:
@@ -1790,6 +1795,9 @@ Common causes:
   promoted-command surface; reach it via the typed `<resource> <endpoint>` form).
 - The command name is a placeholder (`<cli> example`) that should have been replaced
   with a real path.
+- The path exists but the example uses a flag/argument shape the command does not
+  accept; fix the concrete example in `research.json` before it renders into README
+  and SKILL prose.
 
 `narrative.quickstart` drives the README Quick Start and `narrative.recipes` drives
 the SKILL.md recipes; getting either wrong silently ships copy-paste-broken examples
@@ -2137,6 +2145,11 @@ Fix order (update heartbeat between each fix category to prevent stale lock duri
 5. missing novel features (see below)
 6. scorecard-only polish gaps
 
+When category 4 includes narrative examples, rerun
+`printing-press validate-narrative --strict --full-examples` after the fix. The path-only
+mode is not enough before publishing because it cannot catch bad flags on an otherwise
+valid command.
+
 **Missing novel features fix (step 5):** Dogfood writes `novel_features_built` to research.json — only features whose commands actually exist. The original `novel_features` (aspirational list from absorb) is preserved for the audit trail. Dogfood also syncs the generated `.printing-press.json` `novel_features`, `README.md` `## Unique Features` block, `SKILL.md` `## Unique Capabilities` block, and `internal/cli/root.go` `--help` Highlights block from `novel_features_built`; if none survived, it removes the rendered README/SKILL/root help blocks. Dogfood prints `dogfood: synced ... from novel_features_built` for every rendered artifact it changes. After dogfood:
 
 1. Inspect the dogfood planned-vs-built delta

← 8b099ba0 fix(cli): sync root highlights from verified features (#549)  ·  back to Cli Printing Press  ·  fix(cli): include cobratree tools in MCP token scoring (#552 74eb8769 →