← 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
M cmd/printing-press/main.goM internal/cli/exitcodes.goM internal/cli/publish.goM internal/cli/root.goM internal/pipeline/climanifest.goM internal/pipeline/climanifest_test.go
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 →