[object Object]

← back to Cli Printing Press

feat(templates): add flag suggestions, sync NDJSON events, and MCP response quality

038826233985fa6d36864e5b2d547242df361a6e · 2026-03-28 14:58:50 -0700 · Matt Van Horn

Three MCPorter-inspired improvements to generated CLI/MCP templates:

1. Flag typo suggestions: Levenshtein-based suggestFlag() in helpers
   with unknown-flag interception in root. Suggest-only, never auto-correct.
   Threshold: distance <= 3 AND <= 40% of input length.

2. Sync NDJSON progress: dual-mode progress in syncResource matching
   the existing paginatedGet pattern. Emits sync_start, sync_progress,
   sync_complete, and sync_error events in non-humanFriendly mode.

3. MCP response quality: classified errors (409->no-op, 401/403->auth hint,
   404->not found, 429->rate limited, DELETE 404->idempotent no-op) and
   list response wrapping (bare arrays get {"count":N,"items":[...]}).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

Files touched

Diff

commit 038826233985fa6d36864e5b2d547242df361a6e
Author: Matt Van Horn <mvanhorn@MacBook-Pro.local>
Date:   Sat Mar 28 14:58:50 2026 -0700

    feat(templates): add flag suggestions, sync NDJSON events, and MCP response quality
    
    Three MCPorter-inspired improvements to generated CLI/MCP templates:
    
    1. Flag typo suggestions: Levenshtein-based suggestFlag() in helpers
       with unknown-flag interception in root. Suggest-only, never auto-correct.
       Threshold: distance <= 3 AND <= 40% of input length.
    
    2. Sync NDJSON progress: dual-mode progress in syncResource matching
       the existing paginatedGet pattern. Emits sync_start, sync_progress,
       sync_complete, and sync_error events in non-humanFriendly mode.
    
    3. MCP response quality: classified errors (409->no-op, 401/403->auth hint,
       404->not found, 429->rate limited, DELETE 404->idempotent no-op) and
       list response wrapping (bare arrays get {"count":N,"items":[...]}).
    
    Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---
 internal/generator/templates/helpers.go.tmpl   | 64 ++++++++++++++++++++++++++
 internal/generator/templates/mcp_tools.go.tmpl | 32 ++++++++++++-
 internal/generator/templates/root.go.tmpl      | 14 +++++-
 internal/generator/templates/sync.go.tmpl      | 34 ++++++++++++--
 4 files changed, 138 insertions(+), 6 deletions(-)

diff --git a/internal/generator/templates/helpers.go.tmpl b/internal/generator/templates/helpers.go.tmpl
index 2149bb94..5b7fe427 100644
--- a/internal/generator/templates/helpers.go.tmpl
+++ b/internal/generator/templates/helpers.go.tmpl
@@ -12,6 +12,9 @@ import (
 	"sort"
 	"strings"
 	"text/tabwriter"
+
+	"github.com/spf13/cobra"
+	"github.com/spf13/pflag"
 )
 
 var As = errors.As
@@ -436,6 +439,67 @@ func printOutput(w io.Writer, data json.RawMessage, asJSON bool) error {
 	return nil
 }
 
