codex-copilot-dx 0.8.0 → 0.8.2
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 +6 -4
- package/package.json +1 -1
- package/src/cli-image.mjs +3 -2
- package/src/cli-options.mjs +1 -1
- package/src/image-mcp.mjs +54 -6
- package/src/image-provider-config.mjs +6 -5
- package/src/image-skill/SKILL.md +28 -0
- package/src/image-skill/references/editing.md +15 -0
- package/src/image-skill/references/helper.md +27 -0
- package/src/image-skill.mjs +31 -36
- package/src/image-tool-client.mjs +1 -1
package/README.md
CHANGED
|
@@ -145,11 +145,13 @@ Image generation is disabled by default. Configure it interactively only when yo
|
|
|
145
145
|
ccdx enable-image
|
|
146
146
|
```
|
|
147
147
|
|
|
148
|
-
The command
|
|
148
|
+
The command accepts an HTTPS base URL or a full `/images/generations` endpoint, then asks for an API key without echoing the key. A bare origin such as `https://api.example` expands to `/v1/images/generations`; `https://api.example/v1` expands to the same endpoint. Custom base paths are retained, and existing full endpoints remain unchanged. CCDX uses GET for the corresponding `/models` catalog and POST for image requests; there is no endpoint-search or automatic fallback. It asks for a numeric model selection only when multiple image models are available, then detects either the standard OpenAI Images request format or the supported `input.messages` format for the selected model only. Its single validation request omits the prompt and does not generate an image.
|
|
149
149
|
|
|
150
|
-
The provider credential is stored in `~/.config/codex-copilot-dx/image-provider.json`, or under `XDG_CONFIG_HOME`, with mode `0600`. The API key is never written to `~/.codex/config.toml`; that file receives only a marked local MCP entry pointing to the running CCDX adapter with a 210-second tool timeout.
|
|
150
|
+
The provider credential is stored in `~/.config/codex-copilot-dx/image-provider.json`, or under `XDG_CONFIG_HOME`, with mode `0600`. The API key is never written to `~/.codex/config.toml`; that file receives only a marked local MCP entry pointing to the running CCDX adapter with a 210-second tool timeout. Only after enabling, CCDX installs `ccdx-image` guidance under `~/.codex/skills`, with a self-contained Node helper. The concise `SKILL.md` keeps operation selection, safety, and final-image delivery rules; editing details and helper instructions live in references read only for those operations. Codex uses the MCP tool directly, including through deferred tool search; an already-open task without that tool can use the helper to call the same local service and display the saved image. Neither route requires Python, an OpenAI SDK, or copying credentials. Built-in skills and unrelated tools are not modified.
|
|
151
151
|
|
|
152
|
-
The enable command verifies the local MCP handshake and generation-tool directory without generating an image. It reports provider configuration separately from local service readiness; it cannot assert that every running Codex task has refreshed its catalog. Codex normally detects changed skills; restart Codex App once if an open task has not refreshed them. Normal CCDX startup installs or updates this guidance for
|
|
152
|
+
The enable command verifies the local MCP handshake and generation-tool directory without generating an image. It reports provider configuration separately from local service readiness; it cannot assert that every running Codex task has refreshed its catalog. Codex normally detects changed skills; restart Codex App once if an open task has not refreshed them. Normal CCDX startup installs or updates this guidance only for an already-enabled provider, without repeating API setup. Without `enable-image`, it installs neither this skill nor the MCP registration. Unchanged guidance is not rewritten. The existing adapter can remain running because provider settings are loaded when the tool is invoked.
|
|
153
|
+
|
|
154
|
+
Direct MCP generations and edits retain their original inline image content and editing ID, and also save the exact image bytes privately under `~/.local/share/codex-copilot-dx/images`. The result includes ready-to-use image Markdown for Codex's final chat reply, so the image appears as a clickable thumbnail instead of only inside a tool result. Files use unique names and are not overwritten or removed on adapter restart. Asking to show the same image again reuses its existing Markdown, not a new generation or edit. If saving fails, CCDX still returns the generated image content, reports the delivery failure, and does not retry the provider or invent a file path. The bundled helper continues saving once at its existing output location, without a second adapter-side copy. Existing enabled users receive updated, unmodified CCDX-owned guidance on the next CCDX startup; no new setup command is needed.
|
|
153
155
|
|
|
154
156
|
Inspect or disable the optional provider with:
|
|
155
157
|
|
|
@@ -158,7 +160,7 @@ ccdx image-status
|
|
|
158
160
|
ccdx disable-image
|
|
159
161
|
```
|
|
160
162
|
|
|
161
|
-
`image-status` never prints the API key and performs only a local, non-generating tool-readiness check. Disabling removes the stored provider credential, the CCDX-owned MCP block, and unmodified
|
|
163
|
+
`image-status` never prints the API key and performs only a local, non-generating tool-readiness check. Disabling removes the stored provider credential, the CCDX-owned MCP block, and unmodified skill, reference, and helper files; user-modified files are preserved. Cached tool calls then fail closed. The Node helper creates a new file under the task's `output/imagegen` directory by default and reports its absolute path; it never overwrites an existing file or automatically repeats a failed generation. The image endpoint is contacted only by `enable-image` validation and by explicit `generate_image` or `edit_image` tool calls. Ordinary Responses, models, authentication, Fast, Auto-review, compaction, and image-input paths remain on their existing routes.
|
|
162
164
|
|
|
163
165
|
Generated image downloads must use public HTTPS addresses, do not follow redirects, are DNS-checked against private and reserved addresses, and are capped at 32 MiB. DNS resolution and downloading share one deadline and respond to cancellation; a late DNS result cannot start a cancelled download. The local MCP route accepts calls only from the same device and permits at most two concurrent image operations. It requires `application/json` and rejects browser `Origin` headers; native Codex and Node clients do not send an Origin. The tool supports new-image generation at `1024x1024`, `1536x1024`, or `1024x1536`.
|
|
164
166
|
|
package/package.json
CHANGED
package/src/cli-image.mjs
CHANGED
|
@@ -201,9 +201,10 @@ export async function runImageCommand({
|
|
|
201
201
|
const ask = prompt || ((question) => visibleQuestion(input, output, question));
|
|
202
202
|
const askSecret = promptSecret || ((question) => hiddenQuestion(input, output, question));
|
|
203
203
|
output.write(`${commandName} enable-image\n`);
|
|
204
|
-
|
|
204
|
+
output.write("Enter an HTTPS base URL (for example, https://api.example/v1) or a full /images/generations endpoint.\n");
|
|
205
|
+
const endpointAnswer = String(await ask(`API base URL or endpoint${current ? ` [${current.endpoint}]` : ""}: `) || "").trim();
|
|
205
206
|
const endpoint = endpointAnswer || current?.endpoint;
|
|
206
|
-
if (!endpoint) throw new Error("Image API endpoint is required");
|
|
207
|
+
if (!endpoint) throw new Error("Image API base URL or endpoint is required");
|
|
207
208
|
const keyAnswer = String(await askSecret(`API key${current ? " [Enter to keep current]" : ""}: `) || "").trim();
|
|
208
209
|
const apiKey = keyAnswer || current?.api_key;
|
|
209
210
|
if (!apiKey) throw new Error("Image API key is required");
|
package/src/cli-options.mjs
CHANGED
|
@@ -302,7 +302,7 @@ function topicHelp(name, topic) {
|
|
|
302
302
|
models: `Usage:\n ${name} models [--format table|plain]\n\nPerforms a fresh, read-only Copilot model-directory lookup for the saved account. Interactive terminals use a table by default.`,
|
|
303
303
|
usage: `Usage:\n ${name} usage [--format table|plain]\n\nSummarizes local token usage metadata without reading prompt or completion content. Interactive terminals use a table by default.`,
|
|
304
304
|
animation: `Usage:\n ${name} animation\n\nPreviews all terminal activity animations together and selects the theme used the next time the adapter starts.`,
|
|
305
|
-
"enable-image": `Usage:\n ${name} enable-image\n\nInteractively configures an HTTPS image API endpoint and API key, detects its supported image model and protocol, installs CCDX image guidance, and checks the local image tool for Codex App without generating an image.`,
|
|
305
|
+
"enable-image": `Usage:\n ${name} enable-image\n\nInteractively configures an HTTPS image API endpoint (base URLs are also accepted) and API key, detects its supported image model and protocol, installs CCDX image guidance, and checks the local image tool for Codex App without generating an image.`,
|
|
306
306
|
"disable-image": `Usage:\n ${name} disable-image\n\nDisables the local CCDX image tool and removes its stored API credential and unmodified CCDX image guidance.`,
|
|
307
307
|
"image-status": `Usage:\n ${name} image-status\n\nShows provider configuration and probes local image-tool readiness without generating an image or exposing its API key.`,
|
|
308
308
|
"auto-review-model": `Usage:\n ${name} auto-review-model\n\nInteractively selects an advertised Responses model for Codex Auto-review.`,
|
package/src/image-mcp.mjs
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { randomUUID } from "node:crypto";
|
|
1
5
|
import { isLoopbackAddress } from "./observability.mjs";
|
|
2
|
-
import { generateImage, supportsImageEditing } from "./image-provider.mjs";
|
|
6
|
+
import { generateImage, supportsImageEditing, IMAGE_MAX_BYTES } from "./image-provider.mjs";
|
|
3
7
|
import { readImageProviderConfig } from "./image-provider-config.mjs";
|
|
4
8
|
import { createImageReferenceStore } from "./image-references.mjs";
|
|
5
9
|
|
|
@@ -8,6 +12,36 @@ const MCP_PROTOCOL_VERSIONS = new Set(["2025-06-18", "2025-03-26", "2024-11-05"]
|
|
|
8
12
|
const MCP_MAX_BODY_BYTES = 256 * 1024;
|
|
9
13
|
const IMAGE_TOOL_NAME = "generate_image";
|
|
10
14
|
const EDIT_TOOL_NAME = "edit_image";
|
|
15
|
+
const IMAGE_EXTENSIONS = new Map([["image/png", ".png"], ["image/jpeg", ".jpg"], ["image/webp", ".webp"]]);
|
|
16
|
+
|
|
17
|
+
async function saveChatImage(image, directory, signal) {
|
|
18
|
+
const extension = IMAGE_EXTENSIONS.get(image.mimeType);
|
|
19
|
+
if (!extension || typeof image.data !== "string" || !image.data
|
|
20
|
+
|| image.data.length > 4 * Math.ceil(IMAGE_MAX_BYTES / 3)
|
|
21
|
+
|| !/^[A-Za-z0-9+/]+={0,2}$/.test(image.data)) throw new Error("Invalid image data for chat display");
|
|
22
|
+
const bytes = Buffer.from(image.data, "base64");
|
|
23
|
+
if (!bytes.length || bytes.length > IMAGE_MAX_BYTES || bytes.toString("base64") !== image.data) {
|
|
24
|
+
throw new Error("Invalid image data for chat display");
|
|
25
|
+
}
|
|
26
|
+
signal.throwIfAborted();
|
|
27
|
+
await fs.mkdir(directory, { recursive: true, mode: 0o700 });
|
|
28
|
+
if (!(await fs.lstat(directory)).isDirectory()) throw new Error("Image output path is not a regular directory");
|
|
29
|
+
const filePath = path.join(await fs.realpath(directory), `image-${randomUUID()}${extension}`);
|
|
30
|
+
signal.throwIfAborted();
|
|
31
|
+
const file = await fs.open(filePath, "wx", 0o600);
|
|
32
|
+
try {
|
|
33
|
+
await file.writeFile(bytes, { signal });
|
|
34
|
+
signal.throwIfAborted();
|
|
35
|
+
await file.close();
|
|
36
|
+
} catch (error) {
|
|
37
|
+
await file.close().catch(() => {});
|
|
38
|
+
await fs.unlink(filePath).catch(() => {});
|
|
39
|
+
throw error;
|
|
40
|
+
}
|
|
41
|
+
const displayPath = encodeURI(filePath.replaceAll("\\", "/"))
|
|
42
|
+
.replace(/[?#()[\]]/g, (character) => `%${character.charCodeAt(0).toString(16).toUpperCase()}`);
|
|
43
|
+
return { filePath, markdown: `` };
|
|
44
|
+
}
|
|
11
45
|
|
|
12
46
|
function sameHostSocket(socket) {
|
|
13
47
|
const remote = String(socket?.remoteAddress || "").replace(/^::ffff:/, "");
|
|
@@ -44,7 +78,7 @@ function imageTool() {
|
|
|
44
78
|
return {
|
|
45
79
|
name: IMAGE_TOOL_NAME,
|
|
46
80
|
title: "Generate image",
|
|
47
|
-
description: "Generate a
|
|
81
|
+
description: "Generate a raster image with the user's enabled CCDX provider. Input: text prompt and optional size; no source images. Returns image content, saved-image Markdown for the final chat reply, and an edit ID when supported. For changes to an earlier CCDX image use edit_image; for redisplay reuse its Markdown without a tool call.",
|
|
48
82
|
inputSchema: {
|
|
49
83
|
type: "object",
|
|
50
84
|
properties: {
|
|
@@ -74,7 +108,7 @@ function editTool() {
|
|
|
74
108
|
...tool,
|
|
75
109
|
name: EDIT_TOOL_NAME,
|
|
76
110
|
title: "Edit image",
|
|
77
|
-
description: "Edit a
|
|
111
|
+
description: "Edit a retained CCDX image using its exact image_id and a change prompt. Preserves the source and returns a new image, saved-image Markdown for the final chat reply, and a new edit ID when retained. Omit size to preserve source dimensions. Does not accept paths, URLs, masks, or arbitrary uploads. Redisplay requires no tool call.",
|
|
78
112
|
inputSchema: {
|
|
79
113
|
...tool.inputSchema,
|
|
80
114
|
properties: {
|
|
@@ -101,6 +135,7 @@ export function createImageMcpHandler({
|
|
|
101
135
|
generateImageFn = generateImage,
|
|
102
136
|
maxConcurrent = 2,
|
|
103
137
|
imageReferences = createImageReferenceStore(),
|
|
138
|
+
imageDirectory = path.join(os.homedir(), ".local", "share", "codex-copilot-dx", "images"),
|
|
104
139
|
} = {}) {
|
|
105
140
|
let active = 0;
|
|
106
141
|
return async function imageMcpHandler(req, res) {
|
|
@@ -149,7 +184,7 @@ export function createImageMcpHandler({
|
|
|
149
184
|
capabilities: { tools: { listChanged: false } },
|
|
150
185
|
serverInfo: { name: "ccdx-image", version: "1" },
|
|
151
186
|
instructions: config
|
|
152
|
-
? `
|
|
187
|
+
? `CCDX handles the configured image API and credentials; no Python or SDK setup. Embed returned image Markdown in the final reply. Redisplay reuses it without tool calls. Keep each image_id with its own image. Do not automatically retry failed/timed-out requests or switch providers.${supportsImageEditing(config) ? " Use generate_image for new images; edit_image requires the exact retained source ID." : " Use generate_image for new images. Editing is unsupported; do not substitute generation."}`
|
|
153
188
|
: "Image generation is disabled. Enable it with ccdx enable-image before requesting images.",
|
|
154
189
|
});
|
|
155
190
|
return;
|
|
@@ -205,12 +240,25 @@ export function createImageMcpHandler({
|
|
|
205
240
|
}
|
|
206
241
|
const note = imageId ? ` Image ID: ${imageId}. Use this ID for further edits while retained by this adapter.`
|
|
207
242
|
: supportsImageEditing(config) ? " The image is available, but an editing reference could not be retained." : "";
|
|
243
|
+
let saved;
|
|
244
|
+
let delivery;
|
|
245
|
+
// The bundled helper already saves once at its caller-selected output path.
|
|
246
|
+
if (request.params?._meta?.["ccdx/client_saves_image"] === true) {
|
|
247
|
+
delivery = "\nThe calling helper will save this image and return its local path. Embed the saved image in the final chat reply; do not generate or edit again just to display it.";
|
|
248
|
+
} else {
|
|
249
|
+
try {
|
|
250
|
+
saved = await saveChatImage(image, imageDirectory, abort.signal);
|
|
251
|
+
delivery = `\nInclude this image Markdown in your final chat reply to show its thumbnail:\n${saved.markdown}\nTo display this same image again, reuse this Markdown; do not call generate_image or edit_image.`;
|
|
252
|
+
} catch {
|
|
253
|
+
delivery = "\nThe image was generated, but its chat-preview file could not be saved. Image content is still returned. Do not claim it was displayed, invent a file path, or generate/edit again to retry delivery.";
|
|
254
|
+
}
|
|
255
|
+
}
|
|
208
256
|
reply({
|
|
209
257
|
// Codex prioritizes structuredContent over image blocks. Keep IDs in metadata/text.
|
|
210
|
-
...(imageId ? { _meta: { "ccdx/image_id": imageId } } : {}),
|
|
258
|
+
...(imageId || saved ? { _meta: { ...(imageId ? { "ccdx/image_id": imageId } : {}), ...(saved ? { "ccdx/image_path": saved.filePath } : {}) } } : {}),
|
|
211
259
|
content: [
|
|
212
260
|
{ type: "image", data: image.data, mimeType: image.mimeType },
|
|
213
|
-
{ type: "text", text: `${editing ? "Edited" : "Generated"} ${image.width || ""}${image.width && image.height ? "×" : ""}${image.height || image.size} image with ${image.model}.${note}` },
|
|
261
|
+
{ type: "text", text: `${editing ? "Edited" : "Generated"} ${image.width || ""}${image.width && image.height ? "×" : ""}${image.height || image.size} image with ${image.model}.${note}${delivery}` },
|
|
214
262
|
],
|
|
215
263
|
});
|
|
216
264
|
} catch (error) {
|
|
@@ -21,7 +21,7 @@ export function normalizeImageEndpoint(value) {
|
|
|
21
21
|
try {
|
|
22
22
|
url = new URL(String(value || "").trim());
|
|
23
23
|
} catch {
|
|
24
|
-
throw new Error("Image API endpoint must be a valid HTTPS URL");
|
|
24
|
+
throw new Error("Image API base URL or endpoint must be a valid HTTPS URL");
|
|
25
25
|
}
|
|
26
26
|
if (url.protocol !== "https:"
|
|
27
27
|
|| !url.hostname
|
|
@@ -29,13 +29,14 @@ export function normalizeImageEndpoint(value) {
|
|
|
29
29
|
|| url.password
|
|
30
30
|
|| url.search
|
|
31
31
|
|| url.hash) {
|
|
32
|
-
throw new Error("Image API endpoint must be an HTTPS URL without credentials, query, or fragment");
|
|
32
|
+
throw new Error("Image API base URL or endpoint must be an HTTPS URL without credentials, query, or fragment");
|
|
33
33
|
}
|
|
34
34
|
const pathname = url.pathname.replace(/\/+$/, "");
|
|
35
|
-
if (
|
|
36
|
-
|
|
35
|
+
if (pathname.endsWith("/images/generations")) return `${url.origin}${pathname}`;
|
|
36
|
+
if (/\/(?:images(?:\/(?:edits|variations))?|chat\/completions|completions|responses|embeddings|models)$/i.test(pathname)) {
|
|
37
|
+
throw new Error("Image API URL must be a base URL or an endpoint ending with /images/generations");
|
|
37
38
|
}
|
|
38
|
-
return `${url.origin}${pathname}`;
|
|
39
|
+
return `${url.origin}${pathname || "/v1"}/images/generations`;
|
|
39
40
|
}
|
|
40
41
|
|
|
41
42
|
export function imageModelsEndpoint(endpoint) {
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: ccdx-image
|
|
3
|
+
description: Generate raster images with the user's enabled CCDX provider, edit earlier CCDX results, or redisplay their saved images. Not for arbitrary uploaded-image edits, masks, transparent output, or code-native SVG/HTML assets.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# CCDX image generation
|
|
7
|
+
|
|
8
|
+
Use the user's enabled CCDX provider for supported image requests. Respect an explicitly requested different provider. CCDX supplies credentials and execution; no additional API key, Python, or SDK setup is needed.
|
|
9
|
+
|
|
10
|
+
## Select the operation
|
|
11
|
+
|
|
12
|
+
- **Generate:** Call `ccdx_image.generate_image` with a complete prompt. Supported sizes are `1024x1024`, `1536x1024`, and `1024x1536`; the default is square.
|
|
13
|
+
- **Edit:** Read [editing.md](references/editing.md) before calling `ccdx_image.edit_image`. Use the exact source `image_id` from this conversation; never substitute a different image or a fresh generation.
|
|
14
|
+
- **Redisplay:** Reuse the requested image's existing Markdown. Do not call generation or editing tools.
|
|
15
|
+
|
|
16
|
+
Use the MCP tool directly when callable. Otherwise, if tool search is available, make one targeted search for the relevant `ccdx_image` tool. Only if the MCP tool remains unavailable, read [helper.md](references/helper.md) for the bundled fallback. An explicitly unsupported edit or a failed tool call is not a reason to use the fallback.
|
|
17
|
+
|
|
18
|
+
## Deliver the result
|
|
19
|
+
|
|
20
|
+
Embed the returned saved-image Markdown unchanged in the final chat reply to display a clickable thumbnail. Image content in a tool result or a viewer call alone is not final delivery. Do not use a code block or replace the image with a download-only link.
|
|
21
|
+
|
|
22
|
+
Retain the image's Markdown/path together with its returned `image_id`, when present. Reuse that artifact for later redisplay. If saving failed, distinguish generation success from delivery failure: do not invent a path, claim display succeeded, or generate/edit again to repair delivery.
|
|
23
|
+
|
|
24
|
+
## Execution boundaries
|
|
25
|
+
|
|
26
|
+
- Do not automatically retry a failed or timed-out image request, switch providers, or run the helper afterward; the request may already have been processed.
|
|
27
|
+
- Preserve the host's approval policy. A user request to generate an image does not bypass execution permissions.
|
|
28
|
+
- Arbitrary uploaded-image edits, masks, reference-image inputs, and transparent output are outside this integration. Explain the limitation without substituting a different operation. Use another available workflow only when consistent with the user's request.
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# Edit a CCDX result
|
|
2
|
+
|
|
3
|
+
## Select the source
|
|
4
|
+
|
|
5
|
+
Use the specific image's returned `image_id` from this conversation, not a global last image. Resolve an ambiguous source before submitting the edit.
|
|
6
|
+
|
|
7
|
+
A file path is not an edit ID. Handles can expire after cache eviction, provider changes, or adapter restart while saved files remain viewable. For missing or expired handles, explain that this source is unavailable for editing; do not replace it.
|
|
8
|
+
|
|
9
|
+
## Submit and deliver
|
|
10
|
+
|
|
11
|
+
Call `ccdx_image.edit_image` with the exact ID and a prompt specifying changes and what to preserve. Omit `size` to preserve source dimensions unless another supported size was requested.
|
|
12
|
+
|
|
13
|
+
An explicitly unsupported edit must stop. Only an unavailable MCP tool may use [helper.md](helper.md), not a failed or timed-out edit.
|
|
14
|
+
|
|
15
|
+
Each edit preserves the source. Keep the new image/ID mapping alongside the original, and apply `SKILL.md`'s final-image delivery rules.
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# MCP-unavailable fallback
|
|
2
|
+
|
|
3
|
+
Use only when MCP is unavailable, never after a failed, timed-out, or unsupported image operation. The helper calls the same local CCDX service at most once per execution.
|
|
4
|
+
|
|
5
|
+
## Invoke
|
|
6
|
+
|
|
7
|
+
Generate:
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
{{NODE}} {{HELPER}} --prompt 'A complete image prompt' --size 1024x1024
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Edit an identified CCDX result:
|
|
14
|
+
|
|
15
|
+
```sh
|
|
16
|
+
{{NODE}} {{HELPER}} --image-id 'EXACT_SOURCE_IMAGE_ID' --prompt 'Requested changes and what to preserve'
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
For edits, follow [editing.md](editing.md); omitting `--size` preserves source dimensions. Replace the examples and quote actual arguments safely. Optional `--out` selects a new file; existing files are never overwritten.
|
|
20
|
+
|
|
21
|
+
Loopback HTTP can be restricted. If networking is restricted, request normal execution permission for this exact command before running it. Keep the host's approval policy unchanged.
|
|
22
|
+
|
|
23
|
+
## Interpret the result
|
|
24
|
+
|
|
25
|
+
- **Success:** stdout is the saved absolute path; stderr includes `CCDX image_id` when retained. Embed an absolute-path Markdown image in the final reply and retain its ID.
|
|
26
|
+
- **Explicitly not started:** No image operation was submitted. Resolve the reported connection/permission issue before another attempt.
|
|
27
|
+
- **Other failure or timeout:** Dispatch may have completed. Report without retrying, switching providers, or installing dependencies. Saving/display failure must not trigger regeneration.
|
package/src/image-skill.mjs
CHANGED
|
@@ -6,6 +6,8 @@ import { adapterBaseUrl } from "./running-adapter.mjs";
|
|
|
6
6
|
|
|
7
7
|
const SKILL_MARKER = /<!-- ccdx:image-skill:([a-f0-9]{64}) -->\n/;
|
|
8
8
|
const HELPER_MARKER = /^\/\/ ccdx:image-helper:([a-f0-9]{64})\n/;
|
|
9
|
+
const REFERENCE_MARKER = /^<!-- ccdx:image-reference:([a-f0-9]{64}) -->\n/;
|
|
10
|
+
const REFERENCE_FILES = ["editing.md", "helper.md"];
|
|
9
11
|
|
|
10
12
|
function digest(content) {
|
|
11
13
|
return createHash("sha256").update(content).digest("hex");
|
|
@@ -50,7 +52,7 @@ function removeFile(filePath) {
|
|
|
50
52
|
}
|
|
51
53
|
|
|
52
54
|
function removeEmptyDirectories(skillDirectory) {
|
|
53
|
-
for (const directory of [path.join(skillDirectory, "scripts"), skillDirectory]) {
|
|
55
|
+
for (const directory of [path.join(skillDirectory, "scripts"), path.join(skillDirectory, "references"), skillDirectory]) {
|
|
54
56
|
try { fs.rmdirSync(directory); }
|
|
55
57
|
catch (error) { if (!["ENOENT", "ENOTEMPTY", "EEXIST"].includes(error?.code)) throw error; }
|
|
56
58
|
}
|
|
@@ -66,9 +68,11 @@ export function updateImageSkill({
|
|
|
66
68
|
const skillDirectory = path.join(path.dirname(codexPath), "skills", "ccdx-image");
|
|
67
69
|
const skillPath = path.join(skillDirectory, "SKILL.md");
|
|
68
70
|
const helperPath = path.join(skillDirectory, "scripts", "generate.mjs");
|
|
71
|
+
const references = REFERENCE_FILES.map((name) => ({ name, filePath: path.join(skillDirectory, "references", name) }));
|
|
69
72
|
try {
|
|
70
73
|
directoryCheck(skillDirectory);
|
|
71
74
|
directoryCheck(path.dirname(helperPath));
|
|
75
|
+
directoryCheck(path.join(skillDirectory, "references"));
|
|
72
76
|
} catch (error) {
|
|
73
77
|
if (!enabled && error?.code === "CCDX_IMAGE_SKILL_CONFLICT") {
|
|
74
78
|
return { changed: false, preserved: true, skillPath, rollback: () => {} };
|
|
@@ -77,6 +81,8 @@ export function updateImageSkill({
|
|
|
77
81
|
}
|
|
78
82
|
const previousSkill = ownedSnapshot(skillPath, SKILL_MARKER, !enabled);
|
|
79
83
|
const previousHelper = ownedSnapshot(helperPath, HELPER_MARKER, !enabled);
|
|
84
|
+
for (const reference of references) reference.previous = ownedSnapshot(reference.filePath, REFERENCE_MARKER, !enabled);
|
|
85
|
+
const snapshots = [[skillPath, previousSkill], [helperPath, previousHelper], ...references.map(({ filePath, previous }) => [filePath, previous])];
|
|
80
86
|
const touched = [];
|
|
81
87
|
let rolledBack = false;
|
|
82
88
|
const rollback = () => {
|
|
@@ -89,10 +95,10 @@ export function updateImageSkill({
|
|
|
89
95
|
rolledBack = true;
|
|
90
96
|
};
|
|
91
97
|
if (!enabled) {
|
|
92
|
-
const preserved =
|
|
93
|
-
if (!
|
|
98
|
+
const preserved = snapshots.some(([, snapshot]) => snapshot?.unowned);
|
|
99
|
+
if (!snapshots.some(([, snapshot]) => snapshot?.content)) return { changed: false, preserved, skillPath, rollback: () => {} };
|
|
94
100
|
try {
|
|
95
|
-
for (const [filePath, snapshot] of
|
|
101
|
+
for (const [filePath, snapshot] of snapshots) {
|
|
96
102
|
if (!snapshot?.content) continue;
|
|
97
103
|
touched.push([filePath, snapshot]);
|
|
98
104
|
removeFile(filePath);
|
|
@@ -106,42 +112,31 @@ export function updateImageSkill({
|
|
|
106
112
|
}
|
|
107
113
|
const host = ["0.0.0.0", "::", "[::]"].includes(adapterHost) ? (adapterHost === "0.0.0.0" ? "127.0.0.1" : "::1") : adapterHost;
|
|
108
114
|
const endpoint = `${adapterBaseUrl(host, adapterPort)}/mcp/image`;
|
|
109
|
-
const skillBody =
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
The user enabled this provider through ccdx enable-image. Use the configured CCDX provider directly for text-to-image requests and supported edits of its previous results.
|
|
117
|
-
|
|
118
|
-
For a new image, use the ccdx_image server's generate_image tool. Supply a complete prompt and one size: 1024x1024 (square), 1536x1024 (landscape), or 1024x1536 (portrait). Display its returned image and retain the returned image_id in the conversation for later edits. No Python, SDK installation, extra API key, or provider discovery is needed.
|
|
119
|
-
|
|
120
|
-
For an edit, use edit_image with the image_id of the specific CCDX result the user wants to change and a prompt describing the changes and what to preserve. Omit size to keep the source dimensions unless the user requests another size. Select the ID from this conversation; never assume a global last image or substitute a different result. Each edit returns a new image and image_id and preserves the source. IDs belong to the running CCDX process and can expire after cache eviction or restart. If the source ID is missing or expired, explain that the original image is unavailable for this edit. The provider must support edit_image; an unsupported edit is not permission to generate a replacement.
|
|
121
|
-
|
|
122
|
-
If the desired tool is not directly callable and the host provides tool search, make one targeted search for ccdx_image generate_image or ccdx_image edit_image as appropriate and use that tool if it becomes callable. If it is still unavailable, run the bundled Node helper once. To generate:
|
|
123
|
-
|
|
124
|
-
\`\`\`sh
|
|
125
|
-
${quote(nodePath)} ${quote(helperPath)} --prompt 'A complete image prompt' --size 1024x1024
|
|
126
|
-
\`\`\`
|
|
127
|
-
|
|
128
|
-
To edit, add --image-id with the exact source ID returned earlier and supply the editing prompt. Omit --size to preserve the source dimensions. This sends one edit_image call to the same service.
|
|
129
|
-
|
|
130
|
-
Quote the actual prompt safely for the shell. The helper uses the same local CCDX service and prints only the saved absolute image path to stdout; stderr reports CCDX image_id for subsequent edits. An optional --out argument selects a new output file; existing files are never overwritten. Inspect and display the saved image with the available image viewer or an absolute-path Markdown image, and retain its new ID.
|
|
131
|
-
|
|
132
|
-
The helper needs loopback HTTP access. If the task reports restricted network access, use the execution tool's normal permission-request mechanism for this exact command before running it; localhost is also restricted. Keep the host's approval policy unchanged.
|
|
133
|
-
|
|
134
|
-
Only use the helper when the MCP tool is absent, not after generation or editing fails or times out: the first request may already have consumed a generation or edit. A helper error explicitly saying generation or editing has not started means no image request was submitted; resolve its stated local connection or permission issue before trying again. Otherwise report the failure once without switching providers, installing dependencies, or repeating the image request.
|
|
135
|
-
|
|
136
|
-
For arbitrary uploaded images, reference-image workflows, masks, transparent output, or explicitly requested other providers, keep the relevant native tools and skills; do not substitute a fresh CCDX generation.
|
|
137
|
-
`;
|
|
115
|
+
const skillBody = fs.readFileSync(new URL("./image-skill/SKILL.md", import.meta.url), "utf8");
|
|
116
|
+
for (const reference of references) {
|
|
117
|
+
const body = fs.readFileSync(new URL(`./image-skill/references/${reference.name}`, import.meta.url), "utf8")
|
|
118
|
+
.replaceAll("{{NODE}}", () => quote(nodePath)).replaceAll("{{HELPER}}", () => quote(helperPath));
|
|
119
|
+
reference.content = `<!-- ccdx:image-reference:${digest(body)} -->\n${body}`;
|
|
120
|
+
}
|
|
138
121
|
const skillContent = skillBody.replace("# CCDX image generation", `<!-- ccdx:image-skill:${digest(skillBody)} -->\n# CCDX image generation`);
|
|
139
122
|
const helperBody = `${fs.readFileSync(new URL("./image-tool-client.mjs", import.meta.url), "utf8")}\ntry {\n await runImageToolClient({ endpoint: ${JSON.stringify(endpoint)} });\n} catch (error) {\n console.error(String(error?.message || "Image generation failed").replace(/[\\u0000-\\u001f\\u007f-\\u009f]/g, " ").slice(0, 1000));\n process.exitCode = 1;\n}\n`;
|
|
140
123
|
const helperContent = `// ccdx:image-helper:${digest(helperBody)}\n${helperBody}`;
|
|
141
|
-
if (previousSkill?.content === skillContent && previousHelper?.content === helperContent
|
|
124
|
+
if (previousSkill?.content === skillContent && previousHelper?.content === helperContent
|
|
125
|
+
&& references.every(({ previous, content }) => previous?.content === content)) {
|
|
142
126
|
return { changed: false, skillPath, rollback: () => {} };
|
|
143
127
|
}
|
|
144
|
-
|
|
145
|
-
|
|
128
|
+
try {
|
|
129
|
+
// Install conditional resources before publishing the skill entrypoint.
|
|
130
|
+
for (const { filePath, previous, content } of references) {
|
|
131
|
+
if (previous?.content === content) continue;
|
|
132
|
+
touched.push([filePath, previous]);
|
|
133
|
+
atomicWriteFileSync(filePath, content, { mode: 0o600, preserveMode: false });
|
|
134
|
+
}
|
|
135
|
+
atomicWriteFilePairSync(helperPath, helperContent, skillPath, skillContent, { mode: 0o600 });
|
|
136
|
+
touched.push([helperPath, previousHelper], [skillPath, previousSkill]);
|
|
137
|
+
} catch (error) {
|
|
138
|
+
rollback();
|
|
139
|
+
throw error;
|
|
140
|
+
}
|
|
146
141
|
return { changed: true, skillPath, rollback };
|
|
147
142
|
}
|
|
@@ -125,7 +125,7 @@ export async function runImageToolClient({
|
|
|
125
125
|
if (options.imageId) argumentsValue.image_id = options.imageId;
|
|
126
126
|
const result = await readRpcResponse(await post({
|
|
127
127
|
jsonrpc: "2.0", id: 2, method: "tools/call",
|
|
128
|
-
params: { name: options.imageId ? "edit_image" : "generate_image", arguments: argumentsValue },
|
|
128
|
+
params: { name: options.imageId ? "edit_image" : "generate_image", arguments: argumentsValue, _meta: { "ccdx/client_saves_image": true } },
|
|
129
129
|
}), 2);
|
|
130
130
|
if (result.isError) {
|
|
131
131
|
throw new Error(cleanError(result.content?.find((part) => part.type === "text")?.text));
|