[object Object]

← back to Cli Printing Press

fix(cli): write .printing-press.json manifest during generate command (#68)

154626ed337ba85fe92a6a61b1f6698e0b37a562 · 2026-03-30 01:42:29 -0700 · Trevin Chow

* fix(cli): write .printing-press.json manifest during generate command

The generate command wrote CLIs directly to ~/printing-press/library/
without creating the .printing-press.json manifest. The manifest was
only written by PublishWorkingCLI in the fullrun pipeline, which the
standalone generate command never calls. This caused publish validate
to fail with "missing .printing-press.json" for any skill-generated CLI.

Add WriteManifestForGenerate to the pipeline package and call it from
both code paths (--spec and --docs) in the generate command. Also
suppress redundant "validation failed" stderr when --json mode already
contains the structured failure details (Silent field on ExitError).

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

* fix(cli): gofmt struct field alignment in GenerateManifestParams

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

---------

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

Files touched

Diff

commit 154626ed337ba85fe92a6a61b1f6698e0b37a562
Author: Trevin Chow <trevin@trevinchow.com>
Date:   Mon Mar 30 01:42:29 2026 -0700

    fix(cli): write .printing-press.json manifest during generate command (#68)
    
    * fix(cli): write .printing-press.json manifest during generate command
    
    The generate command wrote CLIs directly to ~/printing-press/library/
    without creating the .printing-press.json manifest. The manifest was
    only written by PublishWorkingCLI in the fullrun pipeline, which the
    standalone generate command never calls. This caused publish validate
    to fail with "missing .printing-press.json" for any skill-generated CLI.
    
    Add WriteManifestForGenerate to the pipeline package and call it from
    both code paths (--spec and --docs) in the generate command. Also
    suppress redundant "validation failed" stderr when --json mode already
    contains the structured failure details (Silent field on ExitError).
    
    Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
    
    * fix(cli): gofmt struct field alignment in GenerateManifestParams
    
    Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
    
    ---------
    
    Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---
 cmd/printing-press/main.go            |  5 +-
 internal/cli/exitcodes.go             |  8 ++-
 internal/cli/publish.go               |  2 +-
 internal/cli/root.go                  | 16 ++++++
 internal/pipeline/climanifest.go      | 68 ++++++++++++++++++++++++++
 internal/pipeline/climanifest_test.go | 92 +++++++++++++++++++++++++++++++++++
 6 files changed, 187 insertions(+), 4 deletions(-)

diff --git a/cmd/printing-press/main.go b/cmd/printing-press/main.go
index b8f6268c..b4737c14 100644
--- a/cmd/printing-press/main.go
+++ b/cmd/printing-press/main.go
@@ -10,11 +10,14 @@ import (
 
 func main() {
 	if err := cli.Execute(); err != nil {
-		fmt.Fprintln(os.Stderr, err.Error())
 		var exitErr *cli.ExitError
 		if errors.As(err, &exitErr) {
+			if !exitErr.Silent {
+				fmt.Fprintln(os.Stderr, err.Error())
+			}
 			os.Exit(exitErr.Code)
 		}
+		fmt.Fprintln(os.Stderr, err.Error())
 		os.Exit(cli.ExitUnknownError)
 	}
 }
diff --git a/internal/cli/exitcodes.go b/internal/cli/exitcodes.go
index 9d7be347..0942fe8a 100644
--- a/internal/cli/exitcodes.go
+++ b/internal/cli/exitcodes.go
@@ -11,9 +11,13 @@ const (
 )
 
 // ExitError wraps an error with a specific exit code.
+// When Silent is true, main should exit with the code but not print the
+// error message — used when structured output (--json) already contains
+// the failure details.
 type ExitError struct {
-	Code int
-	Err  error
+	Code   int
+	Err    error
+	Silent bool
 }
 
 func (e *ExitError) Error() string { return e.Err.Error() }
diff --git a/internal/cli/publish.go b/internal/cli/publish.go
index 39031ba7..43dbb96a 100644
--- a/internal/cli/publish.go
+++ b/internal/cli/publish.go
@@ -90,7 +90,7 @@ func newPublishValidateCmd() *cobra.Command {
 					return encErr
 				}
 				if !result.Passed {
-					return &ExitError{Code: ExitPublishError, Err: fmt.Errorf("validation failed")}
+					return &ExitError{Code: ExitPublishError, Err: fmt.Errorf("validation failed"), Silent: true}
 				}
 				return nil
 			}
diff --git a/internal/cli/root.go b/internal/cli/root.go
index 01699954..18ec23a3 100644
--- a/internal/cli/root.go
+++ b/internal/cli/root.go
@@ -177,6 +177,14 @@ func newGenerateCmd() *cobra.Command {
 					}
 				}
 
+				if err := pipeline.WriteManifestForGenerate(pipeline.GenerateManifestParams{
+					APIName:   parsed.Name,
+					DocsURL:   docsURL,
+					OutputDir: absOut,
+				}); err != nil {
+					fmt.Fprintf(os.Stderr, "warning: could not write manifest: %v\n", err)
+				}
+
 				fmt.Fprintf(os.Stderr, "Generated %s at %s (from docs)\n", naming.CLI(parsed.Name), absOut)
 				if asJSON {
 					if err := json.NewEncoder(os.Stdout).Encode(map[string]interface{}{
@@ -309,6 +317,14 @@ func newGenerateCmd() *cobra.Command {
 				}
 			}
 
+			if err := pipeline.WriteManifestForGenerate(pipeline.GenerateManifestParams{
+				APIName:   apiSpec.Name,
+				SpecSrcs:  specFiles,
+				OutputDir: absOut,
+			}); err != nil {
+				fmt.Fprintf(os.Stderr, "warning: could not write manifest: %v\n", err)
+			}
+
 			fmt.Fprintf(os.Stderr, "Generated %s at %s\n", naming.CLI(apiSpec.Name), absOut)
 			if asJSON {
 				if err := json.NewEncoder(os.Stdout).Encode(map[string]interface{}{
diff --git a/internal/pipeline/climanifest.go b/internal/pipeline/climanifest.go
index 63b50ed4..4b90903b 100644
--- a/internal/pipeline/climanifest.go
+++ b/internal/pipeline/climanifest.go
@@ -7,9 +7,14 @@ import (
 	"fmt"
 	"os"
 	"path/filepath"
+	"strings"
 	"time"
 
+	"github.com/mvanhorn/cli-printing-press/catalog"
+	catalogpkg "github.com/mvanhorn/cli-printing-press/internal/catalog"
+	"github.com/mvanhorn/cli-printing-press/internal/naming"
 	"github.com/mvanhorn/cli-printing-press/internal/openapi"
+	"github.com/mvanhorn/cli-printing-press/internal/version"
 )
 
 // CLIManifestFilename is the name of the manifest file written to each
@@ -63,6 +68,69 @@ func specChecksum(path string) (string, error) {
 	return "sha256:" + hex.EncodeToString(h[:]), nil
 }
 
+// GenerateManifestParams holds the information available at generate time
+// for writing a CLI manifest. Unlike PublishWorkingCLI (which has full
+// PipelineState), the standalone generate command only knows the spec
+// sources and output directory.
+type GenerateManifestParams struct {
+	APIName   string
+	SpecSrcs  []string // --spec args (URLs or file paths)
+	DocsURL   string   // --docs URL, if used
+	OutputDir string
+}
+
+// WriteManifestForGenerate writes a .printing-press.json manifest into the
+// generated CLI directory. This is the generate-command counterpart of
+// writeCLIManifestForPublish (which operates on PipelineState).
+func WriteManifestForGenerate(p GenerateManifestParams) error {
+	m := CLIManifest{
+		SchemaVersion:        1,
+		GeneratedAt:          time.Now().UTC(),
+		PrintingPressVersion: version.Version,
+		APIName:              p.APIName,
+		CLIName:              naming.CLI(p.APIName),
+	}
+
+	// Populate spec_url / spec_path from the first spec source.
+	if p.DocsURL != "" {
+		m.SpecURL = p.DocsURL
+		m.SpecFormat = "docs"
+	} else if len(p.SpecSrcs) > 0 {
+		src := p.SpecSrcs[0]
+		if strings.HasPrefix(src, "http://") || strings.HasPrefix(src, "https://") {
+			m.SpecURL = src
+		} else {
+			m.SpecPath = src
+		}
+	}
+
+	// Detect format and checksum from any spec file cached in the output dir.
+	for _, name := range []string{"spec.json", "spec.yaml", "spec.yml"} {
+		specFile := filepath.Join(p.OutputDir, name)
+		data, err := os.ReadFile(specFile)
+		if err != nil {
+			continue
+		}
+		if m.SpecFormat == "" {
+			m.SpecFormat = detectSpecFormat(data)
+		}
+		cs, err := specChecksum(specFile)
+		if err == nil {
+			m.SpecChecksum = cs
+		}
+		break
+	}
+
+	// Look up catalog entry for category/description enrichment.
+	if entry, err := catalogpkg.LookupFS(catalog.FS, p.APIName); err == nil {
+		m.CatalogEntry = entry.Name
+		m.Category = entry.Category
+		m.Description = entry.Description
+	}
+
+	return WriteCLIManifest(p.OutputDir, m)
+}
+
 // detectSpecFormat examines the raw spec bytes and returns a format
 // string: "openapi3", "graphql", or "internal".
 func detectSpecFormat(data []byte) string {
diff --git a/internal/pipeline/climanifest_test.go b/internal/pipeline/climanifest_test.go
index 393a4f7f..1be0d36d 100644
--- a/internal/pipeline/climanifest_test.go
+++ b/internal/pipeline/climanifest_test.go
@@ -290,6 +290,98 @@ func TestPublishWorkingCLIManifestWithoutSpec(t *testing.T) {
 	assert.Empty(t, got.SpecFormat)
 }
 
+func TestWriteManifestForGenerateWithSpecURL(t *testing.T) {
+	dir := t.TempDir()
+
+	// Place an OpenAPI spec in the output dir so format/checksum are detected.
+	specContent := []byte(`{"openapi": "3.0.0", "info": {"title": "Test"}}`)
+	require.NoError(t, os.WriteFile(filepath.Join(dir, "spec.json"), specContent, 0o644))
+
+	err := WriteManifestForGenerate(GenerateManifestParams{
+		APIName:   "test-api",
+		SpecSrcs:  []string{"https://example.com/openapi.json"},
+		OutputDir: dir,
+	})
+	require.NoError(t, err)
+
+	data, err := os.ReadFile(filepath.Join(dir, CLIManifestFilename))
+	require.NoError(t, err)
+
+	var got CLIManifest
+	require.NoError(t, json.Unmarshal(data, &got))
+
+	assert.Equal(t, 1, got.SchemaVersion)
+	assert.Equal(t, "test-api", got.APIName)
+	assert.Equal(t, "test-api-pp-cli", got.CLIName)
+	assert.Equal(t, version.Version, got.PrintingPressVersion)
+	assert.Equal(t, "https://example.com/openapi.json", got.SpecURL)
+	assert.Empty(t, got.SpecPath)
+	assert.Equal(t, "openapi3", got.SpecFormat)
+	assert.NotEmpty(t, got.SpecChecksum)
+	assert.False(t, got.GeneratedAt.IsZero())
+}
+
+func TestWriteManifestForGenerateWithLocalSpec(t *testing.T) {
+	dir := t.TempDir()
+
+	err := WriteManifestForGenerate(GenerateManifestParams{
+		APIName:   "local-test",
+		SpecSrcs:  []string{"/tmp/my-spec.yaml"},
+		OutputDir: dir,
+	})
+	require.NoError(t, err)
+
+	data, err := os.ReadFile(filepath.Join(dir, CLIManifestFilename))
+	require.NoError(t, err)
+
+	var got CLIManifest
+	require.NoError(t, json.Unmarshal(data, &got))
+
+	assert.Empty(t, got.SpecURL, "local path should not appear in spec_url")
+	assert.Equal(t, "/tmp/my-spec.yaml", got.SpecPath)
+}
+
+func TestWriteManifestForGenerateWithDocsURL(t *testing.T) {
+	dir := t.TempDir()
+
+	err := WriteManifestForGenerate(GenerateManifestParams{
+		APIName:   "docs-api",
+		DocsURL:   "https://docs.example.com/api",
+		OutputDir: dir,
+	})
+	require.NoError(t, err)
+
+	data, err := os.ReadFile(filepath.Join(dir, CLIManifestFilename))
+	require.NoError(t, err)
+
+	var got CLIManifest
+	require.NoError(t, json.Unmarshal(data, &got))
+
+	assert.Equal(t, "https://docs.example.com/api", got.SpecURL)
+	assert.Equal(t, "docs", got.SpecFormat)
+}
+
+func TestWriteManifestForGenerateNoSpec(t *testing.T) {
+	dir := t.TempDir()
+
+	err := WriteManifestForGenerate(GenerateManifestParams{
+		APIName:   "bare-api",
+		OutputDir: dir,
+	})
+	require.NoError(t, err)
+
+	data, err := os.ReadFile(filepath.Join(dir, CLIManifestFilename))
+	require.NoError(t, err)
+
+	var got CLIManifest
+	require.NoError(t, json.Unmarshal(data, &got))
+
+	assert.Equal(t, "bare-api", got.APIName)
+	assert.Empty(t, got.SpecURL)
+	assert.Empty(t, got.SpecPath)
+	assert.Empty(t, got.SpecChecksum)
+}
+
 func TestDetectSpecFormat(t *testing.T) {
 	tests := []struct {
 		name     string

← 797049f5 fix(cli): add smart-default table output for POST endpoints  ·  back to Cli Printing Press  ·  fix(skills): add cd to publish-repo before gh pr create/edit 788a696d →