[object Object]

← back to Cli Printing Press

feat(graphql): add GraphQL SDL parser for CLI generation

8c92c19da95f7e9b4ebeae9837caaccd0e28edea · 2026-03-26 15:41:59 -0700 · Matt Van Horn

New internal/graphql package with regex-based SDL parser that converts
GraphQL schemas to spec.APISpec. Enables printing-press to generate
CLIs from GraphQL APIs (Linear, GitHub GraphQL, Shopify, etc.) without
requiring an OpenAPI spec.

Features:
- Parses type, input, enum, and scalar definitions
- Extracts Query fields as list/get endpoints with Relay pagination
- Extracts Mutations as create/update/delete endpoints
- Maps GraphQL types to Go types (String->string, Int->integer, etc.)
- Detects Relay-style connections (*Connection + nodes + pageInfo)
- Known API defaults for Linear and GitHub GraphQL
- Auto-detection: generate command tries GraphQL before OpenAPI

Maps GraphQL reads/writes to HTTP verbs (GET/POST/PATCH/DELETE) so
existing templates work without modification. This means a single
`printing-press generate --spec schema.graphql` produces a full CLI
with domain-specific SQLite tables, sync, search, and commands.

Tests: parser_test.go with minimal SDL schema validates entity
extraction, pagination detection, mutation mapping, and type filtering.

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

Files touched

Diff

commit 8c92c19da95f7e9b4ebeae9837caaccd0e28edea
Author: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
Date:   Thu Mar 26 15:41:59 2026 -0700

    feat(graphql): add GraphQL SDL parser for CLI generation
    
    New internal/graphql package with regex-based SDL parser that converts
    GraphQL schemas to spec.APISpec. Enables printing-press to generate
    CLIs from GraphQL APIs (Linear, GitHub GraphQL, Shopify, etc.) without
    requiring an OpenAPI spec.
    
    Features:
    - Parses type, input, enum, and scalar definitions
    - Extracts Query fields as list/get endpoints with Relay pagination
    - Extracts Mutations as create/update/delete endpoints
    - Maps GraphQL types to Go types (String->string, Int->integer, etc.)
    - Detects Relay-style connections (*Connection + nodes + pageInfo)
    - Known API defaults for Linear and GitHub GraphQL
    - Auto-detection: generate command tries GraphQL before OpenAPI
    
    Maps GraphQL reads/writes to HTTP verbs (GET/POST/PATCH/DELETE) so
    existing templates work without modification. This means a single
    `printing-press generate --spec schema.graphql` produces a full CLI
    with domain-specific SQLite tables, sync, search, and commands.
    
    Tests: parser_test.go with minimal SDL schema validates entity
    extraction, pagination detection, mutation mapping, and type filtering.
    
    Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---
 internal/cli/root.go            |   5 +-
 internal/graphql/parser.go      | 790 ++++++++++++++++++++++++++++++++++++++++
 internal/graphql/parser_test.go | 182 +++++++++
 internal/openapi/detect.go      |  10 +-
 4 files changed, 985 insertions(+), 2 deletions(-)

