Skip to content

Commit ff153c3

Browse files
authored
Merge pull request #95 from LockInTime/t3code/fill-end-of-options
fix(cli): preserve literal fill arguments
2 parents 93c9fa2 + a16be86 commit ff153c3

6 files changed

Lines changed: 72 additions & 9 deletions

File tree

.agents/skills/headless-computer-use/references/commands.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,8 @@ headless --session NAME inspect --context actions --task "TASK"
2727
headless --session NAME inspect --context full --text
2828
headless --session NAME click REF
2929
headless --session NAME click --role ROLE --name NAME
30-
headless --session NAME fill REF TEXT
30+
headless --session NAME fill REF "TEXT"
31+
headless --session NAME fill REF -- "--json stays literal"
3132
headless --session NAME press KEY
3233
headless --session NAME scroll up|down|top|bottom --amount PIXELS
3334
headless --session NAME back
@@ -44,6 +45,10 @@ Use `click --role ... --name ...` for unique accessible controls. Use an `@eN`
4445
ref from the latest inspection when role/name is ambiguous. Inspect again after
4546
navigation or a large rerender.
4647

48+
Pass fill text as one quoted shell argument so whitespace is preserved. Put
49+
`--` before a value that contains a literal global flag such as `--json` or
50+
`--session`; the sentinel itself is not typed into the page.
51+
4752
Use `wait` with the strongest expected condition available:
4853

4954
1. expected URL plus expected text;

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,11 @@ Cutting that release is tracked in
5151
- The website now runs on Next.js 16 with its native flat ESLint configuration;
5252
the legacy `FlatCompat` and `@eslint/eslintrc` path has been removed.
5353

54+
### Fixed
55+
56+
- `fill` preserves quoted whitespace and accepts literal `--json` or
57+
`--session` values after the standard `--` end-of-options sentinel.
58+
5459
### Known gaps
5560

