[object Object]

← back to Cli Printing Press

fix(cli): add --dest flag to publish package for direct repo writes (#111)

0993d60c5cc9a5206c7508c902b7a2c4326720df · 2026-04-02 02:46:16 -0700 · Trevin Chow

The publish skill's two-step flow (stage to temp dir, then shell `cp -r`
into the publish repo) silently dropped `.manuscripts` because the agent
deviated from the exact copy command. Replace with a single-step
`--dest` flag that writes CLI source and manuscripts directly into the
publish repo, eliminating the shell handoff.

Old CLI directories are stashed (renamed) before the copy and restored
on failure, so a failed package never leaves the repo without its
previous version.

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

Files touched

Diff

commit 0993d60c5cc9a5206c7508c902b7a2c4326720df
Author: Trevin Chow <trevin@trevinchow.com>
Date:   Thu Apr 2 02:46:16 2026 -0700

    fix(cli): add --dest flag to publish package for direct repo writes (#111)
    
    The publish skill's two-step flow (stage to temp dir, then shell `cp -r`
    into the publish repo) silently dropped `.manuscripts` because the agent
    deviated from the exact copy command. Replace with a single-step
    `--dest` flag that writes CLI source and manuscripts directly into the
    publish repo, eliminating the shell handoff.
    
    Old CLI directories are stashed (renamed) before the copy and restored
    on failure, so a failed package never leaves the repo without its
    previous version.
    
    Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---
 internal/cli/publish.go                | 203 ++++++++++++++++++++++++---------
 internal/cli/publish_test.go           | 135 +++++++++++++++++++++-
 skills/printing-press-publish/SKILL.md |  62 +++++-----
 3 files changed, 308 insertions(+), 92 deletions(-)

