niahere 0.2.13 → 0.2.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -42,6 +42,10 @@ nia db setup — install PostgreSQL + create database + migrat
42
42
  nia db migrate — run database migrations
43
43
  nia db status — check database connection
44
44
 
45
+ nia config list — show all config
46
+ nia config get <key> — get a config value (dot notation supported)
47
+ nia config set <key> <value> — set a config value
48
+
45
49
  nia channels — show channel status (on/off)
46
50
  nia channels on / off — enable/disable channels
47
51
  ```
@@ -76,7 +80,7 @@ All config and data lives in `~/.niahere/`:
76
80
 
77
81
  ```
78
82
  ~/.niahere/
79
- config.yaml — database, channels, model, timezone, active hours, gemini key
83
+ config.yaml — database, channels, model, timezone, active hours, API keys
80
84
  self/
81
85
  identity.md — agent personality and voice
82
86
  owner.md — who runs this agent
@@ -94,7 +98,8 @@ All config and data lives in `~/.niahere/`:
94
98
  - [Bun](https://bun.sh) runtime (auto-installed if missing)
95
99
  - PostgreSQL (`nia db setup` handles installation)
96
100
  - Claude API access (via `@anthropic-ai/claude-agent-sdk`)
97
- - Gemini API key (optional, for image generation)
101
+ - Gemini API key (optional, for image generation — `nia config set gemini_api_key ...`)
102
+ - OpenAI API key (optional, for image generation — `nia config set openai_api_key ...`)
98
103
 
99
104
  ## Author
100
105
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "niahere",
3
- "version": "0.2.13",
3
+ "version": "0.2.15",
4
4
  "description": "A personal AI assistant daemon — scheduled jobs, chat across Telegram and Slack, persona system, and visual identity.",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -0,0 +1,104 @@
1
+ ---
2
+ name: github-link-repo-explorer
3
+ description: Explore a GitHub repository from a link by cloning locally first, then inspecting files and history. Use when a task includes a GitHub repo URL or owner/repo and code-level analysis is needed. Prefer local clone into /tmp before using web pages.
4
+ ---
5
+
6
+ ## GitHub Link Repo Explorer
7
+
8
+ Explore repositories from GitHub links with a local-first workflow.
9
+
10
+ ## Core Rule
11
+
12
+ Always try local clone before web browsing.
13
+
14
+ - Clone target: `/tmp/<repo-name>`
15
+ - Always run clone/update commands from `/tmp` (`cd /tmp` first).
16
+ - If directory exists, update it (`git fetch --all --prune`) or reclone if corrupted.
17
+ - Use web fallback only when clone/auth/network access fails.
18
+ - Do not start with web exploration when a local clone attempt is possible.
19
+
20
+ ## Inputs
21
+
22
+ Accept any of:
23
+ - full URL: `https://github.com/<owner>/<repo>`
24
+ - SSH URL: `git@github.com:<owner>/<repo>.git`
25
+ - short form: `<owner>/<repo>`
26
+
27
+ Normalize input to:
28
+ - `owner`
29
+ - `repo`
30
+ - `clone_url`
31
+ - `local_path=/tmp/<repo>`
32
+
33
+ ## Workflow
34
+
35
+ ### 1. Prepare local checkout
36
+
37
+ 1. Parse owner/repo from input.
38
+ 2. Set `local_path` to `/tmp/<repo>`.
39
+ 3. Change working directory to `/tmp`.
40
+ 4. Clone if missing:
41
+ - `cd /tmp && git clone --filter=blob:none <clone_url> <repo>`
42
+ 5. If present, refresh:
43
+ - `cd /tmp/<repo> && git fetch --all --prune`
44
+
45
+ ### 2. Establish repository context
46
+
47
+ Run quick context commands:
48
+ - `git -C /tmp/<repo> remote -v`
49
+ - `git -C /tmp/<repo> branch -a`
50
+ - `git -C /tmp/<repo> log --oneline -n 20`
51
+ - `rg --files /tmp/<repo>` (or equivalent)
52
+
53
+ Identify:
54
+ - default branch
55
+ - primary language and build system
56
+ - key entry points and docs (`README`, `docs/`, configs)
57
+
58
+ ### 3. Perform the requested analysis
59
+
60
+ Prefer direct file inspection over assumptions:
61
+ - locate with `rg`
62
+ - read targeted files only
63
+ - cite exact file paths and relevant lines
64
+ - run project checks/tests only if needed and safe
65
+
66
+ ### 4. Keep output actionable
67
+
68
+ Return:
69
+ - what was inspected
70
+ - findings with file references
71
+ - unresolved gaps
72
+ - next concrete checks
73
+
74
+ ## Fallbacks
75
+
76
+ If clone fails:
77
+ 1. Report exact failure reason (auth, not found, network, rate limits).
78
+ 2. Retry clone from `/tmp` using alternate URL form (HTTPS vs SSH) if appropriate.
79
+ 3. If still blocked, use web exploration of GitHub pages for limited analysis.
80
+ 4. Clearly mark web-derived conclusions as lower confidence than local checkout results.
81
+
82
+ ## Safety and Efficiency
83
+
84
+ - Do not modify the cloned repository unless explicitly requested.
85
+ - Avoid broad recursive reads when targeted search is sufficient.
86
+ - Remove or reuse stale `/tmp/<repo>` directories carefully.
87
+ - Prefer shallow/sparse strategies when full history is unnecessary.
88
+
89
+ ## Quick Command Template
90
+
91
+ ```bash
92
+ REPO_INPUT="https://github.com/owner/repo"
93
+ OWNER_REPO="$(printf '%s' "$REPO_INPUT" | sed -E 's#(git@github.com:|https://github.com/)##; s#\.git$##')"
94
+ REPO_NAME="${OWNER_REPO##*/}"
95
+ LOCAL_PATH="/tmp/$REPO_NAME"
96
+
97
+ cd /tmp
98
+ if [ ! -d "$LOCAL_PATH/.git" ]; then
99
+ git clone --filter=blob:none "https://github.com/$OWNER_REPO.git" "$REPO_NAME"
100
+ else
101
+ cd "$LOCAL_PATH"
102
+ git fetch --all --prune
103
+ fi
104
+ ```
@@ -0,0 +1,121 @@
1
+ ---
2
+ name: image-generation
3
+ description: Generate images using OpenAI (default) or Gemini. Use when asked to generate, create, or make images. Supports text-to-image and image editing with reference images.
4
+ ---
5
+
6
+ # Image Generation
7
+
8
+ General-purpose image generation skill supporting **OpenAI** (default) and **Gemini**.
9
+
10
+ ## Script
11
+
12
+ `scripts/generate_image.py` — zero external dependencies (stdlib only).
13
+
14
+ ## Setup
15
+
16
+ API keys in `~/.niahere/config.yaml`:
17
+ ```yaml
18
+ openai_api_key: sk-...
19
+ gemini_api_key: AIza...
20
+ ```
21
+
22
+ Or set via CLI or environment variables:
23
+ ```bash
24
+ nia config set openai_api_key sk-...
25
+ nia config set gemini_api_key AIza...
26
+ ```
27
+
28
+ Keys are resolved in order: `--api-key` flag > env var (`$OPENAI_API_KEY` / `$GEMINI_API_KEY`) > `config.yaml`.
29
+
30
+ ## Providers & Models
31
+
32
+ | Provider | Default Model | Alternatives |
33
+ |----------|--------------|--------------|
34
+ | **OpenAI** (default) | `gpt-image-1.5` | `gpt-image-1`, `gpt-image-1-mini` |
35
+ | **Gemini** | `gemini-3.1-flash-image-preview` | `gemini-3-pro-image-preview`, `gemini-2.5-flash-image` |
36
+
37
+ Note: `dall-e-2` and `dall-e-3` are deprecated (EOL May 2026). Use `gpt-image-1.5` instead.
38
+
39
+ ## Quick Reference
40
+
41
+ ```bash
42
+ SCRIPT="/Users/aman/.shared/skills/image-generation/scripts/generate_image.py"
43
+
44
+ # Basic generation (OpenAI, default)
45
+ python3 $SCRIPT --prompt "A sunset over mountains"
46
+
47
+ # High quality
48
+ python3 $SCRIPT --prompt "Oil painting of a forest" --quality high
49
+
50
+ # With aspect ratio
51
+ python3 $SCRIPT --prompt "Portrait photo" --aspect-ratio 3:4
52
+
53
+ # With reference image (OpenAI edit mode)
54
+ python3 $SCRIPT --prompt "Add a rainbow to this scene" --reference photo.png
55
+
56
+ # Gemini provider
57
+ python3 $SCRIPT --provider gemini --prompt "Watercolor sunset" --aspect-ratio 16:9
58
+
59
+ # Gemini with reference
60
+ python3 $SCRIPT --provider gemini --reference face.png \
61
+ --prompt "Same person sitting in a cafe, natural lighting" --aspect-ratio 9:16
62
+
63
+ # Custom output location
64
+ python3 $SCRIPT --prompt "A cat" --output /path/to/output/
65
+ ```
66
+
67
+ ## Aspect Ratios
68
+
69
+ | Use Case | Ratio | Notes |
70
+ |----------|-------|-------|
71
+ | Square / social | `1:1` | Default |
72
+ | Portrait | `3:4` or `2:3` | Vertical |
73
+ | Landscape | `4:3` or `16:9` | Wide |
74
+ | Phone / story | `9:16` | Vertical tall |
75
+ | Ultrawide | `21:9` | Cinematic |
76
+
77
+ OpenAI maps ratios to closest supported size (`1024x1024`, `1024x1536`, `1536x1024`).
78
+
79
+ ## OpenAI Quality (gpt-image-1 only)
80
+
81
+ - `auto` (default) — let the model decide
82
+ - `high` — best quality, slower
83
+ - `medium` — balanced
84
+ - `low` — fastest, cheapest
85
+
86
+ ## Structured Prompt Tips
87
+
88
+ For photorealistic results, use structured JSON prompts covering separate concerns:
89
+
90
+ ```json
91
+ {
92
+ "subject": "description of the main subject, pose, expression",
93
+ "clothing": "specific garments, colors, fabrics",
94
+ "camera": "lens mm, aperture, shot type, angle",
95
+ "lighting": "source, direction, quality, shadows",
96
+ "environment": "setting, background elements, atmosphere",
97
+ "color_grading": "palette, contrast, mood",
98
+ "technical": "style keywords — photorealistic, 8k, hyper-detailed"
99
+ }
100
+ ```
101
+
102
+ Key principles:
103
+ 1. **Separate concerns** — one aspect per block
104
+ 2. **Specify camera** — lens mm and aperture drive realism
105
+ 3. **Light direction** — "soft light from upper right" > "good lighting"
106
+ 4. **Material callouts** — "ribbed knit", "satin", "denim" > "nice clothes"
107
+ 5. **Avoid over-constraining** — 2-3 adjectives per block max
108
+
109
+ ## Provider Selection Guide
110
+
111
+ | Need | Use |
112
+ |------|-----|
113
+ | General image gen, highest quality | OpenAI `gpt-image-1.5` |
114
+ | Budget-friendly | OpenAI `gpt-image-1-mini` |
115
+ | Reference-based identity (same face) | Gemini (better at preserving identity from reference) |
116
+ | Image editing / inpainting | OpenAI edit mode (`--reference`) |
117
+ | Free tier / no OpenAI key | Gemini |
118
+
119
+ ## Combining with Bella
120
+
121
+ For Bella-specific image generation (identity-locked, reference-based), use the bella skill's `bella-image-generation.md` workflow instead. This skill is for general-purpose image generation without persona constraints.
@@ -0,0 +1,401 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ General-purpose image generation using OpenAI (default) or Gemini.
4
+
5
+ Supports:
6
+ - OpenAI: gpt-image-1.5 (default), gpt-image-1, gpt-image-1-mini
7
+ - Gemini: gemini-3.1-flash-image-preview (default), gemini-3-pro-image-preview, gemini-2.5-flash-image
8
+
9
+ Usage:
10
+ # OpenAI (default)
11
+ python3 generate_image.py --prompt "A sunset over mountains"
12
+
13
+ # OpenAI with reference image (edit mode)
14
+ python3 generate_image.py --prompt "Add a hot air balloon" --reference photo.png
15
+
16
+ # Gemini
17
+ python3 generate_image.py --provider gemini --prompt "A sunset over mountains"
18
+
19
+ # Gemini with reference image
20
+ python3 generate_image.py --provider gemini --prompt "Same person in a cafe" --reference face.png
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import argparse
26
+ import base64
27
+ import json
28
+ import mimetypes
29
+ import os
30
+ import sys
31
+ import time
32
+ import urllib.error
33
+ import urllib.request
34
+ from pathlib import Path
35
+
36
+ SCRIPT_DIR = Path(__file__).resolve().parent
37
+ SKILL_DIR = SCRIPT_DIR.parent
38
+ NIA_HOME = Path(os.environ.get("NIA_HOME", Path.home() / ".niahere"))
39
+ NIA_CONFIG = NIA_HOME / "config.yaml"
40
+ TIMESTAMP_FORMAT = "%Y%m%d_%H%M%S"
41
+
42
+ # --- Provider defaults ---
43
+ OPENAI_DEFAULT_MODEL = "gpt-image-1.5"
44
+ GEMINI_DEFAULT_MODEL = "gemini-3.1-flash-image-preview"
45
+
46
+ DEFAULT_ASPECT_RATIO = "1:1"
47
+ ALLOWED_ASPECT_RATIOS = (
48
+ "1:1", "3:4", "4:3", "9:16", "16:9",
49
+ "2:3", "3:2", "4:5", "5:4", "21:9",
50
+ )
51
+
52
+ # OpenAI size mappings (closest match for aspect ratio)
53
+ OPENAI_SIZE_MAP = {
54
+ "1:1": "1024x1024",
55
+ "3:4": "1024x1536",
56
+ "4:3": "1536x1024",
57
+ "9:16": "1024x1536",
58
+ "16:9": "1536x1024",
59
+ "2:3": "1024x1536",
60
+ "3:2": "1536x1024",
61
+ "4:5": "1024x1536",
62
+ "5:4": "1536x1024",
63
+ "21:9": "1536x1024",
64
+ }
65
+
66
+ # OpenAI quality options
67
+ OPENAI_QUALITIES = ("auto", "high", "medium", "low")
68
+
69
+
70
+ def safe_mime(path: str) -> str:
71
+ mime, _ = mimetypes.guess_type(path)
72
+ return mime or "image/png"
73
+
74
+
75
+ def encode_file(path: str) -> str:
76
+ with open(path, "rb") as f:
77
+ return base64.b64encode(f.read()).decode("utf-8")
78
+
79
+
80
+ def resolve_output_path(output: str | None, ext: str = ".png") -> Path:
81
+ if output:
82
+ out = Path(output).expanduser()
83
+ if out.suffix:
84
+ return out
85
+ return out / f"image_{time.strftime(TIMESTAMP_FORMAT)}{ext}"
86
+ return Path(f"/tmp/image_{time.strftime(TIMESTAMP_FORMAT)}{ext}")
87
+
88
+
89
+ def read_config_key(key: str) -> str:
90
+ """Read a key from ~/.niahere/config.yaml."""
91
+ if not NIA_CONFIG.is_file():
92
+ return ""
93
+ try:
94
+ import importlib
95
+ yaml = importlib.import_module("yaml")
96
+ with NIA_CONFIG.open("r") as f:
97
+ config = yaml.safe_load(f)
98
+ if config and isinstance(config, dict):
99
+ return config.get(key, "") or ""
100
+ except Exception:
101
+ for line in NIA_CONFIG.read_text().splitlines():
102
+ if line.startswith(f"{key}:"):
103
+ val = line.split(":", 1)[1].strip().strip("'\"")
104
+ return val
105
+ return ""
106
+
107
+
108
+ def resolve_api_key(provider: str, cli_key: str | None) -> str:
109
+ if cli_key:
110
+ return cli_key
111
+
112
+ if provider == "openai":
113
+ return (
114
+ os.environ.get("OPENAI_API_KEY", "")
115
+ or read_config_key("openai_api_key")
116
+ )
117
+ else:
118
+ return (
119
+ os.environ.get("GEMINI_API_KEY", "")
120
+ or os.environ.get("GOOGLE_API_KEY", "")
121
+ or read_config_key("gemini_api_key")
122
+ )
123
+
124
+
125
+ # --- OpenAI Generation ---
126
+
127
+ def generate_openai(
128
+ api_key: str,
129
+ prompt: str,
130
+ model: str,
131
+ size: str,
132
+ quality: str,
133
+ reference_path: str | None = None,
134
+ n: int = 1,
135
+ ) -> tuple[bytes, str]:
136
+ """Generate image via OpenAI Images API."""
137
+ if reference_path and Path(reference_path).is_file():
138
+ return _openai_edit(api_key, prompt, reference_path, model, size, quality, n)
139
+ return _openai_generate(api_key, prompt, model, size, quality, n)
140
+
141
+
142
+ def _openai_generate(
143
+ api_key: str, prompt: str, model: str, size: str, quality: str, n: int
144
+ ) -> tuple[bytes, str]:
145
+ url = "https://api.openai.com/v1/images/generations"
146
+ payload: dict = {
147
+ "model": model,
148
+ "prompt": prompt,
149
+ "n": n,
150
+ "size": size,
151
+ "response_format": "b64_json",
152
+ }
153
+ if model == "gpt-image-1":
154
+ payload["quality"] = quality
155
+
156
+ req = urllib.request.Request(
157
+ url=url,
158
+ data=json.dumps(payload).encode("utf-8"),
159
+ method="POST",
160
+ headers={
161
+ "Content-Type": "application/json",
162
+ "Authorization": f"Bearer {api_key}",
163
+ },
164
+ )
165
+ return _openai_request(req)
166
+
167
+
168
+ def _openai_edit(
169
+ api_key: str, prompt: str, reference_path: str, model: str, size: str,
170
+ quality: str, n: int
171
+ ) -> tuple[bytes, str]:
172
+ """Use OpenAI images/edits endpoint with a reference image."""
173
+ import io
174
+
175
+ boundary = f"----PythonBoundary{int(time.time() * 1000)}"
176
+ body = io.BytesIO()
177
+
178
+ def add_field(name: str, value: str) -> None:
179
+ body.write(f"--{boundary}\r\n".encode())
180
+ body.write(f'Content-Disposition: form-data; name="{name}"\r\n\r\n'.encode())
181
+ body.write(f"{value}\r\n".encode())
182
+
183
+ def add_file(name: str, filepath: str) -> None:
184
+ filename = Path(filepath).name
185
+ mime = safe_mime(filepath)
186
+ body.write(f"--{boundary}\r\n".encode())
187
+ body.write(
188
+ f'Content-Disposition: form-data; name="{name}"; filename="{filename}"\r\n'.encode()
189
+ )
190
+ body.write(f"Content-Type: {mime}\r\n\r\n".encode())
191
+ with open(filepath, "rb") as f:
192
+ body.write(f.read())
193
+ body.write(b"\r\n")
194
+
195
+ add_file("image[]", reference_path)
196
+ add_field("prompt", prompt)
197
+ add_field("model", model)
198
+ add_field("n", str(n))
199
+ add_field("size", size)
200
+ if model == "gpt-image-1":
201
+ add_field("quality", quality)
202
+ body.write(f"--{boundary}--\r\n".encode())
203
+
204
+ url = "https://api.openai.com/v1/images/edits"
205
+ req = urllib.request.Request(
206
+ url=url,
207
+ data=body.getvalue(),
208
+ method="POST",
209
+ headers={
210
+ "Content-Type": f"multipart/form-data; boundary={boundary}",
211
+ "Authorization": f"Bearer {api_key}",
212
+ },
213
+ )
214
+ return _openai_request(req)
215
+
216
+
217
+ def _openai_request(req: urllib.request.Request) -> tuple[bytes, str]:
218
+ try:
219
+ with urllib.request.urlopen(req, timeout=180) as resp:
220
+ response = json.loads(resp.read().decode("utf-8"))
221
+ except urllib.error.HTTPError as e:
222
+ detail = e.read().decode("utf-8", errors="ignore")
223
+ raise RuntimeError(
224
+ f"OpenAI API error (HTTP {e.code}): {detail or e.reason}"
225
+ ) from e
226
+
227
+ data_list = response.get("data", [])
228
+ if not data_list:
229
+ raise RuntimeError(f"No data in OpenAI response: {json.dumps(response, indent=2)}")
230
+
231
+ b64 = data_list[0].get("b64_json")
232
+ if not b64:
233
+ raise RuntimeError("No b64_json in OpenAI response.")
234
+
235
+ return base64.b64decode(b64), "image/png"
236
+
237
+
238
+ # --- Gemini Generation ---
239
+
240
+ def generate_gemini(
241
+ api_key: str,
242
+ prompt: str,
243
+ model: str,
244
+ aspect_ratio: str,
245
+ reference_path: str | None = None,
246
+ ) -> tuple[bytes, str]:
247
+ """Generate image via Gemini API."""
248
+ url = (
249
+ "https://generativelanguage.googleapis.com/"
250
+ f"v1beta/models/{model}:generateContent?key={api_key}"
251
+ )
252
+
253
+ parts: list[dict] = []
254
+ if reference_path and Path(reference_path).is_file():
255
+ parts.append({
256
+ "inlineData": {
257
+ "mimeType": safe_mime(reference_path),
258
+ "data": encode_file(reference_path),
259
+ }
260
+ })
261
+ parts.append({"text": prompt})
262
+
263
+ payload = {
264
+ "contents": [{"parts": parts}],
265
+ "generationConfig": {
266
+ "imageConfig": {"aspectRatio": aspect_ratio},
267
+ "responseModalities": ["TEXT", "IMAGE"],
268
+ },
269
+ }
270
+
271
+ req = urllib.request.Request(
272
+ url=url,
273
+ data=json.dumps(payload).encode("utf-8"),
274
+ method="POST",
275
+ headers={"Content-Type": "application/json"},
276
+ )
277
+
278
+ try:
279
+ with urllib.request.urlopen(req, timeout=120) as resp:
280
+ response = json.loads(resp.read().decode("utf-8"))
281
+ except urllib.error.HTTPError as e:
282
+ detail = e.read().decode("utf-8", errors="ignore")
283
+ raise RuntimeError(
284
+ f"Gemini API error (HTTP {e.code}): {detail or e.reason}"
285
+ ) from e
286
+
287
+ candidates = response.get("candidates", [])
288
+ if not candidates:
289
+ raise RuntimeError("No candidates in Gemini response.")
290
+
291
+ parts = candidates[0].get("content", {}).get("parts", [])
292
+ for part in parts:
293
+ inline = part.get("inlineData")
294
+ if not inline:
295
+ continue
296
+ mime = inline.get("mimeType")
297
+ img_data = inline.get("data")
298
+ if mime and img_data:
299
+ return base64.b64decode(img_data), mime
300
+
301
+ raise RuntimeError(
302
+ f"No image in Gemini response: {json.dumps(response, indent=2)}"
303
+ )
304
+
305
+
306
+ # --- CLI ---
307
+
308
+ def main() -> None:
309
+ parser = argparse.ArgumentParser(
310
+ description="Generate images using OpenAI (default) or Gemini.",
311
+ formatter_class=argparse.RawDescriptionHelpFormatter,
312
+ epilog="""
313
+ Examples:
314
+ %(prog)s --prompt "A cat on a skateboard"
315
+ %(prog)s --prompt "Oil painting of a forest" --quality high
316
+ %(prog)s --provider gemini --prompt "Watercolor sunset" --aspect-ratio 16:9
317
+ %(prog)s --prompt "Add wings to this bird" --reference bird.png
318
+ %(prog)s --provider gemini --reference face.png --prompt "Same person at beach" --aspect-ratio 9:16
319
+ """,
320
+ )
321
+ parser.add_argument(
322
+ "--provider", choices=["openai", "gemini"], default="openai",
323
+ help="Image generation provider. Default: openai.",
324
+ )
325
+ parser.add_argument(
326
+ "--prompt", required=True,
327
+ help="Text prompt describing the image to generate.",
328
+ )
329
+ parser.add_argument(
330
+ "--reference", default=None,
331
+ help="Path to a reference image. OpenAI uses edit mode; Gemini includes it as context.",
332
+ )
333
+ parser.add_argument(
334
+ "--model", default=None,
335
+ help=f"Model override. Defaults: OpenAI={OPENAI_DEFAULT_MODEL}, Gemini={GEMINI_DEFAULT_MODEL}.",
336
+ )
337
+ parser.add_argument(
338
+ "--aspect-ratio", default=DEFAULT_ASPECT_RATIO, choices=ALLOWED_ASPECT_RATIOS,
339
+ help=f"Aspect ratio. Default: {DEFAULT_ASPECT_RATIO}.",
340
+ )
341
+ parser.add_argument(
342
+ "--quality", default="auto", choices=OPENAI_QUALITIES,
343
+ help="OpenAI quality (gpt-image-1 only). Default: auto.",
344
+ )
345
+ parser.add_argument(
346
+ "--n", type=int, default=1,
347
+ help="Number of images (OpenAI only). Default: 1.",
348
+ )
349
+ parser.add_argument(
350
+ "--output", default=None,
351
+ help="Output path. Directory = timestamped file. Default: /tmp/.",
352
+ )
353
+ parser.add_argument(
354
+ "--api-key", default=None,
355
+ help="API key override. Otherwise reads from env var or ~/.niahere/config.yaml.",
356
+ )
357
+ args = parser.parse_args()
358
+
359
+ provider = args.provider
360
+ model = args.model or (OPENAI_DEFAULT_MODEL if provider == "openai" else GEMINI_DEFAULT_MODEL)
361
+
362
+ api_key = resolve_api_key(provider, args.api_key)
363
+ if not api_key:
364
+ config_key = "openai_api_key" if provider == "openai" else "gemini_api_key"
365
+ env_var = "OPENAI_API_KEY" if provider == "openai" else "GEMINI_API_KEY"
366
+ raise SystemExit(
367
+ f"Missing API key. Provide --api-key, set {env_var} in environment, "
368
+ f"or add {config_key} to {NIA_CONFIG}."
369
+ )
370
+
371
+ if args.reference and not Path(args.reference).expanduser().is_file():
372
+ raise SystemExit(f"Reference image not found: {args.reference}")
373
+
374
+ ref = str(Path(args.reference).expanduser()) if args.reference else None
375
+
376
+ try:
377
+ if provider == "openai":
378
+ size = OPENAI_SIZE_MAP.get(args.aspect_ratio, "1024x1024")
379
+ image_data, mime = generate_openai(
380
+ api_key=api_key, prompt=args.prompt, model=model,
381
+ size=size, quality=args.quality, reference_path=ref, n=args.n,
382
+ )
383
+ else:
384
+ image_data, mime = generate_gemini(
385
+ api_key=api_key, prompt=args.prompt, model=model,
386
+ aspect_ratio=args.aspect_ratio, reference_path=ref,
387
+ )
388
+
389
+ ext = ".png" if "png" in mime else ".jpg"
390
+ out = resolve_output_path(args.output, ext)
391
+ out.parent.mkdir(parents=True, exist_ok=True)
392
+ out.write_bytes(image_data)
393
+ print(f"Saved: {out}")
394
+ print(f"Provider: {provider} | Model: {model} | Size/Ratio: {args.aspect_ratio}")
395
+ except Exception as exc:
396
+ print(f"Error: {exc}", file=sys.stderr)
397
+ raise SystemExit(1) from exc
398
+
399
+
400
+ if __name__ == "__main__":
401
+ main()