5661
Tracked as [`backlog`](https://github.com/LockInTime/headless/labels/backlog)

apps/headless/Sources/HeadlessProtocol/CLI.swift

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -44,9 +44,17 @@ public struct CLIParser {
4444

4545
public func parse(_ rawArguments: [String]) throws -> CLIInvocation {
4646
var arguments = rawArguments
47+
let literalArguments: [String]
48+
if let sentinel = arguments.firstIndex(of: "--") {
49+
literalArguments = Array(arguments[arguments.index(after: sentinel)...])
50+
arguments.removeSubrange(sentinel...)
51+
} else {
52+
literalArguments = []
53+
}
4754
let jsonOutput = removeFlag("--json", from: &arguments)
4855
let session = try removeOption("--session", from: &arguments)
4956
if let session { try validateIdentifier(session, field: "session") }
57+
arguments.append(contentsOf: literalArguments)
5058
guard let command = arguments.first else { throw CLIParseError.missingCommand }
5159
arguments.removeFirst()
5260

@@ -80,11 +88,9 @@ public struct CLIParser {
8088
case "click":
8189
return try parseTargeted(.click, arguments: arguments, session: session, jsonOutput: jsonOutput)
8290
case "fill":
83-
guard arguments.count >= 2 else { throw CLIParseError.missingArgument("TARGET TEXT") }
84-
let target = arguments[0]
85-
let value = arguments.dropFirst().joined(separator: " ")
91+
guard arguments.count == 2 else { throw CLIParseError.missingArgument("TARGET TEXT") }
8692
return remote(.fill, session: session, parameters: [
87-
"target": .string(target), "value": .string(value),
93+
"target": .string(arguments[0]), "value": .string(arguments[1]),
8894
], jsonOutput: jsonOutput)
8995
case "press":
9096
guard arguments.count == 1 else { throw CLIParseError.missingArgument("KEY") }
@@ -612,7 +618,7 @@ Commands:
612618
inspect [--context summary|outline|text|actions|full] [--task TEXT]
613619
[--within @rN] [--limit N] [--budget TOKENS] [--depth N] [--text]
614620
click REF | click --role ROLE [--name NAME]
615-
fill REF TEXT | press KEY
621+
fill REF TEXT | fill REF -- TEXT_WITH_LITERAL_FLAGS | press KEY
616622
scroll [up|down|top|bottom] [--amount PX]
617623
back | reload
618624
wait [--settled] [--url PATTERN] [--text TEXT] [--timeout MS]
@@ -643,6 +649,7 @@ Commands:
643649
Global options:
644650
--session NAME target a named browser session
645651
--json emit one JSON object on stdout
652+
-- stop parsing global options; quote multi-word fill values
646653
"""
647654

648655
public let capabilitiesDocument: JSONValue = .object([

apps/headless/Tests/HeadlessProtocolTests/ProtocolTests.swift

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -299,6 +299,29 @@ struct ProtocolTests {
299299
)
300300
}
301301

302+
static func cliFillPreservesLiteralValue() throws {
303+
let value = "pass --json\tto API"
304+
let invocation = try CLIParser().parse([
305+
"--session", "qa", "--json", "fill", "@e1", "--", value,
306+
])
307+
try expect(invocation.jsonOutput, "global --json before the sentinel should parse")
308+
try expect(invocation.request?.session == "qa", "global --session before the sentinel should parse")
309+
try expect(invocation.request?.parameters["target"] == .string("@e1"), "fill target should parse")
310+
try expect(invocation.request?.parameters["value"] == .string(value), "fill text should preserve whitespace and literal flags")
311+
312+
let literalFlag = try CLIParser().parse(["fill", "@e1", "--", "--json"])
313+
try expect(!literalFlag.jsonOutput, "--json after the sentinel should not become a global option")
314+
try expect(literalFlag.request?.parameters["value"] == .string("--json"), "a literal --json fill value should survive")
315+
316+
let literalSession = try CLIParser().parse(["fill", "@e1", "--", "--session"])
317+
try expect(literalSession.request?.session == nil, "--session after the sentinel should not become a global option")
318+
try expect(literalSession.request?.parameters["value"] == .string("--session"), "a literal --session fill value should survive")
319+
320+
try expectThrows("multi-word fill text must stay one shell argument") {
321+
_ = try CLIParser().parse(["fill", "@e1", "two", "words"])
322+
}
323+
}
324+
302325
static func cliSemanticClick() throws {
303326
let invocation = try CLIParser().parse(["click", "--role", "button", "--name", "Continue"])
304327
try expect(
@@ -871,6 +894,7 @@ struct ProtocolTests {
871894
("command parameter validation", commandParameterValidation),
872895
("strict request fields", rejectsUnexpectedRequestFields),
873896
("CLI visit", cliVisit),
897+
("CLI fill literal value", cliFillPreservesLiteralValue),
874898
("CLI semantic click", cliSemanticClick),
875899
("CLI inspect context and task", cliInspectContextAndTask),
876900
("CLI conflicting target", cliRejectsConflictingClickTarget),

docs/roadmap/architecture-decisions.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -258,6 +258,25 @@ Checksums on everything; keep release CI's script-reuse design (the workflow
258258
calls the same `build.sh`/`test.sh` a developer runs — preserve that
259259
property when adding PR CI).
260260

261+
## 16. CLI values preserve shell argument boundaries (decided)
262+
263+
**Decision:** global `--json` and `--session` options are recognized only
264+
before the first `--` sentinel. The sentinel is removed before command
265+
parsing. `fill` accepts its text as exactly one shell argument rather than
266+
joining multiple arguments with inserted spaces.
267+
268+
**Status:** decided 2026-08-10 while resolving backlog §A6.
269+
270+
**Rationale:** typed values are data and must reach the browser byte-for-byte
271+
as represented by the Swift string. Searching the whole argv for global flags
272+
could silently remove literal text, while joining tokens normalized tabs and
273+
repeated spaces. Standard shell quoting plus an end-of-options sentinel makes
274+
the boundary explicit and testable.
275+
276+
**Consequences:** callers quote multi-word fill text and place `--` before a
277+
value containing a literal `--json` or `--session`. This changes only CLI
278+
parsing; the wire protocol and protocol version remain unchanged.
279+
261280
---
262281

263282
## Decision log
@@ -271,5 +290,6 @@ property when adding PR CI).
271290
| 8 | Real CDP input on Linux as capability upgrade | Planned (Phase 4) | 2026-08-04 |
272291
| 12 | Version unification on git tag | Planned (Phase 3) | 2026-08-04 |
273292
| 15 | Package-manager distribution set | Decided (owner) | 2026-08-04 |
293+
| 16 | Preserve CLI value boundaries with `--` and shell quoting | Decided | 2026-08-10 |
274294

275295
New decisions append here with the same format.

docs/roadmap/improvements-backlog.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -92,9 +92,11 @@ Documented in P1 under "Reference lifetime"; covered in
9292
stripping (`HP/CLI.swift:46-49`) happens before subcommand parsing, so
9393
`headless fill @e1 pass --json to API` silently drops `--json` from typed
9494
text; `fill` also joins args with single spaces destroying whitespace
95-
(`CLI.swift:83-88`). Add a `--` end-of-options sentinel, only strip globals
96-
before it, and pass the fill value as one argument. Test: fill value
97-
containing `--json`, tabs, double spaces.
95+
(`CLI.swift:83-88`). ~~Add a `--` end-of-options sentinel, only strip globals
96+
before it, and pass the fill value as one argument.~~ **Done:** global options
97+
are stripped only before the first `--`; `fill` now requires one quoted text
98+
argument and preserves its whitespace exactly. Protocol coverage includes
99+
literal `--json`/`--session`, tabs, double spaces, and the quoting boundary.
98100

99101
**A7. Client never verifies response `id`.** ([#18](https://github.com/LockInTime/headless/issues/18)) Failure paths return
100102
`id:"unknown"` (`HP/Transport.swift:201,222`); `LocalSocketClient.send`

0 commit comments

Comments
 (0)