+// levenshteinDistance computes the edit distance between two strings using a two-row DP approach.
+func levenshteinDistance(a, b string) int {
+	if len(a) == 0 {
+		return len(b)
+	}
+	if len(b) == 0 {
+		return len(a)
+	}
+	if len(a) < len(b) {
+		a, b = b, a
+	}
+	prev := make([]int, len(b)+1)
+	curr := make([]int, len(b)+1)
+	for j := range prev {
+		prev[j] = j
+	}
+	for i := 1; i <= len(a); i++ {
+		curr[0] = i
+		for j := 1; j <= len(b); j++ {
+			cost := 1
+			if a[i-1] == b[j-1] {
+				cost = 0
+			}
+			ins := curr[j-1] + 1
+			del := prev[j] + 1
+			sub := prev[j-1] + cost
+			min := ins
+			if del < min {
+				min = del
+			}
+			if sub < min {
+				min = sub
+			}
+			curr[j] = min
+		}
+		prev, curr = curr, prev
+	}
+	return prev[len(b)]
+}
+
+// suggestFlag returns the closest known flag name to the unknown string, or "" if none is close enough.
+func suggestFlag(unknown string, cmd *cobra.Command) string {
+	unknown = strings.TrimLeft(unknown, "-")
+	best := ""
+	bestDist := 4 // only consider distance <= 3
+	check := func(name string) {
+		d := levenshteinDistance(unknown, name)
+		if d < bestDist && d*5 <= len(unknown)*2 {
+			bestDist = d
+			best = name
+		}
+	}
+	cmd.Flags().VisitAll(func(f *pflag.Flag) {
+		check(f.Name)
+	})
+	cmd.InheritedFlags().VisitAll(func(f *pflag.Flag) {
+		check(f.Name)
+	})
+	return best
+}
+
 func printAutoTable(w io.Writer, items []map[string]any) error {
 	if len(items) == 0 {
 		return nil
diff --git a/internal/generator/templates/mcp_tools.go.tmpl b/internal/generator/templates/mcp_tools.go.tmpl
index 1ef94b3d..e11147bb 100644
--- a/internal/generator/templates/mcp_tools.go.tmpl
+++ b/internal/generator/templates/mcp_tools.go.tmpl
@@ -150,9 +150,39 @@ func makeAPIHandler(method, pathTemplate string, positionalParams []string) serv
 		}
 
 		if err != nil {
-			return mcplib.NewToolResultError(err.Error()), nil
+			msg := err.Error()
+			switch {
+			case strings.Contains(msg, "HTTP 409"):
+				return mcplib.NewToolResultText("already exists (no-op)"), nil
+			case strings.Contains(msg, "HTTP 401") || strings.Contains(msg, "HTTP 403"):
+				return mcplib.NewToolResultError("authentication failed: " + msg + "\nhint: check API credentials"), nil
+			case strings.Contains(msg, "HTTP 404"):
+				if method == "DELETE" {
+					return mcplib.NewToolResultText("already deleted (no-op)"), nil
+				}
+				return mcplib.NewToolResultError("not found: " + msg), nil
+			case strings.Contains(msg, "HTTP 429"):
+				return mcplib.NewToolResultError("rate limited: " + msg), nil
+			default:
+				return mcplib.NewToolResultError(msg), nil
+			}
 		}
 
+		// For GET responses, wrap bare arrays with count metadata
+		if method == "GET" {
+			trimmed := strings.TrimSpace(string(data))
+			if len(trimmed) > 0 && trimmed[0] == '[' {
+				var items []json.RawMessage
+				if json.Unmarshal(data, &items) == nil {
+					wrapped := map[string]any{
+						"count": len(items),
+						"items": items,
+					}
+					out, _ := json.Marshal(wrapped)
+					return mcplib.NewToolResultText(string(out)), nil
+				}
+			}
+		}
 		return mcplib.NewToolResultText(string(data)), nil
 	}
 }
diff --git a/internal/generator/templates/root.go.tmpl b/internal/generator/templates/root.go.tmpl
index 91841519..d9604cd3 100644
--- a/internal/generator/templates/root.go.tmpl
+++ b/internal/generator/templates/root.go.tmpl
@@ -6,6 +6,7 @@ package cli
 import (
 	"encoding/json"
 	"fmt"
+	"strings"
 	"text/tabwriter"
 	"time"
 
@@ -93,7 +94,18 @@ func Execute() error {
 {{- end}}
 	rootCmd.AddCommand(newVersionCliCmd())
 
-	return rootCmd.Execute()
+	err := rootCmd.Execute()
+	if err != nil && strings.Contains(err.Error(), "unknown flag") {
+		msg := err.Error()
+		// Extract the flag name from the error message (e.g., "unknown flag: --foob")
+		if idx := strings.Index(msg, "unknown flag: "); idx >= 0 {
+			flagStr := strings.TrimSpace(msg[idx+len("unknown flag: "):])
+			if suggestion := suggestFlag(flagStr, rootCmd); suggestion != "" {
+				return fmt.Errorf("%w\nhint: did you mean --%s?", err, suggestion)
+			}
+		}
+	}
+	return err
 }
 
 func ExitCode(err error) int {
diff --git a/internal/generator/templates/sync.go.tmpl b/internal/generator/templates/sync.go.tmpl
index 746b3efe..cb085eb9 100644
--- a/internal/generator/templates/sync.go.tmpl
+++ b/internal/generator/templates/sync.go.tmpl
@@ -131,10 +131,14 @@ Once synced, use the 'search' command for instant full-text search.`,
 			var errCount int
 			for res := range results {
 				if res.Err != nil {
-					fmt.Fprintf(os.Stderr, "  %s: error: %v\n", res.Resource, res.Err)
+					if humanFriendly {
+						fmt.Fprintf(os.Stderr, "  %s: error: %v\n", res.Resource, res.Err)
+					}
 					errCount++
 				} else {
-					fmt.Fprintf(os.Stderr, "  %s: %d synced (done)\n", res.Resource, res.Count)
+					if humanFriendly {
+						fmt.Fprintf(os.Stderr, "  %s: %d synced (done)\n", res.Resource, res.Count)
+					}
 					totalSynced += res.Count
 				}
 			}
@@ -165,6 +169,11 @@ func syncResource(c interface {
 	Get(string, map[string]string) (json.RawMessage, error)
 }, db *store.Store, resource, sinceTS string, full bool) syncResult {
 	started := time.Now()
+
+	if !humanFriendly {
+		fmt.Fprintf(os.Stderr, `{"event":"sync_start","resource":"%s"}`+"\n", resource)
+	}
+
 	path := "/" + resource
 	var totalCount int
 
@@ -203,6 +212,9 @@ func syncResource(c interface {
 
 		data, err := c.Get(path, params)
 		if err != nil {
+			if !humanFriendly {
+				fmt.Fprintf(os.Stderr, `{"event":"sync_error","resource":"%s","error":"%s"}`+"\n", resource, strings.ReplaceAll(err.Error(), `"`, `\"`))
+			}
 			return syncResult{Resource: resource, Count: totalCount, Err: fmt.Errorf("fetching %s: %w", resource, err), Duration: time.Since(started)}
 		}
 
