codex-copilot-dx 0.8.1 → 0.8.3

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
@@ -147,9 +147,11 @@ ccdx enable-image
147
147
 
148
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
+ Existing compatible `ccdx_image` registrations are reused without requiring CCDX's comment markers or resetting user timeouts/tool policies. Missing or damaged markers alone do not block startup. Core GPT configuration is committed independently before optional image maintenance: image configuration conflicts, image-only write failures, or Skill maintenance failures warn without aborting the core service. No new image guidance is installed after failed image configuration. With images disabled or their provider configuration invalid/unreadable, normal startup performs no image configuration maintenance, Skill installation, or image-network probe; explicit `disable-image` owns cleanup. Automatic updates/removal are limited to recognizable CCDX defaults and isolated TOML declarations, never everything between two comments. Ambiguous shared-inline or customized registrations are left intact; `disable-image` still disables the CCDX provider and removes its owned guidance. Invalid TOML/UTF-8 in the shared Codex config or a failed essential core configuration write remains an explicit error rather than a silent success.
153
155
 
154
156
  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.
155
157
 
@@ -160,7 +162,7 @@ ccdx image-status
160
162
  ccdx disable-image
161
163
  ```
162
164
 
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 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.
165
+ `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.
164
166
 
165
167
  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`.
166
168
 
package/bin/cli.mjs CHANGED
@@ -229,8 +229,8 @@ async function reuseRunningAdapterIfAvailable() {
229
229
 
230
230
  async function configureCodexClient() {
231
231
  const imageProviderEnabled = Boolean(readImageProviderConfig());
232
- ensureCodexConfig(ADAPTER_PORT, { host: ADAPTER_HOST, imageProviderEnabled });
233
- if (imageProviderEnabled) {
232
+ const { imageMcpSkipped } = ensureCodexConfig(ADAPTER_PORT, { host: ADAPTER_HOST, imageProviderEnabled });
233
+ if (imageProviderEnabled && !imageMcpSkipped) {
234
234
  try {
235
235
  const { syncEnabledImageSkill } = await import("../src/cli-image.mjs");
236
236
  syncEnabledImageSkill({ adapterPort: ADAPTER_PORT, adapterHost: ADAPTER_HOST });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codex-copilot-dx",
3
- "version": "0.8.1",
3
+ "version": "0.8.3",
4
4
  "description": "Use Codex App with GitHub Copilot",
5
5
  "author": "Dale Xiao",
6
6
  "type": "module",
package/src/config.mjs CHANGED
@@ -4,6 +4,7 @@ import os from "node:os";
4
4
  import { status } from "./status.mjs";
5
5
  import { atomicWriteFileIfChangedSync } from "./atomic-file.mjs";
6
6
  import { adapterBaseUrl } from "./running-adapter.mjs";
7
+ import { isLoopbackHost } from "./security.mjs";
7
8
  import { codexTomlStatements, configValue, parseCodexToml, validateManagedConfigEdit } from "./config-toml.mjs";
8
9
 
9
10
  const CONFIG_PATH = path.join(os.homedir(), ".codex", "config.toml");
@@ -12,6 +13,10 @@ const MODEL_AUTO_COMPACT_TOKEN_LIMIT = 900_000;
12
13
  const IMAGE_MCP_START = "# ccdx:image-mcp:start";
13
14
  const IMAGE_MCP_END = "# ccdx:image-mcp:end";
14
15
  const IMAGE_MCP_SECTION = "mcp_servers.ccdx_image";
16
+ const IMAGE_MCP_KEYS = ["enabled", "tool_timeout_sec", "url"];
17
+
18
+ // Only optional image-configuration conflicts may degrade to core-only startup.
19
+ export class ImageMcpConfigError extends Error {}
15
20
  const STARTUP_MANAGED_PATHS = [
16
21
  "openai_base_url",
17
22
  "model_context_window",
@@ -92,41 +97,98 @@ function imageMcpHost(adapterHost) {
92
97
  return host;
93
98
  }
94
99
 
95
- function removeManagedImageMcp(lines) {
96
- const comments = codexTomlStatements(lines.join("\n")).filter((item) => item.kind === "comment");
97
- const starts = comments.filter((item) => lines[item.line] === IMAGE_MCP_START).map((item) => item.line);
98
- const ends = comments.filter((item) => lines[item.line] === IMAGE_MCP_END).map((item) => item.line);
99
- let start = starts[0] ?? -1;
100
- const end = ends[0] ?? -1;
101
- if (start === -1 && end === -1) return false;
102
- if (starts.length !== 1 || ends.length !== 1 || end < start) {
103
- throw new Error("Invalid managed CCDX image MCP block in Codex config");
104
- }
105
- lines.splice(start, end - start + 1);
106
- while (start > 0 && start >= lines.length && lines[start - 1].trim() === "") {
107
- lines.splice(start - 1, 1);
108
- start -= 1;
109
- }
110
- while (start < lines.length && lines[start].trim() === "" && start > 0 && lines[start - 1].trim() === "") {
111
- lines.splice(start, 1);
112
- }
113
- return true;
100
+ function imageMcpUrl(value) {
101
+ if (typeof value !== "string" || /[\s\x00-\x1f\x7f]/.test(value)) return null;
102
+ try {
103
+ const url = new URL(value);
104
+ return url.protocol === "http:" && url.pathname === "/mcp/image"
105
+ && !url.username && !url.password && !url.search && !url.hash ? url : null;
106
+ } catch { return null; }
107
+ }
108
+
109
+ function isImageDeclaration(item) {
110
+ return ["table", "value"].includes(item.kind)
111
+ && sameKeys(item.keys.slice(0, IMAGE_MANAGED_PATH.length), IMAGE_MANAGED_PATH);
112
+ }
113
+
114
+ function imageMcpMarkers(content, statements) {
115
+ return statements.filter((item) => item.kind === "comment"
116
+ && [IMAGE_MCP_START, IMAGE_MCP_END].includes(item.source.trim())
117
+ && !content.slice(content.lastIndexOf("\n", item.start - 1) + 1, item.start).trim());
114
118
  }
115
119
 
116
- function hasUnmanagedImageMcp(lines) {
117
- return configValue(parseCodexToml(lines.join("\n")), IMAGE_MANAGED_PATH) !== undefined;
120
+ function knownImageMcp(section, expectedUrl, previousBaseUrl, statements, markers) {
121
+ if (!section || Object.keys(section).sort().join(",") !== IMAGE_MCP_KEYS.join(",")
122
+ || section.enabled !== true || section.tool_timeout_sec !== 210n) return false;
123
+ const url = imageMcpUrl(section.url);
124
+ if (!url) return false;
125
+ if (url.href === new URL(expectedUrl).href) return true;
126
+ // The core and image URLs were configured together. This survives comment
127
+ // stripping and allows an adapter address change without guessing a new owner.
128
+ try {
129
+ const previous = new URL(previousBaseUrl);
130
+ if (previous.protocol === "http:" && previous.pathname === "/v1"
131
+ && !previous.username && !previous.password && !previous.search && !previous.hash
132
+ && previous.origin === url.origin
133
+ && (isLoopbackHost(url.hostname) || url.hostname === new URL(expectedUrl).hostname)) return true;
134
+ } catch {}
135
+ // Legacy markers are an additional ownership hint, never deletion boundaries.
136
+ const starts = markers.filter((item) => item.source.trim() === IMAGE_MCP_START);
137
+ const ends = markers.filter((item) => item.source.trim() === IMAGE_MCP_END);
138
+ if (starts.length !== 1 || ends.length !== 1 || starts[0].start >= ends[0].start
139
+ || !(isLoopbackHost(url.hostname) || url.hostname === new URL(expectedUrl).hostname)) return false;
140
+ const declarations = statements.filter((item) => ["table", "value"].includes(item.kind));
141
+ return declarations.some(isImageDeclaration)
142
+ && declarations.filter(isImageDeclaration).every((item) => item.start > starts[0].start && item.end < ends[0].start)
143
+ && declarations.filter((item) => item.start > starts[0].start && item.start < ends[0].start).every(isImageDeclaration);
118
144
  }
119
145
 
120
- function ensureImageMcp(lines, enabled, adapterPort, adapterHost) {
146
+ function removeImageMcpDeclarations(content, statements, markers) {
147
+ const declarations = statements.filter(isImageDeclaration);
148
+ // A shared inline parent is not a surgical edit target. Reuse it unchanged or
149
+ // leave it for the user; never serialize away sibling settings or credentials.
150
+ if (!declarations.length) return null;
151
+ const ranges = [...declarations, ...markers].map((item) => {
152
+ const start = content.lastIndexOf("\n", item.start - 1) + 1;
153
+ const newline = content.indexOf("\n", item.end);
154
+ return [start, newline < 0 ? content.length : newline + 1];
155
+ }).sort((left, right) => right[0] - left[0]);
156
+ for (const [start, end] of ranges) content = content.slice(0, start) + content.slice(end);
157
+ return content;
158
+ }
159
+
160
+ function ensureImageMcp(lines, enabled, adapterPort, adapterHost, previousBaseUrl) {
121
161
  const previous = lines.join("\n");
122
- removeManagedImageMcp(lines);
162
+ const config = parseCodexToml(previous);
163
+ const section = configValue(config, IMAGE_MANAGED_PATH);
164
+ const url = `${adapterBaseUrl(imageMcpHost(adapterHost), adapterPort)}/mcp/image`;
165
+ if (section !== undefined) {
166
+ // Reuse compatible registrations without rewriting comments, formatting,
167
+ // user tool policies, or timeouts. An explicit disabled flag is preserved.
168
+ if (enabled && imageMcpUrl(section?.url)?.href === new URL(url).href
169
+ && [undefined, true].includes(section?.enabled)) return false;
170
+ const statements = codexTomlStatements(previous);
171
+ const markers = imageMcpMarkers(previous, statements);
172
+ if (!knownImageMcp(section, url, previousBaseUrl, statements, markers)) {
173
+ if (!enabled) return false;
174
+ throw new ImageMcpConfigError(`Codex config already defines [${IMAGE_MCP_SECTION}] outside the CCDX-managed block; its settings were preserved`);
175
+ }
176
+ const removed = removeImageMcpDeclarations(previous, statements, markers);
177
+ if (removed === null) {
178
+ if (!enabled) return false;
179
+ throw new ImageMcpConfigError(`Cannot safely update [${IMAGE_MCP_SECTION}] inside a shared inline table; its settings were preserved`);
180
+ }
181
+ lines.splice(0, lines.length, ...removed.split("\n"));
182
+ while (lines.length && !lines.at(-1).trim()) lines.pop();
183
+ }
123
184
  if (enabled) {
124
- if (hasUnmanagedImageMcp(lines)) {
125
- throw new Error(`Codex config already defines [${IMAGE_MCP_SECTION}] outside the CCDX-managed block`);
185
+ const parent = config.mcp_servers;
186
+ if (parent !== undefined && (typeof parent !== "object" || parent === null || Array.isArray(parent)
187
+ || codexTomlStatements(previous).some((item) => item.kind === "value" && sameKeys(item.keys, ["mcp_servers"])))) {
188
+ throw new ImageMcpConfigError("Cannot append the image MCP to an inline or non-table mcp_servers setting; its settings were preserved");
126
189
  }
127
190
  while (lines.length && lines.at(-1).trim() === "") lines.pop();
128
191
  if (lines.length) lines.push("");
129
- const url = `${adapterBaseUrl(imageMcpHost(adapterHost), adapterPort)}/mcp/image`;
130
192
  lines.push(
131
193
  IMAGE_MCP_START,
132
194
  `[${IMAGE_MCP_SECTION}]`,
@@ -149,7 +211,6 @@ export function computeUpdatedCodexConfig(
149
211
  const baseUrl = `${adapterBaseUrl(adapterHost, adapterPort)}/v1`;
150
212
  const hadTrailingNewline = content.endsWith("\n");
151
213
  const lines = content.split("\n");
152
- if (hadTrailingNewline) lines.pop();
153
214
 
154
215
  let changed = false;
155
216
  const openaiLine = `openai_base_url = "${baseUrl}"`;
@@ -166,10 +227,11 @@ export function computeUpdatedCodexConfig(
166
227
  changed = setTomlKey(lines, "shell_environment_policy.set", "OPENAI_API_KEY", "dummy") || changed;
167
228
  changed = ensureContextManagementDefault(lines) || changed;
168
229
  if (typeof imageProviderEnabled === "boolean") {
169
- changed = ensureImageMcp(lines, imageProviderEnabled, adapterPort, adapterHost) || changed;
230
+ changed = ensureImageMcp(lines, imageProviderEnabled, adapterPort, adapterHost, config.openai_base_url) || changed;
170
231
  }
171
232
 
172
- const updated = lines.join("\n") + (hadTrailingNewline ? "\n" : "");
233
+ const joined = lines.join("\n");
234
+ const updated = joined + (hadTrailingNewline && !joined.endsWith("\n") ? "\n" : "");
173
235
  validateManagedConfigEdit(config, updated, typeof imageProviderEnabled === "boolean"
174
236
  ? [...STARTUP_MANAGED_PATHS, IMAGE_MANAGED_PATH] : STARTUP_MANAGED_PATHS);
175
237
  return { content: updated, changed };
@@ -196,20 +258,26 @@ context_management = true
196
258
  : base;
197
259
  }
198
260
 
199
- export function computeImageMcpCodexConfig(
261
+ function computeImageConfig(
200
262
  content,
201
263
  { enabled, adapterPort = 2026, adapterHost = "127.0.0.1" } = {},
264
+ previousBaseUrl,
202
265
  ) {
203
266
  const config = parseCodexToml(content);
204
267
  const hadTrailingNewline = content.endsWith("\n");
205
268
  const lines = content.split("\n");
206
- if (hadTrailingNewline) lines.pop();
207
- const changed = ensureImageMcp(lines, Boolean(enabled), adapterPort, adapterHost);
208
- const updated = lines.join("\n") + (hadTrailingNewline || lines.length ? "\n" : "");
269
+ const changed = ensureImageMcp(lines, Boolean(enabled), adapterPort, adapterHost, previousBaseUrl ?? config.openai_base_url);
270
+ if (!changed) return { content, changed: false };
271
+ const joined = lines.join("\n");
272
+ const updated = joined + ((hadTrailingNewline || lines.length) && !joined.endsWith("\n") ? "\n" : "");
209
273
  validateManagedConfigEdit(config, updated, [IMAGE_MANAGED_PATH]);
210
274
  return { content: updated, changed };
211
275
  }
212
276
 
277
+ export function computeImageMcpCodexConfig(content, options = {}) {
278
+ return computeImageConfig(content, options);
279
+ }
280
+
213
281
  export function ensureCodexConfig(adapterPort = 2026, {
214
282
  filePath = CONFIG_PATH,
215
283
  host = "127.0.0.1",
@@ -217,26 +285,41 @@ export function ensureCodexConfig(adapterPort = 2026, {
217
285
  } = {}) {
218
286
  const baseUrl = `${adapterBaseUrl(host, adapterPort)}/v1`;
219
287
 
220
- if (!fs.existsSync(filePath)) {
221
- // Codex config does not exist yet; create the local proxy defaults.
222
- atomicWriteFileIfChangedSync(filePath, initialCodexConfig(adapterPort, host, { imageProviderEnabled }));
223
- console.log(status("ok", "Created ~/.codex/config.toml"));
224
- return;
225
- }
226
-
227
288
  let content;
228
289
  try {
229
290
  content = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(fs.readFileSync(filePath));
230
291
  } catch (error) {
231
- if (error?.code !== "ERR_ENCODING_INVALID_ENCODED_DATA") throw error;
232
- throw new Error("Codex configuration is not valid UTF-8 TOML; no configuration changes were written");
292
+ if (error?.code === "ERR_ENCODING_INVALID_ENCODED_DATA") {
293
+ throw new Error("Codex configuration is not valid UTF-8 TOML; no configuration changes were written");
294
+ }
295
+ if (error?.code !== "ENOENT") throw error;
233
296
  }
234
- const updated = computeUpdatedCodexConfig(content, adapterPort, host, { imageProviderEnabled });
235
-
236
- if (!updated.changed) {
297
+ // Commit essential configuration independently. Image-only parse/edit/write
298
+ // failures must not roll it back or close an otherwise usable GPT adapter.
299
+ const core = content === undefined
300
+ ? { content: initialCodexConfig(adapterPort, host), changed: true }
301
+ : computeUpdatedCodexConfig(content, adapterPort, host);
302
+ if (!core.changed) {
237
303
  console.log(status("ok", `Codex already points to ${baseUrl}`));
238
- return;
304
+ } else {
305
+ atomicWriteFileIfChangedSync(filePath, core.content);
306
+ console.log(status("ok", content === undefined ? "Created ~/.codex/config.toml" : `Configured Codex base URL: ${baseUrl}`));
307
+ }
308
+ // Disabled means no image maintenance, including no implicit removal of old
309
+ // or user-owned entries. Explicit disable-image owns the cleanup operation.
310
+ if (!imageProviderEnabled) return { imageMcpSkipped: false };
311
+ try {
312
+ const previousBaseUrl = content === undefined ? undefined : parseCodexToml(content).openai_base_url;
313
+ const image = computeImageConfig(core.content, { enabled: true, adapterPort, adapterHost: host }, previousBaseUrl);
314
+ if (image.changed) {
315
+ if (fs.readFileSync(filePath, "utf8") !== core.content) {
316
+ throw new ImageMcpConfigError("Codex config changed during image setup; the newer configuration was preserved");
317
+ }
318
+ atomicWriteFileIfChangedSync(filePath, image.content);
319
+ }
320
+ return { imageMcpSkipped: false };
321
+ } catch (error) {
322
+ console.warn(status("warn", `Image MCP setup was skipped: ${error.message}. Core proxy startup will continue.`));
323
+ return { imageMcpSkipped: true };
239
324
  }
240
- atomicWriteFileIfChangedSync(filePath, updated.content);
241
- console.log(status("ok", `Configured Codex base URL: ${baseUrl}`));
242
325
  }
package/src/image-mcp.mjs CHANGED
@@ -78,7 +78,7 @@ function imageTool() {
78
78
  return {
79
79
  name: IMAGE_TOOL_NAME,
80
80
  title: "Generate image",
81
- description: "Generate a new image with the image provider the user enabled in CCDX. Returns image content and saved-image Markdown: include that Markdown in the final chat reply to show a thumbnail. Credentials and generation are handled by CCDX, with no Python or OpenAI SDK setup. Does not edit or take reference images. Do not call just to redisplay an existing result.",
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.",
82
82
  inputSchema: {
83
83
  type: "object",
84
84
  properties: {
@@ -108,7 +108,7 @@ function editTool() {
108
108
  ...tool,
109
109
  name: EDIT_TOOL_NAME,
110
110
  title: "Edit image",
111
- 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; include its returned saved-image Markdown in the final chat reply. Source images must still be retained by this running adapter. Does not accept file paths, URLs, masks, or arbitrary uploads. Do not call just to redisplay an existing result.",
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.",
112
112
  inputSchema: {
113
113
  ...tool.inputSchema,
114
114
  properties: {
@@ -184,7 +184,7 @@ export function createImageMcpHandler({
184
184
  capabilities: { tools: { listChanged: false } },
185
185
  serverInfo: { name: "ccdx-image", version: "1" },
186
186
  instructions: config
187
- ? `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."} Include the returned saved-image Markdown in the final chat reply to display a thumbnail; tool image content alone does not ensure chat delivery. To show an existing image again, reuse its Markdown without calling generation or editing. 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."}`
188
188
  : "Image generation is disabled. Enable it with ccdx enable-image before requesting images.",
189
189
  });
190
190
  return;
@@ -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,44 +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
- The tool returns ready-to-use saved-image Markdown in its text result. Include that exact Markdown in your final chat reply so the user sees a clickable thumbnail, alongside any image content you forward. Tool output or saying "generated" is not delivery. Do not put the image Markdown inside a code block or replace it with a download-only link. Keep its image path and image_id together. If the user asks "where is the image", "show it again", or "图呢", embed the same returned Markdown again; do not generate or edit another image just to display it. If the tool reports that saving failed, do not invent a path or claim the image was displayed, and do not submit another image request to fix delivery.
121
-
122
- 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.
123
-
124
- 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:
125
-
126
- \`\`\`sh
127
- ${quote(nodePath)} ${quote(helperPath)} --prompt 'A complete image prompt' --size 1024x1024
128
- \`\`\`
129
-
130
- 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.
131
-
132
- 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. Include an absolute-path Markdown image for that saved file in the final chat reply, and retain its new ID. A viewer call alone does not replace the final thumbnail.
133
-
134
- 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.
135
-
136
- 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.
137
-
138
- 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.
139
- `;
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
+ }
140
121
  const skillContent = skillBody.replace("# CCDX image generation", `<!-- ccdx:image-skill:${digest(skillBody)} -->\n# CCDX image generation`);
141
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`;
142
123
  const helperContent = `// ccdx:image-helper:${digest(helperBody)}\n${helperBody}`;
143
- if (previousSkill?.content === skillContent && previousHelper?.content === helperContent) {
124
+ if (previousSkill?.content === skillContent && previousHelper?.content === helperContent
125
+ && references.every(({ previous, content }) => previous?.content === content)) {
144
126
  return { changed: false, skillPath, rollback: () => {} };
145
127
  }
146
- atomicWriteFilePairSync(helperPath, helperContent, skillPath, skillContent, { mode: 0o600 });
147
- 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
+ }
148
141
  return { changed: true, skillPath, rollback };
149
142
  }