← back to Cli Printing Press
feat(cli): scope verify-skill flag-names; add canonical-sections check (#665)
c69e77c8109b5feec36ed4e17a94ac3eb11237dd · 2026-05-07 02:27:30 -0700 · Trevin Chow
The trigger-dev / linear SKILL.md transcripts revealed two failure modes
in the polish loop: verify-skill's flag-names check regex-scanned the
entire SKILL.md, so external-tool flags (e.g. --cli-only on the npx
installer line) tripped a false positive that pressured agents into
stripping the flag — and in one case fabricating a /ppl install slash
command — to silence the gate.
Two layered fixes:
1. flag-names is now scoped to <cli_binary> ... recipes via
extract_recipes (verify_skill.py). Flags on lines that invoke other
tools (npx, gh, go install, curl, ...) are out of scope by design.
2. New canonical-sections check verifies the printed SKILL.md's
"## Prerequisites: Install the CLI" block matches what the generator
would emit today — re-rendered from .printing-press.json + go.mod.
Hand-edits to this generator-owned section now fail verify-skill
with an explicit "regenerate, don't hand-edit" message, blocking the
install-section drift class entirely (flag strip, fabricated install
methods, duplicated fallback blocks).
CanonicalSkillInstallSection lives in internal/generator and stays in
lockstep with skill.md.tmpl via TestCanonicalSkillInstallSectionMatchesTemplate.
The runtime check fires through both the cobra verify-skill command (used
by polish/shipcheck) and publish package validation.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Files touched
M internal/cli/publish.goM internal/cli/publish_test.goM internal/cli/verify_skill.goM internal/cli/verify_skill_bundled.pyM internal/cli/verify_skill_test.goA internal/generator/install_section.goA internal/generator/install_section_test.goM scripts/verify-skill/verify_skill.pyM skills/printing-press-polish/SKILL.md
Diff
commit c69e77c8109b5feec36ed4e17a94ac3eb11237dd
Author: Trevin Chow <trevin@trevinchow.com>
Date: Thu May 7 02:27:30 2026 -0700
feat(cli): scope verify-skill flag-names; add canonical-sections check (#665)
The trigger-dev / linear SKILL.md transcripts revealed two failure modes
in the polish loop: verify-skill's flag-names check regex-scanned the
entire SKILL.md, so external-tool flags (e.g. --cli-only on the npx
installer line) tripped a false positive that pressured agents into
stripping the flag — and in one case fabricating a /ppl install slash
command — to silence the gate.
Two layered fixes:
1. flag-names is now scoped to <cli_binary> ... recipes via
extract_recipes (verify_skill.py). Flags on lines that invoke other
tools (npx, gh, go install, curl, ...) are out of scope by design.
2. New canonical-sections check verifies the printed SKILL.md's
"## Prerequisites: Install the CLI" block matches what the generator
would emit today — re-rendered from .printing-press.json + go.mod.
Hand-edits to this generator-owned section now fail verify-skill
with an explicit "regenerate, don't hand-edit" message, blocking the
install-section drift class entirely (flag strip, fabricated install
methods, duplicated fallback blocks).
CanonicalSkillInstallSection lives in internal/generator and stays in
lockstep with skill.md.tmpl via TestCanonicalSkillInstallSectionMatchesTemplate.
The runtime check fires through both the cobra verify-skill command (used
by polish/shipcheck) and publish package validation.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---
internal/cli/publish.go | 10 ++
internal/cli/publish_test.go | 4 +-
internal/cli/verify_skill.go | 252 ++++++++++++++++++++++++++---
internal/cli/verify_skill_bundled.py | 39 +++--
internal/cli/verify_skill_test.go | 151 +++++++++++++++++
internal/generator/install_section.go | 94 +++++++++++
internal/generator/install_section_test.go | 85 ++++++++++
scripts/verify-skill/verify_skill.py | 39 +++--
skills/printing-press-polish/SKILL.md | 9 +-
9 files changed, 624 insertions(+), 59 deletions(-)
diff --git a/internal/cli/publish.go b/internal/cli/publish.go
index 7fc3229f..c07442ab 100644
--- a/internal/cli/publish.go
+++ b/internal/cli/publish.go
@@ -687,6 +687,16 @@ func checkVerifySkill(dir string) CheckResult {
}
return CheckResult{Name: "verify-skill", Passed: false, Error: errMsg}
}
+ if run.ExitCode != 0 {
+ return CheckResult{Name: "verify-skill", Passed: false, Error: strings.TrimSpace(run.Stdout + "\n" + run.Stderr)}
+ }
+ finding, hasFinding, _, cErr := runCanonicalSectionsCheck(dir)
+ if cErr != nil {
+ return CheckResult{Name: "verify-skill", Passed: false, Error: cErr.Error()}
+ }
+ if hasFinding {
+ return CheckResult{Name: "verify-skill", Passed: false, Error: fmt.Sprintf("[%s] %s: %s", finding.Check, finding.Command, finding.Detail)}
+ }
return CheckResult{Name: "verify-skill", Passed: true}
}
diff --git a/internal/cli/publish_test.go b/internal/cli/publish_test.go
index 2fd7da14..248958a1 100644
--- a/internal/cli/publish_test.go
+++ b/internal/cli/publish_test.go
@@ -6,6 +6,7 @@ import (
"path/filepath"
"testing"
+ "github.com/mvanhorn/cli-printing-press/v3/internal/generator"
"github.com/mvanhorn/cli-printing-press/v3/internal/naming"
"github.com/mvanhorn/cli-printing-press/v3/internal/pipeline"
"github.com/stretchr/testify/assert"
@@ -854,7 +855,8 @@ func newInsightCmd() *cobra.Command {
return &cobra.Command{Use: "insight", Short: "Show test insight"}
}
`), 0o644))
- require.NoError(t, os.WriteFile(filepath.Join(dir, "SKILL.md"), []byte("# Test CLI\n\n## Command Reference\n\n- `test-pp-cli insight` — Show test insight\n\n## Usage\n\n```bash\ntest-pp-cli insight --agent\n```\n"), 0o644))
+ skillInstall := generator.CanonicalSkillInstallSection("test", "", false)
+ require.NoError(t, os.WriteFile(filepath.Join(dir, "SKILL.md"), []byte("# Test CLI\n\n"+skillInstall+"\n## Command Reference\n\n- `test-pp-cli insight` — Show test insight\n\n## Usage\n\n```bash\ntest-pp-cli insight --agent\n```\n"), 0o644))
writeTestManifest(t, dir, pipeline.CLIManifest{
SchemaVersion: 1,
diff --git a/internal/cli/verify_skill.go b/internal/cli/verify_skill.go
index 5ee96542..a09282d6 100644
--- a/internal/cli/verify_skill.go
+++ b/internal/cli/verify_skill.go
@@ -3,11 +3,16 @@ package cli
import (
"bytes"
_ "embed"
+ "encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
+ "regexp"
+ "strings"
+ "github.com/mvanhorn/cli-printing-press/v3/internal/generator"
+ "github.com/mvanhorn/cli-printing-press/v3/internal/pipeline"
"github.com/spf13/cobra"
)
@@ -19,6 +24,8 @@ import (
//go:embed verify_skill_bundled.py
var verifySkillScript string
+const canonicalSectionsCheckName = "canonical-sections"
+
type verifySkillRunResult struct {
Stdout string
Stderr string
@@ -73,20 +80,133 @@ func runVerifySkillScript(dir string, only []string, asJSON bool, strict bool) (
runErr := py.Run()
result.Stdout = stdout.String()
result.Stderr = stderr.String()
+ // Caller distinguishes the two failure modes:
+ // * err != nil — script could not run (python missing, fork failure)
+ // * result.ExitCode != 0 — script ran and reported findings
+ // Pre-check failures (missing SKILL.md / internal/cli) above are signalled
+ // via err with ExitError so the caller can propagate them as input errors
+ // without confusing them with findings.
if runErr != nil {
if exitErr, ok := runErr.(*exec.ExitError); ok {
result.ExitCode = exitErr.ExitCode()
- return result, &ExitError{
- Code: exitErr.ExitCode(),
- Err: fmt.Errorf("SKILL verification failed"),
- Silent: true,
- }
+ return result, nil
}
return result, fmt.Errorf("running verifier: %w", runErr)
}
return result, nil
}
+// canonicalFinding mirrors the Python script's finding shape so JSON merges
+// stay shape-stable for downstream consumers (polish skill reads
+// /tmp/polish-verify-skill.json).
+type canonicalFinding struct {
+ Check string `json:"check"`
+ Severity string `json:"severity"`
+ Command string `json:"command"`
+ Detail string `json:"detail"`
+ Evidence string `json:"evidence"`
+ LikelyFalsePositive bool `json:"likely_false_positive"`
+}
+
+type pythonReport struct {
+ CLIDir string `json:"cli_dir"`
+ SkillPath string `json:"skill_path"`
+ ChecksRun []string `json:"checks_run"`
+ RecipesChecked int `json:"recipes_checked"`
+ Findings []canonicalFinding `json:"findings"`
+}
+
+// goModVersionRE matches the `go X.Y` directive at the top of go.mod.
+var goModVersionRE = regexp.MustCompile(`(?m)^go\s+(\d+)\.(\d+)`)
+
+// runCanonicalSectionsCheck verifies that the install/prerequisites section
+// of dir/SKILL.md matches what the generator would emit for this CLI today.
+// Detects post-publish edits to a generator-owned section (the failure mode
+// where an automation loop strips --cli-only or fabricates a slash command
+// to silence a flag-names false positive).
+//
+// Skipped (skipped=true, finding zero, error nil) when the inputs needed to
+// compute the canonical text are absent — minimal fixtures used by other
+// verify-skill tests carry no .printing-press.json api_name and no go.mod,
+// and forcing the check to fire on those would convert every fixture into
+// a maintenance burden without catching a real bug.
+func runCanonicalSectionsCheck(dir string) (finding canonicalFinding, hasFinding bool, skipped bool, err error) {
+ manifest, mErr := pipeline.ReadCLIManifest(dir)
+ if mErr != nil {
+ return canonicalFinding{}, false, true, nil
+ }
+ name := manifest.APIName
+ if name == "" {
+ return canonicalFinding{}, false, true, nil
+ }
+
+ goModBytes, gErr := os.ReadFile(filepath.Join(dir, "go.mod"))
+ if gErr != nil {
+ return canonicalFinding{}, false, true, nil
+ }
+ usesBrowserHTTP := false
+ if m := goModVersionRE.FindSubmatch(goModBytes); m != nil {
+ // browser-HTTP transport raises the floor to 1.25; treat any
+ // 1.25+ go directive as the browser-HTTP signal. CLIs not on
+ // browser-HTTP stay on 1.23.
+ if string(m[1]) == "1" && string(m[2]) >= "25" {
+ usesBrowserHTTP = true
+ }
+ }
+
+ skillBytes, sErr := os.ReadFile(filepath.Join(dir, "SKILL.md"))
+ if sErr != nil {
+ return canonicalFinding{}, false, false, fmt.Errorf("reading SKILL.md: %w", sErr)
+ }
+ skill := string(skillBytes)
+
+ expected := generator.CanonicalSkillInstallSection(name, manifest.Category, usesBrowserHTTP)
+ got, ok := ExtractInstallSectionForTest(skill)
+ if !ok {
+ return canonicalFinding{
+ Check: canonicalSectionsCheckName,
+ Severity: "error",
+ Command: "(file: SKILL.md)",
+ Detail: "install section is missing or malformed; this section is generator-owned. Regenerate the printed CLI to restore it.",
+ Evidence: fmt.Sprintf("expected canonical block:\n%s", expected),
+ }, true, false, nil
+ }
+ if got == expected {
+ return canonicalFinding{}, false, false, nil
+ }
+ return canonicalFinding{
+ Check: canonicalSectionsCheckName,
+ Severity: "error",
+ Command: "(file: SKILL.md)",
+ Detail: "install section drift: hand-edit detected in a generator-owned section. Regenerate the printed CLI to restore the canonical text — do not edit this section by hand.",
+ Evidence: fmt.Sprintf("expected (from generator):\n%s\n\ngot (from SKILL.md):\n%s", expected, got),
+ }, true, false, nil
+}
+
+// ExtractInstallSectionForTest re-exports the generator's extractor so
+// in-package tests can build fixtures and assert the runtime check
+// behavior. Defined here to keep the cross-package surface narrow.
+func ExtractInstallSectionForTest(skill string) (string, bool) {
+ return generator.ExtractSkillInstallSection(skill)
+}
+
+// planVerifyChecks splits the user's --only selection into the Go-side
+// canonical-sections check and the Python-side checks, and reports whether
+// each layer should run. An empty --only means "run all checks".
+func planVerifyChecks(only []string) (runCanonical bool, pyOnly []string) {
+ if len(only) == 0 {
+ return true, nil
+ }
+ for _, c := range only {
+ if c == canonicalSectionsCheckName {
+ runCanonical = true
+ continue
+ }
+ pyOnly = append(pyOnly, c)
+ }
+ return
+}
+
func newVerifySkillCmd() *cobra.Command {
var (
dir string
@@ -100,20 +220,24 @@ func newVerifySkillCmd() *cobra.Command {
Short: "Verify SKILL.md matches the shipped CLI source",
SilenceUsage: true,
SilenceErrors: true,
- Long: `Run four checks against a printed CLI's SKILL.md:
+ Long: `Run five checks against a printed CLI's SKILL.md:
- 1. flag-names — every --flag referenced in SKILL.md is declared in internal/cli/*.go
+ 1. flag-names — every --flag used on a <cli> ... invocation in SKILL.md is declared in internal/cli/*.go
2. flag-commands — every --flag used on a specific command is declared on that command (or persistent)
3. positional-args — positional args in bash recipes match the command's Use: field
4. unknown-command — every referenced command path maps to a cobra Use: declaration
+ 5. canonical-sections — the Prerequisites: Install the CLI section matches what the generator would emit (defends against post-publish edits to generator-owned text)
Fails when the SKILL advertises commands, flags, or arguments that the binary
doesn't actually provide — which is how the recipe-goat "search --max-time"
-bug shipped before this gate existed. Runs the same logic as the library-repo
-CI check (scripts/verify-skill/verify_skill.py) via an embedded copy so no
-external script path is needed.
+bug shipped before this gate existed. Also fails when the install section
+has been hand-edited away from the canonical generator output, which is the
+failure mode that produced fabricated /ppl install slash commands and
+mangled fallback blocks during polish loops.
-Requires python3 on PATH (same dependency as the cookie-auth doctor check).`,
+Checks 1-4 run via the bundled scripts/verify-skill/verify_skill.py; check 5
+runs in Go using the CLI manifest (.printing-press.json) and go.mod.
+Requires python3 on PATH for checks 1-4.`,
Example: ` # Run all checks against a generated CLI
printing-press verify-skill --dir ./my-api-pp-cli
@@ -121,31 +245,117 @@ Requires python3 on PATH (same dependency as the cookie-auth doctor check).`,
printing-press verify-skill --dir ./my-api-pp-cli --json
# Only check a specific category
- printing-press verify-skill --dir ./my-api-pp-cli --only flag-commands`,
+ printing-press verify-skill --dir ./my-api-pp-cli --only flag-commands
+ printing-press verify-skill --dir ./my-api-pp-cli --only canonical-sections`,
RunE: func(cmd *cobra.Command, args []string) error {
if dir == "" {
return &ExitError{Code: ExitInputError, Err: fmt.Errorf("--dir is required")}
}
- result, runErr := runVerifySkillScript(dir, only, asJSON, strict)
- // Forward verifier output to the caller regardless of exit code.
- if result.Stdout != "" {
- fmt.Fprint(os.Stdout, result.Stdout)
+ runCanonical, pyOnly := planVerifyChecks(only)
+ runPython := len(only) == 0 || len(pyOnly) > 0
+
+ var (
+ canonicalRan bool
+ canonicalSkipped bool
+ canonicalFind canonicalFinding
+ canonicalHasFind bool
+ )
+ if runCanonical {
+ var cErr error
+ canonicalFind, canonicalHasFind, canonicalSkipped, cErr = runCanonicalSectionsCheck(dir)
+ if cErr != nil {
+ return &ExitError{Code: ExitInputError, Err: cErr}
+ }
+ canonicalRan = !canonicalSkipped
}
- if result.Stderr != "" {
- fmt.Fprint(os.Stderr, result.Stderr)
+
+ var pyResult verifySkillRunResult
+ if runPython {
+ var runErr error
+ pyResult, runErr = runVerifySkillScript(dir, pyOnly, asJSON, strict)
+ if runErr != nil {
+ return runErr
+ }
}
- if runErr != nil {
- return runErr
+
+ pyHasFinding := pyResult.ExitCode != 0
+
+ if asJSON {
+ if err := emitMergedJSON(pyResult, canonicalRan, canonicalHasFind, canonicalFind); err != nil {
+ return err
+ }
+ } else {
+ if pyResult.Stdout != "" {
+ fmt.Fprint(os.Stdout, pyResult.Stdout)
+ }
+ if pyResult.Stderr != "" {
+ fmt.Fprint(os.Stderr, pyResult.Stderr)
+ }
+ if canonicalRan {
+ if canonicalHasFind {
+ fmt.Fprintln(os.Stdout, " ✘ canonical-sections")
+ fmt.Fprintf(os.Stdout, " [%s] %s: %s\n", canonicalFind.Check, canonicalFind.Command, canonicalFind.Detail)
+ if canonicalFind.Evidence != "" {
+ fmt.Fprintf(os.Stdout, " evidence:\n%s\n", indentLines(canonicalFind.Evidence, " "))
+ }
+ } else {
+ fmt.Fprintln(os.Stdout, " ✓ canonical-sections passed")
+ }
+ }
+ }
+
+ if pyHasFinding || canonicalHasFind {
+ return &ExitError{
+ Code: 1,
+ Err: fmt.Errorf("SKILL verification failed"),
+ Silent: true,
+ }
}
return nil
},
}
cmd.Flags().StringVar(&dir, "dir", "", "Path to the printed CLI directory (contains SKILL.md + internal/cli/)")
- cmd.Flags().StringSliceVar(&only, "only", nil, "Run only the named check(s): flag-names, flag-commands, positional-args, unknown-command (repeatable)")
+ cmd.Flags().StringSliceVar(&only, "only", nil, "Run only the named check(s): flag-names, flag-commands, positional-args, unknown-command, canonical-sections (repeatable)")
cmd.Flags().BoolVar(&asJSON, "json", false, "Output as JSON")
cmd.Flags().BoolVar(&strict, "strict", false, "Treat likely-false-positive findings as failures")
return cmd
}
+
+// emitMergedJSON merges Python and canonical-sections findings into one
+// JSON document on stdout. When the Python script ran, parse and append;
+// otherwise emit a Go-only document that mirrors the Python schema.
+func emitMergedJSON(pyResult verifySkillRunResult, runCanonical, hasCanonicalFinding bool, finding canonicalFinding) error {
+ var report pythonReport
+ if pyResult.Stdout != "" {
+ if err := json.Unmarshal([]byte(pyResult.Stdout), &report); err != nil {
+ return fmt.Errorf("parsing verify-skill JSON: %w", err)
+ }
+ }
+ if runCanonical {
+ report.ChecksRun = append(report.ChecksRun, canonicalSectionsCheckName)
+ if hasCanonicalFinding {
+ report.Findings = append(report.Findings, finding)
+ }
+ }
+ out, err := json.MarshalIndent(report, "", " ")
+ if err != nil {
+ return fmt.Errorf("encoding merged report: %w", err)
+ }
+ fmt.Println(string(out))
+ return nil
+}
+
+// indentLines prefixes every line of s with prefix. Used for human-readable
+// evidence blocks that need indentation matching the rest of the output.
+func indentLines(s, prefix string) string {
+ var b bytes.Buffer
+ for line := range strings.SplitSeq(s, "\n") {
+ b.WriteString(prefix)
+ b.WriteString(line)
+ b.WriteByte('\n')
+ }
+ return b.String()
+}
diff --git a/internal/cli/verify_skill_bundled.py b/internal/cli/verify_skill_bundled.py
index 5835a43b..62e35ef0 100755
--- a/internal/cli/verify_skill_bundled.py
+++ b/internal/cli/verify_skill_bundled.py
@@ -3,8 +3,10 @@
Four checks run in sequence:
- 1. flag-names — every `--flag` in SKILL.md is declared as a cobra flag
- somewhere in internal/cli/*.go.
+ 1. flag-names — every `--flag` used on a `<cli_binary> ...` invocation in
+ SKILL.md is declared as a cobra flag somewhere in internal/cli/*.go.
+ Flags on lines that invoke other tools (npx installers, gh, go,
+ curl, etc.) are out of scope and ignored.
2. flag-commands — every `--flag` used on a specific command is declared
on that command (or as a persistent/root flag).
3. positional-args — positional args in bash recipes match the command's
@@ -82,9 +84,6 @@ FLAG_DECL_RE = re.compile(
r'StringSliceVar|StringArrayVar|UintVar|Uint64Var)P?\('
r'&[^,]+,\s*"([a-z][a-z0-9-]*)"'
)
-FLAG_TOKEN_RE = re.compile(r"(?:^|\s)(--[a-z][a-z0-9-]*)")
-
-
@dataclass
class Finding:
check: str
@@ -596,12 +595,6 @@ def persistent_flag_declared(cli_dir: Path, flag_name: str) -> bool:
# ---------------------------------------------------------------------------
-def extract_all_flags(skill: Path) -> set[str]:
- """Return every `--flag-name` token (without `--`) used anywhere in SKILL.md."""
- text = skill.read_text()
- return {t.lstrip("-") for t in FLAG_TOKEN_RE.findall(text)}
-
-
def extract_recipes(skill: Path, cli_binary: str, cli_dir: Path | None = None) -> list[tuple[list[str], list[str], list[str]]]:
"""Return list of (cmd_path, positional_args, flags) tuples from bash blocks.
@@ -710,16 +703,28 @@ def extract_recipes(skill: Path, cli_binary: str, cli_dir: Path | None = None) -
# ---------------------------------------------------------------------------
-def check_flag_names(cli_dir: Path, skill: Path, report: Report) -> None:
+def check_flag_names(cli_dir: Path, skill: Path, cli_binary: str, report: Report) -> None:
+ # Scoped to recipes so flags belonging to other tools invoked from
+ # SKILL.md (npx installers, gh, go, curl, ...) don't get reported as
+ # missing declarations on the printed CLI. extract_recipes already
+ # filters to lines starting with `cli_binary + " "`.
all_files = list((cli_dir / "internal/cli").glob("*.go"))
- flags = extract_all_flags(skill) - COMMON_FLAGS
- for flag in sorted(flags):
- if not flag_declared_in(all_files, flag):
+ recipes = extract_recipes(skill, cli_binary, cli_dir)
+ seen: set[str] = set()
+ for cmd_path, _positional, flags in recipes:
+ for raw_flag in flags:
+ flag = raw_flag.lstrip("-")
+ if flag in COMMON_FLAGS or flag in seen:
+ continue
+ if flag_declared_in(all_files, flag):
+ continue
+ seen.add(flag)
+ path_str = " ".join(cmd_path)
report.findings.append(
Finding(
check="flag-names",
severity="error",
- command="(any)",
+ command=f"{cli_binary} {path_str}",
detail=f"--{flag} is referenced in SKILL.md but not declared in any internal/cli/*.go",
)
)
@@ -957,7 +962,7 @@ def run_checks(cli_dir: Path, only: set[str] | None) -> Report:
checks = only or {"flag-names", "flag-commands", "positional-args", "unknown-command"}
if "flag-names" in checks:
report.checks_run.append("flag-names")
- check_flag_names(cli_dir, skill, report)
+ check_flag_names(cli_dir, skill, cli_binary, report)
if "flag-commands" in checks:
report.checks_run.append("flag-commands")
check_flag_commands(cli_dir, skill, cli_binary, report)
diff --git a/internal/cli/verify_skill_test.go b/internal/cli/verify_skill_test.go
index 5c7de6f5..c7abbd43 100644
--- a/internal/cli/verify_skill_test.go
+++ b/internal/cli/verify_skill_test.go
@@ -1,12 +1,14 @@
package cli_test
import (
+ "fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
+ "github.com/mvanhorn/cli-printing-press/v3/internal/generator"
"github.com/stretchr/testify/require"
)
@@ -276,6 +278,155 @@ func Execute() error {
require.Contains(t, string(out), "--pick")
}
+// TestVerifySkill_IgnoresExternalToolFlags is the regression guard for
+// the trigger-dev / linear SKILL.md slip: the install instructions contain
+// `npx -y @mvanhorn/printing-press install <api> --cli-only`. --cli-only
+// belongs to the outer Printing Press installer, not to <api>-pp-cli, so
+// it must not be reported as an undeclared flag-names finding. Before the
+// scoping fix, flag-names regex-scanned the whole SKILL.md and fired on
+// every external-tool flag, which led an automation loop to strip the
+// flag (and in one case invent a fake /ppl install slash command) just
+// to make verify-skill exit 0.
+func TestVerifySkill_IgnoresExternalToolFlags(t *testing.T) {
+ t.Parallel()
+
+ bin := buildPrintingPressBinary(t)
+ dir := t.TempDir()
+
+ // SKILL.md uses --cli-only on an npx invocation (not on fixture-pp-cli)
+ // and uses only declared flags on its own binary's recipes.
+ skill := "---\nname: pp-fixture\n---\n\n# Fixture\n\n## Prerequisites\n\n" +
+ "```bash\nnpx -y @mvanhorn/printing-press install fixture --cli-only\n```\n\n" +
+ "## Usage\n\n```bash\nfixture-pp-cli search --limit 5\n```\n"
+ writeVerifySkillFixture(t, dir, map[string]string{
+ "search.go": `package cli
+import "github.com/spf13/cobra"
+func newSearchCmd() *cobra.Command {
+ var limit int
+ cmd := &cobra.Command{Use: "search"}
+ cmd.Flags().IntVar(&limit, "limit", 10, "Max results")
+ return cmd
+}
+`,
+ "root.go": `package cli
+import "github.com/spf13/cobra"
+func Execute() error {
+ rootCmd := &cobra.Command{Use: "fixture-pp-cli"}
+ rootCmd.AddCommand(newSearchCmd())
+ return rootCmd.Execute()
+}
+`,
+ }, skill)
+
+ out, err := exec.Command(bin, "verify-skill", "--dir", dir).CombinedOutput()
+ require.NoError(t, err, "external-tool flags must not produce a flag-names finding: %s", string(out))
+ require.NotContains(t, string(out), "--cli-only", "verifier must not mention an external-tool flag")
+}
+
+// TestVerifySkill_CanonicalSectionsPassesOnFreshFixture confirms the
+// canonical-sections check exits 0 when a fixture's SKILL.md install
+// section matches what the generator would emit. The fixture is built
+// from CanonicalSkillInstallSection itself so a test failure here means
+// the runtime check disagrees with the function used to populate the
+// fixture — i.e. real drift, not test brittleness.
+func TestVerifySkill_CanonicalSectionsPassesOnFreshFixture(t *testing.T) {
+ t.Parallel()
+ bin := buildPrintingPressBinary(t)
+ dir := writeCanonicalFixture(t, "myapi", "productivity", false, "")
+ out, err := exec.Command(bin, "verify-skill", "--dir", dir, "--only", "canonical-sections").CombinedOutput()
+ require.NoError(t, err, "fresh fixture must pass canonical-sections: %s", string(out))
+ require.Contains(t, string(out), "canonical-sections passed")
+}
+
+// TestVerifySkill_CanonicalSectionsCatchesFlagStrip is the regression
+// guard for the trigger-dev SKILL slip — an automation loop stripped
+// `--cli-only` from the npx installer line to silence a verify-skill
+// flag-names false positive. The canonical-sections check must catch
+// that edit independent of whether flag-names fires.
+func TestVerifySkill_CanonicalSectionsCatchesFlagStrip(t *testing.T) {
+ t.Parallel()
+ bin := buildPrintingPressBinary(t)
+ tampered := strings.Replace(
+ generator.CanonicalSkillInstallSection("myapi", "productivity", false),
+ " --cli-only", "", 1,
+ )
+ dir := writeCanonicalFixture(t, "myapi", "productivity", false, tampered)
+ out, err := exec.Command(bin, "verify-skill", "--dir", dir, "--only", "canonical-sections").CombinedOutput()
+ require.Error(t, err, "stripping --cli-only must fail canonical-sections: %s", string(out))
+ require.Contains(t, string(out), "canonical-sections")
+ require.Contains(t, string(out), "drift")
+}
+
+// TestVerifySkill_CanonicalSectionsCatchesFabricatedInstall is the
+// regression guard for the linear SKILL slip — an automation loop
+// replaced the entire install instructions with a fabricated
+// `/ppl install linear` slash command that doesn't exist. The canonical
+// section's start-heading is still present but the body is wrong, so
+// the block-equality compare must fire.
+func TestVerifySkill_CanonicalSectionsCatchesFabricatedInstall(t *testing.T) {
+ t.Parallel()
+ bin := buildPrintingPressBinary(t)
+ fabricated := "## Prerequisites: Install the CLI\n\nInstall via the Printing Press Library plugin (`/ppl install myapi` from Claude Code).\n\nIf `--version` reports \"command not found\" after install, the install step did not put the binary on `$PATH`. Do not proceed with skill commands until verification succeeds.\n"
+ dir := writeCanonicalFixture(t, "myapi", "productivity", false, fabricated)
+ out, err := exec.Command(bin, "verify-skill", "--dir", dir, "--only", "canonical-sections").CombinedOutput()
+ require.Error(t, err, "fabricated install instructions must fail canonical-sections: %s", string(out))
+ require.Contains(t, string(out), "drift")
+}
+
+// TestVerifySkill_CanonicalSectionsSkipsWithoutManifest confirms the
+// canonical-sections check is a no-op when the fixture lacks
+// .printing-press.json's api_name or go.mod. Minimal verify-skill test
+// fixtures (writeVerifySkillFixture) are not full printed CLIs and must
+// not trigger spurious canonical-sections failures.
+func TestVerifySkill_CanonicalSectionsSkipsWithoutManifest(t *testing.T) {
+ t.Parallel()
+ bin := buildPrintingPressBinary(t)
+ dir := t.TempDir()
+ writeVerifySkillFixture(t, dir, map[string]string{
+ "root.go": `package cli
+import "github.com/spf13/cobra"
+func Execute() error { return (&cobra.Command{Use: "fixture-pp-cli"}).Execute() }
+`,
+ }, "---\nname: pp-fixture\n---\n\n# Fixture\n")
+ out, err := exec.Command(bin, "verify-skill", "--dir", dir, "--only", "canonical-sections").CombinedOutput()
+ require.NoError(t, err, "fixture without manifest must skip silently: %s", string(out))
+ require.NotContains(t, string(out), "canonical-sections passed", "skipped check must produce no pass/fail line")
+}
+
+// writeCanonicalFixture writes a fully-formed fixture (manifest + go.mod +
+// SKILL.md with canonical install section + minimal cobra source) suitable
+// for exercising the canonical-sections check end-to-end. When skillBody is
+// "", the canonical install section is used verbatim; pass a tampered body
+// to simulate hand-editing of the install section.
+func writeCanonicalFixture(t *testing.T, name, category string, usesBrowserHTTP bool, skillBody string) string {
+ t.Helper()
+ dir := t.TempDir()
+ cliDir := filepath.Join(dir, "internal", "cli")
+ require.NoError(t, os.MkdirAll(cliDir, 0o755))
+ require.NoError(t, os.WriteFile(filepath.Join(cliDir, "root.go"), []byte(`package cli
+import "github.com/spf13/cobra"
+func Execute() error { return (&cobra.Command{Use: "`+name+`-pp-cli"}).Execute() }
+`), 0o644))
+
+ goVersion := "1.23"
+ if usesBrowserHTTP {
+ goVersion = "1.25"
+ }
+ require.NoError(t, os.WriteFile(filepath.Join(dir, "go.mod"),
+ []byte("module github.com/example/"+name+"-pp-cli\n\ngo "+goVersion+"\n"), 0o644))
+
+ manifest := fmt.Sprintf(`{"api_name":%q,"cli_name":%q,"category":%q}`,
+ name, name+"-pp-cli", category)
+ require.NoError(t, os.WriteFile(filepath.Join(dir, ".printing-press.json"), []byte(manifest), 0o644))
+
+ if skillBody == "" {
+ skillBody = generator.CanonicalSkillInstallSection(name, category, usesBrowserHTTP)
+ }
+ skill := "---\nname: pp-" + name + "\ndescription: \"fixture\"\n---\n\n# " + name + "\n\n" + skillBody + "\nFixture body.\n"
+ require.NoError(t, os.WriteFile(filepath.Join(dir, "SKILL.md"), []byte(skill), 0o644))
+ return dir
+}
+
// TestVerifySkill_RejectsMissingInputs confirms usage errors (code 2).
func TestVerifySkill_RejectsMissingInputs(t *testing.T) {
t.Parallel()
diff --git a/internal/generator/install_section.go b/internal/generator/install_section.go
new file mode 100644
index 00000000..6e5166e6
--- /dev/null
+++ b/internal/generator/install_section.go
@@ -0,0 +1,94 @@
+package generator
+
+import (
+ "fmt"
+ "strings"
+)
+
+// SkillInstallSectionStartHeading is the canonical heading that opens the
+// install/prerequisites section in a printed CLI's SKILL.md.
+const SkillInstallSectionStartHeading = "## Prerequisites: Install the CLI"
+
+// SkillInstallSectionEndSubstr is a fragment that uniquely identifies the
+// last line of the canonical install section. The canonical block ends on
+// the line that contains this substring; downstream content (narrative
+// value-prop, "## When to Use This CLI", etc.) is owned by other parts of
+// the template and is not enforced by the canonical-sections check.
+const SkillInstallSectionEndSubstr = "Do not proceed with skill commands until verification succeeds."
+
+// canonicalSkillInstallSectionFormat is the literal text the generator
+// emits into a printed CLI's SKILL.md install section. Indexed verbs:
+//
+// %[1]s — CLI slug (e.g. "linear" — produces linear-pp-cli)
+// %[2]s — Go-fallback floor ("Go 1.23+" or "Go 1.25+")
+// %[3]s — catalog category (or "other" when empty)
+//
+// Stays in lockstep with internal/generator/templates/skill.md.tmpl via
+// TestCanonicalSkillInstallSectionMatchesTemplate.
+const canonicalSkillInstallSectionFormat = "## Prerequisites: Install the CLI\n" +
+ "\n" +
+ "This skill drives the `%[1]s-pp-cli` binary. **You must verify the CLI is installed before invoking any command from this skill.** If it is missing, install it first:\n" +
+ "\n" +
+ "1. Install via the Printing Press installer:\n" +
+ " ```bash\n" +
+ " npx -y @mvanhorn/printing-press install %[1]s --cli-only\n" +
+ " ```\n" +
+ "2. Verify: `%[1]s-pp-cli --version`\n" +
+ "3. Ensure `$GOPATH/bin` (or `$HOME/go/bin`) is on `$PATH`.\n" +
+ "\n" +
+ "If the `npx` install fails (no Node, offline, etc.), fall back to a direct Go install (requires %[2]s):\n" +
+ "\n" +
+ "```bash\n" +
+ "go install github.com/mvanhorn/printing-press-library/library/%[3]s/%[1]s/cmd/%[1]s-pp-cli@latest\n" +
+ "```\n" +
+ "\n" +
+ "If `--version` reports \"command not found\" after install, the install step did not put the binary on `$PATH`. Do not proceed with skill commands until verification succeeds.\n"
+
+// CanonicalSkillInstallSection returns the exact text of the install/
+// prerequisites section that the generator emits into a printed CLI's
+// SKILL.md, given the CLI slug, the catalog category (empty -> "other"),
+// and whether the CLI uses the browser HTTP transport (raises the Go-
+// fallback floor from 1.23 to 1.25).
+//
+// The verify-skill canonical-sections check uses this function to detect
+// post-publish edits to the install instructions. The function is the
+// authoritative source post-generation; the template stays in sync via
+// TestCanonicalSkillInstallSectionMatchesTemplate.
+func CanonicalSkillInstallSection(name, category string, usesBrowserHTTP bool) string {
+ if category == "" {
+ category = "other"
+ }
+ goFloor := "Go 1.23+"
+ if usesBrowserHTTP {
+ goFloor = "Go 1.25+"
+ }
+ return fmt.Sprintf(canonicalSkillInstallSectionFormat, name, goFloor, category)
+}
+
+// ExtractSkillInstallSection slices the install/prerequisites block out of
+// a printed CLI's SKILL.md content. Returns the text from the start
+// heading through the trailing newline of the end sentinel line.
+//
+// Returns ok=false when either delimiter is missing, signalling that the
+// SKILL.md has been edited so heavily the canonical block is no longer
+// recognizable — surfaced as a "section missing" finding by callers.
+func ExtractSkillInstallSection(skill string) (string, bool) {
+ startIdx := strings.Index(skill, SkillInstallSectionStartHeading)
+ if startIdx == -1 {
+ return "", false
+ }
+ if startIdx > 0 && skill[startIdx-1] != '\n' {
+ return "", false
+ }
+ tail := skill[startIdx:]
+ sentinelIdx := strings.Index(tail, SkillInstallSectionEndSubstr)
+ if sentinelIdx == -1 {
+ return "", false
+ }
+ rest := tail[sentinelIdx+len(SkillInstallSectionEndSubstr):]
+ nlIdx := strings.Index(rest, "\n")
+ if nlIdx == -1 {
+ return tail, true
+ }
+ return tail[:sentinelIdx+len(SkillInstallSectionEndSubstr)+nlIdx+1], true
+}
diff --git a/internal/generator/install_section_test.go b/internal/generator/install_section_test.go
new file mode 100644
index 00000000..0cb77114
--- /dev/null
+++ b/internal/generator/install_section_test.go
@@ -0,0 +1,85 @@
+// Copyright 2026 trevin-chow. Licensed under Apache-2.0. See LICENSE.
+
+package generator
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/mvanhorn/cli-printing-press/v3/internal/spec"
+ "github.com/stretchr/testify/require"
+)
+
+// TestCanonicalSkillInstallSectionMatchesTemplate is the sync gate between
+// CanonicalSkillInstallSection and skill.md.tmpl. The two are parallel
+// renderings of the same canonical install block — one Go literal, one
+// Go template — and they must produce byte-identical output for any
+// (name, category, usesBrowserHTTP) tuple. If either drifts, this test
+// fails before any printed CLI ships with a desynced install section.
+//
+// The verify-skill canonical-sections check enforces this contract at
+// the printed-CLI boundary; this test enforces it at the generator
+// boundary so changes to the template (or to the function) cannot
+// silently desync.
+func TestCanonicalSkillInstallSectionMatchesTemplate(t *testing.T) {
+ t.Parallel()
+
+ cases := []struct {
+ label string
+ apiName string
+ category string
+ usesBrowserHTTP bool
+ }{
+ {"empty category and standard transport", "myapi", "", false},
+ {"explicit category", "myapi", "productivity", false},
+ {"browser transport raises Go floor", "myapi", "productivity", true},
+ {"slug with hyphens", "trigger-dev", "developer-tools", false},
+ }
+
+ for _, tc := range cases {
+ t.Run(tc.label, func(t *testing.T) {
+ t.Parallel()
+
+ s := minimalSpec(tc.apiName)
+ s.Category = tc.category
+ if tc.usesBrowserHTTP {
+ s.HTTPTransport = spec.HTTPTransportBrowserChrome
+ }
+
+ outputDir := filepath.Join(t.TempDir(), tc.apiName+"-pp-cli")
+ gen := New(s, outputDir)
+ require.NoError(t, gen.Generate())
+
+ rendered, err := os.ReadFile(filepath.Join(outputDir, "SKILL.md"))
+ require.NoError(t, err)
+
+ extracted, ok := ExtractSkillInstallSection(string(rendered))
+ require.True(t, ok, "extractor must find the install section in a freshly-rendered SKILL.md")
+
+ expected := CanonicalSkillInstallSection(tc.apiName, tc.category, tc.usesBrowserHTTP)
+ require.Equal(t, expected, extracted,
+ "template-rendered install section must equal CanonicalSkillInstallSection output for %s", tc.label)
+ })
+ }
+}
+
+// TestExtractSkillInstallSectionMissingStart confirms the extractor
+// reports ok=false when the canonical heading is missing — the case
+// where an agent has rewritten the section into something unrecognizable.
+func TestExtractSkillInstallSectionMissingStart(t *testing.T) {
+ t.Parallel()
+ _, ok := ExtractSkillInstallSection("# Some Skill\n\nNo prerequisites heading here.\n")
+ require.False(t, ok)
+}
+
+// TestExtractSkillInstallSectionMissingEnd confirms the extractor reports
+// ok=false when the heading exists but the canonical end-sentinel is gone
+// — for example, when an agent stripped the troubleshooting line so the
+// canonical block can't be sliced cleanly.
+func TestExtractSkillInstallSectionMissingEnd(t *testing.T) {
+ t.Parallel()
+ skill := "# Some Skill\n\n## Prerequisites: Install the CLI\n\nrun some command\n\n## When to Use\n"
+ _, ok := ExtractSkillInstallSection(skill)
+ require.False(t, ok)
+}
diff --git a/scripts/verify-skill/verify_skill.py b/scripts/verify-skill/verify_skill.py
index 5835a43b..62e35ef0 100755
--- a/scripts/verify-skill/verify_skill.py
+++ b/scripts/verify-skill/verify_skill.py
@@ -3,8 +3,10 @@
Four checks run in sequence:
- 1. flag-names — every `--flag` in SKILL.md is declared as a cobra flag
- somewhere in internal/cli/*.go.
+ 1. flag-names — every `--flag` used on a `<cli_binary> ...` invocation in
+ SKILL.md is declared as a cobra flag somewhere in internal/cli/*.go.
+ Flags on lines that invoke other tools (npx installers, gh, go,
+ curl, etc.) are out of scope and ignored.
2. flag-commands — every `--flag` used on a specific command is declared
on that command (or as a persistent/root flag).
3. positional-args — positional args in bash recipes match the command's
@@ -82,9 +84,6 @@ FLAG_DECL_RE = re.compile(
r'StringSliceVar|StringArrayVar|UintVar|Uint64Var)P?\('
r'&[^,]+,\s*"([a-z][a-z0-9-]*)"'
)
-FLAG_TOKEN_RE = re.compile(r"(?:^|\s)(--[a-z][a-z0-9-]*)")
-
-
@dataclass
class Finding:
check: str
@@ -596,12 +595,6 @@ def persistent_flag_declared(cli_dir: Path, flag_name: str) -> bool:
# ---------------------------------------------------------------------------
-def extract_all_flags(skill: Path) -> set[str]:
- """Return every `--flag-name` token (without `--`) used anywhere in SKILL.md."""
- text = skill.read_text()
- return {t.lstrip("-") for t in FLAG_TOKEN_RE.findall(text)}
-
-
def extract_recipes(skill: Path, cli_binary: str, cli_dir: Path | None = None) -> list[tuple[list[str], list[str], list[str]]]:
"""Return list of (cmd_path, positional_args, flags) tuples from bash blocks.
@@ -710,16 +703,28 @@ def extract_recipes(skill: Path, cli_binary: str, cli_dir: Path | None = None) -
# ---------------------------------------------------------------------------
-def check_flag_names(cli_dir: Path, skill: Path, report: Report) -> None:
+def check_flag_names(cli_dir: Path, skill: Path, cli_binary: str, report: Report) -> None:
+ # Scoped to recipes so flags belonging to other tools invoked from
+ # SKILL.md (npx installers, gh, go, curl, ...) don't get reported as
+ # missing declarations on the printed CLI. extract_recipes already
+ # filters to lines starting with `cli_binary + " "`.
all_files = list((cli_dir / "internal/cli").glob("*.go"))
- flags = extract_all_flags(skill) - COMMON_FLAGS
- for flag in sorted(flags):
- if not flag_declared_in(all_files, flag):
+ recipes = extract_recipes(skill, cli_binary, cli_dir)
+ seen: set[str] = set()
+ for cmd_path, _positional, flags in recipes:
+ for raw_flag in flags:
+ flag = raw_flag.lstrip("-")
+ if flag in COMMON_FLAGS or flag in seen:
+ continue
+ if flag_declared_in(all_files, flag):
+ continue
+ seen.add(flag)
+ path_str = " ".join(cmd_path)
report.findings.append(
Finding(
check="flag-names",
severity="error",
- command="(any)",
+ command=f"{cli_binary} {path_str}",
detail=f"--{flag} is referenced in SKILL.md but not declared in any internal/cli/*.go",
)
)
@@ -957,7 +962,7 @@ def run_checks(cli_dir: Path, only: set[str] | None) -> Report:
checks = only or {"flag-names", "flag-commands", "positional-args", "unknown-command"}
if "flag-names" in checks:
report.checks_run.append("flag-names")
- check_flag_names(cli_dir, skill, report)
+ check_flag_names(cli_dir, skill, cli_binary, report)
if "flag-commands" in checks:
report.checks_run.append("flag-commands")
check_flag_commands(cli_dir, skill, cli_binary, report)
diff --git a/skills/printing-press-polish/SKILL.md b/skills/printing-press-polish/SKILL.md
index a9644755..a1864e36 100644
--- a/skills/printing-press-polish/SKILL.md
+++ b/skills/printing-press-polish/SKILL.md
@@ -223,7 +223,7 @@ Parse findings into categories:
| Category | Source | What to look for |
|----------|--------|------------------|
| Verify failures | verify --json | Commands with score < 3 |
-| SKILL static-check failures | verify-skill --json | Any `findings[]` with `severity=error` (flag-names, flag-commands, positional-args). Hard ship-gate: ship cannot fire while these exist. |
+| SKILL static-check failures | verify-skill --json | Any `findings[]` with `severity=error` (flag-names, flag-commands, positional-args, unknown-command, canonical-sections). Hard ship-gate: ship cannot fire while these exist. |
| Workflow gaps | workflow-verify --json | Verdict `workflow-fail`. Soft gate: surface in `remaining_issues` and downgrade to `hold` when the workflow is the CLI's primary value. |
| Dead code | dogfood | Dead functions, dead flags |
| Stale files | dogfood | Unregistered commands |
@@ -409,15 +409,18 @@ during generation.
### Priority 4.5: SKILL static-check failures (verify-skill)
-Read `/tmp/polish-verify-skill.json` for the full finding list. Each finding has a `check` (`flag-names`, `flag-commands`, or `positional-args`), a `command` (the path the SKILL claimed), and a `detail` describing the mismatch. Common shapes and fixes:
+Read `/tmp/polish-verify-skill.json` for the full finding list. Each finding has a `check` (`flag-names`, `flag-commands`, `positional-args`, `unknown-command`, or `canonical-sections`), a `command` (the path the SKILL claimed), and a `detail` describing the mismatch. Common shapes and fixes:
-- **`flag-names`** — SKILL references `--foo` but no command in `internal/cli/*.go` declares it. Either the example is wrong (fix the SKILL or remove the recipe) or the flag was deleted (decide if it should come back).
+- **`flag-names`** — SKILL references `--foo` on a `<cli> ...` invocation but no command in `internal/cli/*.go` declares it. Either the example is wrong (fix the SKILL or remove the recipe) or the flag was deleted (decide if it should come back). **Out of scope:** flags on lines that invoke other tools (e.g. `npx -y @mvanhorn/printing-press install <api> --cli-only`, `gh pr create --base ...`, `go install ...`). The recipe-scoped flag-names check ignores those by design — never strip an external-tool flag to make verify-skill exit 0, and never replace the install instructions with a fabricated slash command. If the finding is firing on an external-tool flag anyway, that is a verify-skill bug, not a SKILL bug; report it instead of editing the SKILL.
- **`flag-commands`** — `--foo is declared elsewhere but not on <cmd>`. The flag exists somewhere but not on the command the SKILL invoked it on. Two fixes:
1. If the flag is added via a shared helper like `addXxxFlags(cmd, ...)`, inline the `cmd.Flags().StringVar(...)` declaration directly in the affected command's source file. The verify-skill grep cannot follow function-call indirection.
2. If the SKILL example is genuinely wrong, fix the example to use a flag the command does declare.
- **`positional-args`** — `got N positional args; Use: "<cmd> <arg>" expects M-M`. The SKILL recipe passed N positional args but the command's `Use:` declares M required. Two fixes:
1. If the command also accepts the value via a `--flag`, change `Use: "cmd <arg>"` to `Use: "cmd [arg]"` (square brackets = optional). Verify-skill correctly accepts `--flag`-only invocations against an optional positional.
2. If the SKILL example is missing a required positional, fix the example.
+- **`canonical-sections`** — `install section drift: hand-edit detected in a generator-owned section`. The `## Prerequisites: Install the CLI` block has been edited away from what the generator would emit for this CLI today. **Do not hand-edit the install section.** It's templated from `internal/generator/templates/skill.md.tmpl` parameterized on `(api_name, category, uses_browser_http_transport)`; any drift means an automation step or person modified text the machine owns. Resolve by regenerating the printed CLI (run `printing-press regen` against this directory, or for a published CLI, regenerate from the spec and re-publish). If the canonical text itself is wrong (e.g., a real change to the install instructions is needed), fix the template, not the printed CLI.
+
+When editing other parts of SKILL.md, Read the affected section first and Read it again after the Edit. `Edit` replaces a literal string; if the surrounding context has drifted, a single Edit can graft a second copy of a block onto the first instead of replacing it.
After fixing, re-run `printing-press verify-skill --dir "$CLI_DIR"` and confirm exit 0 before moving on.
← d2eca305 fix(skills): repair publish generated-artifact flow (#672)
·
back to Cli Printing Press
·
Document write-time PII redaction in dogfood reports (#673) a590b6b9 →