diff --git a/internal/cli/root.go b/internal/cli/root.go
index aa2cdfab..68b169d8 100644
--- a/internal/cli/root.go
+++ b/internal/cli/root.go
@@ -13,6 +13,7 @@ import (
 
 	"github.com/mvanhorn/cli-printing-press/internal/docspec"
 	"github.com/mvanhorn/cli-printing-press/internal/generator"
+	"github.com/mvanhorn/cli-printing-press/internal/graphql"
 	"github.com/mvanhorn/cli-printing-press/internal/llm"
 	"github.com/mvanhorn/cli-printing-press/internal/llmpolish"
 	"github.com/mvanhorn/cli-printing-press/internal/openapi"
@@ -146,7 +147,9 @@ func newGenerateCmd() *cobra.Command {
 				}
 
 				var apiSpec *spec.APISpec
-				if openapi.IsOpenAPI(data) {
+				if graphql.IsGraphQLSDL(data) {
+					apiSpec, err = graphql.ParseSDLBytes(specFile, data)
+				} else if openapi.IsOpenAPI(data) {
 					if lenient {
 						apiSpec, err = openapi.ParseLenient(data)
 					} else {
diff --git a/internal/graphql/parser.go b/internal/graphql/parser.go
new file mode 100644
index 00000000..0f9b5b46
--- /dev/null
+++ b/internal/graphql/parser.go
@@ -0,0 +1,790 @@
+package graphql
+
+import (
+	"fmt"
+	"os"
+	"path/filepath"
+	"regexp"
+	"sort"
+	"strings"
+	"unicode"
+
+	"github.com/mvanhorn/cli-printing-press/internal/spec"
+)
+
+var (
+	docBlockRE = regexp.MustCompile(`(?s)""".*?"""`)
+	commentRE  = regexp.MustCompile(`(?m)#.*$`)
+	blockRE    = regexp.MustCompile(`(?s)\b(type|input|enum)\s+([A-Za-z_][A-Za-z0-9_]*)[^{=]*\{(.*?)\}`)
+	fieldRE    = regexp.MustCompile(`^\s*([A-Za-z_][A-Za-z0-9_]*)\s*(?:\((.*)\))?\s*:\s*([A-Za-z0-9_\[\]!]+)`)
+	scalarRE   = regexp.MustCompile(`(?m)^\s*scalar\s+([A-Za-z_][A-Za-z0-9_]*)\s*$`)
+)
+
+type gqlType struct {
+	Kind       string
+	Name       string
+	Fields     []gqlField
+	EnumValues []string
+}
+
+type gqlField struct {
+	Name string
+	Type string
+	Args []gqlArg
+}
+
+type gqlArg struct {
+	Name string
+	Type string
+}
+
+// ParseSDL reads a GraphQL SDL file and converts it to an APISpec.
+func ParseSDL(path string) (*spec.APISpec, error) {
+	data, err := os.ReadFile(path)
+	if err != nil {
+		return nil, fmt.Errorf("reading SDL: %w", err)
+	}
+	return ParseSDLBytes(path, data)
+}
+
+// ParseSDLBytes parses SDL bytes from a file path or URL-like source identifier.
+func ParseSDLBytes(source string, data []byte) (*spec.APISpec, error) {
+	return parseSDLContent(source, string(data))
+}
+
+// IsGraphQLSDL checks if the data looks like a GraphQL schema.
+func IsGraphQLSDL(data []byte) bool {
+	s := string(data)
+	return strings.Contains(s, "type Query") || strings.Contains(s, "type Mutation") ||
+		(strings.Contains(s, "type ") && strings.Contains(s, "scalar "))
+}
+
+func parseSDLContent(source, raw string) (*spec.APISpec, error) {
+	cleaned := stripSDLComments(raw)
+	types, scalars, err := parseSDLTypes(cleaned)
+	if err != nil {
+		return nil, err
+	}
+
+	name := deriveAPIName(source)
+	baseURL, auth := knownGraphQLDefaults(name, source)
+	if baseURL == "" {
+		baseURL = "https://api.example.com/graphql"
+	}
+	if auth.Type == "" {
+		auth = spec.AuthConfig{
+			Type:    "api_key",
+			Header:  "Authorization",
+			EnvVars: []string{strings.ToUpper(strings.ReplaceAll(name, "-", "_")) + "_API_KEY"},
+		}
+	}
+
+	apiSpec := &spec.APISpec{
+		Name:        name,
+		Description: "Generated from GraphQL schema",
+		BaseURL:     baseURL,
+		Auth:        auth,
+		Config: spec.ConfigSpec{
+			Format: "toml",
+			Path:   fmt.Sprintf("~/.config/%s-cli/config.toml", name),
+		},
+		Resources: map[string]spec.Resource{},
+		Types:     map[string]spec.TypeDef{},
+	}
+
+	enumMap := map[string][]string{}
+	for name, typ := range types {
+		if typ.Kind == "enum" {
+			enumMap[name] = append([]string(nil), typ.EnumValues...)
+		}
+	}
+
+	connectionEntities := detectConnections(types)
+	queryType := types["Query"]
+	mutationType := types["Mutation"]
+	resourceEntities := map[string]struct{}{}
+
+	for _, field := range queryType.Fields {
+		baseType := unwrapType(field.Type)
+		entityName, isConnection := connectionEntities[baseType]
+
+		switch {
+		case isConnection:
+			resourceName := resourceNameForQuery(field.Name, entityName)
+			resource := apiSpec.Resources[resourceName]
+			if resource.Description == "" {
+				resource.Description = "Manage " + resourceName
+			}
+			resource.Endpoints = ensureEndpoints(resource.Endpoints)
+			resource.Endpoints["list"] = buildListEndpoint(field, entityName, enumMap)
+			apiSpec.Resources[resourceName] = resource
+			resourceEntities[entityName] = struct{}{}
+		case isEntityType(baseType, types):
+			resourceName := resourceNameFromType(baseType)
+			resource := apiSpec.Resources[resourceName]
+			if resource.Description == "" {
+				resource.Description = "Manage " + resourceName
+			}
+			resource.Endpoints = ensureEndpoints(resource.Endpoints)
+			resource.Endpoints["get"] = buildGetEndpoint(field, baseType, enumMap)
+			apiSpec.Resources[resourceName] = resource
+			resourceEntities[baseType] = struct{}{}
+		}
+	}
+
+	for _, field := range mutationType.Fields {
+		action, entityName := classifyMutation(field, types)
+		if action == "" || entityName == "" {
+			continue
+		}
+
+		resourceName := resourceNameFromType(entityName)
+		resource := apiSpec.Resources[resourceName]
+		if resource.Description == "" {
+			resource.Description = "Manage " + resourceName
+		}
+		resource.Endpoints = ensureEndpoints(resource.Endpoints)
+		resource.Endpoints[action] = buildMutationEndpoint(field, action, entityName, types, enumMap)
+		apiSpec.Resources[resourceName] = resource
+		resourceEntities[entityName] = struct{}{}
+	}
+
+	for _, typ := range types {
+		if !shouldExposeType(typ.Name, typ.Kind) {
+			continue
+		}
+		apiSpec.Types[typ.Name] = buildTypeDef(typ, enumMap)
+	}
+
+	addSupportTypes(types, resourceEntities, apiSpec.Types)
+	addScalarTypes(scalars, apiSpec.Types)
+
+	if err := apiSpec.Validate(); err != nil {
+		return nil, fmt.Errorf("validating parsed SDL: %w", err)
+	}
+
+	return apiSpec, nil
+}
+
+func stripSDLComments(s string) string {
+	s = docBlockRE.ReplaceAllString(s, "")
+	s = commentRE.ReplaceAllString(s, "")
+	return s
+}
+
+func parseSDLTypes(s string) (map[string]gqlType, map[string]struct{}, error) {
+	types := map[string]gqlType{}
+	scalars := map[string]struct{}{}
+
+	for _, match := range scalarRE.FindAllStringSubmatch(s, -1) {
+		if len(match) > 1 {
+			scalars[match[1]] = struct{}{}
+		}
+	}
+
+	matches := blockRE.FindAllStringSubmatch(s, -1)
+	if len(matches) == 0 {
+		return nil, nil, fmt.Errorf("no GraphQL type definitions found")
+	}
+
+	for _, match := range matches {
+		if len(match) < 4 {
+			continue
+		}
+		kind := match[1]
+		name := match[2]
+		body := match[3]
+
+		switch kind {
+		case "enum":
+			enumValues := parseEnumValues(body)
+			types[name] = gqlType{Kind: kind, Name: name, EnumValues: enumValues}
+		case "type", "input":
+			fields := parseFields(body)
+			types[name] = gqlType{Kind: kind, Name: name, Fields: fields}
+		}
+	}
+
+	return types, scalars, nil
+}
+
+func parseEnumValues(body string) []string {
+	var values []string
+	for _, line := range strings.Split(body, "\n") {
+		line = strings.TrimSpace(line)
+		line = strings.TrimSuffix(line, ",")
+		if line == "" {
+			continue
+		}
+		values = append(values, line)
+	}
+	sort.Strings(values)
+	return values
+}
+
+func parseFields(body string) []gqlField {
+	var fields []gqlField
+	for _, rawLine := range strings.Split(body, "\n") {
+		line := strings.TrimSpace(rawLine)
+		if line == "" || strings.HasPrefix(line, "@") {
+			continue
+		}
+		match := fieldRE.FindStringSubmatch(line)
+		if len(match) < 4 {
+			continue
+		}
+		field := gqlField{
+			Name: match[1],
+			Type: strings.TrimSpace(match[3]),
+		}
+		if len(match) > 2 && strings.TrimSpace(match[2]) != "" {
+			field.Args = parseArgs(match[2])
+		}
+		fields = append(fields, field)
+	}
+	return fields
+}
+
+func parseArgs(raw string) []gqlArg {
+	var args []gqlArg
+	for _, part := range splitArgs(raw) {
+		part = strings.TrimSpace(part)
+		if part == "" {
+			continue
+		}
+		name, typeRef, ok := strings.Cut(part, ":")
+		if !ok {
+			continue
+		}
+		typeRef = strings.TrimSpace(typeRef)
+		if idx := strings.Index(typeRef, "="); idx >= 0 {
+			typeRef = strings.TrimSpace(typeRef[:idx])
+		}
+		args = append(args, gqlArg{
+			Name: strings.TrimSpace(name),
+			Type: typeRef,
+		})
+	}
+	return args
+}
+
+func splitArgs(raw string) []string {
+	var parts []string
+	var current strings.Builder
+	depth := 0
+
+	for _, r := range raw {
+		switch r {
+		case '[':
+			depth++
+		case ']':
+			if depth > 0 {
+				depth--
+			}
+		case ',':
+			if depth == 0 {
+				parts = append(parts, current.String())
+				current.Reset()
+				continue
+			}
+		}
+		current.WriteRune(r)
+	}
+	if current.Len() > 0 {
+		parts = append(parts, current.String())
+	}
+	return parts
+}
+
+func detectConnections(types map[string]gqlType) map[string]string {
+	connections := map[string]string{}
+	for name, typ := range types {
+		if !strings.HasSuffix(name, "Connection") || typ.Kind != "type" {
+			continue
+		}
+		hasNodes := false
+		hasPageInfo := false
+		entityName := ""
+
+		for _, field := range typ.Fields {
+			switch field.Name {
+			case "nodes":
+				hasNodes = true
+				entityName = unwrapType(field.Type)
+			case "pageInfo":
+				hasPageInfo = unwrapType(field.Type) == "PageInfo"
+			}
+		}
+
+		if hasNodes && hasPageInfo && entityName != "" {
+			connections[name] = entityName
+		}
+	}
+	return connections
+}
+
+func buildListEndpoint(field gqlField, entityName string, enumMap map[string][]string) spec.Endpoint {
+	params := mapArgs(field.Args, enumMap, false)
+	pagination := &spec.Pagination{
+		Type:           "cursor",
+		LimitParam:     "first",
+		CursorParam:    "after",
+		NextCursorPath: "data." + field.Name + ".pageInfo.endCursor",
+		HasMoreField:   "data." + field.Name + ".pageInfo.hasNextPage",
+	}
+	ensurePaginationParams(&params)
+
+	return spec.Endpoint{
+		Method:       "GET",
+		Path:         "/graphql",
+		Description:  "List " + resourceNameFromType(entityName),
+		Params:       params,
+		Response:     spec.ResponseDef{Type: "array", Item: entityName},
+		Pagination:   pagination,
+		ResponsePath: "data." + field.Name + ".nodes",
+	}
+}
+
+func buildGetEndpoint(field gqlField, entityName string, enumMap map[string][]string) spec.Endpoint {
+	params := mapArgs(field.Args, enumMap, true)
+	return spec.Endpoint{
+		Method:       "GET",
+		Path:         "/graphql",
+		Description:  "Get a single " + strings.ToLower(entityName),
+		Params:       params,
+		Response:     spec.ResponseDef{Type: "object", Item: entityName},
+		ResponsePath: "data." + field.Name,
+	}
+}
+
+func buildMutationEndpoint(field gqlField, action, entityName string, types map[string]gqlType, enumMap map[string][]string) spec.Endpoint {
+	method := mutationMethod(action)
+	params := mapMutationParams(field.Args, enumMap)
+	body := mapMutationBody(field.Args, types, enumMap)
+
+	return spec.Endpoint{
+		Method:       method,
+		Path:         "/graphql",
+		Description:  mutationDescription(action, entityName),
+		Params:       params,
+		Body:         body,
+		Response:     spec.ResponseDef{Type: "object", Item: entityName},
+		ResponsePath: "data." + field.Name,
+	}
+}
+
+func mutationMethod(action string) string {
+	switch action {
+	case "create":
+		return "POST"
+	case "update":
+		return "PATCH"
+	case "delete":
+		return "DELETE"
+	default:
+		return "POST"
+	}
+}
+
+func mutationDescription(action, entityName string) string {
+	switch action {
+	case "create":
+		return "Create a " + strings.ToLower(entityName)
+	case "update":
+		return "Update a " + strings.ToLower(entityName)
+	case "delete":
+		return "Delete a " + strings.ToLower(entityName)
+	default:
+		return strings.Title(action) + " " + strings.ToLower(entityName)
+	}
+}
+
+func mapArgs(args []gqlArg, enumMap map[string][]string, positionalID bool) []spec.Param {
+	params := make([]spec.Param, 0, len(args))
+	for _, arg := range args {
+		param := mapArg(arg, enumMap)
+		if positionalID && arg.Name == "id" {
+			param.Positional = true
+		}
+		params = append(params, param)
+	}
+	return params
+}
+
+func mapMutationParams(args []gqlArg, enumMap map[string][]string) []spec.Param {
+	var params []spec.Param
+	for _, arg := range args {
+		if strings.HasSuffix(unwrapType(arg.Type), "Input") {
+			continue
+		}
+		param := mapArg(arg, enumMap)
+		if arg.Name == "id" {
+			param.Positional = true
+		}
+		params = append(params, param)
+	}
+	return params
+}
+
+func mapMutationBody(args []gqlArg, types map[string]gqlType, enumMap map[string][]string) []spec.Param {
+	var body []spec.Param
+	for _, arg := range args {
+		inputName := unwrapType(arg.Type)
+		inputType, ok := types[inputName]
+		if !ok || inputType.Kind != "input" {
+			continue
+		}
+		for _, field := range inputType.Fields {
+			body = append(body, mapFieldToParam(field, enumMap))
+		}
+	}
+	return body
+}
+
+func mapArg(arg gqlArg, enumMap map[string][]string) spec.Param {
+	paramType, required, format := mapGraphQLType(arg.Type)
+	param := spec.Param{
+		Name:     arg.Name,
+		Type:     paramType,
+		Required: required,
+		Format:   format,
+	}
+	if enumValues := enumMap[unwrapType(arg.Type)]; len(enumValues) > 0 {
+		param.Enum = append([]string(nil), enumValues...)
+	}
+	return param
+}
+
+func mapFieldToParam(field gqlField, enumMap map[string][]string) spec.Param {
+	paramType, required, format := mapGraphQLType(field.Type)
+	param := spec.Param{
+		Name:     field.Name,
+		Type:     paramType,
+		Required: required,
+		Format:   format,
+	}
+	if enumValues := enumMap[unwrapType(field.Type)]; len(enumValues) > 0 {
+		param.Enum = append([]string(nil), enumValues...)
+	}
+	return param
+}
+
+func mapGraphQLType(typeRef string) (paramType string, required bool, format string) {
+	required = strings.HasSuffix(strings.TrimSpace(typeRef), "!")
+	base := unwrapType(typeRef)
+	if strings.Contains(typeRef, "[") {
+		return "array", required, ""
+	}
+
+	switch base {
+	case "String", "ID":
+		return "string", required, ""
+	case "Int":
+		return "integer", required, ""
+	case "Float":
+		return "number", required, ""
+	case "Boolean":
+		return "boolean", required, ""
+	case "DateTime":
+		return "string", required, "date-time"
+	default:
+		return "string", required, ""
+	}
+}
+
+func classifyMutation(field gqlField, types map[string]gqlType) (string, string) {
+	nameLower := strings.ToLower(field.Name)
+	action := ""
+	switch {
+	case strings.Contains(nameLower, "create"):
+		action = "create"
+	case strings.Contains(nameLower, "update"):
+		action = "update"
+	case strings.Contains(nameLower, "delete"), strings.Contains(nameLower, "archive"):
+		action = "delete"
+	default:
+		return "", ""
+	}
+
+	entityName := entityFromMutationInput(field, types)
+	if entityName == "" {
+		entityName = entityFromReturnType(field.Type, types)
+	}
+	return action, entityName
+}
+
+func entityFromMutationInput(field gqlField, types map[string]gqlType) string {
+	for _, arg := range field.Args {
+		inputName := unwrapType(arg.Type)
+		if !strings.HasSuffix(inputName, "Input") {
+			continue
+		}
+		trimmed := strings.TrimSuffix(inputName, "Input")
+		trimmed = strings.TrimSuffix(trimmed, "Create")
+		trimmed = strings.TrimSuffix(trimmed, "Update")
+		if _, ok := types[trimmed]; ok {
+			return trimmed
+		}
+	}
+	return ""
+}
+
+func entityFromReturnType(typeRef string, types map[string]gqlType) string {
+	name := unwrapType(typeRef)
+	if typ, ok := types[name]; ok {
+		for _, field := range typ.Fields {
+			fieldType := unwrapType(field.Type)
+			if isEntityType(fieldType, types) {
+				return fieldType
+			}
+		}
+	}
+	if isEntityType(name, types) {
+		return name
+	}
+	return ""
+}
+
+func isEntityType(name string, types map[string]gqlType) bool {
+	typ, ok := types[name]
+	if !ok || typ.Kind != "type" {
+		return false
+	}
+	return shouldExposeType(name, typ.Kind)
+}
+
+func shouldExposeType(name, kind string) bool {
+	if kind != "type" {
+		return false
+	}
+	switch name {
+	case "Query", "Mutation", "Subscription", "PageInfo":
+		return false
+	}
+	for _, suffix := range []string{"Connection", "Edge", "Payload", "Input"} {
+		if strings.HasSuffix(name, suffix) {
+			return false
+		}
+	}
+	return true
+}
+
+func buildTypeDef(typ gqlType, enumMap map[string][]string) spec.TypeDef {
+	fields := make([]spec.TypeField, 0, len(typ.Fields))
+	for _, field := range typ.Fields {
+		fieldType, _, _ := mapGraphQLType(field.Type)
+		if enumValues := enumMap[unwrapType(field.Type)]; len(enumValues) > 0 && fieldType == "string" {
+			fieldType = "string"
+		}
+		fields = append(fields, spec.TypeField{
+			Name: field.Name,
+			Type: fieldType,
+		})
+	}
+	return spec.TypeDef{Fields: fields}
+}
+
+func addSupportTypes(types map[string]gqlType, resourceEntities map[string]struct{}, dest map[string]spec.TypeDef) {
+	visited := map[string]struct{}{}
+	var walk func(string)
+	walk = func(typeName string) {
+		if _, ok := visited[typeName]; ok {
+			return
+		}
+		visited[typeName] = struct{}{}
+
+		typ, ok := types[typeName]
+		if !ok || !shouldExposeType(typeName, typ.Kind) {
+			return
+		}
+
+		if _, exists := dest[typeName]; !exists {
+			dest[typeName] = buildTypeDef(typ, nil)
+		}
+
+		for _, field := range typ.Fields {
+			ref := unwrapType(field.Type)
+			if shouldExposeType(ref, types[ref].Kind) {
+				walk(ref)
+			}
+		}
+	}
+
+	for typeName := range resourceEntities {
+		walk(typeName)
+	}
+}
+
+func addScalarTypes(scalars map[string]struct{}, dest map[string]spec.TypeDef) {
+	if len(scalars) == 0 {
+		return
+	}
+}
+
+func ensureEndpoints(endpoints map[string]spec.Endpoint) map[string]spec.Endpoint {
+	if endpoints == nil {
+		return map[string]spec.Endpoint{}
+	}
+	return endpoints
+}
+
+func ensurePaginationParams(params *[]spec.Param) {
+	if !hasParam(*params, "first") {
+		*params = append(*params, spec.Param{
+			Name:        "first",
+			Type:        "integer",
+			Default:     50,
+			Description: "Number of results per page",
+		})
+	}
+	if !hasParam(*params, "after") {
+		*params = append(*params, spec.Param{
+			Name:        "after",
+			Type:        "string",
+			Description: "Pagination cursor",
+		})
+	}
+}
+
+func hasParam(params []spec.Param, name string) bool {
+	for _, param := range params {
+		if param.Name == name {
+			return true
+		}
+	}
+	return false
+}
+
+func unwrapType(typeRef string) string {
+	typeRef = strings.TrimSpace(typeRef)
+	typeRef = strings.TrimSuffix(typeRef, "!")
+	typeRef = strings.TrimPrefix(typeRef, "[")
+	typeRef = strings.TrimSuffix(typeRef, "]")
+	typeRef = strings.TrimSuffix(typeRef, "!")
+	return strings.TrimSpace(typeRef)
+}
+
+func resourceNameForQuery(queryName, entityName string) string {
+	queryName = toKebabCase(queryName)
+	if queryName != "" && queryName != toKebabCase(entityName) {
+		return queryName
+	}
+	return resourceNameFromType(entityName)
+}
+
+func resourceNameFromType(typeName string) string {
+	words := splitWords(typeName)
+	if len(words) == 0 {
+		return strings.ToLower(typeName)
+	}
+
+	last := pluralize(strings.ToLower(words[len(words)-1]))
+	words[len(words)-1] = last
+	for i := range words[:len(words)-1] {
+		words[i] = strings.ToLower(words[i])
+	}
+	return strings.Join(words, "-")
+}
+
+func pluralize(s string) string {
+	switch {
+	case strings.HasSuffix(s, "y") && !strings.HasSuffix(s, "ay") && !strings.HasSuffix(s, "ey") && !strings.HasSuffix(s, "iy") && !strings.HasSuffix(s, "oy") && !strings.HasSuffix(s, "uy"):
+		return s[:len(s)-1] + "ies"
+	case strings.HasSuffix(s, "s"), strings.HasSuffix(s, "x"), strings.HasSuffix(s, "z"), strings.HasSuffix(s, "ch"), strings.HasSuffix(s, "sh"):
+		return s + "es"
+	default:
+		return s + "s"
+	}
+}
+
+func splitWords(s string) []string {
+	var words []string
+	var current strings.Builder
+
+	for i, r := range s {
+		if i > 0 && unicode.IsUpper(r) && current.Len() > 0 {
+			words = append(words, current.String())
+			current.Reset()
+		}
+		if r == '-' || r == '_' || unicode.IsSpace(r) {
+			if current.Len() > 0 {
+				words = append(words, current.String())
+				current.Reset()
+			}
+			continue
+		}
+		current.WriteRune(r)
+	}
+	if current.Len() > 0 {
+		words = append(words, current.String())
+	}
+	return words
+}
+
+func toKebabCase(s string) string {
+	words := splitWords(s)
+	for i := range words {
+		words[i] = strings.ToLower(words[i])
+	}
+	return strings.Join(words, "-")
+}
+
+func deriveAPIName(source string) string {
+	source = strings.TrimSpace(source)
+	if source == "" {
+		return "graphql-api"
+	}
+
+	lower := strings.ToLower(source)
+	for _, known := range []string{"linear", "github", "shopify"} {
+		if strings.Contains(lower, known) {
+			return known
+		}
+	}
+
+	base := filepath.Base(source)
+	base = strings.TrimSuffix(base, filepath.Ext(base))
+	if base == "" || base == "schema" || base == "graphql" || base == "spec" {
+		parent := filepath.Base(filepath.Dir(source))
+		if parent != "." && parent != "/" && parent != "" {
+			base = parent
+		}
+	}
+
+	base = strings.TrimSpace(base)
+	if base == "" {
+		return "graphql-api"
+	}
+	base = toKebabCase(base)
+	base = strings.Trim(base, "-")
+	if base == "" {
+		return "graphql-api"
+	}
+	return base
+}
+
+func knownGraphQLDefaults(name, source string) (string, spec.AuthConfig) {
+	match := strings.ToLower(name + " " + source)
+	switch {
+	case strings.Contains(match, "linear"):
+		return "https://api.linear.app/graphql", spec.AuthConfig{
+			Type:    "api_key",
+			Header:  "Authorization",
+			EnvVars: []string{"LINEAR_API_KEY"},
+		}
+	case strings.Contains(match, "github"):
+		return "https://api.github.com/graphql", spec.AuthConfig{
+			Type:    "bearer_token",
+			Header:  "Authorization",
+			EnvVars: []string{"GITHUB_TOKEN"},
+		}
+	case strings.Contains(match, "shopify"):
+		return "https://shopify.dev/graphql", spec.AuthConfig{
+			Type:    "api_key",
+			Header:  "X-Shopify-Access-Token",
+			EnvVars: []string{"SHOPIFY_ACCESS_TOKEN"},
+		}
+	default:
+		return "", spec.AuthConfig{}
+	}
+}
diff --git a/internal/graphql/parser_test.go b/internal/graphql/parser_test.go
new file mode 100644
index 00000000..0263db5b
--- /dev/null
+++ b/internal/graphql/parser_test.go
@@ -0,0 +1,182 @@
+package graphql
+
+import (
+	"testing"
+
+	"github.com/mvanhorn/cli-printing-press/internal/spec"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+const testSDL = `
+type Query {
+  issues(first: Int, after: String, filter: IssueFilter): IssueConnection!
+  issue(id: String!): Issue!
+  teams: TeamConnection!
+  viewer: User!
+}
+
+type Mutation {
+  issueCreate(input: IssueCreateInput!): IssuePayload!
+  issueUpdate(id: String!, input: IssueUpdateInput!): IssuePayload!
+  issueArchive(id: String!): IssueArchivePayload!
+}
+
+type Issue {
+  id: ID!
+  identifier: String!
+  title: String!
+  description: String
+  priority: Int!
+  state: WorkflowState!
+  assignee: User
+  team: Team!
+  createdAt: DateTime!
+  updatedAt: DateTime!
+}
+
+type IssueConnection {
+  nodes: [Issue!]!
+  pageInfo: PageInfo!
+}
+
+type Team {
+  id: ID!
+  name: String!
+  key: String!
+}
+
+type TeamConnection {
+  nodes: [Team!]!
+  pageInfo: PageInfo!
+}
+
+type User {
+  id: ID!
+  name: String!
+  email: String
+}
+
+type WorkflowState {
+  id: ID!
+  name: String!
+  type: String!
+}
+
+type PageInfo {
+  hasNextPage: Boolean!
+  endCursor: String
+}
+
+input IssueCreateInput {
+  title: String!
+  description: String
+  teamId: String!
+  priority: Int
+  assigneeId: String
+}
+
+input IssueUpdateInput {
+  title: String
+  description: String
+  priority: Int
+  assigneeId: String
+  stateId: String
+}
+
+type IssuePayload {
+  issue: Issue
+}
+
+type IssueArchivePayload {
+  entity: Issue
+}
+
+scalar DateTime
+`
+
+func TestParseSDLContent(t *testing.T) {
+	parsed, err := ParseSDLBytes("linear-schema.graphql", []byte(testSDL))
+	require.NoError(t, err)
+
+	assert.Equal(t, "linear", parsed.Name)
+	assert.Equal(t, "https://api.linear.app/graphql", parsed.BaseURL)
+	assert.Equal(t, "api_key", parsed.Auth.Type)
+	assert.Equal(t, []string{"LINEAR_API_KEY"}, parsed.Auth.EnvVars)
+
+	issues := parsed.Resources["issues"]
+	require.NotNil(t, issues.Endpoints)
+
+	list := issues.Endpoints["list"]
+	assert.Equal(t, "GET", list.Method)
+	assert.Equal(t, "/graphql", list.Path)
+	require.NotNil(t, list.Pagination)
+	assert.Equal(t, "cursor", list.Pagination.Type)
+	assert.Equal(t, "first", list.Pagination.LimitParam)
+	assert.Equal(t, "after", list.Pagination.CursorParam)
+	assert.Equal(t, "data.issues.nodes", list.ResponsePath)
+	assert.Equal(t, "Issue", list.Response.Item)
+
+	get := issues.Endpoints["get"]
+	assert.Equal(t, "GET", get.Method)
+	require.Len(t, get.Params, 1)
+	assert.Equal(t, "id", get.Params[0].Name)
+	assert.True(t, get.Params[0].Required)
+	assert.True(t, get.Params[0].Positional)
+
+	create := issues.Endpoints["create"]
+	assert.Equal(t, "POST", create.Method)
+	assert.ElementsMatch(t, []string{"title", "description", "teamId", "priority", "assigneeId"}, paramNames(create.Body))
+	assert.True(t, bodyParam(create.Body, "title").Required)
+	assert.True(t, bodyParam(create.Body, "teamId").Required)
+
+	update := issues.Endpoints["update"]
+	assert.Equal(t, "PATCH", update.Method)
+	require.Len(t, update.Params, 1)
+	assert.Equal(t, "id", update.Params[0].Name)
+	assert.True(t, update.Params[0].Positional)
+	assert.ElementsMatch(t, []string{"title", "description", "priority", "assigneeId", "stateId"}, paramNames(update.Body))
+
+	del := issues.Endpoints["delete"]
+	assert.Equal(t, "DELETE", del.Method)
+	require.Len(t, del.Params, 1)
+	assert.Equal(t, "id", del.Params[0].Name)
+	assert.True(t, del.Params[0].Positional)
+
+	_, hasIssues := parsed.Resources["issues"]
+	_, hasTeams := parsed.Resources["teams"]
+	_, hasUsers := parsed.Resources["users"]
+	_, hasIssueConnection := parsed.Resources["issue-connections"]
+	_, hasPageInfo := parsed.Resources["page-infos"]
+	assert.True(t, hasIssues)
+	assert.True(t, hasTeams)
+	assert.True(t, hasUsers)
+	assert.False(t, hasIssueConnection)
+	assert.False(t, hasPageInfo)
+
+	assert.Contains(t, parsed.Types, "Issue")
+	assert.Contains(t, parsed.Types, "Team")
+	assert.Contains(t, parsed.Types, "User")
+	assert.Contains(t, parsed.Types, "WorkflowState")
+	assert.NotContains(t, parsed.Types, "IssueConnection")
+	assert.NotContains(t, parsed.Types, "PageInfo")
+	assert.NotContains(t, parsed.Types, "IssueCreateInput")
+	assert.NotContains(t, parsed.Types, "IssuePayload")
+}
+
+func paramNames(params []spec.Param) []string {
+	names := make([]string, 0, len(params))
+	for _, param := range params {
+		names = append(names, param.Name)
+	}
+	return names
+}
+
+func bodyParam(params []spec.Param, name string) spec.Param {
+	for _, param := range params {
+		if param.Name == name {
+			return param
+		}
+	}
+	return spec.Param{}
+}
diff --git a/internal/openapi/detect.go b/internal/openapi/detect.go
index 93fc8e17..ee256a0c 100644
--- a/internal/openapi/detect.go
+++ b/internal/openapi/detect.go
@@ -1,6 +1,10 @@
 package openapi
 
-import "bytes"
+import (
+	"bytes"
+
+	"github.com/mvanhorn/cli-printing-press/internal/graphql"
+)
 
 func IsOpenAPI(data []byte) bool {
 	if len(data) == 0 {
@@ -21,3 +25,7 @@ func IsOpenAPI(data []byte) bool {
 
 	return false
 }
+
+func IsGraphQLSDL(data []byte) bool {
+	return graphql.IsGraphQLSDL(data)
+}

← 82bae3ee feat(dogfood): add mechanical CLI validation command  ·  back to Cli Printing Press  ·  feat(templates): agent-friendly error messages, truncation h 64c58d86 →