diff --git a/internal/cli/publish.go b/internal/cli/publish.go
index 6f25db9f..6dee784f 100644
--- a/internal/cli/publish.go
+++ b/internal/cli/publish.go
@@ -132,13 +132,18 @@ func newPublishPackageCmd() *cobra.Command {
 	var dir string
 	var category string
 	var target string
+	var dest string
 	var modulePath string
 	var asJSON bool
 
 	cmd := &cobra.Command{
-		Use:     "package",
-		Short:   "Package a CLI for publishing to the library repo",
-		Example: `  printing-press publish package --dir ~/printing-press/library/notion-pp-cli --category productivity --target /tmp/staging --json`,
+		Use:   "package",
+		Short: "Package a CLI for publishing to the library repo",
+		Example: `  # Stage into a new directory (for inspection)
+  printing-press publish package --dir ~/printing-press/library/notion-pp-cli --category productivity --target /tmp/staging --json
+
+  # Write directly into the publish repo (replaces old CLI, includes manuscripts)
+  printing-press publish package --dir ~/printing-press/library/notion-pp-cli --category productivity --dest ~/printing-press/.publish-repo --json`,
 		RunE: func(cmd *cobra.Command, args []string) error {
 			if dir == "" {
 				return &ExitError{Code: ExitInputError, Err: fmt.Errorf("--dir is required")}
@@ -155,13 +160,23 @@ func newPublishPackageCmd() *cobra.Command {
 					Err:  fmt.Errorf("--category must be one of: %s", strings.Join(catalogpkg.PublicCategories(), ", ")),
 				}
 			}
-			if target == "" {
-				return &ExitError{Code: ExitInputError, Err: fmt.Errorf("--target is required")}
+			if target == "" && dest == "" {
+				return &ExitError{Code: ExitInputError, Err: fmt.Errorf("--target or --dest is required")}
+			}
+			if target != "" && dest != "" {
+				return &ExitError{Code: ExitInputError, Err: fmt.Errorf("--target and --dest are mutually exclusive")}
 			}
 
-			// Check target doesn't exist (before expensive validation)
-			if _, err := os.Stat(target); err == nil {
-				return &ExitError{Code: ExitInputError, Err: fmt.Errorf("target directory already exists: %s", target)}
+			// Cheap existence checks before expensive validation
+			if target != "" {
+				if _, err := os.Stat(target); err == nil {
+					return &ExitError{Code: ExitInputError, Err: fmt.Errorf("target directory already exists: %s", target)}
+				}
+			}
+			if dest != "" {
+				if info, err := os.Stat(dest); err != nil || !info.IsDir() {
+					return &ExitError{Code: ExitInputError, Err: fmt.Errorf("--dest directory does not exist: %s", dest)}
+				}
 			}
 
 			// Re-validate before packaging
@@ -183,81 +198,80 @@ func newPublishPackageCmd() *cobra.Command {
 				cliName = filepath.Base(dir)
 			}
 
-			// Build staging structure: target/library/<category>/<cli-name>/
-			stagingCLIDir := filepath.Join(target, "library", category, cliName)
+			// Choose output mode: --dest writes directly, --target stages
+			var outCLIDir string
+			var rootDir string // root to clean up on failure
+			// stashedDirs holds old CLI dirs moved aside in --dest mode.
+			// Restored on failure, deleted on success.
+			var stashedDirs []stashedDir
+			if dest != "" {
+				rootDir = dest
+				outCLIDir = filepath.Join(dest, "library", category, cliName)
+
+				// Move existing CLI dirs aside (don't delete yet — restore on failure)
+				var err error
+				stashedDirs, err = stashExistingCLI(dest, cliName)
+				if err != nil {
+					return &ExitError{Code: ExitPublishError, Err: fmt.Errorf("stashing old CLI: %w", err)}
+				}
+			} else {
+				rootDir = target
+				outCLIDir = filepath.Join(target, "library", category, cliName)
+			}
 
-			// Verify the resolved path is actually under target (defense in depth)
-			absTarget, _ := filepath.Abs(target)
-			absStaging, _ := filepath.Abs(stagingCLIDir)
-			if !strings.HasPrefix(absStaging, absTarget+string(filepath.Separator)) {
-				return &ExitError{Code: ExitInputError, Err: fmt.Errorf("resolved staging path %s escapes target directory %s", absStaging, absTarget)}
+			// Verify the resolved path is actually under rootDir (defense in depth)
+			absRoot, _ := filepath.Abs(rootDir)
+			absOut, _ := filepath.Abs(outCLIDir)
+			if !strings.HasPrefix(absOut, absRoot+string(filepath.Separator)) {
+				restoreStashedDirs(stashedDirs)
+				return &ExitError{Code: ExitInputError, Err: fmt.Errorf("resolved output path %s escapes root directory %s", absOut, absRoot)}
 			}
 
-			cleanupTarget := func() {
-				_ = os.RemoveAll(target)
+			cleanupOnFailure := func() {
+				if dest != "" {
+					_ = os.RemoveAll(outCLIDir)
+					restoreStashedDirs(stashedDirs)
+				} else {
+					_ = os.RemoveAll(target)
+				}
 			}
 
-			if err := os.MkdirAll(filepath.Dir(stagingCLIDir), 0o755); err != nil {
-				return &ExitError{Code: ExitPublishError, Err: fmt.Errorf("creating staging dir: %w", err)}
+			if err := os.MkdirAll(filepath.Dir(outCLIDir), 0o755); err != nil {
+				cleanupOnFailure()
+				return &ExitError{Code: ExitPublishError, Err: fmt.Errorf("creating output dir: %w", err)}
 			}
 
 			// Copy CLI source
-			if err := pipeline.CopyDir(dir, stagingCLIDir); err != nil {
-				cleanupTarget()
+			if err := pipeline.CopyDir(dir, outCLIDir); err != nil {
+				cleanupOnFailure()
 				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()
+				if err := pipeline.RewriteModulePath(outCLIDir, oldModPath, modulePath); err != nil {
+					cleanupOnFailure()
 					return &ExitError{Code: ExitPublishError, Err: fmt.Errorf("rewriting module path: %w", err)}
 				}
 			}
 
 			// Resolve and copy manuscripts
 			result := PackageResult{
-				StagedDir:  stagingCLIDir,
+				StagedDir:  outCLIDir,
 				CLIName:    cliName,
 				APIName:    vResult.APIName,
 				Category:   category,
 				ModulePath: modulePath,
 			}
 
-			// Look for manuscripts: try CLI name first (new convention),
-			// then API name, then fuzzy resolve (backwards compatibility).
-			apiName := vResult.APIName
-			if apiName == "" {
-				apiName = naming.TrimCLISuffix(cliName)
-			}
-
-			msRoot := pipeline.PublishedManuscriptsRoot()
-			var msDir string
-			var runID string
-
-			// 1. Try CLI name (new convention: manuscripts/<cli-name>/<run>/)
-			cliMsDir := filepath.Join(msRoot, cliName)
-			if rid, err := findMostRecentRun(cliMsDir); err == nil && rid != "" {
-				msDir, runID = cliMsDir, rid
-			}
-			// 2. Try API name (old convention: manuscripts/<api-name>/<run>/)
-			if runID == "" {
-				apiMsDir := filepath.Join(msRoot, apiName)
-				if rid, err := findMostRecentRun(apiMsDir); err == nil && rid != "" {
-					msDir, runID = apiMsDir, rid
-				}
-			}
-			// 3. Fuzzy resolve (strip suffixes, prefix match)
-			if runID == "" {
-				msDir, runID = resolveManuscriptDir(msRoot, apiName)
-			}
+			msDir, runID := resolveManuscripts(cliName, vResult.APIName)
 			if runID != "" {
 				result.RunID = runID
 				srcMsDir := filepath.Join(msDir, runID)
-				dstMsDir := filepath.Join(stagingCLIDir, ".manuscripts", runID)
+				dstMsDir := filepath.Join(outCLIDir, ".manuscripts", runID)
 				if err := pipeline.CopyDir(srcMsDir, dstMsDir); err != nil {
-					cleanupTarget()
+					cleanupOnFailure()
 					return &ExitError{Code: ExitPublishError, Err: fmt.Errorf("copying manuscripts: %w", err)}
 				} else {
 					result.ManuscriptsIncluded = true
@@ -266,13 +280,16 @@ func newPublishPackageCmd() *cobra.Command {
 				fmt.Fprintln(os.Stderr, "warning: no manuscripts found, packaging without them")
 			}
 
+			// Success — remove stashed old CLI dirs
+			removeStashedDirs(stashedDirs)
+
 			if asJSON {
 				enc := json.NewEncoder(os.Stdout)
 				enc.SetIndent("", "  ")
 				return enc.Encode(result)
 			}
 
-			fmt.Fprintf(os.Stderr, "Packaged %s at %s\n", cliName, stagingCLIDir)
+			fmt.Fprintf(os.Stderr, "Packaged %s at %s\n", cliName, outCLIDir)
 			if result.ManuscriptsIncluded {
 				fmt.Fprintf(os.Stderr, "  Manuscripts: %s (run %s)\n", ".manuscripts/"+runID, runID)
 			} else {
@@ -284,13 +301,89 @@ 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(&target, "target", "", "Staging directory to create (mutually exclusive with --dest)")
+	cmd.Flags().StringVar(&dest, "dest", "", "Publish repo to write into directly (mutually exclusive with --target)")
 	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
 }
 
+// stashedDir records an old CLI directory that was moved aside during --dest mode.
+type stashedDir struct {
+	original string // where it was (e.g., library/productivity/notion-pp-cli)
+	stashed  string // where it was moved to (e.g., library/productivity/notion-pp-cli.old-XXXXX)
+}
+
+// stashExistingCLI moves any existing version of a CLI aside (rename, not delete).
+// This handles category changes — if a CLI moved from one category to another,
+// all old dirs across categories are stashed. On success the caller removes
+// the stashed dirs; on failure the caller restores them.
+func stashExistingCLI(repoDir, cliName string) ([]stashedDir, error) {
+	libDir := filepath.Join(repoDir, "library")
+	entries, err := os.ReadDir(libDir)
+	if err != nil {
+		if os.IsNotExist(err) {
+			return nil, nil
+		}
+		return nil, err
+	}
+	var stashed []stashedDir
+	for _, e := range entries {
+		if !e.IsDir() {
+			continue
+		}
+		candidate := filepath.Join(libDir, e.Name(), cliName)
+		if _, statErr := os.Stat(candidate); statErr == nil {
+			tmpName := candidate + ".old"
+			if err := os.Rename(candidate, tmpName); err != nil {
+				// Restore any already-stashed dirs before returning
+				restoreStashedDirs(stashed)
+				return nil, fmt.Errorf("stashing %s: %w", candidate, err)
+			}
+			stashed = append(stashed, stashedDir{original: candidate, stashed: tmpName})
+		}
+	}
+	return stashed, nil
+}
+
+// restoreStashedDirs moves stashed dirs back to their original locations.
+func restoreStashedDirs(dirs []stashedDir) {
+	for _, d := range dirs {
+		_ = os.Rename(d.stashed, d.original)
+	}
+}
+
+// removeStashedDirs permanently deletes stashed dirs after a successful package.
+func removeStashedDirs(dirs []stashedDir) {
+	for _, d := range dirs {
+		_ = os.RemoveAll(d.stashed)
+	}
+}
+
+// resolveManuscripts finds the manuscripts directory and most recent run ID
+// for a CLI. Tries CLI name first (new convention), then API name, then fuzzy.
+func resolveManuscripts(cliName, apiName string) (msDir string, runID string) {
+	if apiName == "" {
+		apiName = naming.TrimCLISuffix(cliName)
+	}
+
+	msRoot := pipeline.PublishedManuscriptsRoot()
+
+	// 1. Try CLI name (new convention: manuscripts/<cli-name>/<run>/)
+	cliMsDir := filepath.Join(msRoot, cliName)
+	if rid, err := findMostRecentRun(cliMsDir); err == nil && rid != "" {
+		return cliMsDir, rid
+	}
+	// 2. Try API name (old convention: manuscripts/<api-name>/<run>/)
+	apiMsDir := filepath.Join(msRoot, apiName)
+	if rid, err := findMostRecentRun(apiMsDir); err == nil && rid != "" {
+		return apiMsDir, rid
+	}
+	// 3. Fuzzy resolve (strip suffixes, prefix match)
+	return resolveManuscriptDir(msRoot, apiName)
+}
+
 func runValidation(dir string) ValidateResult {
 	result := ValidateResult{}
 	allPassed := true
diff --git a/internal/cli/publish_test.go b/internal/cli/publish_test.go
index 2ff0e945..d140aebf 100644
--- a/internal/cli/publish_test.go
+++ b/internal/cli/publish_test.go
@@ -178,12 +178,20 @@ func TestPublishPackageMissingCategoryFlag(t *testing.T) {
 	assert.Contains(t, err.Error(), "--category is required")
 }
 
-func TestPublishPackageMissingTargetFlag(t *testing.T) {
+func TestPublishPackageMissingTargetAndDestFlags(t *testing.T) {
 	cmd := newPublishCmd()
 	cmd.SetArgs([]string{"package", "--dir", "/tmp/fake", "--category", "ai", "--json"})
 	err := cmd.Execute()
 	require.Error(t, err)
-	assert.Contains(t, err.Error(), "--target is required")
+	assert.Contains(t, err.Error(), "--target or --dest is required")
+}
+
+func TestPublishPackageTargetAndDestMutuallyExclusive(t *testing.T) {
+	cmd := newPublishCmd()
+	cmd.SetArgs([]string{"package", "--dir", "/tmp/fake", "--category", "ai", "--target", "/tmp/a", "--dest", "/tmp/b", "--json"})
+	err := cmd.Execute()
+	require.Error(t, err)
+	assert.Contains(t, err.Error(), "mutually exclusive")
 }
 
 func TestPublishPackageTargetExists(t *testing.T) {
@@ -399,6 +407,129 @@ func TestFindMostRecentRunNonexistentDir(t *testing.T) {
 	assert.Error(t, err)
 }
 
+func TestPublishPackageDestWritesDirectly(t *testing.T) {
+	home := setLibraryTestEnv(t)
+	cliDir := filepath.Join(home, "library", "test-pp-cli")
+	writePublishableTestCLI(t, cliDir)
+
+	// Create manuscripts
+	runID := "20260329-100000"
+	researchDir := filepath.Join(home, "manuscripts", "test", runID, "research")
+	require.NoError(t, os.MkdirAll(researchDir, 0o755))
+	require.NoError(t, os.WriteFile(filepath.Join(researchDir, "brief.md"), []byte("# Brief"), 0o644))
+
+	// Create a dest directory (simulating the publish repo)
+	destDir := filepath.Join(t.TempDir(), "publish-repo")
+	require.NoError(t, os.MkdirAll(destDir, 0o755))
+
+	cmd := newPublishCmd()
+	cmd.SetArgs([]string{"package", "--dir", cliDir, "--category", "other", "--dest", destDir, "--json"})
+
+	output, err := runWithCapturedStdout(t, cmd.Execute)
+	require.NoError(t, err)
+
+	var result PackageResult
+	require.NoError(t, json.Unmarshal([]byte(output), &result))
+	assert.True(t, result.ManuscriptsIncluded, "manuscripts should be included")
+	assert.Equal(t, runID, result.RunID)
+
+	// Verify CLI is at dest/library/<category>/<cli-name>/
+	cliOut := filepath.Join(destDir, "library", "other", "test-pp-cli")
+	assert.Equal(t, cliOut, result.StagedDir)
+
+	_, err = os.Stat(filepath.Join(cliOut, "go.mod"))
+	assert.NoError(t, err, "go.mod should exist in dest")
+
+	// Verify .manuscripts is written directly (not in a staging dir)
+	msPath := filepath.Join(cliOut, ".manuscripts", runID, "research", "brief.md")
+	_, err = os.Stat(msPath)
+	assert.NoError(t, err, ".manuscripts should be written into dest")
+}
+
+func TestPublishPackageDestRemovesOldCLI(t *testing.T) {
+	home := setLibraryTestEnv(t)
+	cliDir := filepath.Join(home, "library", "test-pp-cli")
+	writePublishableTestCLI(t, cliDir)
+
+	// Create a dest with an existing CLI in a different category
+	destDir := filepath.Join(t.TempDir(), "publish-repo")
+	oldCLIDir := filepath.Join(destDir, "library", "productivity", "test-pp-cli")
+	require.NoError(t, os.MkdirAll(oldCLIDir, 0o755))
+	require.NoError(t, os.WriteFile(filepath.Join(oldCLIDir, "old-file.go"), []byte("old"), 0o644))
+
+	cmd := newPublishCmd()
+	cmd.SetArgs([]string{"package", "--dir", cliDir, "--category", "other", "--dest", destDir, "--json"})
+
+	output, err := runWithCapturedStdout(t, cmd.Execute)
+	require.NoError(t, err)
+
+	var result PackageResult
+	require.NoError(t, json.Unmarshal([]byte(output), &result))
+
+	// Old CLI directory should be gone (both original and .old stash)
+	_, err = os.Stat(oldCLIDir)
+	assert.ErrorIs(t, err, os.ErrNotExist, "old CLI in different category should be removed")
+	_, err = os.Stat(oldCLIDir + ".old")
+	assert.ErrorIs(t, err, os.ErrNotExist, "stash dir should be cleaned up after success")
+
+	// New CLI should exist at new category
+	newCLIDir := filepath.Join(destDir, "library", "other", "test-pp-cli")
+	_, err = os.Stat(filepath.Join(newCLIDir, "go.mod"))
+	assert.NoError(t, err, "new CLI should exist at new category")
+}
+
+func TestPublishPackageDestRestoresOldCLIOnFailure(t *testing.T) {
+	home := setLibraryTestEnv(t)
+	cliDir := filepath.Join(home, "library", "test-pp-cli")
+	writePublishableTestCLI(t, cliDir)
+
+	// Create manuscripts with an unreadable file to trigger copy failure
+	runID := "20260329-100000"
+	manuscriptFile := filepath.Join(home, "manuscripts", "test", runID, "research", "brief.md")
+	require.NoError(t, os.MkdirAll(filepath.Dir(manuscriptFile), 0o755))
+	require.NoError(t, os.WriteFile(manuscriptFile, []byte("brief"), 0o600))
+	require.NoError(t, os.Chmod(manuscriptFile, 0))
+	defer func() { _ = os.Chmod(manuscriptFile, 0o600) }()
+
+	// Create dest with existing CLI in a different category
+	destDir := filepath.Join(t.TempDir(), "publish-repo")
+	oldCLIDir := filepath.Join(destDir, "library", "productivity", "test-pp-cli")
+	require.NoError(t, os.MkdirAll(oldCLIDir, 0o755))
+	require.NoError(t, os.WriteFile(filepath.Join(oldCLIDir, "old-file.go"), []byte("old"), 0o644))
+
+	cmd := newPublishCmd()
+	cmd.SetArgs([]string{"package", "--dir", cliDir, "--category", "other", "--dest", destDir, "--json"})
+
+	err := cmd.Execute()
+	require.Error(t, err, "should fail due to unreadable manuscript")
+
+	// Old CLI should be restored to its original location
+	_, err = os.Stat(filepath.Join(oldCLIDir, "old-file.go"))
+	assert.NoError(t, err, "old CLI should be restored after failure")
+
+	// No stash leftovers
+	_, err = os.Stat(oldCLIDir + ".old")
+	assert.ErrorIs(t, err, os.ErrNotExist, "stash dir should not remain after restore")
+
+	// New CLI dir should be cleaned up
+	newCLIDir := filepath.Join(destDir, "library", "other", "test-pp-cli")
+	_, err = os.Stat(newCLIDir)
+	assert.ErrorIs(t, err, os.ErrNotExist, "failed new CLI dir should be cleaned up")
+}
+
+func TestPublishPackageDestNonexistent(t *testing.T) {
+	home := setLibraryTestEnv(t)
+	cliDir := filepath.Join(home, "library", "test-pp-cli")
+	writePublishableTestCLI(t, cliDir)
+
+	cmd := newPublishCmd()
+	cmd.SetArgs([]string{"package", "--dir", cliDir, "--category", "other", "--dest", "/nonexistent/path", "--json"})
+
+	err := cmd.Execute()
+	require.Error(t, err)
+	assert.Contains(t, err.Error(), "does not exist")
+}
+
 func writePublishableTestCLI(t *testing.T, dir string) {
 	t.Helper()
 
diff --git a/skills/printing-press-publish/SKILL.md b/skills/printing-press-publish/SKILL.md
index 8a68ff78..557354df 100644
--- a/skills/printing-press-publish/SKILL.md
+++ b/skills/printing-press-publish/SKILL.md
@@ -163,30 +163,7 @@ If `"passed": false`, report the failing checks and **stop**. Do not create a pa
 
 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
-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`, `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
+## Step 5: Managed Clone
 
 The publish skill manages its own clone of the library repo at `$PUBLISH_REPO_DIR`.
 
@@ -260,6 +237,31 @@ If there are uncommitted changes, ask the user via AskUserQuestion:
 
 If reset, run `git checkout -- . && git clean -fd`.
 
+## Step 6: 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`
+
+Run `publish package` with `--dest` to write directly into the publish repo:
+
+```bash
+printing-press publish package \
+  --dir <cli-dir> \
+  --category <category> \
+  --dest "$PUBLISH_REPO_DIR" \
+  --module-path "$MODULE_PATH" \
+  --json
+```
+
+This removes any existing version of the CLI (handling category changes), copies the CLI source and `.manuscripts` directly into `$PUBLISH_REPO_DIR/library/<category>/<cli-name>/`, and rewrites the Go module path.
+
+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 7: Check for Existing PR
 
 Before creating a branch, check whether you have an open PR for this CLI. The `--author @me` filter ensures we only match PRs owned by the current user — if someone else published the same CLI name, we won't stomp their PR.
@@ -314,15 +316,6 @@ git checkout -b feat/<cli-name>
 git checkout -B feat/<cli-name>
 ```
 
-### Replace CLI package
-
-Remove any existing version of this CLI before copying. This prevents stale files from a previous generation persisting (e.g., deleted commands, renamed files). The glob also handles category changes — if the CLI moved from `productivity` to `developer-tools`, the old directory is cleaned up.
-
-```bash
-rm -rf "$PUBLISH_REPO_DIR/library"/*/"<cli-name>"
-cp -r <staging-dir>/library/* "$PUBLISH_REPO_DIR/library/"
-```
-
 ### Update registry.json
 
 The registry file has this structure:
@@ -497,8 +490,7 @@ flagged (e.g., a test fixture with a fake key). Don't block silently.
 - **`gh` not authenticated:** Detect in Step 1, tell user to run `gh auth login`
 - **CLI not found:** Show available CLIs in Step 2, let user pick
 - **Validation fails:** Show per-check results in Step 4, stop
-- **Repo unreachable:** Report clearly in Step 6
+- **Repo unreachable:** Report clearly in Step 5
 - **Existing PR check fails:** Fall back to standard branch-conflict flow (treat as no existing PR)
 - **Branch conflict (no existing PR):** Ask user in Step 8 (overwrite or timestamp)
 - **Push fails:** Report the error, suggest checking `gh auth status`
-- **Staging cleanup:** If any step after packaging (Steps 6-8) fails, remove the staging directory created in Step 5 before stopping. This prevents accumulation of full CLI copies in temp directories across retries

← 1f9148f0 feat(skills): populate README source credits from absorb man  ·  back to Cli Printing Press  ·  fix(cli): add primary workflow verification to printing pres a28d1b48 →