← back to Cli Printing Press
fix(cli): handle Windows shipcheck paths (#783)
d0086ffe385de40e6d5cbb34c75bdb880abadf04 · 2026-05-09 09:58:12 -0700 · Trevin Chow
Files touched
M internal/artifacts/cleanup.goM internal/artifacts/cleanup_test.goM internal/cli/publish.goM internal/cli/shipcheck.goM internal/cli/shipcheck_test.goM internal/cli/validate_narrative.goM internal/cli/validate_narrative_test.goM internal/cli/verify_skill_bundled.pyM internal/narrativecheck/narrativecheck.goM internal/pipeline/dogfood.goM internal/pipeline/live_check.goM internal/pipeline/live_check_test.goM internal/pipeline/runtime_exec.goA internal/platform/executable.goA internal/platform/executable_test.goM scripts/verify-skill/test_resolve_command_path.pyM scripts/verify-skill/verify_skill.py
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 →