Skip to content

Commit 3e47310

Browse files
committed
fix(cli): support unquoted multi-word --content values
Elixir 1.18+ OptionParser in strict mode consumes only one token after a flag, so --content Code review approved parsed as content='Code' and 'review approved' as extra positional args, which CliMate rejected with 'unexpected extra argument review'. Added a pre-parse pass in AdoCli.CLI.run/1 that detects flags in @multivalue_opts (--content --message --body --description --reason --summary --text) and joins all subsequent non-flag tokens into a single value using the --flag=value syntax. This makes the common case of unquoted multi-word content work without forcing users to remember shell quoting rules. Applies to: * ado prs comments add/update --content * ado prs create --description * any other command using these option names The @<file> and - (stdin) shorthands still work because those don't continue the flag sequence. Added 12 unit tests in test/ado_cli/cli_test.exs covering single-word, multi-word, --status-after, = syntax, -/, @file, and combination cases. Updated help text and ado-cli skill to document the new behavior. 337 tests pass, credo clean, dialyzer clean.
1 parent 00e5342 commit 3e47310

4 files changed

Lines changed: 153 additions & 6 deletions

File tree

lib/ado_cli/cli.ex

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,7 @@ defmodule AdoCli.CLI do
160160
System.halt(0)
161161
end
162162

163+
args = join_multivalue_opts(args)
163164
parsed = parse_or_halt!(args, @command)
164165

165166
# Apply global options to runtime env for auth resolution
@@ -168,6 +169,46 @@ defmodule AdoCli.CLI do
168169
parsed.execute.()
169170
end
170171

172+
# String options that should consume all subsequent non-flag tokens.
173+
# Without this, `--content foo bar baz` would parse as content=foo
174+
# and bar/baz as extra positional args, which CliMate rejects.
175+
# This is the same convention as `curl --data`, `tar -cf`, etc.
176+
@multivalue_opts ~w(--content --message --body --description --reason --summary --text)
177+
178+
def join_multivalue_opts(args) do
179+
join_multivalue_opts(args, [])
180+
end
181+
182+
defp join_multivalue_opts([], result), do: Enum.reverse(result)
183+
184+
defp join_multivalue_opts([arg | rest], result) do
185+
if arg in @multivalue_opts do
186+
{joined, remaining} = take_multivalue_tokens(rest)
187+
join_multivalue_opts(remaining, ["#{arg}=#{joined}" | result])
188+
else
189+
join_multivalue_opts(rest, [arg | result])
190+
end
191+
end
192+
193+
# Collects consecutive non-flag tokens after a multivalue flag like
194+
# --content, stopping at the next flag. Returns the joined string
195+
# and the leftover args (so a subsequent --status can still parse).
196+
defp take_multivalue_tokens(tokens) do
197+
take_multivalue_tokens(tokens, [])
198+
end
199+
200+
defp take_multivalue_tokens([arg | rest] = tokens, acc) do
201+
if String.starts_with?(arg, "-") and arg != "-" do
202+
{join_words(Enum.reverse(acc)), tokens}
203+
else
204+
take_multivalue_tokens(rest, [arg | acc])
205+
end
206+
end
207+
208+
defp take_multivalue_tokens([], acc), do: {join_words(Enum.reverse(acc)), []}
209+
210+
defp join_words(words), do: Enum.join(words, " ")
211+
171212
defp apply_global_opts(opts) do
172213
if org = opts[:org], do: :persistent_term.put({:ado_cli, :org}, org)
173214
if pat = opts[:pat], do: :persistent_term.put({:ado_cli, :pat}, pat)

