← 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
M internal/cli/validate_narrative.goM internal/narrativecheck/narrativecheck.goM internal/narrativecheck/narrativecheck_test.goM internal/pipeline/live_check.goM internal/pipeline/live_check_test.goA internal/shellargs/shellargs.goA internal/shellargs/shellargs_test.goM skills/printing-press/SKILL.md
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 →