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 +5 -3
- package/bin/cli.mjs +2 -2
- package/package.json +1 -1
- package/src/config.mjs +131 -48
- package/src/image-mcp.mjs +3 -3
- 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 -38
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.
|
|
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
|
+
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
|
|
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
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
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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
|
|
117
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
125
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
207
|
-
|
|
208
|
-
const
|
|
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
|
|
232
|
-
|
|
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
|
-
|
|
235
|
-
|
|
236
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
? `
|
|
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.
|
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,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
|
-
|
|
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
|
-
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
|
-
|
|
147
|
-
|
|
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
|
}
|