← 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
M internal/generator/templates/helpers.go.tmplM internal/generator/templates/mcp_tools.go.tmplM internal/generator/templates/root.go.tmplM internal/generator/templates/sync.go.tmpl
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 →