Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,8 @@ headless --session NAME inspect --context actions --task "TASK"
headless --session NAME inspect --context full --text
headless --session NAME click REF
headless --session NAME click --role ROLE --name NAME
headless --session NAME fill REF TEXT
headless --session NAME fill REF "TEXT"
headless --session NAME fill REF -- "--json stays literal"
headless --session NAME press KEY
headless --session NAME scroll up|down|top|bottom --amount PIXELS
headless --session NAME back
Expand All @@ -44,6 +45,10 @@ Use `click --role ... --name ...` for unique accessible controls. Use an `@eN`
ref from the latest inspection when role/name is ambiguous. Inspect again after
navigation or a large rerender.

Pass fill text as one quoted shell argument so whitespace is preserved. Put
`--` before a value that contains a literal global flag such as `--json` or
`--session`; the sentinel itself is not typed into the page.

Use `wait` with the strongest expected condition available:

1. expected URL plus expected text;
Expand Down
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,11 @@ Cutting that release is tracked in
committed bundle is checksum-verified in CI.
- Repository moved to the `LockInTime` organisation; site links updated.

### Fixed

- `fill` preserves quoted whitespace and accepts literal `--json` or
`--session` values after the standard `--` end-of-options sentinel.

### Known gaps

Tracked as [`backlog`](https://github.com/LockInTime/headless/labels/backlog)
Expand Down
17 changes: 12 additions & 5 deletions apps/headless/Sources/HeadlessProtocol/CLI.swift
Original file line number Diff line number Diff line change
Expand Up @@ -44,9 +44,17 @@ public struct CLIParser {

public func parse(_ rawArguments: [String]) throws -> CLIInvocation {
var arguments = rawArguments
let literalArguments: [String]
if let sentinel = arguments.firstIndex(of: "--") {
literalArguments = Array(arguments[arguments.index(after: sentinel)...])
arguments.removeSubrange(sentinel...)
} else {
literalArguments = []
}
let jsonOutput = removeFlag("--json", from: &arguments)
let session = try removeOption("--session", from: &arguments)
if let session { try validateIdentifier(session, field: "session") }
arguments.append(contentsOf: literalArguments)
guard let command = arguments.first else { throw CLIParseError.missingCommand }
arguments.removeFirst()

Expand Down Expand Up @@ -80,11 +88,9 @@ public struct CLIParser {
case "click":
return try parseTargeted(.click, arguments: arguments, session: session, jsonOutput: jsonOutput)
case "fill":
guard arguments.count >= 2 else { throw CLIParseError.missingArgument("TARGET TEXT") }
let target = arguments[0]
let value = arguments.dropFirst().joined(separator: " ")
guard arguments.count == 2 else { throw CLIParseError.missingArgument("TARGET TEXT") }
return remote(.fill, session: session, parameters: [
"target": .string(target), "value": .string(value),
"target": .string(arguments[0]), "value": .string(arguments[1]),
], jsonOutput: jsonOutput)
case "press":
guard arguments.count == 1 else { throw CLIParseError.missingArgument("KEY") }
Expand Down Expand Up @@ -612,7 +618,7 @@ Commands:
inspect [--context summary|outline|text|actions|full] [--task TEXT]
[--within @rN] [--limit N] [--budget TOKENS] [--depth N] [--text]
click REF | click --role ROLE [--name NAME]
fill REF TEXT | press KEY
fill REF TEXT | fill REF -- TEXT_WITH_LITERAL_FLAGS | press KEY
scroll [up|down|top|bottom] [--amount PX]
back | reload
wait [--settled] [--url PATTERN] [--text TEXT] [--timeout MS]
Expand Down Expand Up @@ -643,6 +649,7 @@ Commands:
Global options:
--session NAME target a named browser session
--json emit one JSON object on stdout
-- stop parsing global options; quote multi-word fill values
"""

public let capabilitiesDocument: JSONValue = .object([
Expand Down
24 changes: 24 additions & 0 deletions apps/headless/Tests/HeadlessProtocolTests/ProtocolTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -299,6 +299,29 @@ struct ProtocolTests {
)
}

static func cliFillPreservesLiteralValue() throws {
let value = "pass --json\tto API"
let invocation = try CLIParser().parse([
"--session", "qa", "--json", "fill", "@e1", "--", value,
])
try expect(invocation.jsonOutput, "global --json before the sentinel should parse")
try expect(invocation.request?.session == "qa", "global --session before the sentinel should parse")
try expect(invocation.request?.parameters["target"] == .string("@e1"), "fill target should parse")
try expect(invocation.request?.parameters["value"] == .string(value), "fill text should preserve whitespace and literal flags")

let literalFlag = try CLIParser().parse(["fill", "@e1", "--", "--json"])
try expect(!literalFlag.jsonOutput, "--json after the sentinel should not become a global option")
try expect(literalFlag.request?.parameters["value"] == .string("--json"), "a literal --json fill value should survive")

let literalSession = try CLIParser().parse(["fill", "@e1", "--", "--session"])
try expect(literalSession.request?.session == nil, "--session after the sentinel should not become a global option")
try expect(literalSession.request?.parameters["value"] == .string("--session"), "a literal --session fill value should survive")

try expectThrows("multi-word fill text must stay one shell argument") {
_ = try CLIParser().parse(["fill", "@e1", "two", "words"])
}
}

static func cliSemanticClick() throws {
let invocation = try CLIParser().parse(["click", "--role", "button", "--name", "Continue"])
try expect(
Expand Down Expand Up @@ -871,6 +894,7 @@ struct ProtocolTests {
("command parameter validation", commandParameterValidation),
("strict request fields", rejectsUnexpectedRequestFields),
("CLI visit", cliVisit),
("CLI fill literal value", cliFillPreservesLiteralValue),
("CLI semantic click", cliSemanticClick),
("CLI inspect context and task", cliInspectContextAndTask),
("CLI conflicting target", cliRejectsConflictingClickTarget),
Expand Down
20 changes: 20 additions & 0 deletions docs/roadmap/architecture-decisions.md
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,25 @@ Checksums on everything; keep release CI's script-reuse design (the workflow
calls the same `build.sh`/`test.sh` a developer runs — preserve that
property when adding PR CI).

## 16. CLI values preserve shell argument boundaries (decided)

**Decision:** global `--json` and `--session` options are recognized only
before the first `--` sentinel. The sentinel is removed before command
parsing. `fill` accepts its text as exactly one shell argument rather than
joining multiple arguments with inserted spaces.

**Status:** decided 2026-08-10 while resolving backlog §A6.

**Rationale:** typed values are data and must reach the browser byte-for-byte
as represented by the Swift string. Searching the whole argv for global flags
could silently remove literal text, while joining tokens normalized tabs and
repeated spaces. Standard shell quoting plus an end-of-options sentinel makes
the boundary explicit and testable.

**Consequences:** callers quote multi-word fill text and place `--` before a
value containing a literal `--json` or `--session`. This changes only CLI
parsing; the wire protocol and protocol version remain unchanged.

---

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

New decisions append here with the same format.
8 changes: 5 additions & 3 deletions docs/roadmap/improvements-backlog.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,9 +92,11 @@ Documented in P1 under "Reference lifetime"; covered in
stripping (`HP/CLI.swift:46-49`) happens before subcommand parsing, so
`headless fill @e1 pass --json to API` silently drops `--json` from typed
text; `fill` also joins args with single spaces destroying whitespace
(`CLI.swift:83-88`). Add a `--` end-of-options sentinel, only strip globals
before it, and pass the fill value as one argument. Test: fill value
containing `--json`, tabs, double spaces.
(`CLI.swift:83-88`). ~~Add a `--` end-of-options sentinel, only strip globals
before it, and pass the fill value as one argument.~~ **Done:** global options
are stripped only before the first `--`; `fill` now requires one quoted text
argument and preserves its whitespace exactly. Protocol coverage includes
literal `--json`/`--session`, tabs, double spaces, and the quoting boundary.

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