From 7f8d810ccfd880ce7c2eb025e90f0b0eb6e28390 Mon Sep 17 00:00:00 2001 From: SkyLi Date: Sat, 5 Sep 2026 21:08:13 +0800 Subject: [PATCH] first commit --- README.md | 96 +++++++ skill/image-gen-custom/.gitignore | 5 + skill/image-gen-custom/SKILL.md | 55 ++++ skill/image-gen-custom/agents/openai.yaml | 4 + skill/image-gen-custom/scripts/configure.ps1 | 25 ++ skill/image-gen-custom/scripts/configure.sh | 28 ++ .../image-gen-custom/scripts/configure_key.py | 96 +++++++ .../scripts/generate-image.ps1 | 36 +++ .../scripts/generate-image.sh | 18 ++ .../scripts/generate_image.py | 258 ++++++++++++++++++ .../scripts/install-skill.ps1 | 15 + .../image-gen-custom/scripts/install-skill.sh | 12 + .../image-gen-custom/scripts/install_skill.py | 45 +++ skill/image-gen-custom/scripts/runtime.py | 88 ++++++ 14 files changed, 781 insertions(+) create mode 100644 README.md create mode 100644 skill/image-gen-custom/.gitignore create mode 100644 skill/image-gen-custom/SKILL.md create mode 100644 skill/image-gen-custom/agents/openai.yaml create mode 100644 skill/image-gen-custom/scripts/configure.ps1 create mode 100644 skill/image-gen-custom/scripts/configure.sh create mode 100644 skill/image-gen-custom/scripts/configure_key.py create mode 100644 skill/image-gen-custom/scripts/generate-image.ps1 create mode 100644 skill/image-gen-custom/scripts/generate-image.sh create mode 100644 skill/image-gen-custom/scripts/generate_image.py create mode 100644 skill/image-gen-custom/scripts/install-skill.ps1 create mode 100644 skill/image-gen-custom/scripts/install-skill.sh create mode 100644 skill/image-gen-custom/scripts/install_skill.py create mode 100644 skill/image-gen-custom/scripts/runtime.py diff --git a/README.md b/README.md new file mode 100644 index 0000000..01fa25f --- /dev/null +++ b/README.md @@ -0,0 +1,96 @@ +# image-gen-custom-project + +这是一个面向 Codex 的本地技能包仓库,核心内容是 `image-gen-custom`:它通过兼容 OpenAI 的 Images API 生成图片,并把结果稳定地保存为本地 `PNG`、`JPEG`、`GIF` 或 `WebP` 文件。 + +## 仓库内容 + +```text +skill/image-gen-custom/ +├─ SKILL.md +├─ agents/openai.yaml +└─ scripts/ + ├─ configure.ps1 / configure.sh + ├─ generate-image.ps1 / generate-image.sh + ├─ install-skill.ps1 / install-skill.sh + └─ 其他辅助脚本 +``` + +## 主要能力 + +- 支持自定义 OpenAI-compatible API base URL +- 支持指定模型、尺寸、质量和输出路径 +- 自动校验返回内容是否为有效图片 +- 以原子写入方式保存输出文件 +- Windows 上使用 DPAPI 保存密钥,macOS/Linux 使用系统 keyring + +## 安装 + +在对应平台运行安装脚本: + +```powershell +skill\image-gen-custom\scripts\install-skill.ps1 +``` + +```bash +bash skill/image-gen-custom/scripts/install-skill.sh +``` + +如需覆盖已有安装,可加 `-Force` 或 `--force`。 + +## 配置 + +首次使用前先配置 API 基础地址和密钥: + +```powershell +skill\image-gen-custom\scripts\configure.ps1 +``` + +```bash +bash skill/image-gen-custom/scripts/configure.sh +``` + +也可以临时使用环境变量: + +- `OPENAI_API_KEY` +- `OPENAI_BASE_URL` + +## 生成图片 + +Windows: + +```powershell +skill\image-gen-custom\scripts\generate-image.ps1 ` + -Prompt "A concise prompt..." ` + -Out "outputs\generated-image.png" ` + -Size "1024x1024" ` + -Quality "low" ` + -Force +``` + +macOS / Linux: + +```bash +bash skill/image-gen-custom/scripts/generate-image.sh \ + --prompt "A concise prompt..." \ + --out outputs/generated-image.png \ + --size 1024x1024 \ + --quality low \ + --force +``` + +默认模型是 `gpt-image-2`,可通过 `-Model` / `--model` 覆盖。 + +## 运行时说明 + +- 图片 API 支持 `b64_json` 或图片 URL 返回 +- URL 下载上限为 50 MB +- 输出会先写入临时文件,再替换到目标路径 +- 可通过 `CODEX_IMAGEGEN_VENV` 指定私有虚拟环境位置 +- 可通过 `CODEX_IMAGEGEN_PYTHON` 指定用于引导环境的 Python + +## 安全提示 + +- 不要把密钥写进脚本、提交记录或这个 README +- Windows 密钥保存到 DPAPI 保护的本地文件 +- 其他平台保存到系统 keyring + diff --git a/skill/image-gen-custom/.gitignore b/skill/image-gen-custom/.gitignore new file mode 100644 index 0000000..66db573 --- /dev/null +++ b/skill/image-gen-custom/.gitignore @@ -0,0 +1,5 @@ +scripts/connection.local.json +*.env +**/__pycache__/ +*.py[cod] +venv/ diff --git a/skill/image-gen-custom/SKILL.md b/skill/image-gen-custom/SKILL.md new file mode 100644 index 0000000..4f60a3f --- /dev/null +++ b/skill/image-gen-custom/SKILL.md @@ -0,0 +1,55 @@ +--- +name: image-gen-custom +description: Generate images through any OpenAI-compatible Images API and save reproducible local PNG, JPEG, GIF, or WebP files. Use when Codex needs a custom image endpoint, explicit model and output path, or a dependable cross-platform image generation workflow. +--- + +# Image Gen Custom + +Use `scripts/generate-image.ps1` on Windows or `scripts/generate-image.sh` on macOS/Linux. The wrappers create or reuse a private virtual environment; generation uses only the Python standard library. The Python core reads the API key, calls the Images API, validates the returned image, and writes it atomically. + +```powershell +& "$env:USERPROFILE\.codex\skills\image-gen-custom\scripts\generate-image.ps1" ` + -Prompt "A concise prompt..." ` + -Out "outputs\generated-image.png" ` + -Size "1024x1024" ` + -Quality "low" ` + -Force +``` + +```bash +"$HOME/.codex/skills/image-gen-custom/scripts/generate-image.sh" \ + --prompt "A concise prompt..." \ + --out outputs/generated-image.png \ + --size 1024x1024 \ + --quality low \ + --force +``` + +## Credentials + +Run the matching `configure` wrapper once. It prompts for the API base URL first, then prompts for the key in visible text so you can paste it. On Windows, the key is saved in a DPAPI-protected local file; on macOS and Linux, it uses the platform credential store. + +```powershell +& "$env:USERPROFILE\.codex\skills\image-gen-custom\scripts\configure.ps1" +``` + +```bash +"$HOME/.codex/skills/image-gen-custom/scripts/configure.sh" +``` + +The URL and Windows key file are saved under the local Codex shared configuration folder. The normal image command then needs no URL argument. An explicit `-BaseUrl` on the image command still overrides the saved value. + +Set `OPENAI_API_KEY` instead when an ephemeral environment variable is preferred. Set `OPENAI_BASE_URL` for a custom OpenAI-compatible root; `/v1` is appended when omitted. Never put a key in `SKILL.md`, scripts, shell history, command arguments, ZIP files, or source control. + +## Options and behavior + +- Default model: `gpt-image-2`; use `-Model` or `--model` to override it. +- Use low quality for drafts and high quality for final images. +- Accept `b64_json` and absolute HTTP(S) URL responses; URL downloads are capped at 50 MB and checked for image signatures. +- Use `CODEX_IMAGEGEN_VENV` to choose the private virtual environment location. +- Use `CODEX_IMAGEGEN_PYTHON` to choose the Python executable used to bootstrap that environment. +- Report the concrete failure category without exposing credentials: missing runtime/dependency, keyring or authentication, network/permission, unsupported model/endpoint, quota, response parsing, download, or output writing. + +## Install a shared package + +Extract the package and run `scripts/install-skill.ps1` on Windows or `scripts/install-skill.sh` on macOS/Linux. Add `-Force` or `--force` only when updating an existing installation; the installer never overwrites the destination's keyring data or any local secret file. Start a new Codex task after installation. diff --git a/skill/image-gen-custom/agents/openai.yaml b/skill/image-gen-custom/agents/openai.yaml new file mode 100644 index 0000000..d42c3c2 --- /dev/null +++ b/skill/image-gen-custom/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Image Gen Custom" + short_description: "Cross-platform custom image generation." + default_prompt: "Use $image-gen-custom to generate an image and save it to an explicit local file." diff --git a/skill/image-gen-custom/scripts/configure.ps1 b/skill/image-gen-custom/scripts/configure.ps1 new file mode 100644 index 0000000..af1cda5 --- /dev/null +++ b/skill/image-gen-custom/scripts/configure.ps1 @@ -0,0 +1,25 @@ +$ErrorActionPreference = "Stop" +Add-Type -AssemblyName System.Security + +$documents = if ($env:CODEX_DOCUMENTS) { $env:CODEX_DOCUMENTS } else { Join-Path $env:USERPROFILE "Documents" } +$configDir = Join-Path $documents "Codex\.shared\image-gen-custom" +New-Item -ItemType Directory -Force -Path $configDir | Out-Null + +$baseUrl = Read-Host "OpenAI-compatible API base URL" +if ([string]::IsNullOrWhiteSpace($baseUrl)) { throw "API base URL cannot be empty." } + +[IO.File]::WriteAllText((Join-Path $configDir "base_url.txt"), $baseUrl.Trim().TrimEnd("/"), [Text.UTF8Encoding]::new($false)) +Write-Host "API base URL stored in the local configuration." + +$apiKey = Read-Host "OpenAI-compatible API key" +if ([string]::IsNullOrWhiteSpace($apiKey)) { throw "API key cannot be empty." } + +$plain = [Text.Encoding]::UTF8.GetBytes($apiKey.Trim()) +$protected = [System.Security.Cryptography.ProtectedData]::Protect( + $plain, + $null, + [System.Security.Cryptography.DataProtectionScope]::LocalMachine +) +[Convert]::ToBase64String($protected) | Set-Content -LiteralPath (Join-Path $configDir "api_key.dpapi") -Encoding ascii + +Write-Host "API key stored in a Windows DPAPI-protected local file." diff --git a/skill/image-gen-custom/scripts/configure.sh b/skill/image-gen-custom/scripts/configure.sh new file mode 100644 index 0000000..e7b1d43 --- /dev/null +++ b/skill/image-gen-custom/scripts/configure.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +set -euo pipefail + +script_dir="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)" +python_cmd="${CODEX_IMAGEGEN_PYTHON:-}" +if [[ -z "$python_cmd" ]]; then + embedded_python="$HOME/.cache/codex-runtimes/codex-primary-runtime/dependencies/python/python.exe" + if [[ -x "$embedded_python" ]]; then + python_cmd="$embedded_python" + else + python_cmd="$(command -v python3 || command -v python || true)" + fi +fi +if [[ -z "$python_cmd" ]]; then + printf '%s\n' "Python 3 not found; install Python or set CODEX_IMAGEGEN_PYTHON." >&2 + exit 1 +fi + +runtime="$($python_cmd "$script_dir/runtime.py" --keyring)" +read -r -p "OpenAI-compatible API base URL: " base_url +if [[ -z "${base_url// }" ]]; then + printf '%s\n' "API base URL cannot be empty." >&2 + exit 1 +fi + +"$runtime" "$script_dir/configure_key.py" --base-url "$base_url" +printf '%s\n' "Now enter the API key." +"$runtime" "$script_dir/configure_key.py" diff --git a/skill/image-gen-custom/scripts/configure_key.py b/skill/image-gen-custom/scripts/configure_key.py new file mode 100644 index 0000000..eac3acc --- /dev/null +++ b/skill/image-gen-custom/scripts/configure_key.py @@ -0,0 +1,96 @@ +#!/usr/bin/env python3 +"""Store or remove the API key using a protected local store.""" + +from __future__ import annotations + +import argparse +import os +from pathlib import Path +import sys + +SERVICE = "codex-image-gen-custom" + + +def config_dir() -> Path: + documents = os.environ.get("CODEX_DOCUMENTS") or str(Path.home() / "Documents") + return Path(documents) / "Codex" / ".shared" / "image-gen-custom" + + +def base_url_path() -> Path: + return config_dir() / "base_url.txt" + + +def api_key_path() -> Path: + return config_dir() / "api_key.xml" + + +def store_api_key(key: str, user: str) -> None: + if os.name == "nt": + raise RuntimeError("Use configure.ps1 on Windows to store the API key securely.") + keyring = secure_keyring() + keyring.set_password(SERVICE, user, key) + + +def delete_api_key(user: str) -> None: + if os.name == "nt": + api_key_path().unlink(missing_ok=True) + return + keyring = secure_keyring() + keyring.delete_password(SERVICE, user) + + +def load_saved_api_key(user: str) -> str: + if os.name == "nt": + raise RuntimeError("Use generate-image.ps1 on Windows to read the saved API key securely.") + keyring = secure_keyring() + return (keyring.get_password(SERVICE, user) or "").strip() + + +def secure_keyring(): + import keyring + + backend = keyring.get_keyring() + backend_id = f"{backend.__class__.__module__}.{backend.__class__.__name__}".lower() + allowed = ("windows", "macos", "secretservice", "libsecret", "kwallet") + if not any(marker in backend_id for marker in allowed): + raise RuntimeError("The configured keyring is not a recognized protected OS credential store") + return keyring + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--user", default=os.environ.get("IMAGE_GEN_KEYRING_USER", "default")) + parser.add_argument("--delete", action="store_true") + parser.add_argument("--base-url", default="") + parser.add_argument("--delete-base-url", action="store_true") + args = parser.parse_args() + if args.delete_base_url: + base_url_path().unlink(missing_ok=True) + print("API base URL removed from the local configuration.") + return 0 + if args.base_url: + path = base_url_path() + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(args.base_url.rstrip("/") + "\n", encoding="utf-8") + print("API base URL stored in the local configuration.") + return 0 + try: + if args.delete: + delete_api_key(args.user) + print("API key removed from the local secret store.") + else: + key = input("OpenAI-compatible API key: ").strip() + if not key: + raise ValueError("API key cannot be empty") + store_api_key(key, args.user) + if load_saved_api_key(args.user) != key: + raise RuntimeError("Stored key could not be read back") + print("API key stored in the local secret store.") + except Exception as exc: + print(f"Error: {exc}. Set OPENAI_API_KEY for a one-off run or run configure again.", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/skill/image-gen-custom/scripts/generate-image.ps1 b/skill/image-gen-custom/scripts/generate-image.ps1 new file mode 100644 index 0000000..b1955a8 --- /dev/null +++ b/skill/image-gen-custom/scripts/generate-image.ps1 @@ -0,0 +1,36 @@ +param( + [Parameter(Mandatory = $true)][string] $Prompt, + [string] $Out = "outputs\imagegen.png", + [string] $Size = "1024x1024", + [ValidateSet("low", "medium", "high", "auto")][string] $Quality = "low", + [string] $Model = "gpt-image-2", + [string] $BaseUrl = "", + [switch] $Force +) + +$ErrorActionPreference = "Stop" +$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path +$pythonSelector = Join-Path $scriptDir "runtime.py" +$generator = Join-Path $scriptDir "generate_image.py" +function Get-ImageGenPython { + if ($env:CODEX_IMAGEGEN_PYTHON) { + $command = Get-Command $env:CODEX_IMAGEGEN_PYTHON -ErrorAction SilentlyContinue + if ($command) { return $command.Source } + } + $embedded = Join-Path $env:USERPROFILE ".cache\codex-runtimes\codex-primary-runtime\dependencies\python\python.exe" + if (Test-Path -LiteralPath $embedded) { return $embedded } + $command = Get-Command python.exe -ErrorAction SilentlyContinue | Select-Object -First 1 + if ($command) { return $command.Source } + $command = Get-Command py.exe -ErrorAction SilentlyContinue | Select-Object -First 1 + if ($command) { return $command.Source } + throw "Python 3 not found; install Python or set CODEX_IMAGEGEN_PYTHON." +} + +$python = Get-ImageGenPython +$runtime = & $python $pythonSelector +if ($LASTEXITCODE -ne 0 -or -not $runtime) { throw "Could not bootstrap Python runtime." } +$args = @($generator, "--model", $Model, "--prompt", $Prompt, "--size", $Size, "--quality", $Quality, "--out", $Out) +if ($BaseUrl) { $args += @("--base-url", $BaseUrl) } +if ($Force) { $args += "--force" } +& $runtime @args +if ($LASTEXITCODE -ne 0) { throw "Image generation failed with exit code $LASTEXITCODE." } diff --git a/skill/image-gen-custom/scripts/generate-image.sh b/skill/image-gen-custom/scripts/generate-image.sh new file mode 100644 index 0000000..f235926 --- /dev/null +++ b/skill/image-gen-custom/scripts/generate-image.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +set -euo pipefail +script_dir="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)" +python_cmd="${CODEX_IMAGEGEN_PYTHON:-}" +if [[ -z "$python_cmd" ]]; then + embedded_python="$HOME/.cache/codex-runtimes/codex-primary-runtime/dependencies/python/python.exe" + if [[ -x "$embedded_python" ]]; then + python_cmd="$embedded_python" + else + python_cmd="$(command -v python3 || command -v python || true)" + fi +fi +if [[ -z "$python_cmd" ]]; then + printf '%s\n' "Python 3 not found; install Python or set CODEX_IMAGEGEN_PYTHON." >&2 + exit 1 +fi +runtime="$($python_cmd "$script_dir/runtime.py" --keyring)" +exec "$runtime" "$script_dir/generate_image.py" "$@" diff --git a/skill/image-gen-custom/scripts/generate_image.py b/skill/image-gen-custom/scripts/generate_image.py new file mode 100644 index 0000000..1ba646a --- /dev/null +++ b/skill/image-gen-custom/scripts/generate_image.py @@ -0,0 +1,258 @@ +#!/usr/bin/env python3 +"""Generate an image through an OpenAI-compatible Images API.""" + +from __future__ import annotations + +import argparse +import base64 +import binascii +import json +import os +import shutil +import subprocess +from pathlib import Path +import sys +import tempfile +import time +from typing import Any, Callable +from urllib.error import HTTPError, URLError +from urllib.parse import urljoin +from urllib.parse import urlsplit +from urllib.request import Request, urlopen + +MAX_IMAGE_BYTES = 50 * 1024 * 1024 +DOWNLOAD_TIMEOUT_SECONDS = 120 +API_TIMEOUT_SECONDS = 180 +KEYRING_SERVICE = "codex-image-gen-custom" +KEYRING_USER = "default" + + +def config_dir() -> Path: + documents = os.environ.get("CODEX_DOCUMENTS") or str(Path.home() / "Documents") + return Path(documents) / "Codex" / ".shared" / "image-gen-custom" + + +def _windows_saved_key_path() -> Path: + return config_dir() / "api_key.dpapi" + + +def _decrypt_windows_saved_key(path: Path) -> str: + shell = shutil.which("powershell.exe") or shutil.which("pwsh") + if not shell: + raise RuntimeError("PowerShell is required to read the saved Windows API key") + script = r""" +$ErrorActionPreference = 'Stop' +Add-Type -AssemblyName System.Security +$protected = [Convert]::FromBase64String((Get-Content -Raw -LiteralPath $env:CODEX_IMAGEGEN_KEY_PATH).Trim()) +$plain = [System.Security.Cryptography.ProtectedData]::Unprotect( + $protected, + $null, + [System.Security.Cryptography.DataProtectionScope]::LocalMachine +) +[Text.Encoding]::UTF8.GetString($plain) +""" + env = os.environ.copy() + env["CODEX_IMAGEGEN_KEY_PATH"] = str(path) + command = [shell, "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command", script] + result = subprocess.run(command, check=False, capture_output=True, text=True, env=env) + if result.returncode != 0: + raise RuntimeError((result.stderr or "PowerShell could not decrypt the saved API key").strip()) + return result.stdout.strip() + + +def _secure_keyring(): + try: + import keyring + except ImportError as exc: + raise RuntimeError("OPENAI_API_KEY is not set and the keyring package is unavailable") from exc + backend = keyring.get_keyring() + backend_id = f"{backend.__class__.__module__}.{backend.__class__.__name__}".lower() + allowed = ("windows", "macos", "secretservice", "libsecret", "kwallet") + if not any(marker in backend_id for marker in allowed): + raise RuntimeError("The configured keyring is not a recognized protected OS credential store") + return keyring + + +def _field(item: Any, name: str) -> Any: + if isinstance(item, dict): + return item.get(name) + return getattr(item, name, None) + + +def _validate_image(raw: bytes) -> None: + signatures = ( + raw.startswith(b"\x89PNG\r\n\x1a\n"), + raw.startswith(b"\xff\xd8\xff"), + raw.startswith((b"GIF87a", b"GIF89a")), + len(raw) >= 12 and raw[:4] in (b"RIFF", b"RIFX") and raw[8:12] == b"WEBP", + ) + if not raw or not any(signatures): + raise ValueError("Image API returned data without a supported image signature") + + +def _download_image(url: str, *, opener: Callable[..., Any] = urlopen) -> bytes: + parsed = urlsplit(url) + if parsed.scheme not in ("http", "https") or not parsed.netloc: + raise ValueError("Image API returned an invalid image URL") + request = Request(url, headers={"User-Agent": "codex-image-gen-custom/1"}) + with opener(request, timeout=DOWNLOAD_TIMEOUT_SECONDS) as response: + content_length = response.headers.get("Content-Length") + if content_length and int(content_length) > MAX_IMAGE_BYTES: + raise ValueError(f"Image URL exceeds the {MAX_IMAGE_BYTES}-byte limit") + raw = response.read(MAX_IMAGE_BYTES + 1) + if len(raw) > MAX_IMAGE_BYTES: + raise ValueError(f"Downloaded image exceeds the {MAX_IMAGE_BYTES}-byte limit") + return raw + + +def extract_image_bytes(item: Any, *, opener: Callable[..., Any] = urlopen) -> bytes: + image_b64 = _field(item, "b64_json") + if image_b64: + try: + raw = base64.b64decode(image_b64, validate=True) + except (binascii.Error, TypeError, ValueError) as exc: + raise ValueError("Image API returned invalid b64_json") from exc + else: + image_url = _field(item, "url") + if not image_url: + raise ValueError("Image API response contained neither b64_json nor url") + raw = _download_image(str(image_url), opener=opener) + _validate_image(raw) + return raw + + +def write_image(raw: bytes, output: Path, *, force: bool) -> None: + if output.exists() and not force: + raise FileExistsError(f"Output already exists: {output} (use --force to overwrite)") + output.parent.mkdir(parents=True, exist_ok=True) + fd, temporary_name = tempfile.mkstemp(prefix=f".{output.name}.", suffix=".tmp", dir=output.parent) + temporary = Path(temporary_name) + try: + with os.fdopen(fd, "wb") as stream: + stream.write(raw) + stream.flush() + os.fsync(stream.fileno()) + temporary.replace(output) + finally: + temporary.unlink(missing_ok=True) + + +def load_api_key() -> str: + environment_key = os.environ.get("OPENAI_API_KEY", "").strip() + if environment_key: + return environment_key + if os.name == "nt": + key_path = _windows_saved_key_path() + if not key_path.exists(): + raise RuntimeError("No API key found. Run configure or set OPENAI_API_KEY") + key = _decrypt_windows_saved_key(key_path) + if not key: + raise RuntimeError("No API key found. Run configure or set OPENAI_API_KEY") + return key + try: + keyring = _secure_keyring() + key = keyring.get_password(KEYRING_SERVICE, KEYRING_USER) + except Exception as exc: + raise RuntimeError("The OS keyring is unavailable; set OPENAI_API_KEY for this run") from exc + if not key: + raise RuntimeError("No API key found. Run configure or set OPENAI_API_KEY") + return key + + +def _configured_base_url() -> str: + environment_url = os.environ.get("OPENAI_BASE_URL", "").strip() + if environment_url: + return environment_url + config_path = config_dir() / "base_url.txt" + try: + configured_url = config_path.read_text(encoding="utf-8-sig").strip() + except OSError: + configured_url = "" + return configured_url + + +def _base_url(override: str = "") -> str: + base_url = override.strip() or _configured_base_url() or "https://api.openai.com/v1" + base_url = base_url.rstrip("/") + if not base_url.lower().endswith("/v1"): + base_url += "/v1" + return base_url + + +def _request_generation(*, api_key: str, base_url: str, payload: dict[str, Any]) -> dict[str, Any]: + endpoint = urljoin(f"{base_url.rstrip('/')}/", "images/generations") + request = Request( + endpoint, + data=json.dumps(payload).encode("utf-8"), + headers={ + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json", + "Accept": "application/json", + "User-Agent": "codex-image-gen-custom/1", + }, + method="POST", + ) + try: + with urlopen(request, timeout=API_TIMEOUT_SECONDS) as response: + raw = response.read() + except HTTPError as exc: + detail = exc.read(4096).decode("utf-8", errors="replace").strip() + suffix = f": {detail}" if detail else "" + raise RuntimeError(f"Image API returned HTTP {exc.code}{suffix}") from exc + except URLError as exc: + raise RuntimeError(f"Could not reach the Image API: {exc.reason}") from exc + try: + result = json.loads(raw.decode("utf-8")) + except (UnicodeDecodeError, json.JSONDecodeError) as exc: + raise ValueError("Image API returned invalid JSON") from exc + if not isinstance(result, dict): + raise ValueError("Image API returned a non-object JSON response") + return result + + +def generate(args: argparse.Namespace) -> None: + api_key = load_api_key() + base_url = _base_url(args.base_url) + started = time.time() + print("Calling Image API. This can take up to a couple of minutes.", file=sys.stderr) + result = _request_generation( + api_key=api_key, + base_url=base_url, + payload={ + "model": args.model, + "prompt": args.prompt, + "size": args.size, + "quality": args.quality, + }, + ) + print(f"Generation completed in {time.time() - started:.1f}s.", file=sys.stderr) + data = result.get("data") + if not isinstance(data, list) or not data: + raise ValueError("Image API returned an empty data list") + write_image(extract_image_bytes(data[0]), Path(args.out), force=args.force) + print(f"Wrote {args.out}") + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--base-url", default="", help="OpenAI-compatible API root") + parser.add_argument("--model", default="gpt-image-2") + parser.add_argument("--prompt", required=True) + parser.add_argument("--size", default="1024x1024") + parser.add_argument("--quality", choices=("low", "medium", "high", "auto"), default="low") + parser.add_argument("--out", required=True) + parser.add_argument("--force", action="store_true") + return parser + + +def main() -> int: + try: + generate(build_parser().parse_args()) + except Exception as exc: + print(f"Error: {exc}", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/skill/image-gen-custom/scripts/install-skill.ps1 b/skill/image-gen-custom/scripts/install-skill.ps1 new file mode 100644 index 0000000..4d186c3 --- /dev/null +++ b/skill/image-gen-custom/scripts/install-skill.ps1 @@ -0,0 +1,15 @@ +param([string] $CodexHome, [switch] $Force) +$ErrorActionPreference = "Stop" +$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path +$pythonCommand = if ($env:CODEX_IMAGEGEN_PYTHON) { + Get-Command $env:CODEX_IMAGEGEN_PYTHON -ErrorAction SilentlyContinue +} else { + Get-Command python.exe -ErrorAction SilentlyContinue | Select-Object -First 1 +} +if (-not $pythonCommand) { $pythonCommand = Get-Command py.exe -ErrorAction SilentlyContinue | Select-Object -First 1 } +if (-not $pythonCommand) { throw "Python 3 not found; install Python or set CODEX_IMAGEGEN_PYTHON." } +$args = @((Join-Path $scriptDir "install_skill.py")) +if ($CodexHome) { $args += @("--codex-home", $CodexHome) } +if ($Force) { $args += "--force" } +& $pythonCommand.Source @args +if ($LASTEXITCODE -ne 0) { throw "Skill installation failed with exit code $LASTEXITCODE." } diff --git a/skill/image-gen-custom/scripts/install-skill.sh b/skill/image-gen-custom/scripts/install-skill.sh new file mode 100644 index 0000000..7b6d9e2 --- /dev/null +++ b/skill/image-gen-custom/scripts/install-skill.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env bash +set -euo pipefail +script_dir="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)" +python_cmd="${CODEX_IMAGEGEN_PYTHON:-}" +if [[ -z "$python_cmd" ]]; then + python_cmd="$(command -v python3 || command -v python || true)" +fi +if [[ -z "$python_cmd" ]]; then + printf '%s\n' "Python 3 not found; install Python or set CODEX_IMAGEGEN_PYTHON." >&2 + exit 1 +fi +exec "$python_cmd" "$script_dir/install_skill.py" "$@" diff --git a/skill/image-gen-custom/scripts/install_skill.py b/skill/image-gen-custom/scripts/install_skill.py new file mode 100644 index 0000000..b32d0cb --- /dev/null +++ b/skill/image-gen-custom/scripts/install_skill.py @@ -0,0 +1,45 @@ +#!/usr/bin/env python3 +"""Install this skill without copying local secrets or runtime caches.""" + +from __future__ import annotations + +import argparse +import os +from pathlib import Path +import shutil + +EXCLUDED_FILES = {"connection.local.json"} +EXCLUDED_DIRS = {".git", "__pycache__", "venv"} + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--codex-home", type=Path) + parser.add_argument("--force", action="store_true") + args = parser.parse_args() + source = Path(__file__).resolve().parent.parent + codex_home = args.codex_home or Path(os.environ.get("CODEX_HOME", Path.home() / ".codex")) + destination = codex_home.expanduser() / "skills" / "image-gen-custom" + if source == destination.resolve(): + print(f"Skill is already installed at: {destination}") + return 0 + if destination.exists() and not args.force: + raise SystemExit(f"Skill already exists at {destination}; use --force to update it") + destination.mkdir(parents=True, exist_ok=True) + for item in source.rglob("*"): + relative = item.relative_to(source) + if item.name in EXCLUDED_FILES or any(part in EXCLUDED_DIRS for part in relative.parts): + continue + target = destination / relative + if item.is_dir(): + target.mkdir(parents=True, exist_ok=True) + else: + target.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(item, target) + print(f"Installed image-gen-custom to: {destination}") + print(f"Configure the API with: python {destination / 'scripts' / 'configure_key.py'}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/skill/image-gen-custom/scripts/runtime.py b/skill/image-gen-custom/scripts/runtime.py new file mode 100644 index 0000000..b3a8986 --- /dev/null +++ b/skill/image-gen-custom/scripts/runtime.py @@ -0,0 +1,88 @@ +#!/usr/bin/env python3 +"""Create the private runtime used by the platform wrappers.""" + +from __future__ import annotations + +import os +from pathlib import Path +import shutil +import subprocess +import sys +import venv + + +def default_venv() -> Path: + override = os.environ.get("CODEX_IMAGEGEN_VENV", "").strip() + if override: + return Path(override).expanduser() + if os.name == "nt": + documents = os.environ.get("CODEX_DOCUMENTS") or (Path.home() / "Documents") + return Path(documents) / "Codex" / ".shared" / "image-gen-custom" / "venv" + if sys.platform == "darwin": + return Path.home() / "Library" / "Caches" / "Codex" / "image-gen-custom" / "venv" + root = os.environ.get("XDG_CACHE_HOME") or (Path.home() / ".cache") + return Path(root) / "codex" / "image-gen-custom" / "venv" + + +def bootstrap_python() -> list[str]: + override = os.environ.get("CODEX_IMAGEGEN_PYTHON", "").strip() + if override: + return [override] + candidates: list[list[str]] = [] + if os.name == "nt": + cache_root = Path.home() / ".cache" / "codex-runtimes" + for path in sorted(cache_root.glob("**/dependencies/python/python.exe")): + if path.is_file(): + candidates.append([str(path)]) + for candidate in ("py", "python", "python3"): + found = shutil.which(candidate) + if found: + candidates.append([found, "-3"] if candidate == "py" else [found]) + else: + for candidate in ("python3", "python"): + found = shutil.which(candidate) + if found: + candidates.append([found]) + probe = "import sys" + for candidate in candidates: + try: + result = subprocess.run([*candidate, "-c", probe], check=False, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + except OSError: + continue + if result.returncode == 0: + return candidate + raise RuntimeError("Python 3 was not found; install Python or set CODEX_IMAGEGEN_PYTHON") + + +def ensure_runtime(require_keyring: bool = False) -> Path: + target = default_venv() + python_path = target / ("Scripts/python.exe" if os.name == "nt" else "bin/python") + if not python_path.exists(): + target.parent.mkdir(parents=True, exist_ok=True) + command = bootstrap_python() + ["-m", "venv", str(target)] + subprocess.run(command, check=True) + if require_keyring: + check = subprocess.run([str(python_path), "-c", "import keyring"], check=False) + else: + check = None + if check is not None and check.returncode: + packages = ["keyring"] + subprocess.run( + [str(python_path), "-m", "pip", "install", "--disable-pip-version-check", *packages], + check=True, + stdout=subprocess.DEVNULL, + ) + return python_path + + +if __name__ == "__main__": + import argparse + + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--keyring", action="store_true") + options = parser.parse_args() + try: + print(ensure_runtime(options.keyring)) + except Exception as exc: + print(f"Error: {exc}", file=sys.stderr) + raise SystemExit(1)