[object Object]

← back to Cli Printing Press

feat(cli): use local module path at generation, rewrite at publish (#63)

244c4845521378fa1e248ec34d3482469367f02d · 2026-03-29 21:18:00 -0700 · Trevin Chow

* feat(cli): use local module path at generation, rewrite at publish

Generated CLIs now use a bare module path (e.g., `notion-pp-cli`) instead
of `github.com/user/notion-pp-cli`. This avoids broken `go install` URLs
and defers the real module path to publish time.

- Add `modulePath` template function returning the CLI name
- Update all templates to use `{{modulePath}}` for Go import paths
- Add `--module-path` flag to `publish package` for rewriting go.mod
  and import paths to the configured library repo URL
- Add `RewriteModulePath` to pipeline package with tests
- Update publish skill with configurable `module_path_base` setting

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

* fix(cli): restrict module path rewrite to imports and ldflags only

RewriteModulePath was using strings.ReplaceAll on the bare CLI name,
which corrupted non-import strings like command Use fields, User-Agent
headers, and config paths. Now only replaces oldPath/internal/ patterns,
which precisely targets Go import paths and goreleaser ldflags.

Also extends file walk to .yaml/.yml files so .goreleaser.yaml ldflags
get the correct module path for release builds.

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

* fix(cli): rewrite README go install path at publish time

README template now uses {{modulePath}}/cmd/... for the go install line
instead of hardcoding github.com/{{.Owner}}/... which was inconsistent
with the staged module path after publish packaging.

RewriteModulePath now also replaces oldPath/cmd/ patterns and walks .md
files, so the README install instruction gets the correct published path.

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

* fix(cli): rewrite published README and repo paths

---------

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

Files touched

Diff

commit 244c4845521378fa1e248ec34d3482469367f02d
Author: Trevin Chow <trevin@trevinchow.com>
Date:   Sun Mar 29 21:18:00 2026 -0700

    feat(cli): use local module path at generation, rewrite at publish (#63)
    
    * feat(cli): use local module path at generation, rewrite at publish
    
    Generated CLIs now use a bare module path (e.g., `notion-pp-cli`) instead
    of `github.com/user/notion-pp-cli`. This avoids broken `go install` URLs
    and defers the real module path to publish time.
    
    - Add `modulePath` template function returning the CLI name
    - Update all templates to use `{{modulePath}}` for Go import paths
    - Add `--module-path` flag to `publish package` for rewriting go.mod
      and import paths to the configured library repo URL
    - Add `RewriteModulePath` to pipeline package with tests
    - Update publish skill with configurable `module_path_base` setting
    
    Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
    
    * fix(cli): restrict module path rewrite to imports and ldflags only
    
    RewriteModulePath was using strings.ReplaceAll on the bare CLI name,
    which corrupted non-import strings like command Use fields, User-Agent
    headers, and config paths. Now only replaces oldPath/internal/ patterns,
    which precisely targets Go import paths and goreleaser ldflags.
    
    Also extends file walk to .yaml/.yml files so .goreleaser.yaml ldflags
    get the correct module path for release builds.
    
    Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
    
    * fix(cli): rewrite README go install path at publish time
    
    README template now uses {{modulePath}}/cmd/... for the go install line
    instead of hardcoding github.com/{{.Owner}}/... which was inconsistent
    with the staged module path after publish packaging.
    
    RewriteModulePath now also replaces oldPath/cmd/ patterns and walks .md
    files, so the README install instruction gets the correct published path.
    
    Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
    
    * fix(cli): rewrite published README and repo paths
    
    ---------
    
    Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---
 internal/cli/publish.go                            |  21 ++-
 internal/generator/generator.go                    |   1 +
 internal/generator/generator_test.go               |  15 +-
 internal/generator/templates/analytics.go.tmpl     |   2 +-
 internal/generator/templates/auth.go.tmpl          |   2 +-
 internal/generator/templates/auth_simple.go.tmpl   |   2 +-
 .../generator/templates/channel_workflow.go.tmpl   |   2 +-
 internal/generator/templates/client.go.tmpl        |   2 +-
 internal/generator/templates/doctor.go.tmpl        |   2 +-
 internal/generator/templates/go.mod.tmpl           |   2 +-
 internal/generator/templates/goreleaser.yaml.tmpl  |   2 +-
 .../templates/insights/health_score.go.tmpl        |   2 +-
 .../generator/templates/insights/similar.go.tmpl   |   2 +-
 internal/generator/templates/main.go.tmpl          |   2 +-
 internal/generator/templates/main_mcp.go.tmpl      |   2 +-
 internal/generator/templates/mcp_tools.go.tmpl     |   6 +-
 internal/generator/templates/root.go.tmpl          |   4 +-
 internal/generator/templates/search.go.tmpl        |   2 +-
 internal/generator/templates/sync.go.tmpl          |   2 +-
 .../generator/templates/workflows/pm_load.go.tmpl  |   2 +-
 .../templates/workflows/pm_orphans.go.tmpl         |   2 +-
 .../generator/templates/workflows/pm_stale.go.tmpl |   2 +-
 internal/pipeline/modulepath.go                    | 116 ++++++++++++
 internal/pipeline/modulepath_test.go               | 199 +++++++++++++++++++++
 skills/printing-press-publish/SKILL.md             |  30 +++-
 25 files changed, 396 insertions(+), 30 deletions(-)

diff --git a/internal/cli/publish.go b/internal/cli/publish.go
index 0d016886..39031ba7 100644
--- a/internal/cli/publish.go
+++ b/internal/cli/publish.go
@@ -62,6 +62,7 @@ type PackageResult struct {
 	CLIName             string `json:"cli_name"`
 	APIName             string `json:"api_name"`
 	Category            string `json:"category"`
+	ModulePath          string `json:"module_path,omitempty"`
 	ManuscriptsIncluded bool   `json:"manuscripts_included"`
 	RunID               string `json:"run_id,omitempty"`
 }
@@ -131,6 +132,7 @@ func newPublishPackageCmd() *cobra.Command {
 	var dir string
 	var category string
 	var target string
+	var modulePath string
 	var asJSON bool
 
 	cmd := &cobra.Command{
@@ -205,12 +207,22 @@ func newPublishPackageCmd() *cobra.Command {
 				return &ExitError{Code: ExitPublishError, Err: fmt.Errorf("copying CLI: %w", err)}
 			}
 
+			// Rewrite go.mod module path if --module-path is set
+			if modulePath != "" {
+				oldModPath := cliName // generated CLIs use bare CLI name as module path
+				if err := pipeline.RewriteModulePath(stagingCLIDir, oldModPath, modulePath); err != nil {
+					cleanupTarget()
+					return &ExitError{Code: ExitPublishError, Err: fmt.Errorf("rewriting module path: %w", err)}
+				}
+			}
+
 			// Resolve and copy manuscripts
 			result := PackageResult{
-				StagedDir: stagingCLIDir,
-				CLIName:   cliName,
-				APIName:   vResult.APIName,
-				Category:  category,
+				StagedDir:  stagingCLIDir,
+				CLIName:    cliName,
+				APIName:    vResult.APIName,
+				Category:   category,
+				ModulePath: modulePath,
 			}
 
 			apiName := vResult.APIName
@@ -254,6 +266,7 @@ func newPublishPackageCmd() *cobra.Command {
 	cmd.Flags().StringVar(&dir, "dir", "", "CLI directory to package (required)")
 	cmd.Flags().StringVar(&category, "category", "", "Category for the CLI (required)")
 	cmd.Flags().StringVar(&target, "target", "", "Staging directory to create (required)")
+	cmd.Flags().StringVar(&modulePath, "module-path", "", "Go module path to set (e.g., github.com/org/repo/library/category/cli-name)")
 	cmd.Flags().BoolVar(&asJSON, "json", false, "Output as JSON")
 
 	return cmd
diff --git a/internal/generator/generator.go b/internal/generator/generator.go
index 6fa29301..5e1d245a 100644
--- a/internal/generator/generator.go
+++ b/internal/generator/generator.go
@@ -84,6 +84,7 @@ func New(s *spec.APISpec, outputDir string) *Generator {
 		"safeTypeName":       safeTypeName,
 		"exampleLine":        g.exampleLine,
 		"currentYear":        func() string { return strconv.Itoa(time.Now().Year()) },
+		"modulePath":         func() string { return naming.CLI(s.Name) },
 	}
 	return g
 }
diff --git a/internal/generator/generator_test.go b/internal/generator/generator_test.go
index ed60b89d..f3cbf4ef 100644
--- a/internal/generator/generator_test.go
+++ b/internal/generator/generator_test.go
@@ -187,7 +187,15 @@ func TestGenerateWithOwnerField(t *testing.T) {
 
 	gomod, err := os.ReadFile(filepath.Join(outputDir, "go.mod"))
 	require.NoError(t, err)
-	assert.Contains(t, string(gomod), "github.com/testowner/")
+	// Module path uses bare CLI name (no github.com/owner prefix)
+	assert.Contains(t, string(gomod), "module owned-pp-cli")
+	// Owner is still used for copyright
+	mainGo, err := os.ReadFile(filepath.Join(outputDir, "cmd", "owned-pp-cli", "main.go"))
+	require.NoError(t, err)
+	assert.Contains(t, string(mainGo), "testowner")
+	readme, err := os.ReadFile(filepath.Join(outputDir, "README.md"))
+	require.NoError(t, err)
+	assert.Contains(t, string(readme), "go install github.com/testowner/owned-pp-cli/cmd/owned-pp-cli@latest")
 }
 
 func TestGenerateWithEmptyOwner(t *testing.T) {
@@ -228,7 +236,10 @@ func TestGenerateWithEmptyOwner(t *testing.T) {
 
 	gomod, err := os.ReadFile(filepath.Join(outputDir, "go.mod"))
 	require.NoError(t, err)
-	assert.Contains(t, string(gomod), "github.com/")
+	// Module path uses bare CLI name regardless of Owner
+	assert.Contains(t, string(gomod), "module unowned-pp-cli")
+	// Module line should not have a github.com prefix
+	assert.NotContains(t, string(gomod), "module github.com/")
 }
 
 // --- Unit 7: Feature Verification Tests ---
diff --git a/internal/generator/templates/analytics.go.tmpl b/internal/generator/templates/analytics.go.tmpl
index c5e70716..5e18a2bb 100644
--- a/internal/generator/templates/analytics.go.tmpl
+++ b/internal/generator/templates/analytics.go.tmpl
@@ -10,7 +10,7 @@ import (
 	"path/filepath"
 	"sort"
 
-	"github.com/{{.Owner}}/{{.Name}}-pp-cli/internal/store"
+	"{{modulePath}}/internal/store"
 	"github.com/spf13/cobra"
 )
 
diff --git a/internal/generator/templates/auth.go.tmpl b/internal/generator/templates/auth.go.tmpl
index 76a28359..21ec0b02 100644
--- a/internal/generator/templates/auth.go.tmpl
+++ b/internal/generator/templates/auth.go.tmpl
@@ -18,7 +18,7 @@ import (
 	"strings"
 	"time"
 
-	"github.com/{{.Owner}}/{{.Name}}-pp-cli/internal/config"
+	"{{modulePath}}/internal/config"
 	"github.com/spf13/cobra"
 )
 
diff --git a/internal/generator/templates/auth_simple.go.tmpl b/internal/generator/templates/auth_simple.go.tmpl
index 4c5bef36..4d1decc6 100644
--- a/internal/generator/templates/auth_simple.go.tmpl
+++ b/internal/generator/templates/auth_simple.go.tmpl
@@ -9,7 +9,7 @@ import (
 	"os"
 {{- end}}
 
-	"github.com/{{.Owner}}/{{.Name}}-pp-cli/internal/config"
+	"{{modulePath}}/internal/config"
 	"github.com/spf13/cobra"
 )
 
diff --git a/internal/generator/templates/channel_workflow.go.tmpl b/internal/generator/templates/channel_workflow.go.tmpl
index d24caa7b..d8619527 100644
--- a/internal/generator/templates/channel_workflow.go.tmpl
+++ b/internal/generator/templates/channel_workflow.go.tmpl
@@ -10,7 +10,7 @@ import (
 	"path/filepath"
 	"time"
 
-	"github.com/{{.Owner}}/{{.Name}}-pp-cli/internal/store"
+	"{{modulePath}}/internal/store"
 	"github.com/spf13/cobra"
 )
 
diff --git a/internal/generator/templates/client.go.tmpl b/internal/generator/templates/client.go.tmpl
index f10cd9a7..98c8e12e 100644
--- a/internal/generator/templates/client.go.tmpl
+++ b/internal/generator/templates/client.go.tmpl
@@ -19,7 +19,7 @@ import (
 	"sync"
 	"time"
 
-	"github.com/{{.Owner}}/{{.Name}}-pp-cli/internal/config"
+	"{{modulePath}}/internal/config"
 )
 
 type Client struct {
diff --git a/internal/generator/templates/doctor.go.tmpl b/internal/generator/templates/doctor.go.tmpl
index 8f28faa0..d89e7434 100644
--- a/internal/generator/templates/doctor.go.tmpl
+++ b/internal/generator/templates/doctor.go.tmpl
@@ -12,7 +12,7 @@ import (
 	"strings"
 	"time"
 
-	"github.com/{{.Owner}}/{{.Name}}-pp-cli/internal/config"
+	"{{modulePath}}/internal/config"
 	"github.com/spf13/cobra"
 )
 
diff --git a/internal/generator/templates/go.mod.tmpl b/internal/generator/templates/go.mod.tmpl
index fcd2421c..0f975573 100644
--- a/internal/generator/templates/go.mod.tmpl
+++ b/internal/generator/templates/go.mod.tmpl
@@ -1,4 +1,4 @@
-module github.com/{{.Owner}}/{{.Name}}-pp-cli
+module {{modulePath}}
 
 go 1.23
 
diff --git a/internal/generator/templates/goreleaser.yaml.tmpl b/internal/generator/templates/goreleaser.yaml.tmpl
index b1526a8d..5c02a392 100644
--- a/internal/generator/templates/goreleaser.yaml.tmpl
+++ b/internal/generator/templates/goreleaser.yaml.tmpl
@@ -9,7 +9,7 @@ builds:
     env:
       - CGO_ENABLED=0
     ldflags:
-      - -s -w -X github.com/{{.Owner}}/{{.Name}}-pp-cli/internal/cli.version={{"{{"}} .Version {{"}}"}}
+      - -s -w -X {{modulePath}}/internal/cli.version={{"{{"}} .Version {{"}}"}}
     targets:
       - darwin_amd64
       - darwin_arm64
diff --git a/internal/generator/templates/insights/health_score.go.tmpl b/internal/generator/templates/insights/health_score.go.tmpl
index 47c4eb24..2aa76dbb 100644
--- a/internal/generator/templates/insights/health_score.go.tmpl
+++ b/internal/generator/templates/insights/health_score.go.tmpl
@@ -10,7 +10,7 @@ import (
 	"path/filepath"
 	"time"
 
-	"github.com/{{.Owner}}/{{.Name}}-pp-cli/internal/store"
+	"{{modulePath}}/internal/store"
 	"github.com/spf13/cobra"
 )
 
diff --git a/internal/generator/templates/insights/similar.go.tmpl b/internal/generator/templates/insights/similar.go.tmpl
index f520da99..b31468aa 100644
--- a/internal/generator/templates/insights/similar.go.tmpl
+++ b/internal/generator/templates/insights/similar.go.tmpl
@@ -9,7 +9,7 @@ import (
 	"os"
 	"path/filepath"
 
-	"github.com/{{.Owner}}/{{.Name}}-pp-cli/internal/store"
+	"{{modulePath}}/internal/store"
 	"github.com/spf13/cobra"
 )
 
diff --git a/internal/generator/templates/main.go.tmpl b/internal/generator/templates/main.go.tmpl
index 49527b8e..8cef8696 100644
--- a/internal/generator/templates/main.go.tmpl
+++ b/internal/generator/templates/main.go.tmpl
@@ -7,7 +7,7 @@ import (
 	"fmt"
 	"os"
 
-	"github.com/{{.Owner}}/{{.Name}}-pp-cli/internal/cli"
+	"{{modulePath}}/internal/cli"
 )
 
 func main() {
diff --git a/internal/generator/templates/main_mcp.go.tmpl b/internal/generator/templates/main_mcp.go.tmpl
index 6ee51c47..80161b23 100644
--- a/internal/generator/templates/main_mcp.go.tmpl
+++ b/internal/generator/templates/main_mcp.go.tmpl
@@ -8,7 +8,7 @@ import (
 	"os"
 
 	"github.com/mark3labs/mcp-go/server"
-	mcptools "github.com/{{.Owner}}/{{.Name}}-pp-cli/internal/mcp"
+	mcptools "{{modulePath}}/internal/mcp"
 )
 
 func main() {
diff --git a/internal/generator/templates/mcp_tools.go.tmpl b/internal/generator/templates/mcp_tools.go.tmpl
index 3dd92747..4defc890 100644
--- a/internal/generator/templates/mcp_tools.go.tmpl
+++ b/internal/generator/templates/mcp_tools.go.tmpl
@@ -14,10 +14,10 @@ import (
 
 	mcplib "github.com/mark3labs/mcp-go/mcp"
 	"github.com/mark3labs/mcp-go/server"
-	"github.com/{{.Owner}}/{{.Name}}-pp-cli/internal/client"
-	"github.com/{{.Owner}}/{{.Name}}-pp-cli/internal/config"
+	"{{modulePath}}/internal/client"
+	"{{modulePath}}/internal/config"
 {{- if .VisionSet.Store}}
-	"github.com/{{.Owner}}/{{.Name}}-pp-cli/internal/store"
+	"{{modulePath}}/internal/store"
 {{- end}}
 )
 
diff --git a/internal/generator/templates/root.go.tmpl b/internal/generator/templates/root.go.tmpl
index 7dc403ea..29dfbd67 100644
--- a/internal/generator/templates/root.go.tmpl
+++ b/internal/generator/templates/root.go.tmpl
@@ -10,8 +10,8 @@ import (
 	"text/tabwriter"
 	"time"
 
-	"github.com/{{.Owner}}/{{.Name}}-pp-cli/internal/client"
-	"github.com/{{.Owner}}/{{.Name}}-pp-cli/internal/config"
+	"{{modulePath}}/internal/client"
+	"{{modulePath}}/internal/config"
 	"github.com/spf13/cobra"
 )
 
diff --git a/internal/generator/templates/search.go.tmpl b/internal/generator/templates/search.go.tmpl
index 3d080c32..65626478 100644
--- a/internal/generator/templates/search.go.tmpl
+++ b/internal/generator/templates/search.go.tmpl
@@ -10,7 +10,7 @@ import (
 	"path/filepath"
 	"strings"
 
-	"github.com/{{.Owner}}/{{.Name}}-pp-cli/internal/store"
+	"{{modulePath}}/internal/store"
 	"github.com/spf13/cobra"
 )
 
diff --git a/internal/generator/templates/sync.go.tmpl b/internal/generator/templates/sync.go.tmpl
index cac31a91..b5802f1d 100644
--- a/internal/generator/templates/sync.go.tmpl
+++ b/internal/generator/templates/sync.go.tmpl
@@ -15,7 +15,7 @@ import (
 	"sync/atomic"
 	"time"
 
-	"github.com/{{.Owner}}/{{.Name}}-pp-cli/internal/store"
+	"{{modulePath}}/internal/store"
 	"github.com/spf13/cobra"
 )
 
diff --git a/internal/generator/templates/workflows/pm_load.go.tmpl b/internal/generator/templates/workflows/pm_load.go.tmpl
index 18bbfe5f..c2c2b18e 100644
--- a/internal/generator/templates/workflows/pm_load.go.tmpl
+++ b/internal/generator/templates/workflows/pm_load.go.tmpl
@@ -10,7 +10,7 @@ import (
 	"path/filepath"
 	"sort"
 
-	"github.com/{{.Owner}}/{{.Name}}-pp-cli/internal/store"
+	"{{modulePath}}/internal/store"
 	"github.com/spf13/cobra"
 )
 
diff --git a/internal/generator/templates/workflows/pm_orphans.go.tmpl b/internal/generator/templates/workflows/pm_orphans.go.tmpl
index fefe3b18..16e9b5bc 100644
--- a/internal/generator/templates/workflows/pm_orphans.go.tmpl
+++ b/internal/generator/templates/workflows/pm_orphans.go.tmpl
@@ -10,7 +10,7 @@ import (
 	"path/filepath"
 	"strings"
 
-	"github.com/{{.Owner}}/{{.Name}}-pp-cli/internal/store"
+	"{{modulePath}}/internal/store"
 	"github.com/spf13/cobra"
 )
 
diff --git a/internal/generator/templates/workflows/pm_stale.go.tmpl b/internal/generator/templates/workflows/pm_stale.go.tmpl
index c6850e0c..24ea4ea6 100644
--- a/internal/generator/templates/workflows/pm_stale.go.tmpl
+++ b/internal/generator/templates/workflows/pm_stale.go.tmpl
@@ -10,7 +10,7 @@ import (
 	"path/filepath"
 	"time"
 
-	"github.com/{{.Owner}}/{{.Name}}-pp-cli/internal/store"
+	"{{modulePath}}/internal/store"
 	"github.com/spf13/cobra"
 )
 
diff --git a/internal/pipeline/modulepath.go b/internal/pipeline/modulepath.go
new file mode 100644
index 00000000..9d4daa8e
--- /dev/null
+++ b/internal/pipeline/modulepath.go
@@ -0,0 +1,116 @@
+package pipeline
+
+import (
+	"fmt"
+	"os"
+	"path/filepath"
+	"regexp"
+	"strings"
+)
+
+// rewriteExtensions lists file extensions that may contain Go import paths,
+// module-path references (e.g., goreleaser ldflags), or install instructions.
+var rewriteExtensions = []string{".go", ".yaml", ".yml", ".md"}
+
+// RewriteModulePath replaces the Go module path in a CLI directory.
+// It rewrites the module declaration in go.mod and import paths
+// (oldPath/internal/...) in .go and .yaml files from oldPath to newPath.
+//
+// Only import-style references (oldPath + "/internal/") are replaced in
+// source files. Bare CLI name occurrences in command strings, User-Agent
+// headers, and config paths are intentionally left untouched.
+func RewriteModulePath(dir, oldPath, newPath string) error {
+	if oldPath == newPath {
+		return nil
+	}
+
+	// Rewrite go.mod module line
+	gomodPath := filepath.Join(dir, "go.mod")
+	gomod, err := os.ReadFile(gomodPath)
+	if err != nil {
+		return fmt.Errorf("reading go.mod: %w", err)
+	}
+
+	oldModule := "module " + oldPath
+	newModule := "module " + newPath
+	updated := strings.Replace(string(gomod), oldModule, newModule, 1)
+	if updated == string(gomod) {
+		return fmt.Errorf("go.mod does not contain expected module path %q", oldPath)
+	}
+	if err := os.WriteFile(gomodPath, []byte(updated), 0o644); err != nil {
+		return fmt.Errorf("writing go.mod: %w", err)
+	}
+
+	// Only replace subpath references: oldPath/internal/... and oldPath/cmd/...
+	// This avoids corrupting command Use strings, User-Agent headers,
+	// config paths, and other runtime literals that contain the CLI name.
+	replacements := []struct{ old, new string }{
+		{oldPath + "/internal/", newPath + "/internal/"}, // Go imports, goreleaser ldflags
+	}
+
+	return filepath.WalkDir(dir, func(path string, d os.DirEntry, walkErr error) error {
+		if walkErr != nil {
+			return walkErr
+		}
+		if d.IsDir() {
+			return nil
+		}
+
+		if !hasRewriteExtension(path) {
+			return nil
+		}
+
+		content, err := os.ReadFile(path)
+		if err != nil {
+			return fmt.Errorf("reading %s: %w", path, err)
+		}
+
+		result := string(content)
+		for _, r := range replacements {
+			result = strings.ReplaceAll(result, r.old, r.new)
+		}
+		result = rewriteModuleInstallPaths(result, oldPath, newPath)
+		result = rewriteGitHubRepoURLs(result, oldPath, newPath)
+		if result == string(content) {
+			return nil // no changes needed
+		}
+
+		return os.WriteFile(path, []byte(result), 0o644)
+	})
+}
+
+func hasRewriteExtension(path string) bool {
+	for _, ext := range rewriteExtensions {
+		if strings.HasSuffix(path, ext) {
+			return true
+		}
+	}
+	return false
+}
+
+func rewriteModuleInstallPaths(content, oldPath, newPath string) string {
+	pattern := regexp.MustCompile(`(?:github\.com/[^/\s"]+/)?` + regexp.QuoteMeta(oldPath) + `/cmd/`)
+	return pattern.ReplaceAllString(content, newPath+"/cmd/")
+}
+
+func rewriteGitHubRepoURLs(content, oldPath, newPath string) string {
+	newRepoURL, ok := githubRepoURL(newPath)
+	if !ok {
+		return content
+	}
+
+	releasesPattern := regexp.MustCompile(`https://github\.com/[^/\s"]+/` + regexp.QuoteMeta(oldPath) + `/releases\b`)
+	content = releasesPattern.ReplaceAllString(content, newRepoURL+"/releases")
+
+	repoPattern := regexp.MustCompile(`https://github\.com/[^/\s"]+/` + regexp.QuoteMeta(oldPath) + `\b`)
+	return repoPattern.ReplaceAllString(content, newRepoURL)
+}
+
+func githubRepoURL(modulePath string) (string, bool) {
+	parts := strings.Split(modulePath, "/")
+	if len(parts) < 3 || parts[0] != "github.com" {
+		return "", false
+	}
+
+	return "https://" + strings.Join(parts[:3], "/"), true
+}
diff --git a/internal/pipeline/modulepath_test.go b/internal/pipeline/modulepath_test.go
new file mode 100644
index 00000000..9877c7bf
--- /dev/null
+++ b/internal/pipeline/modulepath_test.go
@@ -0,0 +1,199 @@
+package pipeline
+
+import (
+	"os"
+	"path/filepath"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+func TestRewriteModulePath(t *testing.T) {
+	t.Parallel()
+
+	t.Run("rewrites go.mod and go imports", func(t *testing.T) {
+		dir := t.TempDir()
+
+		// Write a go.mod with the old module path
+		gomod := "module notion-pp-cli\n\ngo 1.23\n"
+		require.NoError(t, os.WriteFile(filepath.Join(dir, "go.mod"), []byte(gomod), 0o644))
+
+		// Write a .go file with import paths
+		goFile := `package main
+
+import (
+	"notion-pp-cli/internal/cli"
+	"notion-pp-cli/internal/config"
+)
+
+func main() {}
+`
+		require.NoError(t, os.MkdirAll(filepath.Join(dir, "cmd", "notion-pp-cli"), 0o755))
+		require.NoError(t, os.WriteFile(filepath.Join(dir, "cmd", "notion-pp-cli", "main.go"), []byte(goFile), 0o644))
+
+		err := RewriteModulePath(dir, "notion-pp-cli", "github.com/mvanhorn/printing-press-library/library/productivity/notion-pp-cli")
+		require.NoError(t, err)
+
+		// Check go.mod
+		updatedMod, err := os.ReadFile(filepath.Join(dir, "go.mod"))
+		require.NoError(t, err)
+		assert.Contains(t, string(updatedMod), "module github.com/mvanhorn/printing-press-library/library/productivity/notion-pp-cli")
+		assert.NotContains(t, string(updatedMod), "module notion-pp-cli\n")
+
+		// Check .go file imports
+		updatedGo, err := os.ReadFile(filepath.Join(dir, "cmd", "notion-pp-cli", "main.go"))
+		require.NoError(t, err)
+		assert.Contains(t, string(updatedGo), `"github.com/mvanhorn/printing-press-library/library/productivity/notion-pp-cli/internal/cli"`)
+		assert.Contains(t, string(updatedGo), `"github.com/mvanhorn/printing-press-library/library/productivity/notion-pp-cli/internal/config"`)
+	})
+
+	t.Run("does not corrupt non-import strings", func(t *testing.T) {
+		dir := t.TempDir()
+
+		gomod := "module notion-pp-cli\n\ngo 1.23\n"
+		require.NoError(t, os.WriteFile(filepath.Join(dir, "go.mod"), []byte(gomod), 0o644))
+
+		// Simulate a generated root.go with both imports AND runtime literals
+		goFile := `package cli
+
+import (
+	"notion-pp-cli/internal/client"
+	"notion-pp-cli/internal/config"
+)
+
+var version = "0.1.0"
+
+func Execute() {
+	rootCmd := &cobra.Command{
+		Use:   "notion-pp-cli",
+		Short: "CLI for Notion API",
+	}
+	rootCmd.SetVersionTemplate("notion-pp-cli {{ .Version }}\n")
+}
+`
+		require.NoError(t, os.MkdirAll(filepath.Join(dir, "internal", "cli"), 0o755))
+		require.NoError(t, os.WriteFile(filepath.Join(dir, "internal", "cli", "root.go"), []byte(goFile), 0o644))
+
+		// Simulate a client.go with User-Agent
+		clientFile := `package client
+
+func (c *Client) do() {
+	req.Header.Set("User-Agent", "notion-pp-cli/0.1.0")
+}
+`
+		require.NoError(t, os.MkdirAll(filepath.Join(dir, "internal", "client"), 0o755))
+		require.NoError(t, os.WriteFile(filepath.Join(dir, "internal", "client", "client.go"), []byte(clientFile), 0o644))
+
+		newPath := "github.com/acme/library/notion-pp-cli"
+		err := RewriteModulePath(dir, "notion-pp-cli", newPath)
+		require.NoError(t, err)
+
+		// Imports should be rewritten
+		updatedRoot, err := os.ReadFile(filepath.Join(dir, "internal", "cli", "root.go"))
+		require.NoError(t, err)
+		rootContent := string(updatedRoot)
+		assert.Contains(t, rootContent, `"github.com/acme/library/notion-pp-cli/internal/client"`)
+		assert.Contains(t, rootContent, `"github.com/acme/library/notion-pp-cli/internal/config"`)
+
+		// Command Use string must NOT be rewritten
+		assert.Contains(t, rootContent, `Use:   "notion-pp-cli"`)
+		assert.NotContains(t, rootContent, `Use:   "github.com/acme/library/notion-pp-cli"`)
+
+		// Version template must NOT be rewritten
+		assert.Contains(t, rootContent, `"notion-pp-cli {{ .Version }}\n"`)
+
+		// User-Agent must NOT be rewritten
+		updatedClient, err := os.ReadFile(filepath.Join(dir, "internal", "client", "client.go"))
+		require.NoError(t, err)
+		assert.Contains(t, string(updatedClient), `"notion-pp-cli/0.1.0"`)
+	})
+
+	t.Run("rewrites goreleaser ldflags", func(t *testing.T) {
+		dir := t.TempDir()
+
+		gomod := "module notion-pp-cli\n\ngo 1.23\n"
+		require.NoError(t, os.WriteFile(filepath.Join(dir, "go.mod"), []byte(gomod), 0o644))
+
+		goreleaser := `version: 2
+builds:
+  - ldflags:
+      - -s -w -X notion-pp-cli/internal/cli.version={{ .Version }}
+`
+		require.NoError(t, os.WriteFile(filepath.Join(dir, ".goreleaser.yaml"), []byte(goreleaser), 0o644))
+
+		err := RewriteModulePath(dir, "notion-pp-cli", "github.com/acme/library/notion-pp-cli")
+		require.NoError(t, err)
+
+		updatedGR, err := os.ReadFile(filepath.Join(dir, ".goreleaser.yaml"))
+		require.NoError(t, err)
+		assert.Contains(t, string(updatedGR), "-X github.com/acme/library/notion-pp-cli/internal/cli.version=")
+		assert.NotContains(t, string(updatedGR), "-X notion-pp-cli/internal/cli.version=")
+	})
+
+	t.Run("rewrites README go install path", func(t *testing.T) {
+		dir := t.TempDir()
+
+		gomod := "module notion-pp-cli\n\ngo 1.23\n"
+		require.NoError(t, os.WriteFile(filepath.Join(dir, "go.mod"), []byte(gomod), 0o644))
+
+		readme := "# notion-pp-cli\n\n## Install\n\n```\ngo install github.com/testowner/notion-pp-cli/cmd/notion-pp-cli@latest\n```\n\n### Binary\n\nDownload from [Releases](https://github.com/testowner/notion-pp-cli/releases).\n\n```bash\nnotion-pp-cli doctor\nnotion-pp-cli users list\n```\n"
+		require.NoError(t, os.WriteFile(filepath.Join(dir, "README.md"), []byte(readme), 0o644))
+
+		err := RewriteModulePath(dir, "notion-pp-cli", "github.com/acme/library/notion-pp-cli")
+		require.NoError(t, err)
+
+		updatedReadme, err := os.ReadFile(filepath.Join(dir, "README.md"))
+		require.NoError(t, err)
+		content := string(updatedReadme)
+
+		// go install path should be rewritten
+		assert.Contains(t, content, "go install github.com/acme/library/notion-pp-cli/cmd/notion-pp-cli@latest")
+		assert.Contains(t, content, "https://github.com/acme/library/releases")
+
+		// Bare CLI name in usage examples must NOT be rewritten
+		assert.Contains(t, content, "notion-pp-cli doctor")
+		assert.Contains(t, content, "notion-pp-cli users list")
+		assert.Contains(t, content, "# notion-pp-cli")
+	})
+
+	t.Run("rewrites goreleaser homepage to published repo", func(t *testing.T) {
+		dir := t.TempDir()
+
+		gomod := "module notion-pp-cli\n\ngo 1.23\n"
+		require.NoError(t, os.WriteFile(filepath.Join(dir, "go.mod"), []byte(gomod), 0o644))
+
+		goreleaser := `version: 2
+brews:
+  - homepage: "https://github.com/testowner/notion-pp-cli"
+`
+		require.NoError(t, os.WriteFile(filepath.Join(dir, ".goreleaser.yaml"), []byte(goreleaser), 0o644))
+
+		err := RewriteModulePath(dir, "notion-pp-cli", "github.com/acme/library/notion-pp-cli")
+		require.NoError(t, err)
+
+		updatedGR, err := os.ReadFile(filepath.Join(dir, ".goreleaser.yaml"))
+		require.NoError(t, err)
+		assert.Contains(t, string(updatedGR), `homepage: "https://github.com/acme/library"`)
+		assert.NotContains(t, string(updatedGR), `homepage: "https://github.com/testowner/notion-pp-cli"`)
+	})
+
+	t.Run("noop when paths are equal", func(t *testing.T) {
+		dir := t.TempDir()
+		gomod := "module notion-pp-cli\n\ngo 1.23\n"
+		require.NoError(t, os.WriteFile(filepath.Join(dir, "go.mod"), []byte(gomod), 0o644))
+
+		err := RewriteModulePath(dir, "notion-pp-cli", "notion-pp-cli")
+		require.NoError(t, err)
+	})
+
+	t.Run("error when go.mod missing old path", func(t *testing.T) {
+		dir := t.TempDir()
+		gomod := "module other-cli\n\ngo 1.23\n"
+		require.NoError(t, os.WriteFile(filepath.Join(dir, "go.mod"), []byte(gomod), 0o644))
+
+		err := RewriteModulePath(dir, "notion-pp-cli", "github.com/org/repo/notion-pp-cli")
+		require.Error(t, err)
+		assert.Contains(t, err.Error(), "does not contain expected module path")
+	})
+}
diff --git a/skills/printing-press-publish/SKILL.md b/skills/printing-press-publish/SKILL.md
index c92a1d54..76e7049d 100644
--- a/skills/printing-press-publish/SKILL.md
+++ b/skills/printing-press-publish/SKILL.md
@@ -71,6 +71,22 @@ PUBLISH_REPO_DIR="$PRESS_HOME/.publish-repo"
 PUBLISH_CONFIG="$PRESS_HOME/.publish-config.json"
 ```
 
+### Publish config
+
+`$PUBLISH_CONFIG` stores persistent publish settings as JSON. On first publish, create it with defaults. The user can edit it to change the library repo or module path base.
+
+```json
+{
+  "repo_url": "https://github.com/mvanhorn/printing-press-library",
+  "access": "push",
+  "protocol": "ssh",
+  "clone_path": "~/printing-press/.publish-repo",
+  "module_path_base": "github.com/mvanhorn/printing-press-library/library"
+}
+```
+
+The `module_path_base` field sets the Go module path prefix for published CLIs. During packaging, the full module path is constructed as `<module_path_base>/<category>/<cli-name>`. If the user wants CLIs published to a different repo or path, they edit this field.
+
 ## Step 1: Prerequisites
 
 Verify `gh` is authenticated:
@@ -149,6 +165,14 @@ Save the `help_output` field from the result — it's used in the PR description
 
 ## Step 5: Package
 
+Read `$PUBLISH_CONFIG` to get `module_path_base`. Construct the full module path:
+
+```
+MODULE_PATH="<module_path_base>/<category>/<cli-name>"
+```
+
+For example: `github.com/mvanhorn/printing-press-library/library/productivity/notion-pp-cli`
+
 Create a temporary staging directory and run:
 
 ```bash
@@ -156,10 +180,11 @@ printing-press publish package \
   --dir <cli-dir> \
   --category <category> \
   --target <staging-dir> \
+  --module-path "$MODULE_PATH" \
   --json
 ```
 
-Parse the JSON result. Note the `staged_dir`, `manuscripts_included`, and `run_id`.
+Parse the JSON result. Note the `staged_dir`, `module_path`, `manuscripts_included`, and `run_id`. The `module_path` field confirms the Go module path that was set in the packaged CLI's `go.mod` and import paths.
 
 ## Step 6: Managed Clone
 
@@ -190,7 +215,8 @@ If `$PUBLISH_REPO_DIR` does not exist:
      "repo_url": "https://github.com/mvanhorn/printing-press-library",
      "access": "push",
      "protocol": "ssh",
-     "clone_path": "~/printing-press/.publish-repo"
+     "clone_path": "~/printing-press/.publish-repo",
+     "module_path_base": "github.com/mvanhorn/printing-press-library/library"
    }
    ```
    Write to `$PUBLISH_CONFIG`.

← bafe3563 fix(skills): require CLI description rewrite after generatio  ·  back to Cli Printing Press  ·  feat(cli): add proxy-envelope client pattern to generator (# f0bb0de2 →