From 6188e6b1daf030856887a02451d3297e637ca81f Mon Sep 17 00:00:00 2001 From: jochen Date: Sat, 10 Oct 2026 18:36:47 +0200 Subject: [PATCH 1/2] Say where a command line's quote is left open and how a quote is written (hq issue 294) An apostrophe inside a single-quoted value ends that quote, and the line then failed as a bare 'unclosed quote' with no position and no way out named. The splitter's rules stay the shell's; the refusal now names the character, shows the line from there and gives the two ways to write a quote. --- cmd/mesh-controller/seatverbs.go | 22 ++++++++++++-- cmd/mesh-controller/seatverbs_test.go | 42 +++++++++++++++++++++++++++ 2 files changed, 61 insertions(+), 3 deletions(-) diff --git a/cmd/mesh-controller/seatverbs.go b/cmd/mesh-controller/seatverbs.go index d3979dfc..7116e6da 100644 --- a/cmd/mesh-controller/seatverbs.go +++ b/cmd/mesh-controller/seatverbs.go @@ -1366,7 +1366,7 @@ func splitCommandLine(line string) ([]string, error) { var words []string var cur strings.Builder inWord := false - quote := rune(0) + quote, opened := rune(0), 0 runes := []rune(line) for i := 0; i < len(runes); i++ { r := runes[i] @@ -1387,7 +1387,7 @@ func splitCommandLine(line string) ([]string, error) { cur.WriteRune(r) } case r == '\'' || r == '"': - quote = r + quote, opened = r, i inWord = true case r == '\\' && i+1 < len(runes): i++ @@ -1405,7 +1405,7 @@ func splitCommandLine(line string) ([]string, error) { } } if quote != 0 { - return nil, fmt.Errorf("command has an unclosed %c quote", quote) + return nil, unclosedQuote(runes, quote, opened) } if inWord { words = append(words, cur.String()) @@ -1413,6 +1413,22 @@ func splitCommandLine(line string) ([]string, error) { return words, nil } +// unclosedQuote is the refusal of a line whose quote is never closed. Most often an apostrophe inside +// a single-quoted value ended that quote early and a later quote was left open, so the refusal says +// where the open quote is, shows the line from there, and says how a quote is written inside a quoted +// value — the shell's own two ways, which this splitter already reads (novox/hq issue 294). +func unclosedQuote(runes []rune, quote rune, opened int) error { + const shown = 40 + from := runes[opened:] + excerpt := string(from) + if len(from) > shown { + excerpt = string(from[:shown]) + "…" + } + return fmt.Errorf("command has an unclosed %c quote, opened at character %d: %s — an apostrophe "+ + "inside a single-quoted value ends it; write a ' inside single quotes as '\\'' (it'\\''s), or use "+ + "double quotes and write \\\" for a \" and \\\\ for a \\ inside them", quote, opened+1, excerpt) +} + // seatAnnouncement is what the controller says it serves on the bus (novox/hq ADR 0197): the // mesh-controller seat, one endpoint per verb it answers, each with the seat's own description and // argument schema — the same facts `tools` answers from the records, as NATS's services format. diff --git a/cmd/mesh-controller/seatverbs_test.go b/cmd/mesh-controller/seatverbs_test.go index e1a5bf24..dd1c638e 100644 --- a/cmd/mesh-controller/seatverbs_test.go +++ b/cmd/mesh-controller/seatverbs_test.go @@ -415,3 +415,45 @@ func TestNoVerbSetsTheOperatorsKeyOrRevealsASecret(t *testing.T) { t.Fatalf("behind %v: %v", behind, err) } } + +// A value with a quote in it can be said on a `command` line, as in a shell, and a line whose quote +// is left open is refused naming where it opened and how a quote is written (novox/hq issue 294: an +// apostrophe inside a single-quoted JSON value cut the line, and the refusal named no cause). +func TestAQuotedValueCanHoldAQuote(t *testing.T) { + for _, c := range []struct{ line, want string }{ + {`settings set claude-code '{"role":"the operator'\''s laptop"}'`, `{"role":"the operator's laptop"}`}, + {`settings set claude-code "{\"role\":\"the operator's laptop\"}"`, `{"role":"the operator's laptop"}`}, + {"x \"a \\\\ b\nc\"", "a \\ b\nc"}, + {`x 'a\b'`, `a\b`}, + } { + argv, err := splitCommandLine(c.line) + if err != nil || argv[len(argv)-1] != c.want { + t.Errorf("%s: read back %q %v, want %q", c.line, argv, err, c.want) + } + } + + _, err := splitCommandLine(`settings set claude-code '{"role":"the operator's laptop"}' --node g14`) + if err == nil { + t.Fatal("an apostrophe that leaves a quote open was accepted") + } + for _, want := range []string{"character 57", `"}' --node g14`, `'\''`, `\"`} { + if !strings.Contains(err.Error(), want) { + t.Errorf("the refusal does not say %q: %v", want, err) + } + } +} + +// Every value reads back as it was when written the way the refusal says: single-quoted with '\” +// for each quote, or double-quoted with \ before each " and \ — newlines and backslashes included. +func TestAQuotedValueRoundTrips(t *testing.T) { + for _, v := range []string{`plain`, `it's`, `say "hi"`, `back\slash\`, "two\nlines", `'"\'\"`, `''`, ``} { + single := "x '" + strings.ReplaceAll(v, "'", `'\''`) + "'" + double := `x "` + strings.NewReplacer(`\`, `\\`, `"`, `\"`).Replace(v) + `"` + for _, line := range []string{single, double} { + argv, err := splitCommandLine(line) + if err != nil || len(argv) != 2 || argv[1] != v { + t.Errorf("%s: read back %q %v, want %q", line, argv, err, v) + } + } + } +} From d52de218c85af0866ba322f90c040bb292a9b144 Mon Sep 17 00:00:00 2001 From: jochen Date: Sat, 10 Oct 2026 18:39:20 +0200 Subject: [PATCH 2/2] Quote none of the line in the unclosed-quote refusal: a refusal is kept on the bus as the call's answer (hq issue 294) --- cmd/mesh-controller/seatverbs.go | 19 +++++++------------ cmd/mesh-controller/seatverbs_test.go | 7 +++++-- 2 files changed, 12 insertions(+), 14 deletions(-) diff --git a/cmd/mesh-controller/seatverbs.go b/cmd/mesh-controller/seatverbs.go index 7116e6da..81838973 100644 --- a/cmd/mesh-controller/seatverbs.go +++ b/cmd/mesh-controller/seatverbs.go @@ -1405,7 +1405,7 @@ func splitCommandLine(line string) ([]string, error) { } } if quote != 0 { - return nil, unclosedQuote(runes, quote, opened) + return nil, unclosedQuote(quote, opened) } if inWord { words = append(words, cur.String()) @@ -1415,18 +1415,13 @@ func splitCommandLine(line string) ([]string, error) { // unclosedQuote is the refusal of a line whose quote is never closed. Most often an apostrophe inside // a single-quoted value ended that quote early and a later quote was left open, so the refusal says -// where the open quote is, shows the line from there, and says how a quote is written inside a quoted -// value — the shell's own two ways, which this splitter already reads (novox/hq issue 294). -func unclosedQuote(runes []rune, quote rune, opened int) error { - const shown = 40 - from := runes[opened:] - excerpt := string(from) - if len(from) > shown { - excerpt = string(from[:shown]) + "…" - } - return fmt.Errorf("command has an unclosed %c quote, opened at character %d: %s — an apostrophe "+ +// where the open quote is and how a quote is written inside a quoted value — the shell's own two +// ways, which this splitter already reads (novox/hq issue 294). It quotes none of the line: a refusal +// is kept on the bus as the call's answer, and the line may carry a setting's value. +func unclosedQuote(quote rune, opened int) error { + return fmt.Errorf("command has an unclosed %c quote, opened at character %d — an apostrophe "+ "inside a single-quoted value ends it; write a ' inside single quotes as '\\'' (it'\\''s), or use "+ - "double quotes and write \\\" for a \" and \\\\ for a \\ inside them", quote, opened+1, excerpt) + "double quotes and write \\\" for a \" and \\\\ for a \\ inside them", quote, opened+1) } // seatAnnouncement is what the controller says it serves on the bus (novox/hq ADR 0197): the diff --git a/cmd/mesh-controller/seatverbs_test.go b/cmd/mesh-controller/seatverbs_test.go index dd1c638e..98df004c 100644 --- a/cmd/mesh-controller/seatverbs_test.go +++ b/cmd/mesh-controller/seatverbs_test.go @@ -417,7 +417,7 @@ func TestNoVerbSetsTheOperatorsKeyOrRevealsASecret(t *testing.T) { } // A value with a quote in it can be said on a `command` line, as in a shell, and a line whose quote -// is left open is refused naming where it opened and how a quote is written (novox/hq issue 294: an +// is left open is refused naming where it opened and how a quote is written, quoting none of it (novox/hq issue 294: an // apostrophe inside a single-quoted JSON value cut the line, and the refusal named no cause). func TestAQuotedValueCanHoldAQuote(t *testing.T) { for _, c := range []struct{ line, want string }{ @@ -436,11 +436,14 @@ func TestAQuotedValueCanHoldAQuote(t *testing.T) { if err == nil { t.Fatal("an apostrophe that leaves a quote open was accepted") } - for _, want := range []string{"character 57", `"}' --node g14`, `'\''`, `\"`} { + for _, want := range []string{"character 57", `'\''`, `\"`} { if !strings.Contains(err.Error(), want) { t.Errorf("the refusal does not say %q: %v", want, err) } } + if strings.Contains(err.Error(), "laptop") || strings.Contains(err.Error(), "g14") { + t.Errorf("the refusal quotes the line, which may carry a setting's value: %v", err) + } } // Every value reads back as it was when written the way the refusal says: single-quoted with '\”