gpteam 0.1.17 → 0.1.19

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
@@ -16,6 +16,19 @@ Client config writing follows the same safety pattern as cc-switch: keep Codex t
16
16
 
17
17
  The Image MCP config uses cc-switch-style per-client env blocks. Codex writes `[mcp_servers.gpteam_image.env]`, OpenCode writes `mcp.gpteam_image.environment`, and Claude Code writes `mcpServers.gpteam_image.env` in `~/.claude.json`. The MCP receives `GPTEAM_API_KEY` and `GPTEAM_BASE_URL` from that MCP config, so it does not depend on Codex `auth.json` or inherited `OPENAI_API_KEY`.
18
18
 
19
+ The Image MCP exposes both a synchronous compatibility tool and a local async job flow:
20
+
21
+ - `create_image_job`: recommended for normal use. It starts a local background image job and returns `job_id` quickly, which avoids losing the whole generation when a VPN, proxy, or client has a 60-second idle timeout.
22
+ - `get_image_job_status`: checks whether the local job is queued, running, succeeded, failed, canceled, or expired.
23
+ - `cancel_image_job`: cancels a queued/running local job.
24
+ - `download_image_result`: returns the completed file metadata and image content. Use `metadata_only`, `include_image`, and `include_revised_prompt` to control large result payloads.
25
+ - `get_capabilities`: returns supported sizes, formats, quality values, async support, cancellation semantics, queue limits, and image-to-image support.
26
+ - `generate_image`: waits for completion and writes the image file immediately. This is kept for synchronous compatibility.
27
+
28
+ Image MCP results are returned as stable JSON text and MCP `structuredContent`. Successful results include final file path, model, action, size, format, quality, byte size, SHA-256, MIME type, image dimensions, duration, retry count, `job_id`, `trace_id`, and optional `idempotency_key`. Error results use stable `error.code`, `error.message`, `error.retryable`, `error.stage`, `error.upstream_status`, and `error.trace_id` fields while keeping compatibility fields such as `category` and `http_status`.
29
+
30
+ The MCP supports normal text-to-image generation and image-to-image/edit inputs. Pass `images` as data URLs, HTTPS URLs, or local file paths. Pass `mask` the same way for masked edits, and use `input_fidelity` when needed. File writes create missing directories, avoid overwriting existing files by adding `-v2`, `-v3`, etc., and validate PNG/JPEG/WebP before returning success. `overwrite: true` is available for explicit replacement. `return_revised_prompt` controls whether the upstream revised prompt is included in the result.
31
+
19
32
  Claude Code is written to `~/.claude/settings.json` under the `env` section, using the GPTeam `/anthropic` base URL. OpenClaw writes `models.providers.gpteam` and also selects `gpteam/<model>` under `agents.defaults.model`, so the chosen model is active without an extra manual step.
20
33
 
21
34
  Supported clients:
