[object Object]

← back to Cli Printing Press

fix(cli): handle Windows shipcheck paths (#783)

d0086ffe385de40e6d5cbb34c75bdb880abadf04 · 2026-05-09 09:58:12 -0700 · Trevin Chow

Files touched

Diff

commit d0086ffe385de40e6d5cbb34c75bdb880abadf04
Author: Trevin Chow <trevin@trevinchow.com>
Date:   Sat May 9 09:58:12 2026 -0700

    fix(cli): handle Windows shipcheck paths (#783)
---
 internal/artifacts/cleanup.go                     |  5 ++-
 internal/artifacts/cleanup_test.go                |  6 +++
 internal/cli/publish.go                           |  3 +-
 internal/cli/shipcheck.go                         |  7 +++-
 internal/cli/shipcheck_test.go                    | 12 ++++++
 internal/cli/validate_narrative.go                | 12 ++++++
 internal/cli/validate_narrative_test.go           | 22 ++++++++++
 internal/cli/verify_skill_bundled.py              | 24 ++++++-----
 internal/narrativecheck/narrativecheck.go         |  6 ++-
 internal/pipeline/dogfood.go                      |  2 +
 internal/pipeline/live_check.go                   | 44 ++++++++++++++-----
 internal/pipeline/live_check_test.go              | 11 +++++
 internal/pipeline/runtime_exec.go                 |  2 +
 internal/platform/executable.go                   | 18 ++++++++
 internal/platform/executable_test.go              | 51 +++++++++++++++++++++++
 scripts/verify-skill/test_resolve_command_path.py | 21 ++++++++++
 scripts/verify-skill/verify_skill.py              | 24 ++++++-----
 17 files changed, 235 insertions(+), 35 deletions(-)

diff --git a/internal/artifacts/cleanup.go b/internal/artifacts/cleanup.go
index 62a8e937..0b5751e5 100644
--- a/internal/artifacts/cleanup.go
+++ b/internal/artifacts/cleanup.go
@@ -25,6 +25,7 @@ func CleanupGeneratedCLI(dir string, opts CleanupOptions) error {
 		name := filepath.Base(filepath.Clean(dir))
 		if name != "." && name != string(filepath.Separator) {
 			errs = append(errs, removeFileIfExists(filepath.Join(dir, name)))
+			errs = append(errs, removeFileIfExists(filepath.Join(dir, name+".exe")))
 		}
 	}
 
@@ -39,9 +40,9 @@ func CleanupGeneratedCLI(dir string, opts CleanupOptions) error {
 			}
 			name := entry.Name()
 			switch {
-			case opts.RemoveValidationBinaries && strings.HasSuffix(name, "-validation"):
+			case opts.RemoveValidationBinaries && (strings.HasSuffix(name, "-validation") || strings.HasSuffix(name, "-validation.exe")):
 				errs = append(errs, removeFileIfExists(filepath.Join(dir, name)))
-			case opts.RemoveDogfoodBinaries && strings.HasSuffix(name, "-dogfood"):
+			case opts.RemoveDogfoodBinaries && (strings.HasSuffix(name, "-dogfood") || strings.HasSuffix(name, "-dogfood.exe")):
 				errs = append(errs, removeFileIfExists(filepath.Join(dir, name)))
 			}
 		}
diff --git a/internal/artifacts/cleanup_test.go b/internal/artifacts/cleanup_test.go
index a2bceb41..7b4ef80c 100644
--- a/internal/artifacts/cleanup_test.go
+++ b/internal/artifacts/cleanup_test.go
@@ -16,8 +16,11 @@ func TestCleanupGeneratedCLI(t *testing.T) {
 	require.NoError(t, os.MkdirAll(filepath.Join(dir, "nested"), 0o755))
 
 	writeArtifactFile(t, filepath.Join(dir, "sample-cli"))
+	writeArtifactFile(t, filepath.Join(dir, "sample-cli.exe"))
 	writeArtifactFile(t, filepath.Join(dir, "sample-cli-validation"))
+	writeArtifactFile(t, filepath.Join(dir, "sample-cli-validation.exe"))
 	writeArtifactFile(t, filepath.Join(dir, "sample-cli-dogfood"))
+	writeArtifactFile(t, filepath.Join(dir, "sample-cli-dogfood.exe"))
 	writeArtifactFile(t, filepath.Join(dir, ".DS_Store"))
 	writeArtifactFile(t, filepath.Join(dir, "nested", ".DS_Store"))
 	writeArtifactFile(t, filepath.Join(dir, ".cache", "go-build", "index"))
@@ -33,8 +36,11 @@ func TestCleanupGeneratedCLI(t *testing.T) {
 	require.NoError(t, err)
 
 	assert.NoFileExists(t, filepath.Join(dir, "sample-cli"))
+	assert.NoFileExists(t, filepath.Join(dir, "sample-cli.exe"))
 	assert.NoFileExists(t, filepath.Join(dir, "sample-cli-validation"))
+	assert.NoFileExists(t, filepath.Join(dir, "sample-cli-validation.exe"))
 	assert.NoFileExists(t, filepath.Join(dir, "sample-cli-dogfood"))
+	assert.NoFileExists(t, filepath.Join(dir, "sample-cli-dogfood.exe"))
 	assert.NoFileExists(t, filepath.Join(dir, ".DS_Store"))
 	assert.NoFileExists(t, filepath.Join(dir, "nested", ".DS_Store"))
 	assert.NoDirExists(t, filepath.Join(dir, "cmd", "library"))
diff --git a/internal/cli/publish.go b/internal/cli/publish.go
index fa92d50e..149a88e7 100644
--- a/internal/cli/publish.go
+++ b/internal/cli/publish.go
@@ -15,6 +15,7 @@ import (
 	"github.com/mvanhorn/cli-printing-press/v4/internal/govulncheck"
 	"github.com/mvanhorn/cli-printing-press/v4/internal/naming"
 	"github.com/mvanhorn/cli-printing-press/v4/internal/pipeline"
+	"github.com/mvanhorn/cli-printing-press/v4/internal/platform"
 	"github.com/spf13/cobra"
 )
 
@@ -801,7 +802,7 @@ func buildValidationBinary(dir, cliName string) (path string, cleanup func(), er
 		_ = os.RemoveAll(tempDir)
 	}
 
-	outPath := filepath.Join(tempDir, cliName)
+	outPath := platform.ExecutablePath(filepath.Join(tempDir, cliName))
 	if err := buildBinaryAtPath(dir, outPath, "./cmd/"+cliName); err == nil {
 		return outPath, cleanup, nil
 	}
diff --git a/internal/cli/shipcheck.go b/internal/cli/shipcheck.go
index db3b5bf0..f0d6a4a9 100644
--- a/internal/cli/shipcheck.go
+++ b/internal/cli/shipcheck.go
@@ -11,6 +11,7 @@ import (
 	"strings"
 	"time"
 
+	"github.com/mvanhorn/cli-printing-press/v4/internal/platform"
 	"github.com/spf13/cobra"
 )
 
@@ -156,7 +157,11 @@ func shipcheckResearchPath(o *shipcheckOpts) string {
 }
 
 func shipcheckCLIPath(o *shipcheckOpts) string {
-	return filepath.Join(o.dir, filepath.Base(o.dir))
+	return platform.ExecutablePath(filepath.Join(o.dir, filepath.Base(o.dir)))
+}
+
+func shipcheckCLIPathForGOOS(o *shipcheckOpts, goos string) string {
+	return platform.ExecutablePathForGOOS(filepath.Join(o.dir, filepath.Base(o.dir)), goos)
 }
 
 // shipcheckLegResult is the per-leg outcome of one umbrella run.
diff --git a/internal/cli/shipcheck_test.go b/internal/cli/shipcheck_test.go
index b00fac44..d017f61c 100644
--- a/internal/cli/shipcheck_test.go
+++ b/internal/cli/shipcheck_test.go
@@ -52,6 +52,18 @@ func useShipcheckStub(t *testing.T) {
 	t.Cleanup(withStubBinary(t, stub))
 }
 
+func TestShipcheckCLIPathForGOOS(t *testing.T) {
+	t.Parallel()
+
+	opts := &shipcheckOpts{dir: filepath.Join("tmp", "sample-cli")}
+	if got, want := shipcheckCLIPathForGOOS(opts, "windows"), filepath.Join("tmp", "sample-cli", "sample-cli.exe"); got != want {
+		t.Fatalf("windows path = %q, want %q", got, want)
+	}
+	if got, want := shipcheckCLIPathForGOOS(opts, "linux"), filepath.Join("tmp", "sample-cli", "sample-cli"); got != want {
+		t.Fatalf("linux path = %q, want %q", got, want)
+	}
+}
+
 type shipcheckHarness struct {
 	dir     string
 	logFile string
diff --git a/internal/cli/validate_narrative.go b/internal/cli/validate_narrative.go
index 156bdd44..209f2f46 100644
--- a/internal/cli/validate_narrative.go
+++ b/internal/cli/validate_narrative.go
@@ -6,6 +6,7 @@ import (
 	"errors"
 	"fmt"
 	"io"
+	"io/fs"
 	"os"
 	"os/signal"
 
@@ -70,6 +71,17 @@ the SKILL's recipes; users hit "unknown command" on copy-paste.`,
 				FullExamples: fullExamples,
 			})
 			if err != nil {
+				if errors.Is(err, fs.ErrNotExist) {
+					if asJSON {
+						report := &narrativecheck.Report{ResearchNotApplicable: true}
+						if err := json.NewEncoder(cmd.OutOrStdout()).Encode(report); err != nil {
+							return err
+						}
+					} else {
+						fmt.Fprintf(cmd.OutOrStderr(), "N/A: research.json not found at %s; narrative validation skipped\n", researchPath)
+					}
+					return nil
+				}
 				return &ExitError{Code: ExitInputError, Err: err}
 			}
 
diff --git a/internal/cli/validate_narrative_test.go b/internal/cli/validate_narrative_test.go
index 2c75bd69..a26bf068 100644
--- a/internal/cli/validate_narrative_test.go
+++ b/internal/cli/validate_narrative_test.go
@@ -68,3 +68,25 @@ func TestValidateNarrativeCmd_StrictExitCode(t *testing.T) {
 		t.Errorf("Code = %d, want ExitInputError (%d)", exitErr.Code, ExitInputError)
 	}
 }
+
+func TestValidateNarrativeCmd_MissingResearchIsNotApplicable(t *testing.T) {
+	t.Parallel()
+
+	missingResearch := filepath.Join(t.TempDir(), "missing", "research.json")
+	cmd := newValidateNarrativeCmd()
+	var stdout, stderr bytes.Buffer
+	cmd.SetArgs([]string{
+		"--strict",
+		"--research", missingResearch,
+		"--binary", "/nonexistent-but-not-invoked",
+	})
+	cmd.SetOut(&stdout)
+	cmd.SetErr(&stderr)
+
+	if err := cmd.Execute(); err != nil {
+		t.Fatalf("missing research.json should be treated as not applicable, got %v", err)
+	}
+	if got := stdout.String() + stderr.String(); !strings.Contains(got, "N/A: research.json not found") {
+		t.Fatalf("stderr = %q, want N/A skip message", got)
+	}
+}
diff --git a/internal/cli/verify_skill_bundled.py b/internal/cli/verify_skill_bundled.py
index 62e35ef0..60a9bca6 100755
--- a/internal/cli/verify_skill_bundled.py
+++ b/internal/cli/verify_skill_bundled.py
@@ -56,6 +56,10 @@ from pathlib import Path
 from typing import Iterable
 
 
+def read_utf8(path: Path) -> str:
+    return path.read_text(encoding="utf-8")
+
+
 COMMON_FLAGS = {
     "help", "version", "json", "csv", "plain", "quiet", "agent",
     "select", "compact", "dry-run", "no-cache", "yes", "no-input",
@@ -241,7 +245,7 @@ def collect_command_constructors(cli_dir: Path) -> dict[str, CommandConstructor]
         if go_file.name.endswith("_test.go"):
             continue
         try:
-            text = go_file.read_text()
+            text = read_utf8(go_file)
         except Exception:
             continue
         for m in CONSTRUCTOR_RE.finditer(text):
@@ -283,7 +287,7 @@ def find_root_children(cli_dir: Path) -> list[str]:
         if go_file.name.endswith("_test.go"):
             continue
         try:
-            text = go_file.read_text()
+            text = read_utf8(go_file)
         except Exception:
             continue
         for m in ROOT_ADDCMD_RE.finditer(text):
@@ -395,7 +399,7 @@ def _legacy_find_command_source(cli_dir: Path, cmd_path: list[str]):
         if go_file.name.endswith("_test.go"):
             continue
         try:
-            text = go_file.read_text()
+            text = read_utf8(go_file)
         except Exception:
             continue
         for m in USE_RE.finditer(text):
@@ -428,7 +432,7 @@ def _legacy_find_command_source(cli_dir: Path, cmd_path: list[str]):
 def flag_declared_in(files: Iterable[Path], flag_name: str) -> bool:
     for f in files:
         try:
-            text = f.read_text()
+            text = read_utf8(f)
         except Exception:
             continue
         for m in FLAG_DECL_RE.finditer(text):
@@ -540,7 +544,7 @@ def flag_declared_via_helper(cli_dir: Path, cmd_files: Iterable[Path], flag_name
     helper_names: set[str] = set()
     for f in cmd_files:
         try:
-            text = f.read_text()
+            text = read_utf8(f)
         except Exception:
             continue
         for m in HELPER_CALL_RE.finditer(text):
@@ -563,7 +567,7 @@ def flag_declared_via_helper(cli_dir: Path, cmd_files: Iterable[Path], flag_name
         if go_file.name.endswith("_test.go"):
             continue
         try:
-            text = go_file.read_text()
+            text = read_utf8(go_file)
         except Exception:
             continue
         for m in func_re.finditer(text):
@@ -580,7 +584,7 @@ def persistent_flag_declared(cli_dir: Path, flag_name: str) -> bool:
         return False
     for go_file in src.glob("*.go"):
         try:
-            text = go_file.read_text()
+            text = read_utf8(go_file)
         except Exception:
             continue
         for m in FLAG_DECL_RE.finditer(text):
@@ -602,7 +606,7 @@ def extract_recipes(skill: Path, cli_binary: str, cli_dir: Path | None = None) -
     positional_args: non-flag tokens after cmd_path (shell-quoted strings preserved)
     flags: --flag tokens (with their -- prefix)
     """
-    text = skill.read_text()
+    text = read_utf8(skill)
     blocks = CODEBLOCK_BASH.findall(text)
     results = []
     for block in blocks:
@@ -877,7 +881,7 @@ def check_unknown_commands(cli_dir: Path, skill: Path, cli_binary: str, report:
     graph and resolves multi-level command paths (e.g., `links stale` vs
     `profile save`) without false-positive collisions on shared leaf names.
     """
-    skill_text = skill.read_text()
+    skill_text = read_utf8(skill)
     seen: set[tuple[str, ...]] = set()
     sources: list[tuple[list[str], str]] = []
 
@@ -938,7 +942,7 @@ def derive_cli_binary(cli_dir: Path) -> str:
     manifest = cli_dir / ".printing-press.json"
     if manifest.exists():
         try:
-            data = json.loads(manifest.read_text())
+            data = json.loads(read_utf8(manifest))
             if data.get("cli_name"):
                 return data["cli_name"]
         except Exception:
diff --git a/internal/narrativecheck/narrativecheck.go b/internal/narrativecheck/narrativecheck.go
index fd3c65d5..4c1b9159 100644
--- a/internal/narrativecheck/narrativecheck.go
+++ b/internal/narrativecheck/narrativecheck.go
@@ -72,6 +72,10 @@ type Report struct {
 	// omitted both sections by mistake; the caller's --strict flag
 	// can decide whether that's an error.
 	ResearchEmpty bool `json:"research_empty,omitempty"`
+	// ResearchNotApplicable is true when the caller pointed at an
+	// absent research.json, which is valid for hand-built specs that did
+	// not run the Printing Press research pipeline.
+	ResearchNotApplicable bool `json:"research_not_applicable,omitempty"`
 }
 
 // Options controls optional narrative validation checks.
@@ -137,7 +141,7 @@ func loadCommands(researchPath string) ([]sectionCommand, error) {
 	data, err := os.ReadFile(researchPath)
 	if err != nil {
 		if errors.Is(err, fs.ErrNotExist) {
-			return nil, fmt.Errorf("research file %s not found; cannot validate narrative commands", researchPath)
+			return nil, fmt.Errorf("research file %s not found: %w", researchPath, err)
 		}
 		return nil, fmt.Errorf("reading %s: %w", researchPath, err)
 	}
diff --git a/internal/pipeline/dogfood.go b/internal/pipeline/dogfood.go
index 1e2ce634..b4eccad1 100644
--- a/internal/pipeline/dogfood.go
+++ b/internal/pipeline/dogfood.go
@@ -16,6 +16,7 @@ import (
 
 	"github.com/mvanhorn/cli-printing-press/v4/internal/naming"
 	openapiparser "github.com/mvanhorn/cli-printing-press/v4/internal/openapi"
+	"github.com/mvanhorn/cli-printing-press/v4/internal/platform"
 	apispec "github.com/mvanhorn/cli-printing-press/v4/internal/spec"
 	"gopkg.in/yaml.v3"
 )
@@ -1705,6 +1706,7 @@ func buildDogfoodBinary(dir, cliName string) (string, error) {
 	if err != nil {
 		return "", fmt.Errorf("resolving dogfood binary path: %w", err)
 	}
+	buildPath = platform.ExecutablePath(buildPath)
 	ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
 	defer cancel()
 	cmd := exec.CommandContext(ctx, "go", "build", "-o", buildPath, "./cmd/"+cliName)
diff --git a/internal/pipeline/live_check.go b/internal/pipeline/live_check.go
index 8efd41db..3bb7a5d2 100644
--- a/internal/pipeline/live_check.go
+++ b/internal/pipeline/live_check.go
@@ -11,11 +11,13 @@ import (
 	"os/exec"
 	"path/filepath"
 	"regexp"
+	"runtime"
 	"strings"
 	"sync"
 	"time"
 	"unicode"
 
+	"github.com/mvanhorn/cli-printing-press/v4/internal/platform"
 	"github.com/mvanhorn/cli-printing-press/v4/internal/shellargs"
 )
 
@@ -205,16 +207,8 @@ func RunLiveCheck(opts LiveCheckOptions) *LiveCheckResult {
 // is non-empty it's used verbatim; otherwise RunLiveCheck tries the common
 // `<base>-pp-cli` naming convention and falls back to `<base>`.
 func resolveBinaryPath(cliDir, name string) (string, error) {
-	candidates := []string{name}
-	if name == "" {
-		base := filepath.Base(cliDir)
-		candidates = []string{base + "-pp-cli", base}
-	}
-	for _, candidate := range candidates {
-		if candidate == "" {
-			continue
-		}
-		path := filepath.Join(cliDir, candidate)
+	candidates := liveCheckBinaryCandidates(cliDir, name)
+	for _, path := range candidates {
 		info, err := os.Stat(path)
 		if err != nil {
 			continue
@@ -227,6 +221,36 @@ func resolveBinaryPath(cliDir, name string) (string, error) {
 	return "", fmt.Errorf("no runnable binary found in %q (tried %v)", cliDir, candidates)
 }
 
+func liveCheckBinaryCandidates(cliDir, name string) []string {
+	return liveCheckBinaryCandidatesForGOOS(cliDir, name, runtime.GOOS)
+}
+
+func liveCheckBinaryCandidatesForGOOS(cliDir, name, goos string) []string {
+	names := []string{name}
+	if name == "" {
+		base := filepath.Base(cliDir)
+		names = []string{base + "-pp-cli", base}
+	}
+	candidates := make([]string, 0, len(names)*2)
+	seen := map[string]struct{}{}
+	for _, candidate := range names {
+		if candidate == "" {
+			continue
+		}
+		for _, path := range []string{
+			filepath.Join(cliDir, candidate),
+			platform.ExecutablePathForGOOS(filepath.Join(cliDir, candidate), goos),
+		} {
+			if _, ok := seen[path]; ok {
+				continue
+			}
+			seen[path] = struct{}{}
+			candidates = append(candidates, path)
+		}
+	}
+	return candidates
+}
+
 // runFeaturesConcurrent distributes the per-feature checks across a worker
 // pool. Results are collected in-order so LiveCheckResult.Features stays
 // stable across runs.
diff --git a/internal/pipeline/live_check_test.go b/internal/pipeline/live_check_test.go
index ec231353..089df6eb 100644
--- a/internal/pipeline/live_check_test.go
+++ b/internal/pipeline/live_check_test.go
@@ -9,6 +9,8 @@ import (
 	"testing"
 	"time"
 
+	"github.com/mvanhorn/cli-printing-press/v4/internal/platform"
+	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/require"
 )
 
@@ -441,6 +443,15 @@ func TestLiveCheck_BinaryAutoDerivation(t *testing.T) {
 	require.Contains(t, result.Features[0].Example, "stub x matched")
 }
 
+func TestLiveCheckBinaryCandidatesIncludeHostExecutableName(t *testing.T) {
+	t.Parallel()
+
+	dir := filepath.Join("tmp", "sample-cli")
+	assert.Contains(t, liveCheckBinaryCandidatesForGOOS(dir, "", "windows"), filepath.Join("tmp", "sample-cli", "sample-cli.exe"))
+	assert.Contains(t, liveCheckBinaryCandidatesForGOOS(dir, "custom-cli", "windows"), filepath.Join("tmp", "sample-cli", "custom-cli"))
+	assert.Contains(t, liveCheckBinaryCandidatesForGOOS(dir, "custom-cli", "windows"), platform.ExecutablePathForGOOS(filepath.Join("tmp", "sample-cli", "custom-cli"), "windows"))
+}
+
 // TestChecked_DerivedFromCounters ensures the Checked() method is a pure
 // derivation — if it ever drifts from Passed+Failed+Skipped the live-check
 // invariant is broken.
diff --git a/internal/pipeline/runtime_exec.go b/internal/pipeline/runtime_exec.go
index 3d3155e2..f60d59e4 100644
--- a/internal/pipeline/runtime_exec.go
+++ b/internal/pipeline/runtime_exec.go
@@ -10,6 +10,7 @@ import (
 	"time"
 
 	"github.com/mvanhorn/cli-printing-press/v4/internal/naming"
+	"github.com/mvanhorn/cli-printing-press/v4/internal/platform"
 )
 
 func buildCLI(dir string) (string, error) {
@@ -17,6 +18,7 @@ func buildCLI(dir string) (string, error) {
 	if err != nil {
 		return "", fmt.Errorf("resolving binary path: %w", err)
 	}
+	binaryPath = platform.ExecutablePath(binaryPath)
 	cmdDir, err := findCLICommandDir(dir)
 	if err != nil {
 		return "", err
diff --git a/internal/platform/executable.go b/internal/platform/executable.go
new file mode 100644
index 00000000..c9e83ae7
--- /dev/null
+++ b/internal/platform/executable.go
@@ -0,0 +1,18 @@
+package platform
+
+import (
+	"path/filepath"
+	"runtime"
+	"strings"
+)
+
+func ExecutablePath(path string) string {
+	return ExecutablePathForGOOS(path, runtime.GOOS)
+}
+
+func ExecutablePathForGOOS(path, goos string) string {
+	if goos == "windows" && !strings.EqualFold(filepath.Ext(path), ".exe") {
+		return path + ".exe"
+	}
+	return path
+}
diff --git a/internal/platform/executable_test.go b/internal/platform/executable_test.go
new file mode 100644
index 00000000..82a26ef9
--- /dev/null
+++ b/internal/platform/executable_test.go
@@ -0,0 +1,51 @@
+package platform
+
+import (
+	"path/filepath"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+)
+
+func TestExecutablePathForGOOS(t *testing.T) {
+	t.Parallel()
+
+	cases := []struct {
+		name string
+		path string
+		goos string
+		want string
+	}{
+		{
+			name: "windows appends exe",
+			path: filepath.Join("tmp", "sample-cli"),
+			goos: "windows",
+			want: filepath.Join("tmp", "sample-cli.exe"),
+		},
+		{
+			name: "windows preserves exe",
+			path: filepath.Join("tmp", "sample-cli.exe"),
+			goos: "windows",
+			want: filepath.Join("tmp", "sample-cli.exe"),
+		},
+		{
+			name: "darwin unchanged",
+			path: filepath.Join("tmp", "sample-cli"),
+			goos: "darwin",
+			want: filepath.Join("tmp", "sample-cli"),
+		},
+		{
+			name: "linux unchanged",
+			path: filepath.Join("tmp", "sample-cli"),
+			goos: "linux",
+			want: filepath.Join("tmp", "sample-cli"),
+		},
+	}
+
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			t.Parallel()
+			assert.Equal(t, tc.want, ExecutablePathForGOOS(tc.path, tc.goos))
+		})
+	}
+}
diff --git a/scripts/verify-skill/test_resolve_command_path.py b/scripts/verify-skill/test_resolve_command_path.py
index 044a8528..df30e0db 100644
--- a/scripts/verify-skill/test_resolve_command_path.py
+++ b/scripts/verify-skill/test_resolve_command_path.py
@@ -11,10 +11,12 @@ from __future__ import annotations
 import sys
 import tempfile
 import unittest
+from unittest.mock import patch
 from pathlib import Path
 
 sys.path.insert(0, str(Path(__file__).parent))
 
+import verify_skill  # noqa: E402
 from verify_skill import (  # noqa: E402
     collect_command_constructors,
     find_root_children,
@@ -238,5 +240,24 @@ func newSearchCmd() *cobra.Command {
             self.assertEqual(use, "search <query>")
 
 
+class UTF8ReadTest(unittest.TestCase):
+    def test_read_text_uses_explicit_utf8_encoding(self):
+        with tempfile.TemporaryDirectory() as tmp:
+            path = Path(tmp) / "SKILL.md"
+            path.write_text("# 한국어 테스트\n", encoding="utf-8")
+
+            seen = []
+            original = Path.read_text
+
+            def spy(self, *args, **kwargs):
+                seen.append(kwargs.get("encoding"))
+                return original(self, *args, **kwargs)
+
+            with patch.object(Path, "read_text", spy):
+                self.assertIn("한국어", verify_skill.read_utf8(path))
+
+            self.assertEqual(seen, ["utf-8"])
+
+
 if __name__ == "__main__":
     unittest.main()
diff --git a/scripts/verify-skill/verify_skill.py b/scripts/verify-skill/verify_skill.py
index 62e35ef0..60a9bca6 100755
--- a/scripts/verify-skill/verify_skill.py
+++ b/scripts/verify-skill/verify_skill.py
@@ -56,6 +56,10 @@ from pathlib import Path
 from typing import Iterable
 
 
+def read_utf8(path: Path) -> str:
+    return path.read_text(encoding="utf-8")
+
+
 COMMON_FLAGS = {
     "help", "version", "json", "csv", "plain", "quiet", "agent",
     "select", "compact", "dry-run", "no-cache", "yes", "no-input",
@@ -241,7 +245,7 @@ def collect_command_constructors(cli_dir: Path) -> dict[str, CommandConstructor]
         if go_file.name.endswith("_test.go"):
             continue
         try:
-            text = go_file.read_text()
+            text = read_utf8(go_file)
         except Exception:
             continue
         for m in CONSTRUCTOR_RE.finditer(text):
@@ -283,7 +287,7 @@ def find_root_children(cli_dir: Path) -> list[str]:
         if go_file.name.endswith("_test.go"):
             continue
         try:
-            text = go_file.read_text()
+            text = read_utf8(go_file)
         except Exception:
             continue
         for m in ROOT_ADDCMD_RE.finditer(text):
@@ -395,7 +399,7 @@ def _legacy_find_command_source(cli_dir: Path, cmd_path: list[str]):
         if go_file.name.endswith("_test.go"):
             continue
         try:
-            text = go_file.read_text()
+            text = read_utf8(go_file)
         except Exception:
             continue
         for m in USE_RE.finditer(text):
@@ -428,7 +432,7 @@ def _legacy_find_command_source(cli_dir: Path, cmd_path: list[str]):
 def flag_declared_in(files: Iterable[Path], flag_name: str) -> bool:
     for f in files:
         try:
-            text = f.read_text()
+            text = read_utf8(f)
         except Exception:
             continue
         for m in FLAG_DECL_RE.finditer(text):
@@ -540,7 +544,7 @@ def flag_declared_via_helper(cli_dir: Path, cmd_files: Iterable[Path], flag_name
     helper_names: set[str] = set()
     for f in cmd_files:
         try:
-            text = f.read_text()
+            text = read_utf8(f)
         except Exception:
             continue
         for m in HELPER_CALL_RE.finditer(text):
@@ -563,7 +567,7 @@ def flag_declared_via_helper(cli_dir: Path, cmd_files: Iterable[Path], flag_name
         if go_file.name.endswith("_test.go"):
             continue
         try:
-            text = go_file.read_text()
+            text = read_utf8(go_file)
         except Exception:
             continue
         for m in func_re.finditer(text):
@@ -580,7 +584,7 @@ def persistent_flag_declared(cli_dir: Path, flag_name: str) -> bool:
         return False
     for go_file in src.glob("*.go"):
         try:
-            text = go_file.read_text()
+            text = read_utf8(go_file)
         except Exception:
             continue
         for m in FLAG_DECL_RE.finditer(text):
@@ -602,7 +606,7 @@ def extract_recipes(skill: Path, cli_binary: str, cli_dir: Path | None = None) -
     positional_args: non-flag tokens after cmd_path (shell-quoted strings preserved)
     flags: --flag tokens (with their -- prefix)
     """
-    text = skill.read_text()
+    text = read_utf8(skill)
     blocks = CODEBLOCK_BASH.findall(text)
     results = []
     for block in blocks:
@@ -877,7 +881,7 @@ def check_unknown_commands(cli_dir: Path, skill: Path, cli_binary: str, report:
     graph and resolves multi-level command paths (e.g., `links stale` vs
     `profile save`) without false-positive collisions on shared leaf names.
     """
-    skill_text = skill.read_text()
+    skill_text = read_utf8(skill)
     seen: set[tuple[str, ...]] = set()
     sources: list[tuple[list[str], str]] = []
 
@@ -938,7 +942,7 @@ def derive_cli_binary(cli_dir: Path) -> str:
     manifest = cli_dir / ".printing-press.json"
     if manifest.exists():
         try:
-            data = json.loads(manifest.read_text())
+            data = json.loads(read_utf8(manifest))
             if data.get("cli_name"):
                 return data["cli_name"]
         except Exception:

← 2ff069e0 fix(cli): stop credential status overclaims (#779)  ·  back to Cli Printing Press  ·  fix(skills): gate polish ship verdict on publish validate (# 5e04776b →