diff --git a/skills/.experimental/fax-machine/LICENSE.txt b/skills/.experimental/fax-machine/LICENSE.txt new file mode 100644 index 00000000..13e25df8 --- /dev/null +++ b/skills/.experimental/fax-machine/LICENSE.txt @@ -0,0 +1,201 @@ +Apache License +Version 2.0, January 2004 +http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf of + any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don\'t include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + +Copyright [yyyy] [name of copyright owner] + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. diff --git a/skills/.experimental/fax-machine/SKILL.md b/skills/.experimental/fax-machine/SKILL.md new file mode 100644 index 00000000..bb2405fe --- /dev/null +++ b/skills/.experimental/fax-machine/SKILL.md @@ -0,0 +1,72 @@ +--- +name: fax-machine +description: Send outbound faxes from text or PDF files with a bundled CLI. Use when the user wants to render text to PDF, stamp text onto an existing PDF, send a fax via Phaxio, or check fax delivery status. +--- + +# Fax Machine + +## What this skill does +- Render plain text into a simple PDF. +- Stamp text onto the first page of an existing PDF. +- Send outbound faxes through Phaxio. +- Check delivery status and fetch the transmitted PDF. + +## Prerequisites +- `PHAXIO_API_KEY` +- `PHAXIO_API_SECRET` +- Python 3 with `requests` and `pypdf` + +## How to think about auth +- Default to environment variables for local CLI use so secrets do not land in command history, repo files, or skill docs. +- Treat fax providers as account-scoped senders: auth proves which account is sending, while the destination number and PDF are per-request data. +- Prefer API key + secret providers for v1 automation because they work cleanly in headless CLI flows. +- Prefer OAuth or JWT only when the provider requires user delegation, org policy, or scoped multi-user access. +- Never ask the user to paste raw secrets into chat. Ask them to export env vars locally and rerun the command. +- If auth fails, verify missing env vars first, then invalid credentials, then provider account permissions or sender-number restrictions. + +## Quick start + +Render text to a PDF: + +```bash +python skills/.experimental/fax-machine/scripts/fax_cli.py render --text "hello from codex" --out /tmp/fax.pdf +``` + +Stamp text onto an existing PDF: + +```bash +python skills/.experimental/fax-machine/scripts/fax_cli.py overlay --pdf /tmp/input.pdf --text "Approved" --x 72 --y 720 --out /tmp/stamped.pdf +``` + +Send a fax: + +```bash +python skills/.experimental/fax-machine/scripts/fax_cli.py send --to +14155551212 --pdf /tmp/fax.pdf --wait +``` + +Check status: + +```bash +python skills/.experimental/fax-machine/scripts/fax_cli.py status --json +``` + +Fetch the transmitted PDF: + +```bash +python skills/.experimental/fax-machine/scripts/fax_cli.py fetch --out /tmp/sent.pdf +``` + +## Workflow +1. If the user provides text only, run `render` first. +2. If the user needs text added to an existing document, run `overlay`. +3. Run `send` with the destination number in E.164 format. +4. Use `--wait` for synchronous polling, or `status` for a later check. +5. Use `fetch` when the user needs the final transmitted artifact. + +## References +- `references/phaxio.md` for provider auth, endpoints, and response shape. + +## Guardrails +- Outbound-only in v1. +- Do not claim HIPAA or compliance guarantees from this skill alone. +- Prefer local PDF generation before upload so the sent artifact is inspectable. diff --git a/skills/.experimental/fax-machine/agents/openai.yaml b/skills/.experimental/fax-machine/agents/openai.yaml new file mode 100644 index 00000000..7be2d6be --- /dev/null +++ b/skills/.experimental/fax-machine/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Fax Machine" + short_description: "Render PDFs and send outbound faxes via Phaxio" + default_prompt: "Render text into a PDF, stamp an existing PDF, or send and track an outbound fax." diff --git a/skills/.experimental/fax-machine/references/phaxio.md b/skills/.experimental/fax-machine/references/phaxio.md new file mode 100644 index 00000000..11f9fe31 --- /dev/null +++ b/skills/.experimental/fax-machine/references/phaxio.md @@ -0,0 +1,54 @@ +# Phaxio Reference + +## Auth + +The v1 CLI uses environment variables: + +- `PHAXIO_API_KEY` +- `PHAXIO_API_SECRET` + +The helper sends these as request auth on every API call. + +## Auth model + +Use Phaxio as the default v1 provider because API key + secret auth is a good fit for a headless CLI: + +- no browser login +- no token refresh flow +- no local credential cache +- easy CI or automation support through environment variables + +For future providers, choose auth in this order: + +1. API key + secret for single-account automation. +2. OAuth client credentials for service-to-service org setups. +3. OAuth auth code or JWT for delegated user access. + +Avoid storing provider secrets in repo config files. If a local config file is added later, keep only non-secret defaults there and continue reading credentials from environment variables. + +## Endpoints + +Base URL: + +- `https://api.phaxio.com/v2` + +Implemented endpoints: + +- `POST /faxes` +- `GET /faxes/{id}` +- `GET /faxes/{id}/file` + +## Send behavior + +The CLI sends a multipart request with: + +- `to`: destination fax number +- `file`: local PDF +- `cover_page`: optional cover page text +- `callback_url`: optional webhook callback URL + +## Notes + +- Destination numbers should use E.164 format. +- `--wait` polls `GET /faxes/{id}` until the fax reaches a terminal state or times out. +- The provider adapter is intentionally isolated in `scripts/fax_cli.py` so additional providers can be added without changing the command surface. diff --git a/skills/.experimental/fax-machine/scripts/fax_cli.py b/skills/.experimental/fax-machine/scripts/fax_cli.py new file mode 100755 index 00000000..0a62d583 --- /dev/null +++ b/skills/.experimental/fax-machine/scripts/fax_cli.py @@ -0,0 +1,276 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import io +import json +import os +import sys +import time +import textwrap +from pathlib import Path +from typing import Any + +import requests +from pypdf import PdfReader, PdfWriter + + +LETTER_WIDTH = 612 +LETTER_HEIGHT = 792 +TERMINAL_STATES = {"success", "failure", "canceled", "cancelled", "busy", "no-answer"} + + +class FaxCliError(Exception): + pass + + +def pdf_escape(value: str) -> str: + return ( + value.replace("\\", "\\\\") + .replace("(", "\\(") + .replace(")", "\\)") + .replace("\r", "") + ) + + +def wrap_text(text: str, width: int = 90) -> list[str]: + lines: list[str] = [] + for raw_line in text.splitlines() or [""]: + if not raw_line: + lines.append("") + continue + lines.extend(textwrap.wrap(raw_line, width=width, replace_whitespace=False)) + return lines + + +def build_text_pdf_bytes( + text: str, + *, + width: float = LETTER_WIDTH, + height: float = LETTER_HEIGHT, + x: float = 72, + y: float = 720, + font_size: int = 12, + line_gap: int = 16, +) -> bytes: + lines = wrap_text(text) + stream_lines = ["BT", f"/F1 {font_size} Tf", f"{x} {y} Td"] + for index, line in enumerate(lines): + if index: + stream_lines.append(f"0 -{line_gap} Td") + stream_lines.append(f"({pdf_escape(line)}) Tj") + stream_lines.append("ET") + stream = "\n".join(stream_lines).encode("latin-1", errors="replace") + + objects = [ + b"<< /Type /Catalog /Pages 2 0 R >>", + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>", + ( + f"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 {width} {height}] " + "/Resources << /Font << /F1 4 0 R >> >> /Contents 5 0 R >>" + ).encode("ascii"), + b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>", + b"<< /Length " + str(len(stream)).encode("ascii") + b" >>\nstream\n" + stream + b"\nendstream", + ] + + output = io.BytesIO() + output.write(b"%PDF-1.4\n") + offsets = [0] + for index, obj in enumerate(objects, start=1): + offsets.append(output.tell()) + output.write(f"{index} 0 obj\n".encode("ascii")) + output.write(obj) + output.write(b"\nendobj\n") + + xref_offset = output.tell() + output.write(f"xref\n0 {len(objects) + 1}\n".encode("ascii")) + output.write(b"0000000000 65535 f \n") + for offset in offsets[1:]: + output.write(f"{offset:010d} 00000 n \n".encode("ascii")) + output.write( + f"trailer\n<< /Size {len(objects) + 1} /Root 1 0 R >>\n" + f"startxref\n{xref_offset}\n%%EOF\n".encode("ascii") + ) + return output.getvalue() + + +def render_text_pdf(text: str, out_path: Path) -> None: + out_path.write_bytes(build_text_pdf_bytes(text)) + + +def overlay_text_pdf(input_path: Path, text: str, x: float, y: float, out_path: Path) -> None: + writer = PdfWriter(clone_from=str(input_path)) + if not writer.pages: + raise FaxCliError(f"PDF has no pages: {input_path}") + + first_page = writer.pages[0] + width = float(first_page.mediabox.width) + height = float(first_page.mediabox.height) + overlay_reader = PdfReader(io.BytesIO(build_text_pdf_bytes(text, width=width, height=height, x=x, y=y))) + + first_page.merge_page(overlay_reader.pages[0]) + + with out_path.open("wb") as handle: + writer.write(handle) + + +class PhaxioClient: + def __init__(self, api_key: str, api_secret: str, base_url: str = "https://api.phaxio.com/v2"): + self.auth = (api_key, api_secret) + self.base_url = base_url.rstrip("/") + + @classmethod + def from_env(cls) -> PhaxioClient: + api_key = os.environ.get("PHAXIO_API_KEY") + api_secret = os.environ.get("PHAXIO_API_SECRET") + if not api_key or not api_secret: + raise FaxCliError("PHAXIO_API_KEY and PHAXIO_API_SECRET must be set") + return cls(api_key=api_key, api_secret=api_secret) + + def _request(self, method: str, path: str, **kwargs: Any) -> requests.Response: + response = requests.request(method, f"{self.base_url}{path}", auth=self.auth, timeout=30, **kwargs) + if response.status_code >= 400: + raise FaxCliError(f"Phaxio API error {response.status_code}: {response.text}") + return response + + def send_fax( + self, + *, + to: str, + pdf_path: Path, + cover_page: str | None = None, + callback_url: str | None = None, + ) -> dict[str, Any]: + data = {"to": to} + if cover_page: + data["cover_page"] = cover_page + if callback_url: + data["callback_url"] = callback_url + + with pdf_path.open("rb") as handle: + response = self._request( + "POST", + "/faxes", + data=data, + files={"file": (pdf_path.name, handle, "application/pdf")}, + ) + return response.json() + + def get_status(self, fax_id: str) -> dict[str, Any]: + return self._request("GET", f"/faxes/{fax_id}").json() + + def fetch_file(self, fax_id: str) -> bytes: + return self._request("GET", f"/faxes/{fax_id}/file").content + + +def wait_for_terminal_status(client: PhaxioClient, fax_id: str, interval: int, timeout: int) -> dict[str, Any]: + deadline = time.time() + timeout + while True: + payload = client.get_status(fax_id) + status = str(payload.get("data", {}).get("status", "")).lower() + if status in TERMINAL_STATES: + return payload + if time.time() >= deadline: + raise FaxCliError(f"Timed out waiting for fax {fax_id}; last status={status or 'unknown'}") + time.sleep(interval) + + +def print_payload(payload: dict[str, Any], as_json: bool) -> None: + if as_json: + print(json.dumps(payload, indent=2, sort_keys=True)) + return + print(json.dumps(payload, sort_keys=True)) + + +def cmd_render(args: argparse.Namespace) -> int: + render_text_pdf(args.text, Path(args.out)) + return 0 + + +def cmd_overlay(args: argparse.Namespace) -> int: + overlay_text_pdf(Path(args.pdf), args.text, args.x, args.y, Path(args.out)) + return 0 + + +def cmd_send(args: argparse.Namespace) -> int: + client = PhaxioClient.from_env() + payload = client.send_fax( + to=args.to, + pdf_path=Path(args.pdf), + cover_page=args.cover, + callback_url=args.callback_url, + ) + if args.wait: + fax_id = str(payload.get("data", {}).get("id") or payload.get("id")) + if not fax_id: + raise FaxCliError(f"Unable to determine fax id from response: {payload}") + payload = wait_for_terminal_status(client, fax_id, args.interval, args.timeout) + print_payload(payload, args.json) + return 0 + + +def cmd_status(args: argparse.Namespace) -> int: + client = PhaxioClient.from_env() + print_payload(client.get_status(args.fax_id), args.json) + return 0 + + +def cmd_fetch(args: argparse.Namespace) -> int: + client = PhaxioClient.from_env() + Path(args.out).write_bytes(client.fetch_file(args.fax_id)) + return 0 + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description="Render PDFs and send outbound faxes via Phaxio") + subparsers = parser.add_subparsers(dest="command", required=True) + + render_parser = subparsers.add_parser("render", help="Render text to a PDF") + render_parser.add_argument("--text", required=True) + render_parser.add_argument("--out", required=True) + render_parser.set_defaults(func=cmd_render) + + overlay_parser = subparsers.add_parser("overlay", help="Stamp text onto the first page of a PDF") + overlay_parser.add_argument("--pdf", required=True) + overlay_parser.add_argument("--text", required=True) + overlay_parser.add_argument("--x", type=float, required=True) + overlay_parser.add_argument("--y", type=float, required=True) + overlay_parser.add_argument("--out", required=True) + overlay_parser.set_defaults(func=cmd_overlay) + + send_parser = subparsers.add_parser("send", help="Send a PDF as a fax") + send_parser.add_argument("--to", required=True) + send_parser.add_argument("--pdf", required=True) + send_parser.add_argument("--cover") + send_parser.add_argument("--callback-url") + send_parser.add_argument("--wait", action="store_true") + send_parser.add_argument("--interval", type=int, default=5) + send_parser.add_argument("--timeout", type=int, default=300) + send_parser.add_argument("--json", action="store_true") + send_parser.set_defaults(func=cmd_send) + + status_parser = subparsers.add_parser("status", help="Check fax delivery status") + status_parser.add_argument("fax_id") + status_parser.add_argument("--json", action="store_true") + status_parser.set_defaults(func=cmd_status) + + fetch_parser = subparsers.add_parser("fetch", help="Fetch the transmitted PDF") + fetch_parser.add_argument("fax_id") + fetch_parser.add_argument("--out", required=True) + fetch_parser.set_defaults(func=cmd_fetch) + + return parser + + +def main(argv: list[str] | None = None) -> int: + parser = build_parser() + args = parser.parse_args(argv) + try: + return args.func(args) + except FaxCliError as exc: + print(f"error: {exc}", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/skills/.experimental/fax-machine/tests/test_fax_cli.py b/skills/.experimental/fax-machine/tests/test_fax_cli.py new file mode 100644 index 00000000..2b9c0cc4 --- /dev/null +++ b/skills/.experimental/fax-machine/tests/test_fax_cli.py @@ -0,0 +1,125 @@ +from __future__ import annotations + +import importlib.util +import io +from pathlib import Path +from unittest.mock import Mock + +import pytest +from pypdf import PdfReader + + +SCRIPT_PATH = Path(__file__).resolve().parents[1] / "scripts" / "fax_cli.py" +spec = importlib.util.spec_from_file_location("fax_cli", SCRIPT_PATH) +fax_cli = importlib.util.module_from_spec(spec) +assert spec and spec.loader +spec.loader.exec_module(fax_cli) + + +def test_render_text_pdf_creates_readable_pdf(tmp_path: Path) -> None: + out_path = tmp_path / "rendered.pdf" + + fax_cli.render_text_pdf("hello from codex", out_path) + + reader = PdfReader(str(out_path)) + assert len(reader.pages) == 1 + assert "hello from codex" in (reader.pages[0].extract_text() or "") + + +def test_overlay_text_pdf_preserves_pages_and_adds_text(tmp_path: Path) -> None: + input_path = tmp_path / "input.pdf" + out_path = tmp_path / "overlay.pdf" + fax_cli.render_text_pdf("base document", input_path) + + fax_cli.overlay_text_pdf(input_path, "Approved", 72, 700, out_path) + + reader = PdfReader(str(out_path)) + assert len(reader.pages) == 1 + text = reader.pages[0].extract_text() or "" + assert "base document" in text + assert "Approved" in text + + +def test_phaxio_client_from_env_requires_credentials(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("PHAXIO_API_KEY", raising=False) + monkeypatch.delenv("PHAXIO_API_SECRET", raising=False) + + with pytest.raises(fax_cli.FaxCliError, match="must be set"): + fax_cli.PhaxioClient.from_env() + + +def test_phaxio_client_from_env_reads_credentials(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("PHAXIO_API_KEY", "key") + monkeypatch.setenv("PHAXIO_API_SECRET", "secret") + + client = fax_cli.PhaxioClient.from_env() + + assert client.auth == ("key", "secret") + + +def test_send_fax_shapes_multipart_request(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + pdf_path = tmp_path / "fax.pdf" + pdf_path.write_bytes(fax_cli.build_text_pdf_bytes("hello")) + response = Mock(status_code=200) + response.json.return_value = {"success": True, "data": {"id": 123}} + request = Mock(return_value=response) + monkeypatch.setattr(fax_cli.requests, "request", request) + client = fax_cli.PhaxioClient("key", "secret", base_url="https://api.example.test/v2") + + payload = client.send_fax(to="+14155551212", pdf_path=pdf_path, cover_page="Cover") + + assert payload == {"success": True, "data": {"id": 123}} + request.assert_called_once() + _, url = request.call_args.args + assert url == "https://api.example.test/v2/faxes" + assert request.call_args.kwargs["auth"] == ("key", "secret") + assert request.call_args.kwargs["data"] == {"to": "+14155551212", "cover_page": "Cover"} + assert request.call_args.kwargs["files"]["file"][0] == "fax.pdf" + + +def test_get_status_uses_expected_endpoint(monkeypatch: pytest.MonkeyPatch) -> None: + response = Mock(status_code=200) + response.json.return_value = {"success": True, "data": {"id": 123, "status": "queued"}} + request = Mock(return_value=response) + monkeypatch.setattr(fax_cli.requests, "request", request) + client = fax_cli.PhaxioClient("key", "secret") + + payload = client.get_status("123") + + assert payload["data"]["status"] == "queued" + assert request.call_args.args[:2] == ("GET", "https://api.phaxio.com/v2/faxes/123") + + +def test_fetch_file_returns_response_content(monkeypatch: pytest.MonkeyPatch) -> None: + response = Mock(status_code=200, content=b"%PDF-1.4") + request = Mock(return_value=response) + monkeypatch.setattr(fax_cli.requests, "request", request) + client = fax_cli.PhaxioClient("key", "secret") + + content = client.fetch_file("123") + + assert content == b"%PDF-1.4" + assert request.call_args.args[:2] == ("GET", "https://api.phaxio.com/v2/faxes/123/file") + + +def test_request_raises_for_http_errors(monkeypatch: pytest.MonkeyPatch) -> None: + response = Mock(status_code=400, text="bad request") + monkeypatch.setattr(fax_cli.requests, "request", Mock(return_value=response)) + client = fax_cli.PhaxioClient("key", "secret") + + with pytest.raises(fax_cli.FaxCliError, match="Phaxio API error 400"): + client.get_status("123") + + +def test_wait_for_terminal_status_stops_on_success(monkeypatch: pytest.MonkeyPatch) -> None: + client = Mock() + client.get_status.side_effect = [ + {"data": {"status": "queued"}}, + {"data": {"status": "success"}}, + ] + monkeypatch.setattr(fax_cli.time, "sleep", Mock()) + + payload = fax_cli.wait_for_terminal_status(client, "123", interval=1, timeout=10) + + assert payload == {"data": {"status": "success"}} + assert client.get_status.call_count == 2