From 90ac67fcf3aa1740bdd4577af6352bf7dd9c36e2 Mon Sep 17 00:00:00 2001 From: Sharoon Thomas Date: Thu, 16 Apr 2026 19:02:24 -0400 Subject: [PATCH] Support @file prefix for call --data Accept '--data @payload.json' to read the JSON body from a file, matching the convention used by curl, gh, aws, and similar CLIs. Agents and scripts commonly generate larger payloads to a temp file rather than shell-escape them inline, and the previous behavior was to treat the literal string '@path' as JSON and fail with a confusing parse error. A literal value starting with '@' can still be passed by escaping as '\@literal'. Missing files surface a clear "Cannot read file" message instead of a JSON parse error. Scoped to 'model call --data' for now; --where and other JSON args are unchanged. --- src/fulfil_cli/cli/commands/common.py | 19 +++++++++++++++++++ src/fulfil_cli/cli/commands/model.py | 9 ++++++--- tests/test_cli/test_model_commands.py | 20 ++++++++++++++++++++ 3 files changed, 45 insertions(+), 3 deletions(-) diff --git a/src/fulfil_cli/cli/commands/common.py b/src/fulfil_cli/cli/commands/common.py index f9b625b..71d73b2 100644 --- a/src/fulfil_cli/cli/commands/common.py +++ b/src/fulfil_cli/cli/commands/common.py @@ -4,6 +4,7 @@ import json import sys +from pathlib import Path from typing import Any, NoReturn import click @@ -52,3 +53,21 @@ def parse_json_arg(value: str, arg_name: str) -> Any: except json.JSONDecodeError as exc: console.print(f"[red]Invalid JSON for {arg_name}: {exc}[/red]") raise typer.Exit(code=EXIT_VALIDATION) from None + + +def resolve_value_or_file(value: str, arg_name: str) -> str: + """Return *value*, or the contents of the referenced file when prefixed with '@'. + + Follows the curl/gh convention: '@path/to/file.json' reads from the file. + Use '\\@literal' to pass a value that actually starts with a literal '@'. + """ + if value.startswith("\\@"): + return value[1:] + if value.startswith("@"): + path = Path(value[1:]) + try: + return path.read_text() + except OSError as exc: + console.print(f"[red]Cannot read file for {arg_name}: {exc}[/red]") + raise typer.Exit(code=EXIT_VALIDATION) from None + return value diff --git a/src/fulfil_cli/cli/commands/model.py b/src/fulfil_cli/cli/commands/model.py index 829de26..7cd5ca9 100644 --- a/src/fulfil_cli/cli/commands/model.py +++ b/src/fulfil_cli/cli/commands/model.py @@ -10,7 +10,7 @@ import typer from rich.console import Console -from fulfil_cli.cli.commands.common import handle_error, parse_json_arg +from fulfil_cli.cli.commands.common import handle_error, parse_json_arg, resolve_value_or_file from fulfil_cli.cli.state import AppContext, format_option from fulfil_cli.client.errors import EXIT_NOT_FOUND, EXIT_OK, EXIT_USAGE, FulfilError from fulfil_cli.output.formatter import output, output_model_describe @@ -313,7 +313,10 @@ def count_cmd(ctx: click.Context, where: str | None, output_format: str | None) @click.option( "--data", default=None, - help=("Extra method arguments as a JSON object. Example: '{\"warehouse\": 1}'"), + help=( + "Extra method arguments as a JSON object. Example: '{\"warehouse\": 1}'. " + "Prefix with '@' to read from a file: --data @payload.json" + ), ) @format_option @click.pass_context @@ -339,7 +342,7 @@ def call_cmd( if ids: params["ids"] = _parse_ids(ids) if data: - extra = parse_json_arg(data, "--data") + extra = parse_json_arg(resolve_value_or_file(data, "--data"), "--data") if isinstance(extra, dict): params.update(extra) diff --git a/tests/test_cli/test_model_commands.py b/tests/test_cli/test_model_commands.py index c03f2df..b9be636 100644 --- a/tests/test_cli/test_model_commands.py +++ b/tests/test_cli/test_model_commands.py @@ -306,6 +306,26 @@ def test_with_ids_and_data(self, httpx_mock, cli_env, jsonrpc_success): assert body["params"]["ids"] == [42] assert body["params"]["force"] is True + def test_data_from_file(self, httpx_mock, cli_env, jsonrpc_success, tmp_path): + httpx_mock.add_response(json=jsonrpc_success({"ok": True})) + + payload = tmp_path / "payload.json" + payload.write_text('{"warehouse": 7, "force": true}') + + result = runner.invoke(app, ["sale_order", "call", "process", "--data", f"@{payload}"]) + assert result.exit_code == 0, result.stdout + result.stderr + + body = json.loads(httpx_mock.get_request().content) + assert body["params"]["warehouse"] == 7 + assert body["params"]["force"] is True + + def test_data_file_missing(self, cli_env): + result = runner.invoke( + app, ["sale_order", "call", "process", "--data", "@/no/such/file.json"] + ) + assert result.exit_code == 7 + assert "Cannot read file" in (result.stdout + result.stderr) + class TestDescribeCommand: def test_full_describe(self, httpx_mock, cli_env, jsonrpc_success):