[object Object]

← back to Cli Printing Press

fix(cli): transliterate spec strings to ASCII via Unidecode at every chokepoint (#371)

283c9923a771c87c9f8b67f47adebabb7a801e75 · 2026-04-28 18:45:01 -0700 · Trevin Chow

* fix(cli): transliterate spec strings to ASCII via Unidecode at every chokepoint

Spec titles, resource names, operationIds, and schema names with non-ASCII
characters (accents, fused-diacritic Latin like ø/ß/æ, CJK, Cyrillic, Greek)
were leaking into directory names, binary names, Go import paths, Cobra
command names, and MCP tool names. The visible failure was mcp-sync
emitting spurious cmd/pokémon-pp-{cli,mcp}/ alongside cmd/pokemon-pp-{cli,mcp}/
on regen, but the same drift class affected any chokepoint that turned a
spec string into a file/folder/identifier.

Add naming.ASCIIFold (a thin wrapper over github.com/mozillazg/go-unidecode,
the same transliteration table Django's slugify, Rails, and most modern
slug libraries use) and call it at every chokepoint:

  internal/openapi/parser.go
    - cleanSpecName    (API slug → top-level cmd dir / binary / module)
    - toSnakeCase      (param names, operationIds, path segments)
    - toKebabCase      (Cobra command names, kebab identifiers)
    - sanitizeTypeName (Go struct names from schema property names)
    - toCamelCase      (Go field names from schema keys)

  internal/graphql/parser.go
    - toKebabCase      (GraphQL schema-derived command names)

  internal/naming/naming.go
    - SnakeIdentifier  (MCP tool names from free-form command specs)
    - EnvPrefix        (env var prefixes; replaces hand-rolled NFD+Mn)
    - Snake            (CamelCase → snake_case for tool name segments)

Output samples (cleanSpecName):
  Pokémon API     → pokemon
  Großhandel API  → grosshandel
  Łódź Transit    → lodz-transit
  Encyclopædia    → encyclopaedia
  Ørsted Energy   → orsted-energy
  東京 API         → dong-jing
  русский API     → russkii
  Δelta API       → delta

EnvPrefix's previous hand-rolled NFD+Mn implementation handled
precomposed accents but dropped fused-diacritic Latin (ß) and non-Latin
scripts (CJK, Cyrillic). Switching to Unidecode covers everything with
the same one-line call.

go test ./... passes (2217/2217). Golden verify passes (8/8 cases).
golangci-lint clean.

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

* test(cli): golden case locks Unicode-to-ASCII slug contract

Adds testdata/golden/cases/generate-golden-api-unicode using a fixture
with title "Café Bistro API". Expected artifacts capture every ASCII
contract this PR establishes:

  api_name      = "cafe-bistro"           (cleanSpecName)
  cli_name      = "cafe-bistro-pp-cli"    (naming.CLI)
  mcp_binary    = "cafe-bistro-pp-mcp"    (naming.MCP)
  auth_env_vars = ["CAFE_BISTRO_..."]     (naming.EnvPrefix)
  display_name  = "Cafe Bistro"           (HumanName of folded slug)

The cmd/cafe-bistro-pp-cli/ and cmd/cafe-bistro-pp-mcp/ artifact paths
double as filesystem assertions: the harness fails with
"missing artifact" if the slug ever surfaces as café-bistro again.

Without this fixture, the existing 8 golden cases use ASCII-only specs,
so the Unidecode wiring is a no-op and bit-identical output makes the
new contract invisible to the suite. AGENTS.md: "When adding a new
deterministic CLI behavior or artifact contract, explicitly decide
whether the golden suite needs a new or expanded case."

Note: display_name is "Cafe Bistro" (not "Café Bistro") because
EffectiveDisplayName falls through to naming.HumanName(s.Name) when no
explicit DisplayName is set, and Name is the ASCII slug. Preserving
Unicode display_name for OpenAPI specs without an explicit
display_name field is a separate concern — needs the parser to set
DisplayName from the raw doc.Info.Title — and is out of scope here.

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

* simplify: trim narrating comments and add ASCII fast path to ASCIIFold

Three findings from /simplify:

1. ASCII fast path in naming.ASCIIFold (efficiency). unidecode.Unidecode
   always allocates a builder and walks every rune; for OpenAPI specs
   the fold runs thousands of times per parse on >99% ASCII input
   (sanitizeTypeName + toCamelCase per schema field, etc.). A byte
   scan now skips the table lookup when no continuation bytes are
   present, returning the input string directly with zero allocation.

2. Drop ~40 lines of narrating/duplicating comments (per CLAUDE.md
   "default to no comments"):
   - ASCIIFold godoc trimmed from ~20 lines to 6
   - Removed "Non-ASCII input is folded first" tags from
     SnakeIdentifier/EnvPrefix/Snake (duplicates ASCIIFold's godoc)
   - Removed the 7-line block in cleanSpecName explaining why the
     fold runs (the function call name is enough)
   - Test files keep the category labels (Precomposed accents /
     Fused-diacritic Latin / Non-Latin scripts) but drop the
     cross-references and rationale prose

3. Removed dead U+2019 strip in cleanSpecName. ASCIIFold maps the
   smart quote to ASCII apostrophe upstream, so the second
   ReplaceAll was a no-op.

Tests: 2217/2217 passing. Golden verify: 9/9 cases pass. No
behavioral change.

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

* review: address ce-code-review walk-through findings

Two findings from /compound-engineering:ce-code-review walk-through:

1. Document the implicit ASCIIFold boundary in internal/generator/.
   The PR's chokepoint audit covered openapi/graphql/naming packages
   but didn't reach the template helpers (toCamel, toPascal, flagName,
   safeTypeName, schema_builder.toSnakeCase). Investigation confirmed
   they receive APISpec data already folded by the openapi parser, so
   the boundary is correct — but it was implicit. Add 1-line godoc
   notes making the invariant visible: any new caller feeding raw spec
   strings into these helpers must fold first via naming.ASCIIFold.

2. Strengthen golden-api-unicode coverage. The previous fixture used
   ASCII-only schema/property names, so a regression bypassing
   sanitizeTypeName / toCamelCase wouldn't fail the golden. Renamed
   the schema to "CaféMenu" with property "spécialité"; added the
   generated types.go to the artifact list. The captured types.go now
   shows `type CafeMenu struct { ... Specialite string }`, locking in
   that both schema-name and property-name chokepoints fold to ASCII.

go test ./... passes (2217/2217). Golden verify passes (9/9 cases).
golangci-lint clean.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

Files touched

Diff

commit 283c9923a771c87c9f8b67f47adebabb7a801e75
Author: Trevin Chow <trevin@trevinchow.com>
Date:   Tue Apr 28 18:45:01 2026 -0700

    fix(cli): transliterate spec strings to ASCII via Unidecode at every chokepoint (#371)
    
    * fix(cli): transliterate spec strings to ASCII via Unidecode at every chokepoint
    
    Spec titles, resource names, operationIds, and schema names with non-ASCII
    characters (accents, fused-diacritic Latin like ø/ß/æ, CJK, Cyrillic, Greek)
    were leaking into directory names, binary names, Go import paths, Cobra
    command names, and MCP tool names. The visible failure was mcp-sync
    emitting spurious cmd/pokémon-pp-{cli,mcp}/ alongside cmd/pokemon-pp-{cli,mcp}/
    on regen, but the same drift class affected any chokepoint that turned a
    spec string into a file/folder/identifier.
    
    Add naming.ASCIIFold (a thin wrapper over github.com/mozillazg/go-unidecode,
    the same transliteration table Django's slugify, Rails, and most modern
    slug libraries use) and call it at every chokepoint:
    
      internal/openapi/parser.go
        - cleanSpecName    (API slug → top-level cmd dir / binary / module)
        - toSnakeCase      (param names, operationIds, path segments)
        - toKebabCase      (Cobra command names, kebab identifiers)
        - sanitizeTypeName (Go struct names from schema property names)
        - toCamelCase      (Go field names from schema keys)
    
      internal/graphql/parser.go
        - toKebabCase      (GraphQL schema-derived command names)
    
      internal/naming/naming.go
        - SnakeIdentifier  (MCP tool names from free-form command specs)
        - EnvPrefix        (env var prefixes; replaces hand-rolled NFD+Mn)
        - Snake            (CamelCase → snake_case for tool name segments)
    
    Output samples (cleanSpecName):
      Pokémon API     → pokemon
      Großhandel API  → grosshandel
      Łódź Transit    → lodz-transit
      Encyclopædia    → encyclopaedia
      Ørsted Energy   → orsted-energy
      東京 API         → dong-jing
      русский API     → russkii
      Δelta API       → delta
    
    EnvPrefix's previous hand-rolled NFD+Mn implementation handled
    precomposed accents but dropped fused-diacritic Latin (ß) and non-Latin
    scripts (CJK, Cyrillic). Switching to Unidecode covers everything with
    the same one-line call.
    
    go test ./... passes (2217/2217). Golden verify passes (8/8 cases).
    golangci-lint clean.
    
    Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
    
    * test(cli): golden case locks Unicode-to-ASCII slug contract
    
    Adds testdata/golden/cases/generate-golden-api-unicode using a fixture
    with title "Café Bistro API". Expected artifacts capture every ASCII
    contract this PR establishes:
    
      api_name      = "cafe-bistro"           (cleanSpecName)
      cli_name      = "cafe-bistro-pp-cli"    (naming.CLI)
      mcp_binary    = "cafe-bistro-pp-mcp"    (naming.MCP)
      auth_env_vars = ["CAFE_BISTRO_..."]     (naming.EnvPrefix)
      display_name  = "Cafe Bistro"           (HumanName of folded slug)
    
    The cmd/cafe-bistro-pp-cli/ and cmd/cafe-bistro-pp-mcp/ artifact paths
    double as filesystem assertions: the harness fails with
    "missing artifact" if the slug ever surfaces as café-bistro again.
    
    Without this fixture, the existing 8 golden cases use ASCII-only specs,
    so the Unidecode wiring is a no-op and bit-identical output makes the
    new contract invisible to the suite. AGENTS.md: "When adding a new
    deterministic CLI behavior or artifact contract, explicitly decide
    whether the golden suite needs a new or expanded case."
    
    Note: display_name is "Cafe Bistro" (not "Café Bistro") because
    EffectiveDisplayName falls through to naming.HumanName(s.Name) when no
    explicit DisplayName is set, and Name is the ASCII slug. Preserving
    Unicode display_name for OpenAPI specs without an explicit
    display_name field is a separate concern — needs the parser to set
    DisplayName from the raw doc.Info.Title — and is out of scope here.
    
    Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
    
    * simplify: trim narrating comments and add ASCII fast path to ASCIIFold
    
    Three findings from /simplify:
    
    1. ASCII fast path in naming.ASCIIFold (efficiency). unidecode.Unidecode
       always allocates a builder and walks every rune; for OpenAPI specs
       the fold runs thousands of times per parse on >99% ASCII input
       (sanitizeTypeName + toCamelCase per schema field, etc.). A byte
       scan now skips the table lookup when no continuation bytes are
       present, returning the input string directly with zero allocation.
    
    2. Drop ~40 lines of narrating/duplicating comments (per CLAUDE.md
       "default to no comments"):
       - ASCIIFold godoc trimmed from ~20 lines to 6
       - Removed "Non-ASCII input is folded first" tags from
         SnakeIdentifier/EnvPrefix/Snake (duplicates ASCIIFold's godoc)
       - Removed the 7-line block in cleanSpecName explaining why the
         fold runs (the function call name is enough)
       - Test files keep the category labels (Precomposed accents /
         Fused-diacritic Latin / Non-Latin scripts) but drop the
         cross-references and rationale prose
    
    3. Removed dead U+2019 strip in cleanSpecName. ASCIIFold maps the
       smart quote to ASCII apostrophe upstream, so the second
       ReplaceAll was a no-op.
    
    Tests: 2217/2217 passing. Golden verify: 9/9 cases pass. No
    behavioral change.
    
    Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
    
    * review: address ce-code-review walk-through findings
    
    Two findings from /compound-engineering:ce-code-review walk-through:
    
    1. Document the implicit ASCIIFold boundary in internal/generator/.
       The PR's chokepoint audit covered openapi/graphql/naming packages
       but didn't reach the template helpers (toCamel, toPascal, flagName,
       safeTypeName, schema_builder.toSnakeCase). Investigation confirmed
       they receive APISpec data already folded by the openapi parser, so
       the boundary is correct — but it was implicit. Add 1-line godoc
       notes making the invariant visible: any new caller feeding raw spec
       strings into these helpers must fold first via naming.ASCIIFold.
    
    2. Strengthen golden-api-unicode coverage. The previous fixture used
       ASCII-only schema/property names, so a regression bypassing
       sanitizeTypeName / toCamelCase wouldn't fail the golden. Renamed
       the schema to "CaféMenu" with property "spécialité"; added the
       generated types.go to the artifact list. The captured types.go now
       shows `type CafeMenu struct { ... Specialite string }`, locking in
       that both schema-name and property-name chokepoints fold to ASCII.
    
    go test ./... passes (2217/2217). Golden verify passes (9/9 cases).
    golangci-lint clean.
    
    Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
    
    ---------
    
    Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---
 go.mod                                             |  1 +
 go.sum                                             |  2 +
 internal/generator/generator.go                    |  9 +++-
 internal/generator/schema_builder.go               |  3 ++
 internal/graphql/parser.go                         |  2 +
 internal/naming/naming.go                          | 33 ++++++++++++---
 internal/naming/naming_test.go                     | 49 ++++++++++++++++++++++
 internal/openapi/parser.go                         |  9 +++-
 internal/openapi/parser_test.go                    | 14 +++++++
 .../generate-golden-api-unicode/artifacts.txt      | 26 ++++++++++++
 .../cases/generate-golden-api-unicode/command.txt  |  2 +
 .../cafe-bistro/.printing-press.json               | 20 +++++++++
 .../cafe-bistro/cmd/cafe-bistro-pp-cli/main.go     | 18 ++++++++
 .../cafe-bistro/cmd/cafe-bistro-pp-mcp/main.go     | 27 ++++++++++++
 .../cafe-bistro/internal/types/types.go            | 12 ++++++
 .../cafe-bistro/manifest.json                      | 39 +++++++++++++++++
 .../expected/generate-golden-api-unicode/exit.txt  |  1 +
 .../generate-golden-api-unicode/stderr.txt         |  1 +
 .../generate-golden-api-unicode/stdout.txt         |  0
 testdata/golden/fixtures/golden-api-unicode.yaml   | 44 +++++++++++++++++++
 20 files changed, 302 insertions(+), 10 deletions(-)

diff --git a/go.mod b/go.mod
index 8ea72aac..3138c2c0 100644
--- a/go.mod
+++ b/go.mod
@@ -6,6 +6,7 @@ require (
 	github.com/dave/dst v0.27.4
 	github.com/enetx/surf v1.0.199
 	github.com/getkin/kin-openapi v0.137.0
+	github.com/mozillazg/go-unidecode v0.2.0
 	github.com/spf13/cobra v1.10.2
 	github.com/stretchr/testify v1.11.1
 	golang.org/x/mod v0.35.0
diff --git a/go.sum b/go.sum
index db8f5195..15f848f6 100644
--- a/go.sum
+++ b/go.sum
@@ -52,6 +52,8 @@ github.com/mailru/easyjson v0.9.2 h1:dX8U45hQsZpxd80nLvDGihsQ/OxlvTkVUXH2r/8cb2M
 github.com/mailru/easyjson v0.9.2/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU=
 github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9M+97sNutRR1RKhG96O6jWumTTnw=
 github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8=
+github.com/mozillazg/go-unidecode v0.2.0 h1:vFGEzAH9KSwyWmXCOblazEWDh7fOkpmy/Z4ArmamSUc=
+github.com/mozillazg/go-unidecode v0.2.0/go.mod h1:zB48+/Z5toiRolOZy9ksLryJ976VIwmDmpQ2quyt1aA=
 github.com/oasdiff/yaml v0.0.9 h1:zQOvd2UKoozsSsAknnWoDJlSK4lC0mpmjfDsfqNwX48=
 github.com/oasdiff/yaml v0.0.9/go.mod h1:8lvhgJG4xiKPj3HN5lDow4jZHPlx1i7dIwzkdAo6oAM=
 github.com/oasdiff/yaml3 v0.0.12 h1:75urAtPeDg2/iDEWwzNrLOWxI9N/dCh81nTTJtokt2M=
diff --git a/internal/generator/generator.go b/internal/generator/generator.go
index 2c10f4a1..4fae9c94 100644
--- a/internal/generator/generator.go
+++ b/internal/generator/generator.go
@@ -2005,8 +2005,13 @@ func (g *Generator) template(tmplName string) (*template.Template, error) {
 	return tmpl, nil
 }
 
-// Template helper functions
-
+// Template helper functions.
+//
+// These run inside Go templates over parsed APISpec data, so their inputs
+// have already been ASCII-folded by the openapi/graphql parsers (which
+// route raw spec strings through naming.ASCIIFold at sanitizeTypeName,
+// toCamelCase, etc). Treat any new caller that feeds raw spec strings
+// directly into these helpers as a bug — fold first, then shape.
 func toCamel(s string) string {
 	// Strip characters that are invalid in Go identifiers
 	s = strings.TrimLeft(s, "$")
diff --git a/internal/generator/schema_builder.go b/internal/generator/schema_builder.go
index 9dddaf3c..f0098885 100644
--- a/internal/generator/schema_builder.go
+++ b/internal/generator/schema_builder.go
@@ -369,6 +369,9 @@ func sqlStringLiteral(s string) string {
 }
 
 // toSnakeCase converts camelCase, PascalCase, or kebab-case to snake_case.
+// Expects ASCII input — callers are SQL identifier paths whose inputs come
+// from parsed APISpec types/fields that already passed through the
+// openapi parser's ASCIIFold chokepoints.
 func toSnakeCase(s string) string {
 	s = strings.ReplaceAll(s, ".", "_")
 	s = strings.ReplaceAll(s, "-", "_")
diff --git a/internal/graphql/parser.go b/internal/graphql/parser.go
index 983b99f1..d3413a29 100644
--- a/internal/graphql/parser.go
+++ b/internal/graphql/parser.go
@@ -9,6 +9,7 @@ import (
 	"strings"
 	"unicode"
 
+	"github.com/mvanhorn/cli-printing-press/v2/internal/naming"
 	"github.com/mvanhorn/cli-printing-press/v2/internal/spec"
 	"golang.org/x/text/cases"
 	"golang.org/x/text/language"
@@ -729,6 +730,7 @@ func splitWords(s string) []string {
 }
 
 func toKebabCase(s string) string {
+	s = naming.ASCIIFold(s)
 	words := splitWords(s)
 	for i := range words {
 		words[i] = strings.ToLower(words[i])
diff --git a/internal/naming/naming.go b/internal/naming/naming.go
index 85935bee..eb548548 100644
--- a/internal/naming/naming.go
+++ b/internal/naming/naming.go
@@ -5,11 +5,31 @@ import (
 	"strings"
 	"unicode"
 
+	"github.com/mozillazg/go-unidecode"
 	"golang.org/x/text/cases"
 	"golang.org/x/text/language"
-	"golang.org/x/text/unicode/norm"
 )
 
+// ASCIIFold transliterates Unicode to ASCII via Unidecode tables (the
+// same ones Django's slugify and Rails use). Apply at every chokepoint
+// that turns user-supplied spec strings (titles, resource names,
+// operationIds, schema names, path segments) into file/folder names or
+// Go identifiers. Output preserves spacing/casing — downstream
+// to{Snake,Kebab,Camel}Case still owns identifier shape.
+func ASCIIFold(s string) string {
+	// Pure-ASCII fast path. unidecode.Unidecode allocates a builder and
+	// walks every rune unconditionally; for the common case of
+	// well-behaved OpenAPI specs this fold runs thousands of times per
+	// parse on inputs that are >99% ASCII. A byte scan suffices: any
+	// non-ASCII codepoint has a continuation byte ≥0x80.
+	for i := 0; i < len(s); i++ {
+		if s[i] >= 0x80 {
+			return unidecode.Unidecode(s)
+		}
+	}
+	return s
+}
+
 const (
 	CurrentCLISuffix = "-pp-cli"
 	LegacyCLISuffix  = "-cli"
@@ -48,6 +68,7 @@ func HumanName(slug string) string {
 // "FUNDING-TREND" → "funding_trend". Used by the generator's mcpToolName
 // template helper.
 func SnakeIdentifier(s string) string {
+	s = ASCIIFold(s)
 	var b strings.Builder
 	lastUnder := false
 	for _, r := range s {
@@ -69,12 +90,13 @@ func SnakeIdentifier(s string) string {
 }
 
 // EnvPrefix returns an ASCII-only shell-safe environment variable prefix.
-// API display names and OpenAPI titles can contain accents or punctuation
-// ("PokéAPI", "Cal.com", "1Password"); generated env vars must not.
+// API display names and OpenAPI titles can contain accents or non-Latin
+// scripts ("PokéAPI", "Cal.com", "1Password", "東京"); generated env vars
+// must not.
 func EnvPrefix(name string) string {
 	var b strings.Builder
 	lastUnderscore := false
-	for _, r := range norm.NFD.String(name) {
+	for _, r := range ASCIIFold(name) {
 		switch {
 		case r >= 'a' && r <= 'z':
 			b.WriteRune(r - ('a' - 'A'))
@@ -85,8 +107,6 @@ func EnvPrefix(name string) string {
 		case r >= '0' && r <= '9':
 			b.WriteRune(r)
 			lastUnderscore = false
-		case unicode.Is(unicode.Mn, r):
-			continue
 		default:
 			if !lastUnderscore && b.Len() > 0 {
 				b.WriteByte('_')
@@ -108,6 +128,7 @@ func EnvPrefix(name string) string {
 // Hyphens are intentionally preserved to match the historical MCP template
 // helper behavior.
 func Snake(s string) string {
+	s = ASCIIFold(s)
 	var result strings.Builder
 	for i, r := range s {
 		if unicode.IsUpper(r) && i > 0 {
diff --git a/internal/naming/naming_test.go b/internal/naming/naming_test.go
index 86bfa127..a09477cc 100644
--- a/internal/naming/naming_test.go
+++ b/internal/naming/naming_test.go
@@ -61,6 +61,11 @@ func TestEnvPrefix(t *testing.T) {
 		"food & dining": "FOOD_DINING",
 		"1password":     "API_1PASSWORD",
 		"!!!":           "API",
+		"Großhandel":    "GROSSHANDEL",
+		"Łódź":          "LODZ",
+		"Ørsted":        "ORSTED",
+		"東京":            "DONG_JING",
+		"русский":       "RUSSKII",
 	}
 
 	for input, want := range tests {
@@ -70,6 +75,50 @@ func TestEnvPrefix(t *testing.T) {
 	}
 }
 
+func TestASCIIFold(t *testing.T) {
+	tests := map[string]string{
+		"":              "",
+		"already-ascii": "already-ascii",
+		// Precomposed accents:
+		"Pokémon": "Pokemon",
+		"naïve":   "naive",
+		"café":    "cafe",
+		// Fused-diacritic Latin:
+		"Großhandel":   "Grosshandel",
+		"Łódź":         "Lodz",
+		"Encyclopædia": "Encyclopaedia",
+		"Ørsted":       "Orsted",
+		"Þingvellir":   "Thingvellir",
+		// Non-Latin scripts:
+		"東京":      "Dong Jing ",
+		"русский": "russkii",
+		"Δelta":   "Delta",
+	}
+
+	for input, want := range tests {
+		if got := ASCIIFold(input); got != want {
+			t.Fatalf("ASCIIFold(%q) = %q, want %q", input, got, want)
+		}
+	}
+}
+
+func TestSnakeIdentifier(t *testing.T) {
+	tests := map[string]string{
+		"funding --who":     "funding_who",
+		"FUNDING-TREND":     "funding_trend",
+		"already_snake":     "already_snake",
+		"Pokémon list":      "pokemon_list",
+		"Großhandel--query": "grosshandel_query",
+		"русский_kpi":       "russkii_kpi",
+	}
+
+	for input, want := range tests {
+		if got := SnakeIdentifier(input); got != want {
+			t.Fatalf("SnakeIdentifier(%q) = %q, want %q", input, got, want)
+		}
+	}
+}
+
 func TestSnake(t *testing.T) {
 	tests := map[string]string{
 		"Pets":          "pets",
diff --git a/internal/openapi/parser.go b/internal/openapi/parser.go
index abf34736..93ad0be9 100644
--- a/internal/openapi/parser.go
+++ b/internal/openapi/parser.go
@@ -2562,6 +2562,7 @@ func stripOperationIDResourceSegments(name string, variants []string) string {
 }
 
 func toSnakeCase(input string) string {
+	input = naming.ASCIIFold(input)
 	var b strings.Builder
 	var prev rune
 	lastUnderscore := true
@@ -2595,6 +2596,7 @@ func sanitizeResourceName(name string) string {
 }
 
 func sanitizeTypeName(name string) string {
+	name = naming.ASCIIFold(name)
 	name = strings.TrimLeft(name, "$")
 	name = strings.NewReplacer(".", "_", "/", "_", "\\", "_", "-", "_", " ", "_").Replace(name)
 	var b strings.Builder
@@ -2633,6 +2635,7 @@ func isGoKeyword(s string) bool {
 }
 
 func toCamelCase(s string) string {
+	s = naming.ASCIIFold(s)
 	s = strings.TrimLeft(s, "$")
 	parts := strings.FieldsFunc(s, func(r rune) bool {
 		return r == '_' || r == '-' || r == ' ' || r == '.' || r == '/' || r == '\\' || r == '$' || r == '#' || r == '@'
@@ -2650,6 +2653,7 @@ func toCamelCase(s string) string {
 }
 
 func toKebabCase(input string) string {
+	input = naming.ASCIIFold(input)
 	var b strings.Builder
 	lastHyphen := true
 
@@ -2670,6 +2674,7 @@ func toKebabCase(input string) string {
 }
 
 func cleanSpecName(title string) string {
+	title = naming.ASCIIFold(title)
 	title = strings.ToLower(strings.TrimSpace(title))
 	if title == "" {
 		return "api"
@@ -2677,9 +2682,9 @@ func cleanSpecName(title string) string {
 
 	title = strings.ReplaceAll(title, "open api", " ")
 
-	// Strip apostrophes so brand names like "Domino's" become "dominos" not "domino-s"
+	// Strip apostrophes so brand names like "Domino's" become "dominos" not
+	// "domino-s". Smart-quote U+2019 already folds to ASCII via ASCIIFold above.
 	title = strings.ReplaceAll(title, "'", "")
-	title = strings.ReplaceAll(title, "\u2019", "") // Unicode right single quotation mark
 
 	var normalized strings.Builder
 	lastSpace := true
diff --git a/internal/openapi/parser_test.go b/internal/openapi/parser_test.go
index 28f28486..cb146387 100644
--- a/internal/openapi/parser_test.go
+++ b/internal/openapi/parser_test.go
@@ -423,6 +423,20 @@ func TestCleanSpecName(t *testing.T) {
 		{title: "Domino\u2019s Pizza API", want: "dominos-pizza"},
 		// Multiple apostrophes
 		{title: "Rock'n'Roll API", want: "rocknroll"},
+		// Precomposed accents:
+		{title: "Pok\u00e9mon API", want: "pokemon"},
+		{title: "Caf\u00e9 Reservations", want: "cafe-reservations"},
+		{title: "Na\u00efve Bayes API", want: "naive-bayes"},
+		// Fused-diacritic Latin:
+		{title: "Gro\u00dfhandel API", want: "grosshandel"},
+		{title: "Encyclop\u00e6dia API", want: "encyclopaedia"},
+		{title: "\u00d8rsted Energy", want: "orsted-energy"},
+		{title: "\u0141\u00f3d\u017a Transit", want: "lodz-transit"},
+		{title: "\u00deingvellir Tours", want: "thingvellir-tours"},
+		// Non-Latin scripts:
+		{title: "\u6771\u4eac API", want: "dong-jing"},
+		{title: "\u0440\u0443\u0441\u0441\u043a\u0438\u0439 API", want: "russkii"},
+		{title: "\u0394elta API", want: "delta"},
 	}
 
 	for _, tt := range tests {
diff --git a/testdata/golden/cases/generate-golden-api-unicode/artifacts.txt b/testdata/golden/cases/generate-golden-api-unicode/artifacts.txt
new file mode 100644
index 00000000..dc399c63
--- /dev/null
+++ b/testdata/golden/cases/generate-golden-api-unicode/artifacts.txt
@@ -0,0 +1,26 @@
+# Locks the Unicode-to-ASCII slug contract. The fixture title is
+# "Café Bistro API" — every path below is the ASCII slug "cafe-bistro",
+# proving naming.ASCIIFold (Unidecode) ran on cleanSpecName, naming.CLI,
+# naming.MCP, and naming.EnvPrefix. If a future refactor narrows the
+# fold to "Latin precomposed accents only" or drops it entirely, these
+# paths would either disappear (the artifact lookup fails) or the JSON
+# fields below would surface raw "café" / "CAFÉ_*" content.
+
+# Provenance manifest: api_name, cli_name, mcp_binary, display_name, auth_env_vars
+cafe-bistro/.printing-press.json
+
+# MCPB manifest: server identity (binary name + display_name)
+cafe-bistro/manifest.json
+
+# Listing this path asserts the directory cmd/cafe-bistro-pp-cli/
+# exists with an ASCII name. The harness fails with "missing artifact"
+# if the slug ever surfaces as "café-bistro-pp-cli/" again.
+cafe-bistro/cmd/cafe-bistro-pp-cli/main.go
+cafe-bistro/cmd/cafe-bistro-pp-mcp/main.go
+
+# Generated Go types: locks that sanitizeTypeName + toCamelCase fold
+# non-ASCII schema/property names to ASCII Go identifiers. The fixture
+# defines schema "CaféMenu" with property "spécialité"; this file must
+# show the ASCII-folded forms (no é, no extended bytes), proving every
+# schema-derived chokepoint actually ran ASCIIFold.
+cafe-bistro/internal/types/types.go
diff --git a/testdata/golden/cases/generate-golden-api-unicode/command.txt b/testdata/golden/cases/generate-golden-api-unicode/command.txt
new file mode 100644
index 00000000..34ca92ec
--- /dev/null
+++ b/testdata/golden/cases/generate-golden-api-unicode/command.txt
@@ -0,0 +1,2 @@
+set -euo pipefail
+"$BINARY" generate --spec testdata/golden/fixtures/golden-api-unicode.yaml --output "$CASE_ACTUAL_DIR/cafe-bistro" --force --spec-url file://testdata/golden/fixtures/golden-api-unicode.yaml --validate=false
diff --git a/testdata/golden/expected/generate-golden-api-unicode/cafe-bistro/.printing-press.json b/testdata/golden/expected/generate-golden-api-unicode/cafe-bistro/.printing-press.json
new file mode 100644
index 00000000..293d4c0d
--- /dev/null
+++ b/testdata/golden/expected/generate-golden-api-unicode/cafe-bistro/.printing-press.json
@@ -0,0 +1,20 @@
+{
+  "api_name": "cafe-bistro",
+  "api_version": "1.0.0",
+  "auth_env_vars": [
+    "CAFE_BISTRO_API_KEY_AUTH"
+  ],
+  "auth_type": "api_key",
+  "cli_name": "cafe-bistro-pp-cli",
+  "display_name": "Cafe Bistro",
+  "generated_at": "<GENERATED_AT>",
+  "mcp_binary": "cafe-bistro-pp-mcp",
+  "mcp_ready": "full",
+  "mcp_tool_count": 1,
+  "printing_press_version": "<PRINTING_PRESS_VERSION>",
+  "schema_version": 1,
+  "spec_checksum": "sha256:8cfba131c384836e8dde461511c9fd63cf2cbab7793553067765d515f2007aa1",
+  "spec_format": "openapi3",
+  "spec_path": "testdata/golden/fixtures/golden-api-unicode.yaml",
+  "spec_url": "file://testdata/golden/fixtures/golden-api-unicode.yaml"
+}
diff --git a/testdata/golden/expected/generate-golden-api-unicode/cafe-bistro/cmd/cafe-bistro-pp-cli/main.go b/testdata/golden/expected/generate-golden-api-unicode/cafe-bistro/cmd/cafe-bistro-pp-cli/main.go
new file mode 100644
index 00000000..d7b5d06c
--- /dev/null
+++ b/testdata/golden/expected/generate-golden-api-unicode/cafe-bistro/cmd/cafe-bistro-pp-cli/main.go
@@ -0,0 +1,18 @@
+// Copyright 2026 printing-press-golden. Licensed under Apache-2.0. See LICENSE.
+// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.
+
+package main
+
+import (
+	"fmt"
+	"os"
+
+	"cafe-bistro-pp-cli/internal/cli"
+)
+
+func main() {
+	if err := cli.Execute(); err != nil {
+		fmt.Fprintln(os.Stderr, err.Error())
+		os.Exit(cli.ExitCode(err))
+	}
+}
diff --git a/testdata/golden/expected/generate-golden-api-unicode/cafe-bistro/cmd/cafe-bistro-pp-mcp/main.go b/testdata/golden/expected/generate-golden-api-unicode/cafe-bistro/cmd/cafe-bistro-pp-mcp/main.go
new file mode 100644
index 00000000..5e287c9d
--- /dev/null
+++ b/testdata/golden/expected/generate-golden-api-unicode/cafe-bistro/cmd/cafe-bistro-pp-mcp/main.go
@@ -0,0 +1,27 @@
+// Copyright 2026 printing-press-golden. Licensed under Apache-2.0. See LICENSE.
+// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.
+
+package main
+
+import (
+	"fmt"
+	"os"
+
+	"github.com/mark3labs/mcp-go/server"
+	mcptools "cafe-bistro-pp-cli/internal/mcp"
+)
+
+func main() {
+	s := server.NewMCPServer(
+		"Cafe Bistro",
+		"1.0.0",
+		server.WithToolCapabilities(false),
+	)
+
+	mcptools.RegisterTools(s)
+
+	if err := server.ServeStdio(s); err != nil {
+		fmt.Fprintf(os.Stderr, "MCP server error: %v\n", err)
+		os.Exit(1)
+	}
+}
diff --git a/testdata/golden/expected/generate-golden-api-unicode/cafe-bistro/internal/types/types.go b/testdata/golden/expected/generate-golden-api-unicode/cafe-bistro/internal/types/types.go
new file mode 100644
index 00000000..b3b5ed26
--- /dev/null
+++ b/testdata/golden/expected/generate-golden-api-unicode/cafe-bistro/internal/types/types.go
@@ -0,0 +1,12 @@
+// Copyright 2026 printing-press-golden. Licensed under Apache-2.0. See LICENSE.
+// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.
+
+package types
+
+
+type CafeMenu struct {
+	Id string `json:"id"`
+	Name string `json:"name"`
+	Specialite string `json:"specialite"`
+}
+
diff --git a/testdata/golden/expected/generate-golden-api-unicode/cafe-bistro/manifest.json b/testdata/golden/expected/generate-golden-api-unicode/cafe-bistro/manifest.json
new file mode 100644
index 00000000..6fb278a4
--- /dev/null
+++ b/testdata/golden/expected/generate-golden-api-unicode/cafe-bistro/manifest.json
@@ -0,0 +1,39 @@
+{
+  "author": {
+    "name": "CLI Printing Press"
+  },
+  "compatibility": {
+    "claude_desktop": ">=1.0.0",
+    "platforms": [
+      "darwin",
+      "linux",
+      "win32"
+    ]
+  },
+  "description": "Cafe Bistro API surface as MCP tools.",
+  "display_name": "Cafe Bistro",
+  "license": "Apache-2.0",
+  "manifest_version": "0.3",
+  "name": "cafe-bistro-pp-mcp",
+  "server": {
+    "entry_point": "bin/cafe-bistro-pp-mcp",
+    "mcp_config": {
+      "args": [],
+      "command": "${__dirname}/bin/cafe-bistro-pp-mcp",
+      "env": {
+        "CAFE_BISTRO_API_KEY_AUTH": "${user_config.cafe_bistro_api_key_auth}"
+      }
+    },
+    "type": "binary"
+  },
+  "user_config": {
+    "cafe_bistro_api_key_auth": {
+      "description": "Sets CAFE_BISTRO_API_KEY_AUTH for the Cafe Bistro MCP server.",
+      "required": true,
+      "sensitive": true,
+      "title": "CAFE_BISTRO_API_KEY_AUTH",
+      "type": "string"
+    }
+  },
+  "version": "<PRINTING_PRESS_VERSION>"
+}
diff --git a/testdata/golden/expected/generate-golden-api-unicode/exit.txt b/testdata/golden/expected/generate-golden-api-unicode/exit.txt
new file mode 100644
index 00000000..573541ac
--- /dev/null
+++ b/testdata/golden/expected/generate-golden-api-unicode/exit.txt
@@ -0,0 +1 @@
+0
diff --git a/testdata/golden/expected/generate-golden-api-unicode/stderr.txt b/testdata/golden/expected/generate-golden-api-unicode/stderr.txt
new file mode 100644
index 00000000..45c603f2
--- /dev/null
+++ b/testdata/golden/expected/generate-golden-api-unicode/stderr.txt
@@ -0,0 +1 @@
+Generated cafe-bistro at <ARTIFACT_DIR>/generate-golden-api-unicode/cafe-bistro
diff --git a/testdata/golden/expected/generate-golden-api-unicode/stdout.txt b/testdata/golden/expected/generate-golden-api-unicode/stdout.txt
new file mode 100644
index 00000000..e69de29b
diff --git a/testdata/golden/fixtures/golden-api-unicode.yaml b/testdata/golden/fixtures/golden-api-unicode.yaml
new file mode 100644
index 00000000..0fcc21d5
--- /dev/null
+++ b/testdata/golden/fixtures/golden-api-unicode.yaml
@@ -0,0 +1,44 @@
+openapi: "3.0.3"
+info:
+  title: Café Bistro API
+  version: "1.0.0"
+  description: Purpose-built fixture for the Unicode-to-ASCII slug contract.
+servers:
+  - url: https://api.cafe-bistro.example/v1
+security:
+  - ApiKeyAuth: []
+components:
+  securitySchemes:
+    ApiKeyAuth:
+      type: apiKey
+      in: header
+      name: X-API-Key
+  schemas:
+    # Schema name and one property are deliberately non-ASCII to lock in
+    # that sanitizeTypeName + toCamelCase fold to ASCII — the resulting
+    # types.go must define `type CafEMenu struct` (or similar ASCII shape)
+    # with `Specialite string` rather than retaining the accented bytes.
+    CaféMenu:
+      type: object
+      properties:
+        id:
+          type: string
+        name:
+          type: string
+        spécialité:
+          type: string
+paths:
+  /menus:
+    get:
+      tags: [menus]
+      operationId: listMenus
+      summary: List menus
+      responses:
+        "200":
+          description: OK
+          content:
+            application/json:
+              schema:
+                type: array
+                items:
+                  $ref: "#/components/schemas/CaféMenu"

← 1a4bcae8 fix(cli): mcp-sync handles older library CLI drift (cliutil  ·  back to Cli Printing Press  ·  fix(cli): mcp-sync preserves manifest brand-casing and hand- 9f57c41d →