← 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
M internal/shellargs/shellargs.goM internal/shellargs/shellargs_test.go
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 →