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 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 asks for an HTTPS `/images/generations` endpoint and an API key without echoing the key. It reads the endpoint's `/models` catalog and asks for a numeric model selection only when multiple image models are available. It 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.
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. Enabling also installs CCDX-owned `ccdx-image` guidance under `~/.codex/skills`, with a self-contained Node helper. 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. The original built-in imagegen skill remains available for capabilities such as editing uploaded images and other reference-image workflows.
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 providers already enabled in 0.7.8, without repeating API setup. Unchanged guidance is not rewritten. The existing adapter can remain running because provider settings are loaded when the tool is invoked.
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 CCDX image guidance; user-modified skill 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` tool calls. Ordinary Responses, models, authentication, Fast, Auto-review, compaction, and image-input paths remain on their existing routes.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codex-copilot-dx",
3
- "version": "0.8.0",
3
+ "version": "0.8.2",
4
4
  "description": "Use Codex App with GitHub Copilot",
5
5
  "author": "Dale Xiao",
6
6
  "type": "module",
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
- const endpointAnswer = String(await ask(`API endpoint${current ? ` [${current.endpoint}]` : ""}: `) || "").trim();
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");
@@ -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: `![Generated image](<${displayPath}>)` };
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 new image with the image provider the user enabled in CCDX. Call directly for text-to-image requests; returns the image inline. Credentials and generation are handled by CCDX, with no Python or OpenAI SDK setup. Does not edit or take reference images.",
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 previously generated CCDX image using its exact image_id and a change instruction. Preserve the source and return a new image with a new ID for further edits. Source images must still be retained by this running adapter. Does not accept file paths, URLs, masks, or arbitrary uploads.",
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
- ? `The user enabled CCDX as their image provider. Call generate_image for new images.${supportsImageEditing(config) ? " For changes to a CCDX image, call edit_image with that image's returned image_id. Keep each returned ID with its image; there is no global last image." : " Editing is not supported by this configured provider."} Display the returned image. CCDX handles credentials and the API; no Python, SDK installation, extra API key or alternate endpoint is needed. Do not automatically retry failed generations or edits.`
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 (!pathname.endsWith("/images/generations")) {
36
- throw new Error("Image API endpoint path must end with /images/generations");
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.
@@ -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 = Boolean(previousSkill?.unowned || previousHelper?.unowned);
93
- if (!previousSkill?.content && !previousHelper?.content) return { changed: false, preserved, skillPath, rollback: () => {} };
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 [[skillPath, previousSkill], [helperPath, previousHelper]]) {
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
- name: ccdx-image
111
- description: Generate images from text and edit previously generated CCDX images using the user's configured provider. Use for drawing a new image or modifying a CCDX result identified in the conversation. Excludes arbitrary image uploads, masks, transparent output, and code-native SVG or diagrams.
112
- ---
113
-
114
- # CCDX image generation
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
- atomicWriteFilePairSync(helperPath, helperContent, skillPath, skillContent, { mode: 0o600 });
145
- touched.push([helperPath, previousHelper], [skillPath, previousSkill]);
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));