diff --git a/.agents/skills/headless-computer-use/references/commands.md b/.agents/skills/headless-computer-use/references/commands.md index 347b799..db46516 100644 --- a/.agents/skills/headless-computer-use/references/commands.md +++ b/.agents/skills/headless-computer-use/references/commands.md @@ -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 @@ -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; diff --git a/CHANGELOG.md b/CHANGELOG.md index b602525..0e94c74 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/apps/headless/Sources/HeadlessProtocol/CLI.swift b/apps/headless/Sources/HeadlessProtocol/CLI.swift index f0a5cca..67c558a 100644 --- a/apps/headless/Sources/HeadlessProtocol/CLI.swift +++ b/apps/headless/Sources/HeadlessProtocol/CLI.swift @@ -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() @@ -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") } @@ -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] @@ -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([ diff --git a/apps/headless/Tests/HeadlessProtocolTests/ProtocolTests.swift b/apps/headless/Tests/HeadlessProtocolTests/ProtocolTests.swift index 7c4c8b4..fab8342 100644 --- a/apps/headless/Tests/HeadlessProtocolTests/ProtocolTests.swift +++ b/apps/headless/Tests/HeadlessProtocolTests/ProtocolTests.swift @@ -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( @@ -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), diff --git a/docs/roadmap/architecture-decisions.md b/docs/roadmap/architecture-decisions.md index f8aebd1..d52772b 100644 --- a/docs/roadmap/architecture-decisions.md +++ b/docs/roadmap/architecture-decisions.md @@ -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 @@ -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. diff --git a/docs/roadmap/improvements-backlog.md b/docs/roadmap/improvements-backlog.md index ce6f063..19de7c1 100644 --- a/docs/roadmap/improvements-backlog.md +++ b/docs/roadmap/improvements-backlog.md @@ -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`