@@ -213,6 +225,9 @@ func syncResource(c interface {
 		if len(items) == 0 {
 			// Single object response - try to store as-is
 			if err := upsertSingleObject(db, resource, data); err != nil {
+				if !humanFriendly {
+					fmt.Fprintf(os.Stderr, `{"event":"sync_error","resource":"%s","error":"%s"}`+"\n", resource, strings.ReplaceAll(err.Error(), `"`, `\"`))
+				}
 				return syncResult{Resource: resource, Err: err, Duration: time.Since(started)}
 			}
 			totalCount++
@@ -221,14 +236,21 @@ func syncResource(c interface {
 
 		// Batch upsert all items from this page
 		if err := db.UpsertBatch(resource, items); err != nil {
+			if !humanFriendly {
+				fmt.Fprintf(os.Stderr, `{"event":"sync_error","resource":"%s","error":"%s"}`+"\n", resource, strings.ReplaceAll(err.Error(), `"`, `\"`))
+			}
 			return syncResult{Resource: resource, Count: totalCount, Err: fmt.Errorf("upserting batch for %s: %w", resource, err), Duration: time.Since(started)}
 		}
 
 		totalCount += len(items)
 		atomic.AddInt64(&progressCount, int64(len(items)))
 
-		// Progress reporting (carriage return for in-place update)
-		fmt.Fprintf(os.Stderr, "\r  %s: %d synced", resource, atomic.LoadInt64(&progressCount))
+		// Progress reporting
+		if humanFriendly {
+			fmt.Fprintf(os.Stderr, "\r  %s: %d synced", resource, atomic.LoadInt64(&progressCount))
+		} else {
+			fmt.Fprintf(os.Stderr, `{"event":"sync_progress","resource":"%s","fetched":%d}`+"\n", resource, atomic.LoadInt64(&progressCount))
+		}
 
 		// Save cursor after each page for resumability
 		if err := db.SaveSyncState(resource, nextCursor, totalCount); err != nil {
@@ -247,6 +269,10 @@ func syncResource(c interface {
 	// Final sync state: clear cursor (sync is complete), update count
 	_ = db.SaveSyncState(resource, "", totalCount)
 
+	if !humanFriendly {
+		fmt.Fprintf(os.Stderr, `{"event":"sync_complete","resource":"%s","total":%d,"duration_ms":%d}`+"\n", resource, totalCount, time.Since(started).Milliseconds())
+	}
+
 	return syncResult{Resource: resource, Count: totalCount, Duration: time.Since(started)}
 }
 

← d9801c33 fix(skill): require interactive API key consent in Phase 0  ·  back to Cli Printing Press  ·  fix(ci): shared build cache and Go module caching to prevent 03a19223 →