lib/ado_cli/cli/pull_requests.ex

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -244,8 +244,10 @@ defmodule AdoCli.CLI.PullRequests do
244244
content: [
245245
type: :string,
246246
doc:
247-
"New comment content. Supports @<file> to read from a file " <>
248-
"or - to read from stdin. Omit to update status only.",
247+
"New comment content. Multi-word values do NOT need quoting " <>
248+
"— all subsequent args are joined until the next flag. " <>
249+
"Use @<file> to read from a file or `-` to read from stdin. " <>
250+
"Omit to update status only.",
249251
doc_arg: "TEXT"
250252
],
251253
status: [
@@ -287,7 +289,11 @@ defmodule AdoCli.CLI.PullRequests do
287289
content: [
288290
type: :string,
289291
required: true,
290-
doc: "Comment text (markdown allowed)",
292+
doc:
293+
"Comment text (markdown allowed). Multi-word values do NOT " <>
294+
"need quoting — all subsequent args are joined until the " <>
295+
"next flag. Use @<file> to read from a file or `-` to read " <>
296+
"from stdin.",
291297
doc_arg: "TEXT"
292298
],
293299
file_path: [

priv/skills/ado-cli/SKILL.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -193,10 +193,12 @@ ado prs reviewers list MyProject MyRepo 42
193193
ado prs reviewers add MyProject MyRepo 42 --reviewer USER_GUID
194194
ado prs reviewers remove MyProject MyRepo 42 --reviewer USER_GUID
195195

196-
# Review PR comments
197-
ado prs comments add MyProject MyRepo 42 --content "LGTM!"
196+
# Review PR comments (multi-word --content does not need quoting)
197+
ado prs comments add MyProject MyRepo 42 --content LGTM ship it
198+
ado prs comments add MyProject MyRepo 42 --content @notes.md
199+
echo "review body" | ado prs comments add MyProject MyRepo 42 --content -
198200
ado prs comments list MyProject MyRepo 42 --all
199-
ado prs comments update MyProject MyRepo 42 7 5 --content "updated" --status fixed
201+
ado prs comments update MyProject MyRepo 42 7 5 --content updated text --status fixed
200202

201203
# Trigger a pipeline with variables
202204
ado pipelines run MyProject 42 --branch main --variables "ENV=staging,DEBUG=true"

test/ado_cli/cli_test.exs

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
defmodule AdoCli.CLITest do
2+
use ExUnit.Case, async: true
3+
4+
# The public entry point is the same one used by escript/Burrito.
5+
# We test join_multivalue_opts indirectly by calling run/1 with
6+
# a list of args and observing the parsed execute function's
7+
# arguments. To keep the test focused, we test the
8+
# join_multivalue_opts/1 helper directly.
9+
describe "join_multivalue_opts/1" do
10+
test "joins unquoted multi-word content into --content=value" do
11+
args = ["--content", "Code", "review", "approved", "and", "merged."]
12+
13+
assert AdoCli.CLI.join_multivalue_opts(args) == [
14+
"--content=Code review approved and merged."
15+
]
16+
end
17+
18+
test "leaves --content with --status after intact" do
19+
args = ["--content", "Code", "review", "--status", "active"]
20+
21+
assert AdoCli.CLI.join_multivalue_opts(args) == [
22+
"--content=Code review",
23+
"--status",
24+
"active"
25+
]
26+
end
27+
28+
test "leaves single-word --content unchanged" do
29+
args = ["--content", "Approved"]
30+
assert AdoCli.CLI.join_multivalue_opts(args) == ["--content=Approved"]
31+
end
32+
33+
test "handles --content=... pre-joined form" do
34+
args = ["--content=Code review", "--status", "active"]
35+
36+
assert AdoCli.CLI.join_multivalue_opts(args) == [
37+
"--content=Code review",
38+
"--status",
39+
"active"
40+
]
41+
end
42+
43+
test "leaves --content - (stdin) alone" do
44+
args = ["--content", "-"]
45+
assert AdoCli.CLI.join_multivalue_opts(args) == ["--content=-"]
46+
end
47+
48+
test "leaves --content @file alone" do
49+
args = ["--content", "@/tmp/note.md"]
50+
assert AdoCli.CLI.join_multivalue_opts(args) == ["--content=@/tmp/note.md"]
51+
end
52+
53+
test "stops at the next flag even with quoted-looking words" do
54+
args = ["--content", "LGTM!", "--json"]
55+
assert AdoCli.CLI.join_multivalue_opts(args) == ["--content=LGTM!", "--json"]
56+
end
57+
58+
test "preserves unrelated args around the multivalue" do
59+
args = ["--org", "myorg", "pos1", "pos2", "--content", "hello world", "--json"]
60+
61+
assert AdoCli.CLI.join_multivalue_opts(args) == [
62+
"--org",
63+
"myorg",
64+
"pos1",
65+
"pos2",
66+
"--content=hello world",
67+
"--json"
68+
]
69+
end
70+
71+
test "applies to --description too" do
72+
args = ["--description", "This is a", "multi-line", "description"]
73+
74+
assert AdoCli.CLI.join_multivalue_opts(args) == [
75+
"--description=This is a multi-line description"
76+
]
77+
end
78+
79+
test "joins empty list at end of args" do
80+
args = ["--content", "hello", "world"]
81+
assert AdoCli.CLI.join_multivalue_opts(args) == ["--content=hello world"]
82+
end
83+
84+
test "handles multiple multivalue options" do
85+
args = ["--content", "foo bar", "--description", "baz qux"]
86+
87+
assert AdoCli.CLI.join_multivalue_opts(args) == [
88+
"--content=foo bar",
89+
"--description=baz qux"
90+
]
91+
end
92+
93+
test "leaves other flags untouched" do
94+
args = ["--json", "--org", "myorg"]
95+
assert AdoCli.CLI.join_multivalue_opts(args) == ["--json", "--org", "myorg"]
96+
end
97+
end
98+
end

0 commit comments

Comments
 (0)