[object Object]

← back to Cli Printing Press

fix(cli): handle single-quoted args in validate-narrative shell tokenizer (#1424)

33a6668bd88b48ae3f1bf8f2b55c0bdff42b1620 · 2026-05-14 22:13:04 -0700 · Trevin Chow

shellargs.Split only recognized double quotes, so any narrative recipe
with single-quoted args (`tag-find 'Autumn 2025 Cohort'`,
`normalize-date '5/12/2026'`) reached the underlying command with the
quotes still attached. validate-narrative --full-examples then failed
with downstream parse errors, and authors silently scrubbed the quotes
from research.json -- which made the rendered README/SKILL recipes
broken for any user pasting them into a real shell.

Add single-quote handling with POSIX semantics: contents are literal,
backslash escapes do not apply, and an unbalanced single quote produces
a quote-type-specific error so authors can locate the problem instead
of chasing an argument-parse failure downstream.

Closes #1159

Files touched

Diff

commit 33a6668bd88b48ae3f1bf8f2b55c0bdff42b1620
Author: Trevin Chow <trevin@trevinchow.com>
Date:   Thu May 14 22:13:04 2026 -0700

    fix(cli): handle single-quoted args in validate-narrative shell tokenizer (#1424)
    
    shellargs.Split only recognized double quotes, so any narrative recipe
    with single-quoted args (`tag-find 'Autumn 2025 Cohort'`,
    `normalize-date '5/12/2026'`) reached the underlying command with the
    quotes still attached. validate-narrative --full-examples then failed
    with downstream parse errors, and authors silently scrubbed the quotes
    from research.json -- which made the rendered README/SKILL recipes
    broken for any user pasting them into a real shell.
    
    Add single-quote handling with POSIX semantics: contents are literal,
    backslash escapes do not apply, and an unbalanced single quote produces
    a quote-type-specific error so authors can locate the problem instead
    of chasing an argument-parse failure downstream.
    
    Closes #1159
---
 internal/shellargs/shellargs.go      | 40 ++++++++++++++++++++++++++++--------
 internal/shellargs/shellargs_test.go | 39 +++++++++++++++++++++++++++++++++--
 2 files changed, 68 insertions(+), 11 deletions(-)

diff --git a/internal/shellargs/shellargs.go b/internal/shellargs/shellargs.go
index 13d4e39e..334d2e0c 100644
--- a/internal/shellargs/shellargs.go
+++ b/internal/shellargs/shellargs.go
@@ -6,14 +6,16 @@ import (
 )
 
 // Split tokenizes the simple command examples the Printing Press emits in
-// README/SKILL narrative. It preserves double-quoted tokens and backslash
-// escapes, but intentionally does not perform shell expansion.
+// README/SKILL narrative. It preserves double-quoted and single-quoted
+// tokens and backslash escapes (POSIX semantics: backslashes are literal
+// inside single quotes), but intentionally does not perform shell
+// expansion.
 func Split(s string) ([]string, error) {
 	s = joinLineContinuations(s)
 
 	var tokens []string
 	var current strings.Builder
-	inQuote := false
+	var quoteChar rune // 0 outside quotes; '"' or '\'' while inside.
 	tokenStarted := false
 	escaped := false
 
@@ -30,14 +32,27 @@ func Split(s string) ([]string, error) {
 			escaped = false
 			continue
 		}
+		// Single quotes: everything is literal until the closing quote.
+		// POSIX forbids backslash escapes inside single quotes, so the
+		// '\\' branch must be skipped while quoteChar is '\''.
+		if quoteChar == '\'' {
+			if r == '\'' {
+				quoteChar = 0
+				tokenStarted = true
+				continue
+			}
+			current.WriteRune(r)
+			tokenStarted = true
+			continue
+		}
 		if r == '\\' {
 			escaped = true
 			tokenStarted = true
 			continue
 		}
-		if inQuote {
+		if quoteChar == '"' {
 			if r == '"' {
-				inQuote = false
+				quoteChar = 0
 				tokenStarted = true
 				continue
 			}
@@ -46,8 +61,8 @@ func Split(s string) ([]string, error) {
 			continue
 		}
 		switch r {
-		case '"':
-			inQuote = true
+		case '"', '\'':
+			quoteChar = r
 			tokenStarted = true
 		case '#':
 			if !tokenStarted {
@@ -71,8 +86,8 @@ func Split(s string) ([]string, error) {
 	if escaped {
 		current.WriteRune('\\')
 	}
-	if inQuote {
-		return nil, fmt.Errorf("unclosed quote in %q", s)
+	if quoteChar != 0 {
+		return nil, fmt.Errorf("unclosed %s quote in %q", quoteName(quoteChar), s)
 	}
 	if tokenStarted {
 		flush()
@@ -80,6 +95,13 @@ func Split(s string) ([]string, error) {
 	return tokens, nil
 }
 
+func quoteName(r rune) string {
+	if r == '\'' {
+		return "single"
+	}
+	return "double"
+}
+
 func joinLineContinuations(s string) string {
 	for _, newline := range []string{"\\\r\n", "\\\n"} {
 		s = strings.ReplaceAll(s, newline, "")
diff --git a/internal/shellargs/shellargs_test.go b/internal/shellargs/shellargs_test.go
index f186ba49..34c53e54 100644
--- a/internal/shellargs/shellargs_test.go
+++ b/internal/shellargs/shellargs_test.go
@@ -2,6 +2,7 @@ package shellargs
 
 import (
 	"reflect"
+	"strings"
 	"testing"
 )
 
@@ -32,6 +33,24 @@ func TestSplit(t *testing.T) {
 		{`cli regex foo#bar`, []string{"cli", "regex", "foo#bar"}},
 		// Escaped '#' is a literal.
 		{`cli \#literal`, []string{"cli", "#literal"}},
+		// Single-quoted args: contents are literal, surrounding quotes
+		// are stripped so the consumer sees the value the author meant.
+		{`cli normalize-date '5/12/2026'`, []string{"cli", "normalize-date", "5/12/2026"}},
+		{`cli tag-find 'Autumn 2025 Cohort'`, []string{"cli", "tag-find", "Autumn 2025 Cohort"}},
+		// POSIX rule: backslashes are literal inside single quotes.
+		{`cli echo 'C:\path\to\file'`, []string{"cli", "echo", `C:\path\to\file`}},
+		// Single-quoted '#' is part of the value, not a comment, mirroring
+		// the existing double-quote case.
+		{`cli query '# not a comment'`, []string{"cli", "query", "# not a comment"}},
+		// Mixed quoting in one example: double for some args, single for
+		// others. Both sets get stripped, both preserve embedded spaces.
+		{`cli echo "double quoted" 'single quoted'`, []string{"cli", "echo", "double quoted", "single quoted"}},
+		// Adjacent quoted segments concatenate within the same token, the
+		// way bash joins "foo"'bar' into a single argument 'foobar'.
+		{`cli echo "foo"'bar'`, []string{"cli", "echo", "foobar"}},
+		// Single-quoted segments cannot contain a literal single quote;
+		// authors close, escape, and reopen — `foo'\''bar` -> foo'bar.
+		{`cli echo 'foo'\''bar'`, []string{"cli", "echo", "foo'bar"}},
 	}
 	for _, tc := range cases {
 		got, err := Split(tc.in)
@@ -45,8 +64,24 @@ func TestSplit(t *testing.T) {
 }
 
 func TestSplitUnclosedQuote(t *testing.T) {
-	if _, err := Split(`cli "unclosed`); err == nil {
-		t.Fatal("expected unclosed quote error")
+	cases := []struct {
+		in       string
+		wantSubs string
+	}{
+		// Per #1159 acceptance criteria: an unbalanced single quote must
+		// surface a clear, quote-type-specific error so authors don't
+		// chase a downstream argument-parse failure.
+		{`cli tag-find 'Autumn`, "unclosed single quote"},
+		{`cli "unclosed`, "unclosed double quote"},
+	}
+	for _, tc := range cases {
+		_, err := Split(tc.in)
+		if err == nil {
+			t.Fatalf("Split(%q): expected error, got nil", tc.in)
+		}
+		if !strings.Contains(err.Error(), tc.wantSubs) {
+			t.Fatalf("Split(%q) error = %q, want substring %q", tc.in, err, tc.wantSubs)
+		}
 	}
 }
 

← d4b173d6 docs(cli): document sync --resources parent-keyed dependent  ·  back to Cli Printing Press  ·  fix(cli): scope HOME/XDG_CONFIG_HOME for verify/dogfood subp c5a51ead →