[object Object]

← back to Cli Printing Press

fix(cli): research-dir state.json api_name overrides info.title-derived name (#1476)

a9ffa08f0d373b28406feb94acd6b1aacf11f819 · 2026-05-15 13:29:46 -0700 · Trevin Chow

When `generate --research-dir <dir>` ran with a state.json that recorded
`api_name: canvas`, the generator still derived the CLI/module name
from `cleanSpecName(info.title)` ("Canvas LMS API" -> `canvas-lms`).
The mismatch made `publish validate --help/--version` fail because the
manifest's `cli_name: canvas-pp-cli` pointed at a `cmd/canvas-pp-cli/`
directory that didn't exist; only `cmd/canvas-lms-pp-cli/` did.

Add `pipeline.LoadAPINameFromResearchDir(dir)` that reads
`<dir>/state.json` and returns the recorded `api_name`. Wire it into the
generate command's single-spec branch as an implicit `--name` override
that yields to an explicit `--name` flag. Behavior without
`--research-dir` is unchanged.

Closes #1375

Files touched

Diff

commit a9ffa08f0d373b28406feb94acd6b1aacf11f819
Author: Trevin Chow <trevin@trevinchow.com>
Date:   Fri May 15 13:29:46 2026 -0700

    fix(cli): research-dir state.json api_name overrides info.title-derived name (#1476)
    
    When `generate --research-dir <dir>` ran with a state.json that recorded
    `api_name: canvas`, the generator still derived the CLI/module name
    from `cleanSpecName(info.title)` ("Canvas LMS API" -> `canvas-lms`).
    The mismatch made `publish validate --help/--version` fail because the
    manifest's `cli_name: canvas-pp-cli` pointed at a `cmd/canvas-pp-cli/`
    directory that didn't exist; only `cmd/canvas-lms-pp-cli/` did.
    
    Add `pipeline.LoadAPINameFromResearchDir(dir)` that reads
    `<dir>/state.json` and returns the recorded `api_name`. Wire it into the
    generate command's single-spec branch as an implicit `--name` override
    that yields to an explicit `--name` flag. Behavior without
    `--research-dir` is unchanged.
    
    Closes #1375
---
 internal/cli/root.go                  | 10 ++++++-
 internal/pipeline/climanifest.go      | 26 ++++++++++++++++++
 internal/pipeline/climanifest_test.go | 50 +++++++++++++++++++++++++++++++++++
 3 files changed, 85 insertions(+), 1 deletion(-)

diff --git a/internal/cli/root.go b/internal/cli/root.go
index 1945a8e4..9debd614 100644
--- a/internal/cli/root.go
+++ b/internal/cli/root.go
@@ -304,9 +304,17 @@ func newGenerateCmd() *cobra.Command {
 			var apiSpec *spec.APISpec
 			if len(specs) == 1 {
 				apiSpec = specs[0]
-				// Override spec-derived name when --name is explicitly provided
+				// Override spec-derived name when --name is explicitly provided.
+				// When --name is empty but --research-dir points at a state.json
+				// whose api_name slug differs from the title-derived name (e.g.
+				// "Canvas LMS API" → `canvas-lms` vs the user's intended
+				// `canvas`), prefer the state.json slug so the generated
+				// cmd/<slug>-pp-cli matches what manifest/publish-validate look
+				// for. Explicit --name still wins.
 				if cliName != "" {
 					apiSpec.Name = cliName
+				} else if researchName := pipeline.LoadAPINameFromResearchDir(researchDir); researchName != "" {
+					apiSpec.Name = researchName
 				}
 			} else {
 				if cliName == "" {
diff --git a/internal/pipeline/climanifest.go b/internal/pipeline/climanifest.go
index 56fc218d..1780139d 100644
--- a/internal/pipeline/climanifest.go
+++ b/internal/pipeline/climanifest.go
@@ -560,6 +560,32 @@ func DeriveRunIDFromResearchDir(researchDir string) string {
 	return ""
 }
 
+// LoadAPINameFromResearchDir reads `<researchDir>/state.json` and returns the
+// recorded api_name slug, or "" when the file is absent, unreadable, malformed,
+// or has no api_name. The generate command uses this as an implicit --name
+// override so a spec whose `info.title` derives to something different from
+// the user's intended slug (e.g. "Canvas LMS API" vs `canvas`) still produces
+// the slug-keyed cmd/ directory the rest of the pipeline expects. Explicit
+// --name wins over this; an absent or unreadable state.json silently yields
+// to the title-derived default.
+func LoadAPINameFromResearchDir(researchDir string) string {
+	if researchDir == "" {
+		return ""
+	}
+	statePath := filepath.Join(researchDir, "state.json")
+	data, err := os.ReadFile(statePath)
+	if err != nil {
+		return ""
+	}
+	var probe struct {
+		APIName string `json:"api_name"`
+	}
+	if err := json.Unmarshal(data, &probe); err != nil {
+		return ""
+	}
+	return strings.TrimSpace(probe.APIName)
+}
+
 // WriteManifestForGenerate writes a .printing-press.json manifest into the
 // generated CLI directory. This is the generate-command counterpart of
 // writeCLIManifestForPublish (which operates on PipelineState).
diff --git a/internal/pipeline/climanifest_test.go b/internal/pipeline/climanifest_test.go
index d577ec25..a79ca9ea 100644
--- a/internal/pipeline/climanifest_test.go
+++ b/internal/pipeline/climanifest_test.go
@@ -637,6 +637,56 @@ func TestDeriveRunIDFromResearchDir(t *testing.T) {
 	}
 }
 
+func TestLoadAPINameFromResearchDir(t *testing.T) {
+	t.Parallel()
+
+	t.Run("returns api_name when state.json present", func(t *testing.T) {
+		t.Parallel()
+		dir := t.TempDir()
+		require.NoError(t, os.WriteFile(
+			filepath.Join(dir, "state.json"),
+			[]byte(`{"api_name":"canvas","run_id":"20260514-070718"}`),
+			0o644,
+		))
+		assert.Equal(t, "canvas", LoadAPINameFromResearchDir(dir))
+	})
+
+	t.Run("trims whitespace from api_name", func(t *testing.T) {
+		t.Parallel()
+		dir := t.TempDir()
+		require.NoError(t, os.WriteFile(
+			filepath.Join(dir, "state.json"),
+			[]byte(`{"api_name":"  canvas  "}`),
+			0o644,
+		))
+		assert.Equal(t, "canvas", LoadAPINameFromResearchDir(dir))
+	})
+
+	t.Run("empty string when researchDir empty", func(t *testing.T) {
+		t.Parallel()
+		assert.Equal(t, "", LoadAPINameFromResearchDir(""))
+	})
+
+	t.Run("empty string when state.json absent", func(t *testing.T) {
+		t.Parallel()
+		assert.Equal(t, "", LoadAPINameFromResearchDir(t.TempDir()))
+	})
+
+	t.Run("empty string when state.json malformed", func(t *testing.T) {
+		t.Parallel()
+		dir := t.TempDir()
+		require.NoError(t, os.WriteFile(filepath.Join(dir, "state.json"), []byte(`not json`), 0o644))
+		assert.Equal(t, "", LoadAPINameFromResearchDir(dir))
+	})
+
+	t.Run("empty string when api_name field missing", func(t *testing.T) {
+		t.Parallel()
+		dir := t.TempDir()
+		require.NoError(t, os.WriteFile(filepath.Join(dir, "state.json"), []byte(`{"run_id":"20260514-070718"}`), 0o644))
+		assert.Equal(t, "", LoadAPINameFromResearchDir(dir))
+	})
+}
+
 func TestArchiveRunArtifactsCopiesDiscovery(t *testing.T) {
 	home := setPressTestEnv(t)
 

← 272d8e83 fix(cli): preserve OpenAPI param casing in detected paginati  ·  back to Cli Printing Press  ·  docs(cli): canonicalize README install block + cross-repo sw 77b91be6 →