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) + } + } + } +}