package/lib/cli.js CHANGED
@@ -71,8 +71,8 @@ export async function runCli(argv = []) {
71
71
  for (const filePath of written) console.log(`- ${filePath}`);
72
72
  console.log(`入口:${formatNodeLabel(selectedNode)}`);
73
73
  console.log(`地址:${selectedNode.baseUrl}`);
74
- if (client.id === 'codex') {
75
- printHint(theme, '已写入 GPTeam Image MCP。Codex 对话里需要生图时可调用 generate_image,MCP 使用专用环境变量读取 API key。');
74
+ if (['codex', 'opencode', 'claude-code'].includes(client.id)) {
75
+ printHint(theme, '已写入 GPTeam Image MCP。客户端对话里需要生图或图生图时优先调用 create_image_job,再用 get_image_job_status 和 download_image_result 取结果,get_capabilities 可查看支持能力,MCP 使用专用环境变量读取 API key。');
76
76
  }
77
77
  } finally {
78
78
  rl.close();
package/lib/config.js CHANGED
@@ -8,6 +8,14 @@ const PROVIDER_ID = 'gpteam';
8
8
  const IMAGE_MCP_ID = 'gpteam_image';
9
9
  const IMAGE_MCP_PACKAGE = 'gpteam';
10
10
  const IMAGE_MCP_BIN = 'gpteam-image-mcp';
11
+ const IMAGE_MCP_ENABLED_TOOLS = [
12
+ 'create_image_job',
13
+ 'get_image_job_status',
14
+ 'download_image_result',
15
+ 'cancel_image_job',
16
+ 'get_capabilities',
17
+ 'generate_image'
18
+ ];
11
19
 
12
20
  export const CLIENTS = [
13
21
  { id: 'codex', label: 'Codex' },
@@ -56,7 +64,7 @@ export function writeCodexConfig(settings) {
56
64
  `args = [${mcpCommand.args.map((arg) => tomlString(arg)).join(', ')}]`,
57
65
  'startup_timeout_sec = 20',
58
66
  'tool_timeout_sec = 300',
59
- 'enabled_tools = ["generate_image"]',
67
+ `enabled_tools = [${IMAGE_MCP_ENABLED_TOOLS.map((name) => tomlString(name)).join(', ')}]`,
60
68
  'default_tools_approval_mode = "prompt"',
61
69
  '',
62
70
  `[mcp_servers.${IMAGE_MCP_ID}.env]`,
package/lib/help.js CHANGED
@@ -1,5 +1,5 @@
1
1
  export const PACKAGE_NAME = 'gpteam';
2
- export const PACKAGE_VERSION = '0.1.17';
2
+ export const PACKAGE_VERSION = '0.1.19';
3
3
 
4
4
  export function getHelpText() {
5
5
  return [
@@ -22,6 +22,6 @@ export function getHelpText() {
22
22
  ' --help 显示帮助',
23
23
  ' --version 显示版本',
24
24
  '',
25
- '说明:输入 key 后会先请求 /v1/models 校验,通过后才继续。选好客户端后会检测本机命令是否可用,缺失时提示安装。Windows 暂不支持自动配置 OpenClaw。测速会请求 GET /api/health 和流式 POST /v1/responses。入口之间并行测速,写新配置前会先备份旧配置。Codex、OpenCode、Claude Code 会写入 GPTeam Image MCP,生图工具从 MCP env 读取 key'
25
+ '说明:输入 key 后会先请求 /v1/models 校验,通过后才继续。选好客户端后会检测本机命令是否可用,缺失时提示安装。Windows 暂不支持自动配置 OpenClaw。测速会请求 GET /api/health 和流式 POST /v1/responses。入口之间并行测速,写新配置前会先备份旧配置。Codex、OpenCode、Claude Code 会写入 GPTeam Image MCP,生图和图生图工具从 MCP env 读取 key,长时间任务优先用 create_image_job 异步任务,get_capabilities 可查看支持能力。'
26
26
  ].join('\n');
27
27
  }
@@ -0,0 +1,161 @@
1
+ import { formatNetworkError } from '../errors.js';
2
+
3
+ export class ImageMCPError extends Error {
4
+ constructor(message, options = {}) {
5
+ super(String(message || 'GPTeam image MCP error'));
6
+ this.name = 'ImageMCPError';
7
+ this.code = String(options.code || 'image_mcp_error');
8
+ this.category = String(options.category || 'unknown');
9
+ this.stage = String(options.stage || stageFromCategory(this.category));
10
+ this.retryable = Boolean(options.retryable);
11
+ this.http_status = Number.isFinite(options.http_status) ? options.http_status : undefined;
12
+ this.upstream_status = Number.isFinite(options.upstream_status) ? options.upstream_status : this.http_status;
13
+ this.details = options.details || undefined;
14
+ }
15
+ }
16
+
17
+ export async function imageErrorFromHTTPResponse(response, apiKey) {
18
+ const status = Number(response && response.status) || 0;
19
+ const rawText = typeof response.text === 'function' ? await response.text() : '';
20
+ const detail = parseUpstreamError(rawText);
21
+ const upstreamCode = String(detail.code || detail.type || '').trim();
22
+ const bodyMessage = detail.message || rawText || '';
23
+ const message = redactSecret(bodyMessage ? `HTTP ${status}: ${bodyMessage}` : `HTTP ${status}`, apiKey);
24
+ if (status === 429) {
25
+ return new ImageMCPError(message, {
26
+ code: upstreamCode || 'rate_limit_exceeded',
27
+ category: 'upstream_rate_limit',
28
+ stage: 'upstream',
29
+ http_status: status,
30
+ retryable: true
31
+ });
32
+ }
33
+ if (status >= 500 || status === 408) {
34
+ return new ImageMCPError(message, {
35
+ code: upstreamCode || 'upstream_server_error',
36
+ category: status === 408 ? 'timeout' : 'upstream_server',
37
+ stage: 'upstream',
38
+ http_status: status,
39
+ retryable: true
40
+ });
41
+ }
42
+ if (isContentSafetyError(upstreamCode, message)) {
43
+ return new ImageMCPError(message, {
44
+ code: upstreamCode || 'content_safety_rejected',
45
+ category: 'content_safety',
46
+ stage: 'upstream',
47
+ http_status: status,
48
+ retryable: false
49
+ });
50
+ }
51
+ return new ImageMCPError(message, {
52
+ code: upstreamCode || (status === 401 || status === 403 ? 'authentication_failed' : 'invalid_request_error'),
53
+ category: status === 401 || status === 403 ? 'authentication' : 'parameter',
54
+ stage: status === 401 || status === 403 ? 'upstream' : 'validate',
55
+ http_status: status,
56
+ retryable: false
57
+ });
58
+ }
59
+
60
+ export function imageErrorFromFetch(error, options = {}) {
61
+ if (options.cancelled) {
62
+ return new ImageMCPError('图片生成任务已取消。', {
63
+ code: 'job_cancelled',
64
+ category: 'canceled',
65
+ stage: 'cancel',
66
+ retryable: false
67
+ });
68
+ }
69
+ const message = redactSecret(formatNetworkError(error), options.apiKey);
70
+ const timeoutLike = options.timedOut || /timeout|timedout|etimedout/i.test(message);
71
+ return new ImageMCPError(message, {
72
+ code: timeoutLike ? 'network_timeout' : 'network_error',
73
+ category: timeoutLike ? 'timeout' : 'network',
74
+ stage: 'network',
75
+ retryable: true
76
+ });
77
+ }
78
+
79
+ export function serializeImageError(error, meta = {}) {
80
+ if (error instanceof ImageMCPError) {
81
+ return {
82
+ code: error.code,
83
+ message: error.message,
84
+ retryable: error.retryable,
85
+ stage: error.stage,
86
+ upstream_status: error.upstream_status,
87
+ trace_id: meta.trace_id || '',
88
+ category: error.category,
89
+ http_status: error.http_status,
90
+ details: error.details
91
+ };
92
+ }
93
+ return {
94
+ code: 'image_mcp_error',
95
+ message: error && error.message ? String(error.message) : String(error || 'unknown error'),
96
+ retryable: false,
97
+ stage: 'unknown',
98
+ upstream_status: undefined,
99
+ trace_id: meta.trace_id || '',
100
+ category: 'unknown'
101
+ };
102
+ }
103
+
104
+ export function redactSecret(text, secret) {
105
+ let result = String(text || '');
106
+ if (secret) result = result.split(secret).join('[redacted]');
107
+ result = result.replace(/sk-[A-Za-z0-9_-]{6,}/g, 'sk-[redacted]');
108
+ return result;
109
+ }
110
+
111
+ function parseUpstreamError(text) {
112
+ try {
113
+ const parsed = JSON.parse(String(text || ''));
114
+ const error = parsed && typeof parsed === 'object' ? parsed.error : null;
115
+ if (error && typeof error === 'object') {
116
+ return {
117
+ code: error.code,
118
+ type: error.type,
119
+ message: error.message
120
+ };
121
+ }
122
+ } catch {
123
+ return {};
124
+ }
125
+ return {};
126
+ }
127
+
128
+ function isContentSafetyError(code, message) {
129
+ const text = `${code || ''} ${message || ''}`.toLowerCase();
130
+ return /content|safety|policy|moderation/.test(text);
131
+ }
132
+
133
+ function stageFromCategory(category) {
134
+ switch (String(category || '').toLowerCase()) {
135
+ case 'parameter':
136
+ return 'validate';
137
+ case 'configuration':
138
+ case 'environment':
139
+ return 'configuration';
140
+ case 'file_system':
141
+ case 'response_invalid':
142
+ return 'local';
143
+ case 'authentication':
144
+ case 'content_safety':
145
+ case 'upstream_rate_limit':
146
+ case 'upstream_server':
147
+ return 'upstream';
148
+ case 'timeout':
149
+ case 'network':
150
+ return 'network';
151
+ case 'canceled':
152
+ case 'cancelled':
153
+ return 'cancel';
154
+ case 'not_found':
155
+ return 'lookup';
156
+ case 'queue':
157
+ return 'queue';
158
+ default:
159
+ return 'unknown';
160
+ }
161
+ }
@@ -0,0 +1,303 @@
1
+ import crypto from 'node:crypto';
2
+ import fs from 'node:fs';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import { ImageMCPError } from './errors.js';
6
+
7
+ export const DEFAULT_IMAGE_FORMAT = 'png';
8
+
9
+ export function writeImageOutput(input = {}, options = {}) {
10
+ const bytes = decodeBase64Image(input.b64);
11
+ const image = inspectImageBytes(bytes);
12
+ const format = image.format || normalizeImageFormat(input.format || input.output_format || DEFAULT_IMAGE_FORMAT);
13
+ const outputPath = writeImageFile(input.output_path, format, bytes, {
14
+ ...options,
15
+ overwrite: Boolean(input.overwrite)
16
+ });
17
+ return {
18
+ file: outputPath,
19
+ final_file: outputPath,
20
+ path: outputPath,
21
+ bytes: bytes.length,
22
+ sha256: crypto.createHash('sha256').update(bytes).digest('hex'),
23
+ mime_type: image.mime_type,
24
+ mimeType: image.mime_type,
25
+ width: image.width,
26
+ height: image.height,
27
+ format,
28
+ output_format: format
29
+ };
30
+ }
31
+
32
+ export function localImageToDataURL(rawPath, options = {}) {
33
+ const filePath = path.resolve(expandHome(String(rawPath || ''), options.home || os.homedir()));
34
+ const bytes = fs.readFileSync(filePath);
35
+ const image = inspectImageBytes(bytes);
36
+ return `data:${image.mime_type};base64,${bytes.toString('base64')}`;
37
+ }
38
+
39
+ function writeImageFile(rawOutputPath, format, bytes, options) {
40
+ if (options.overwrite) {
41
+ const outputPath = resolveOutputPath(rawOutputPath, format, options);
42
+ fs.mkdirSync(path.dirname(outputPath), { recursive: true });
43
+ atomicReplaceFile(outputPath, bytes);
44
+ return outputPath;
45
+ }
46
+ return writeImageFileNonOverwriting(rawOutputPath, format, bytes, options);
47
+ }
48
+
49
+ function writeImageFileNonOverwriting(rawOutputPath, format, bytes, options) {
50
+ let lastConflict;
51
+ for (let attempt = 0; attempt < 10000; attempt += 1) {
52
+ const outputPath = resolveAvailableOutputPath(rawOutputPath, format, options);
53
+ fs.mkdirSync(path.dirname(outputPath), { recursive: true });
54
+ try {
55
+ atomicWriteFile(outputPath, bytes);
56
+ return outputPath;
57
+ } catch (error) {
58
+ if (!isOutputPathConflict(error)) throw error;
59
+ lastConflict = error;
60
+ }
61
+ }
62
+ throw lastConflict || new ImageMCPError('无法生成不覆盖现有文件的输出路径。', {
63
+ code: 'output_path_conflict',
64
+ category: 'file_system',
65
+ stage: 'local',
66
+ retryable: false
67
+ });
68
+ }
69
+
70
+ export function normalizeImageFormat(value) {
71
+ const normalized = String(value || DEFAULT_IMAGE_FORMAT).trim().toLowerCase();
72
+ if (normalized === 'jpg') return 'jpeg';
73
+ if (['png', 'jpeg', 'webp'].includes(normalized)) return normalized;
74
+ return DEFAULT_IMAGE_FORMAT;
75
+ }
76
+
77
+ function decodeBase64Image(value) {
78
+ const text = String(value || '').trim();
79
+ if (!text) {
80
+ throw new ImageMCPError('GPTeam 图片接口没有返回 b64_json 图片数据。', {
81
+ code: 'image_data_missing',
82
+ category: 'response_invalid',
83
+ stage: 'local',
84
+ retryable: false
85
+ });
86
+ }
87
+ const bytes = Buffer.from(text, 'base64');
88
+ if (bytes.length === 0) {
89
+ throw new ImageMCPError('GPTeam 图片接口返回的图片数据为空。', {
90
+ code: 'image_data_empty',
91
+ category: 'response_invalid',
92
+ stage: 'local',
93
+ retryable: false
94
+ });
95
+ }
96
+ return bytes;
97
+ }
98
+
99
+ export function inspectImageBytes(bytes) {
100
+ const png = inspectPNG(bytes);
101
+ if (png) return png;
102
+ const jpeg = inspectJPEG(bytes);
103
+ if (jpeg) return jpeg;
104
+ const webp = inspectWebP(bytes);
105
+ if (webp) return webp;
106
+ throw new ImageMCPError('写入前图片校验失败:无法识别 PNG/JPEG/WebP。', {
107
+ code: 'image_mime_invalid',
108
+ category: 'response_invalid',
109
+ stage: 'local',
110
+ retryable: false
111
+ });
112
+ }
113
+
114
+ function inspectPNG(bytes) {
115
+ if (bytes.length < 24) return null;
116
+ const signature = '89504e470d0a1a0a';
117
+ if (bytes.subarray(0, 8).toString('hex') !== signature) return null;
118
+ return {
119
+ format: 'png',
120
+ mime_type: 'image/png',
121
+ width: bytes.readUInt32BE(16),
122
+ height: bytes.readUInt32BE(20)
123
+ };
124
+ }
125
+
126
+ function inspectJPEG(bytes) {
127
+ if (bytes.length < 4 || bytes[0] !== 0xff || bytes[1] !== 0xd8) return null;
128
+ let offset = 2;
129
+ while (offset + 9 < bytes.length) {
130
+ if (bytes[offset] !== 0xff) {
131
+ offset += 1;
132
+ continue;
133
+ }
134
+ const marker = bytes[offset + 1];
135
+ const length = bytes.readUInt16BE(offset + 2);
136
+ if (length < 2) break;
137
+ if ([0xc0, 0xc1, 0xc2, 0xc3].includes(marker)) {
138
+ return {
139
+ format: 'jpeg',
140
+ mime_type: 'image/jpeg',
141
+ height: bytes.readUInt16BE(offset + 5),
142
+ width: bytes.readUInt16BE(offset + 7)
143
+ };
144
+ }
145
+ offset += 2 + length;
146
+ }
147
+ return { format: 'jpeg', mime_type: 'image/jpeg', width: undefined, height: undefined };
148
+ }
149
+
150
+ function inspectWebP(bytes) {
151
+ if (bytes.length < 16) return null;
152
+ if (bytes.subarray(0, 4).toString('ascii') !== 'RIFF') return null;
153
+ if (bytes.subarray(8, 12).toString('ascii') !== 'WEBP') return null;
154
+ if (bytes.subarray(12, 16).toString('ascii') === 'VP8X' && bytes.length >= 30) {
155
+ return {
156
+ format: 'webp',
157
+ mime_type: 'image/webp',
158
+ width: 1 + readUInt24LE(bytes, 24),
159
+ height: 1 + readUInt24LE(bytes, 27)
160
+ };
161
+ }
162
+ return { format: 'webp', mime_type: 'image/webp', width: undefined, height: undefined };
163
+ }
164
+
165
+ function readUInt24LE(bytes, offset) {
166
+ return bytes[offset] + (bytes[offset + 1] << 8) + (bytes[offset + 2] << 16);
167
+ }
168
+
169
+ function resolveAvailableOutputPath(rawOutputPath, format, options) {
170
+ const resolved = resolveOutputPath(rawOutputPath, format, options);
171
+ if (!fs.existsSync(resolved)) return resolved;
172
+ const parsed = path.parse(resolved);
173
+ for (let version = 2; version < 10000; version += 1) {
174
+ const candidate = path.join(parsed.dir, `${parsed.name}-v${version}${parsed.ext}`);
175
+ if (!fs.existsSync(candidate)) return candidate;
176
+ }
177
+ throw new ImageMCPError('无法生成不覆盖现有文件的输出路径。', {
178
+ code: 'output_path_conflict',
179
+ category: 'file_system',
180
+ stage: 'local',
181
+ retryable: false
182
+ });
183
+ }
184
+
185
+ function resolveOutputPath(rawOutputPath, format, options) {
186
+ const env = options.env || process.env;
187
+ const home = options.home || os.homedir();
188
+ const name = `gpteam-image-${new Date().toISOString().replace(/[-:]/g, '').replace(/\..+/, '').replace('T', '-')}.${format}`;
189
+ if (rawOutputPath) {
190
+ const expanded = expandHome(String(rawOutputPath), home);
191
+ if (isDirectoryLike(expanded)) return path.resolve(expanded, name);
192
+ return path.resolve(withImageExtension(expanded, format));
193
+ }
194
+ const outputDir = expandHome(firstNonEmpty(env.GPTEAM_IMAGE_OUTPUT_DIR, defaultImageOutputDir(home)), home);
195
+ return path.resolve(outputDir, name);
196
+ }
197
+
198
+ function atomicWriteFile(outputPath, bytes) {
199
+ const tempPath = path.join(path.dirname(outputPath), `.${path.basename(outputPath)}.${process.pid}.${Date.now()}.${crypto.randomBytes(6).toString('hex')}.tmp`);
200
+ let fd;
201
+ try {
202
+ fd = fs.openSync(tempPath, 'wx', 0o600);
203
+ fs.writeFileSync(fd, bytes);
204
+ fs.fsyncSync(fd);
205
+ fs.closeSync(fd);
206
+ fd = undefined;
207
+ fs.linkSync(tempPath, outputPath);
208
+ try {
209
+ fs.rmSync(tempPath, { force: true });
210
+ } catch {
211
+ // 目标文件已通过独占链接落盘,临时文件清理失败不影响返回结果。
212
+ }
213
+ } catch (error) {
214
+ if (fd !== undefined) {
215
+ try {
216
+ fs.closeSync(fd);
217
+ } catch {
218
+ // 忽略关闭临时文件失败,下面会清理临时路径。
219
+ }
220
+ }
221
+ try {
222
+ fs.rmSync(tempPath, { force: true });
223
+ } catch {
224
+ // 忽略清理失败,返回主要写入错误。
225
+ }
226
+ const conflict = error && error.code === 'EEXIST';
227
+ throw new ImageMCPError(conflict ? '输出文件已存在,正在重新选择不覆盖路径。' : `图片文件写入失败:${error.message}`, {
228
+ code: conflict ? 'output_path_conflict' : 'file_write_failed',
229
+ category: 'file_system',
230
+ stage: 'local',
231
+ retryable: false
232
+ });
233
+ }
234
+ }
235
+
236
+ function atomicReplaceFile(outputPath, bytes) {
237
+ const tempPath = path.join(path.dirname(outputPath), `.${path.basename(outputPath)}.${process.pid}.${Date.now()}.${crypto.randomBytes(6).toString('hex')}.tmp`);
238
+ let fd;
239
+ try {
240
+ fd = fs.openSync(tempPath, 'wx', 0o600);
241
+ fs.writeFileSync(fd, bytes);
242
+ fs.fsyncSync(fd);
243
+ fs.closeSync(fd);
244
+ fd = undefined;
245
+ fs.renameSync(tempPath, outputPath);
246
+ } catch (error) {
247
+ if (fd !== undefined) {
248
+ try {
249
+ fs.closeSync(fd);
250
+ } catch {
251
+ // 忽略关闭临时文件失败,下面会清理临时路径。
252
+ }
253
+ }
254
+ try {
255
+ fs.rmSync(tempPath, { force: true });
256
+ } catch {
257
+ // 忽略清理失败,返回主要写入错误。
258
+ }
259
+ throw new ImageMCPError(`图片文件写入失败:${error.message}`, {
260
+ code: 'file_write_failed',
261
+ category: 'file_system',
262
+ stage: 'local',
263
+ retryable: false
264
+ });
265
+ }
266
+ }
267
+
268
+ function isOutputPathConflict(error) {
269
+ return error instanceof ImageMCPError && error.code === 'output_path_conflict';
270
+ }
271
+
272
+ function defaultImageOutputDir(home) {
273
+ const desktop = path.join(home, 'Desktop');
274
+ return fs.existsSync(desktop) ? desktop : process.cwd();
275
+ }
276
+
277
+ function isDirectoryLike(value) {
278
+ return /[\\/]$/.test(value) || (fs.existsSync(value) && fs.statSync(value).isDirectory());
279
+ }
280
+
281
+ function firstNonEmpty(...values) {
282
+ for (const value of values) {
283
+ const text = String(value || '').trim();
284
+ if (text) return text;
285
+ }
286
+ return '';
287
+ }
288
+
289
+ function expandHome(value, home) {
290
+ const text = String(value || '');
291
+ if (text === '~') return home;
292
+ if (text.startsWith(`~${path.sep}`)) return path.join(home, text.slice(2));
293
+ if (text.startsWith('~/')) return path.join(home, text.slice(2));
294
+ return text;
295
+ }
296
+
297
+ function withImageExtension(filePath, format) {
298
+ const parsed = path.parse(filePath);
299
+ const ext = parsed.ext.toLowerCase();
300
+ const normalizedExt = ext === '.jpg' ? '.jpeg' : ext;
301
+ if (normalizedExt === `.${format}`) return filePath;
302
+ return path.join(parsed.dir, `${parsed.name}.${format}`);
303
+ }