codex-copilot-dx 0.7.8 → 0.7.9
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 +4 -2
- package/bin/cli.mjs +15 -8
- package/package.json +1 -1
- package/src/cli-image.mjs +47 -16
- package/src/cli-options.mjs +3 -3
- package/src/image-mcp.mjs +4 -2
- package/src/image-readiness.mjs +55 -0
- package/src/image-skill.mjs +143 -0
- package/src/image-tool-client.mjs +141 -0
package/README.md
CHANGED
|
@@ -147,7 +147,9 @@ ccdx enable-image
|
|
|
147
147
|
|
|
148
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, detects either the standard OpenAI Images request format or the supported `input.messages` format, and asks for a numeric model selection only when multiple image models are available. Its 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. 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 and reference images.
|
|
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.
|
|
151
153
|
|
|
152
154
|
Inspect or disable the optional provider with:
|
|
153
155
|
|
|
@@ -156,7 +158,7 @@ ccdx image-status
|
|
|
156
158
|
ccdx disable-image
|
|
157
159
|
```
|
|
158
160
|
|
|
159
|
-
`image-status` never prints the API key. Disabling removes the stored provider credential
|
|
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.
|
|
160
162
|
|
|
161
163
|
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. The local MCP route accepts calls only from the same device and permits at most two concurrent generations. The tool supports new-image generation at `1024x1024`, `1536x1024`, or `1024x1536`; it does not claim image editing or multi-turn image editing support.
|
|
162
164
|
|
package/bin/cli.mjs
CHANGED
|
@@ -214,10 +214,7 @@ async function reuseRunningAdapterIfAvailable() {
|
|
|
214
214
|
if (!running.ok) return false;
|
|
215
215
|
|
|
216
216
|
console.log(status("ok", `Using existing adapter at ${running.baseUrl}`));
|
|
217
|
-
|
|
218
|
-
host: ADAPTER_HOST,
|
|
219
|
-
imageProviderEnabled: Boolean(readImageProviderConfig()),
|
|
220
|
-
});
|
|
217
|
+
await configureCodexClient();
|
|
221
218
|
|
|
222
219
|
await openCodex();
|
|
223
220
|
console.log(`
|
|
@@ -230,6 +227,19 @@ async function reuseRunningAdapterIfAvailable() {
|
|
|
230
227
|
return true;
|
|
231
228
|
}
|
|
232
229
|
|
|
230
|
+
async function configureCodexClient() {
|
|
231
|
+
const imageProviderEnabled = Boolean(readImageProviderConfig());
|
|
232
|
+
ensureCodexConfig(ADAPTER_PORT, { host: ADAPTER_HOST, imageProviderEnabled });
|
|
233
|
+
if (imageProviderEnabled) {
|
|
234
|
+
try {
|
|
235
|
+
const { syncEnabledImageSkill } = await import("../src/cli-image.mjs");
|
|
236
|
+
syncEnabledImageSkill({ adapterPort: ADAPTER_PORT, adapterHost: ADAPTER_HOST });
|
|
237
|
+
} catch (error) {
|
|
238
|
+
console.warn(status("warn", `Image guidance could not be updated: ${error.message}`));
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
233
243
|
if (CLI.command === "doctor") {
|
|
234
244
|
const checks = await runDoctor({
|
|
235
245
|
commandName: CLI_NAME,
|
|
@@ -298,10 +308,7 @@ try {
|
|
|
298
308
|
});
|
|
299
309
|
|
|
300
310
|
// Point Codex App at the adapter.
|
|
301
|
-
|
|
302
|
-
host: ADAPTER_HOST,
|
|
303
|
-
imageProviderEnabled: Boolean(readImageProviderConfig()),
|
|
304
|
-
});
|
|
311
|
+
await configureCodexClient();
|
|
305
312
|
|
|
306
313
|
// Launch Codex when available.
|
|
307
314
|
await openCodex();
|
package/package.json
CHANGED
package/src/cli-image.mjs
CHANGED
|
@@ -6,6 +6,8 @@ import { createInterface } from "node:readline/promises";
|
|
|
6
6
|
import { atomicWriteFilePairSync } from "./atomic-file.mjs";
|
|
7
7
|
import { terminalCell } from "./cli-table.mjs";
|
|
8
8
|
import { computeImageMcpCodexConfig } from "./config.mjs";
|
|
9
|
+
import { probeImageTool } from "./image-readiness.mjs";
|
|
10
|
+
import { updateImageSkill } from "./image-skill.mjs";
|
|
9
11
|
import {
|
|
10
12
|
imageProviderConfigPath,
|
|
11
13
|
inspectImageProvider,
|
|
@@ -103,13 +105,19 @@ function enableImageProvider({ providerConfig, providerPath, codexPath, codexCon
|
|
|
103
105
|
adapterHost,
|
|
104
106
|
});
|
|
105
107
|
const normalized = validateImageProviderConfig(providerConfig, { filePath: providerPath });
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
108
|
+
const skill = updateImageSkill({ enabled: true, codexPath, adapterPort, adapterHost });
|
|
109
|
+
try {
|
|
110
|
+
atomicWriteFilePairSync(
|
|
111
|
+
codexPath,
|
|
112
|
+
codex.content,
|
|
113
|
+
providerPath,
|
|
114
|
+
`${JSON.stringify(normalized, null, 2)}\n`,
|
|
115
|
+
{ mode: 0o600 },
|
|
116
|
+
);
|
|
117
|
+
} catch (error) {
|
|
118
|
+
skill.rollback();
|
|
119
|
+
throw error;
|
|
120
|
+
}
|
|
113
121
|
return { codexChanged: codex.changed, config: normalized };
|
|
114
122
|
}
|
|
115
123
|
|
|
@@ -120,12 +128,27 @@ function disableImageProvider({ providerPath, codexPath, codexContent, adapterPo
|
|
|
120
128
|
adapterHost,
|
|
121
129
|
});
|
|
122
130
|
const providerExists = fs.existsSync(providerPath);
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
131
|
+
const skill = updateImageSkill({ enabled: false, codexPath, adapterPort, adapterHost });
|
|
132
|
+
try {
|
|
133
|
+
if (fs.existsSync(codexPath)) {
|
|
134
|
+
atomicWriteFilePairSync(codexPath, codex.content, providerPath, null, { mode: 0o600 });
|
|
135
|
+
} else if (providerExists) {
|
|
136
|
+
fs.unlinkSync(providerPath);
|
|
137
|
+
}
|
|
138
|
+
} catch (error) {
|
|
139
|
+
skill.rollback();
|
|
140
|
+
throw error;
|
|
127
141
|
}
|
|
128
|
-
return { changed: providerExists || codex.changed };
|
|
142
|
+
return { changed: providerExists || codex.changed || skill.changed, preservedGuidance: Boolean(skill.preserved) };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function syncEnabledImageSkill({ home = os.homedir(), codexPath = defaultCodexConfigPath(home), ...options } = {}) {
|
|
146
|
+
return updateImageSkill({ ...options, enabled: true, codexPath });
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function printImageReadiness(readiness, output, commandName) {
|
|
150
|
+
if (readiness.ready) output.write("Local image service: ready (generation tool verified; no image generated).\n");
|
|
151
|
+
else output.write(`Local image service: unavailable (${readiness.reason}). Start or restart ${commandName} to use images.\n`);
|
|
129
152
|
}
|
|
130
153
|
|
|
131
154
|
export async function runImageCommand({
|
|
@@ -138,6 +161,7 @@ export async function runImageCommand({
|
|
|
138
161
|
prompt,
|
|
139
162
|
promptSecret,
|
|
140
163
|
fetchImpl = fetch,
|
|
164
|
+
probeFetchImpl = fetch,
|
|
141
165
|
adapterPort = 2026,
|
|
142
166
|
adapterHost = "127.0.0.1",
|
|
143
167
|
codexPath = defaultCodexConfigPath(home),
|
|
@@ -151,14 +175,18 @@ export async function runImageCommand({
|
|
|
151
175
|
}
|
|
152
176
|
const summary = configSummary(config);
|
|
153
177
|
output.write(`Image generation: enabled\nEndpoint: ${terminalCell(summary.endpoint)}\nModel: ${terminalCell(summary.model)}\nProtocol: ${terminalCell(summary.protocol)}\n`);
|
|
154
|
-
|
|
178
|
+
const readiness = await probeImageTool(fileContent(codexPath), { fetchImpl: probeFetchImpl });
|
|
179
|
+
printImageReadiness(readiness, output, commandName);
|
|
180
|
+
return { enabled: true, ...summary, readiness };
|
|
155
181
|
}
|
|
156
182
|
|
|
157
183
|
const codexContent = fileContent(codexPath);
|
|
158
184
|
if (action === "disable") {
|
|
159
185
|
const result = disableImageProvider({ providerPath, codexPath, codexContent, adapterPort, adapterHost });
|
|
160
186
|
output.write(`${result.changed ? "Disabled" : "Kept disabled"} image generation.\n`);
|
|
161
|
-
output.write(
|
|
187
|
+
output.write(result.preservedGuidance
|
|
188
|
+
? "Kept user-modified image guidance. Existing image tool calls are now disabled.\n"
|
|
189
|
+
: "Removed CCDX image guidance. Existing image tool calls are now disabled.\n");
|
|
162
190
|
return { enabled: false, ...result };
|
|
163
191
|
}
|
|
164
192
|
if (action !== "enable") throw new Error(`Unknown image command action: ${action}`);
|
|
@@ -194,6 +222,9 @@ export async function runImageCommand({
|
|
|
194
222
|
adapterHost,
|
|
195
223
|
});
|
|
196
224
|
output.write(`Enabled image generation with ${terminalCell(model)}.\n`);
|
|
197
|
-
|
|
198
|
-
|
|
225
|
+
const readiness = await probeImageTool(fileContent(codexPath), { fetchImpl: probeFetchImpl });
|
|
226
|
+
printImageReadiness(readiness, output, commandName);
|
|
227
|
+
output.write("Installed CCDX image guidance for new and existing tasks. Ask Codex to draw an image.\n");
|
|
228
|
+
output.write("If the open task has not refreshed its skills, restart Codex App once.\n");
|
|
229
|
+
return { enabled: true, ...configSummary(result.config), codexChanged: result.codexChanged, readiness };
|
|
199
230
|
}
|
package/src/cli-options.mjs
CHANGED
|
@@ -302,9 +302,9 @@ 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\nInteractively selects the terminal activity animation 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, and
|
|
306
|
-
"disable-image": `Usage:\n ${name} disable-image\n\nDisables the local CCDX image tool and removes its stored API credential.`,
|
|
307
|
-
"image-status": `Usage:\n ${name} image-status\n\nShows
|
|
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.`,
|
|
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
|
+
"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.`,
|
|
309
309
|
update: `Usage:\n ${name} update [npm|github]\n\nUpdates the global package from the configured npm registry or GitHub main. With no source, an interactive terminal prompts for one.`,
|
|
310
310
|
version: `Usage:\n ${name} --version`,
|
package/src/image-mcp.mjs
CHANGED
|
@@ -42,7 +42,7 @@ function imageTool() {
|
|
|
42
42
|
return {
|
|
43
43
|
name: IMAGE_TOOL_NAME,
|
|
44
44
|
title: "Generate image",
|
|
45
|
-
description: "Generate a new image
|
|
45
|
+
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.",
|
|
46
46
|
inputSchema: {
|
|
47
47
|
type: "object",
|
|
48
48
|
properties: {
|
|
@@ -113,7 +113,9 @@ export function createImageMcpHandler({
|
|
|
113
113
|
protocolVersion: MCP_PROTOCOL_VERSIONS.has(requestedVersion) ? requestedVersion : MCP_PROTOCOL_VERSION,
|
|
114
114
|
capabilities: { tools: { listChanged: false } },
|
|
115
115
|
serverInfo: { name: "ccdx-image", version: "1" },
|
|
116
|
-
instructions:
|
|
116
|
+
instructions: configLoader()
|
|
117
|
+
? "The user enabled CCDX as their image provider. For new text-to-image requests, call generate_image directly and display the returned image. CCDX handles credentials and the API; no Python, SDK installation, OPENAI_API_KEY, or alternate endpoint is needed. Follow the user's image request; do not retry a failed generation automatically. Editing and reference-image requests require a different capable tool."
|
|
118
|
+
: "Image generation is disabled. Enable it with ccdx enable-image before requesting images.",
|
|
117
119
|
});
|
|
118
120
|
return;
|
|
119
121
|
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import os from "node:os";
|
|
2
|
+
import { parse } from "smol-toml";
|
|
3
|
+
import { readBoundedResponseText } from "./http-transport.mjs";
|
|
4
|
+
|
|
5
|
+
export async function probeImageTool(codexContent, { fetchImpl = fetch, timeoutMs = 1500 } = {}) {
|
|
6
|
+
let server;
|
|
7
|
+
let url;
|
|
8
|
+
try {
|
|
9
|
+
server = parse(codexContent).mcp_servers?.ccdx_image;
|
|
10
|
+
if (!server || server.enabled === false) return { ready: false, reason: "not_configured" };
|
|
11
|
+
url = new URL(server.url);
|
|
12
|
+
const host = url.hostname.replace(/^\[|\]$/g, "");
|
|
13
|
+
const local = ["localhost", "127.0.0.1", "::1", ...Object.values(os.networkInterfaces())
|
|
14
|
+
.flat().filter(Boolean).map((entry) => entry.address)];
|
|
15
|
+
if (url.protocol !== "http:" || !local.includes(host) || url.username || url.password
|
|
16
|
+
|| url.pathname !== "/mcp/image" || url.search || url.hash) {
|
|
17
|
+
return { ready: false, reason: "invalid_local_endpoint" };
|
|
18
|
+
}
|
|
19
|
+
} catch {
|
|
20
|
+
return { ready: false, reason: "invalid_config" };
|
|
21
|
+
}
|
|
22
|
+
const signal = AbortSignal.timeout(timeoutMs);
|
|
23
|
+
const headers = { "Content-Type": "application/json", Accept: "application/json, text/event-stream" };
|
|
24
|
+
async function rpc(id, method, params) {
|
|
25
|
+
const response = await fetchImpl(url.href, {
|
|
26
|
+
method: "POST", headers, signal, redirect: "error",
|
|
27
|
+
body: JSON.stringify({ jsonrpc: "2.0", ...(id === undefined ? {} : { id }), method, params }),
|
|
28
|
+
});
|
|
29
|
+
if (!response.ok) {
|
|
30
|
+
await response.body?.cancel?.().catch(() => {});
|
|
31
|
+
throw new Error(`HTTP ${response.status}`);
|
|
32
|
+
}
|
|
33
|
+
if (id === undefined) {
|
|
34
|
+
await response.body?.cancel?.().catch(() => {});
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
const text = await readBoundedResponseText(response, { maxBytes: 64 * 1024, signal, label: "Image tool catalog" });
|
|
38
|
+
const message = JSON.parse(text);
|
|
39
|
+
if (message.jsonrpc !== "2.0" || message.id !== id || message.error) throw new Error("invalid MCP response");
|
|
40
|
+
return message.result;
|
|
41
|
+
}
|
|
42
|
+
try {
|
|
43
|
+
const initialized = await rpc(1, "initialize", {
|
|
44
|
+
protocolVersion: "2025-06-18", capabilities: {}, clientInfo: { name: "ccdx-image-status", version: "1" },
|
|
45
|
+
});
|
|
46
|
+
if (initialized?.serverInfo?.name !== "ccdx-image") return { ready: false, reason: "unexpected_server" };
|
|
47
|
+
await rpc(undefined, "notifications/initialized", {});
|
|
48
|
+
const result = await rpc(2, "tools/list", {});
|
|
49
|
+
return result?.tools?.some((tool) => tool.name === "generate_image")
|
|
50
|
+
? { ready: true }
|
|
51
|
+
: { ready: false, reason: "tool_unavailable" };
|
|
52
|
+
} catch (error) {
|
|
53
|
+
return { ready: false, reason: signal.aborted ? "timeout" : /^HTTP \d+$/.test(error.message) ? error.message : "unreachable" };
|
|
54
|
+
}
|
|
55
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
4
|
+
import { atomicWriteFilePairSync, atomicWriteFileSync } from "./atomic-file.mjs";
|
|
5
|
+
import { adapterBaseUrl } from "./running-adapter.mjs";
|
|
6
|
+
|
|
7
|
+
const SKILL_MARKER = /<!-- ccdx:image-skill:([a-f0-9]{64}) -->\n/;
|
|
8
|
+
const HELPER_MARKER = /^\/\/ ccdx:image-helper:([a-f0-9]{64})\n/;
|
|
9
|
+
|
|
10
|
+
function digest(content) {
|
|
11
|
+
return createHash("sha256").update(content).digest("hex");
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function quote(value) {
|
|
15
|
+
return `'${String(value).replaceAll("'", "'\\''")}'`;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function conflict(message) {
|
|
19
|
+
return Object.assign(new Error(message), { code: "CCDX_IMAGE_SKILL_CONFLICT" });
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function directoryCheck(directory) {
|
|
23
|
+
try {
|
|
24
|
+
if (!fs.lstatSync(directory).isDirectory()) throw conflict(`CCDX image skill path is not a regular directory: ${directory}`);
|
|
25
|
+
} catch (error) {
|
|
26
|
+
if (error?.code !== "ENOENT") throw error;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function ownedSnapshot(filePath, marker, preserveUnowned = false) {
|
|
31
|
+
try {
|
|
32
|
+
const stat = fs.lstatSync(filePath);
|
|
33
|
+
if (!stat.isFile()) throw conflict(`Refusing to replace an unowned CCDX image skill path: ${filePath}`);
|
|
34
|
+
const content = fs.readFileSync(filePath, "utf8");
|
|
35
|
+
const match = marker.exec(content);
|
|
36
|
+
if (!match || digest(content.replace(marker, "")) !== match[1]) {
|
|
37
|
+
throw conflict(`Preserving an existing or modified image skill file: ${filePath}`);
|
|
38
|
+
}
|
|
39
|
+
return { content, mode: stat.mode & 0o777 };
|
|
40
|
+
} catch (error) {
|
|
41
|
+
if (error?.code === "ENOENT") return null;
|
|
42
|
+
if (preserveUnowned && error?.code === "CCDX_IMAGE_SKILL_CONFLICT") return { unowned: true };
|
|
43
|
+
throw error;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function removeFile(filePath) {
|
|
48
|
+
try { fs.unlinkSync(filePath); }
|
|
49
|
+
catch (error) { if (error?.code !== "ENOENT") throw error; }
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function removeEmptyDirectories(skillDirectory) {
|
|
53
|
+
for (const directory of [path.join(skillDirectory, "scripts"), skillDirectory]) {
|
|
54
|
+
try { fs.rmdirSync(directory); }
|
|
55
|
+
catch (error) { if (!["ENOENT", "ENOTEMPTY", "EEXIST"].includes(error?.code)) throw error; }
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function updateImageSkill({
|
|
60
|
+
enabled,
|
|
61
|
+
codexPath,
|
|
62
|
+
adapterPort = 2026,
|
|
63
|
+
adapterHost = "127.0.0.1",
|
|
64
|
+
nodePath = process.execPath,
|
|
65
|
+
} = {}) {
|
|
66
|
+
const skillDirectory = path.join(path.dirname(codexPath), "skills", "ccdx-image");
|
|
67
|
+
const skillPath = path.join(skillDirectory, "SKILL.md");
|
|
68
|
+
const helperPath = path.join(skillDirectory, "scripts", "generate.mjs");
|
|
69
|
+
try {
|
|
70
|
+
directoryCheck(skillDirectory);
|
|
71
|
+
directoryCheck(path.dirname(helperPath));
|
|
72
|
+
} catch (error) {
|
|
73
|
+
if (!enabled && error?.code === "CCDX_IMAGE_SKILL_CONFLICT") {
|
|
74
|
+
return { changed: false, preserved: true, skillPath, rollback: () => {} };
|
|
75
|
+
}
|
|
76
|
+
throw error;
|
|
77
|
+
}
|
|
78
|
+
const previousSkill = ownedSnapshot(skillPath, SKILL_MARKER, !enabled);
|
|
79
|
+
const previousHelper = ownedSnapshot(helperPath, HELPER_MARKER, !enabled);
|
|
80
|
+
const touched = [];
|
|
81
|
+
let rolledBack = false;
|
|
82
|
+
const rollback = () => {
|
|
83
|
+
if (rolledBack) return;
|
|
84
|
+
for (const [filePath, snapshot] of touched) {
|
|
85
|
+
if (snapshot) atomicWriteFileSync(filePath, snapshot.content, { mode: snapshot.mode, preserveMode: false });
|
|
86
|
+
else removeFile(filePath);
|
|
87
|
+
}
|
|
88
|
+
removeEmptyDirectories(skillDirectory);
|
|
89
|
+
rolledBack = true;
|
|
90
|
+
};
|
|
91
|
+
if (!enabled) {
|
|
92
|
+
const preserved = Boolean(previousSkill?.unowned || previousHelper?.unowned);
|
|
93
|
+
if (!previousSkill?.content && !previousHelper?.content) return { changed: false, preserved, skillPath, rollback: () => {} };
|
|
94
|
+
try {
|
|
95
|
+
for (const [filePath, snapshot] of [[skillPath, previousSkill], [helperPath, previousHelper]]) {
|
|
96
|
+
if (!snapshot?.content) continue;
|
|
97
|
+
touched.push([filePath, snapshot]);
|
|
98
|
+
removeFile(filePath);
|
|
99
|
+
}
|
|
100
|
+
removeEmptyDirectories(skillDirectory);
|
|
101
|
+
} catch (error) {
|
|
102
|
+
rollback();
|
|
103
|
+
throw error;
|
|
104
|
+
}
|
|
105
|
+
return { changed: true, preserved, skillPath, rollback };
|
|
106
|
+
}
|
|
107
|
+
const host = ["0.0.0.0", "::", "[::]"].includes(adapterHost) ? (adapterHost === "0.0.0.0" ? "127.0.0.1" : "::1") : adapterHost;
|
|
108
|
+
const endpoint = `${adapterBaseUrl(host, adapterPort)}/mcp/image`;
|
|
109
|
+
const skillBody = `---
|
|
110
|
+
name: ccdx-image
|
|
111
|
+
description: Generate new images from text using the user's configured CCDX image provider. Use for requests to draw, create, or render a new image. Excludes editing existing images, image references, 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. For new text-to-image requests, use the configured CCDX provider directly.
|
|
117
|
+
|
|
118
|
+
Use the ccdx_image server's generate_image tool when available. Supply a complete prompt and one size: 1024x1024 (square), 1536x1024 (landscape), or 1024x1536 (portrait). Display its returned image. No Python, SDK installation, extra API key, or provider discovery is needed.
|
|
119
|
+
|
|
120
|
+
If the tool is not directly callable and the host provides tool search, make one targeted search for ccdx_image generate_image and use the tool if it becomes callable. If it is still unavailable, run the bundled Node helper once:
|
|
121
|
+
|
|
122
|
+
\`\`\`sh
|
|
123
|
+
${quote(nodePath)} ${quote(helperPath)} --prompt 'A complete image prompt' --size 1024x1024
|
|
124
|
+
\`\`\`
|
|
125
|
+
|
|
126
|
+
Quote the actual prompt safely for the shell. The helper uses the same local CCDX service and prints the saved absolute image path. 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.
|
|
127
|
+
|
|
128
|
+
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.
|
|
129
|
+
|
|
130
|
+
Only use the helper when the MCP tool is absent, not after a generation fails or times out: the first request may already have consumed a generation. A helper error explicitly saying generation has not started means no generation 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 generation.
|
|
131
|
+
|
|
132
|
+
This provider supports text-to-image only. For edits, reference images, transparent output, or explicitly requested other providers, keep the relevant native tools and skills; do not substitute a fresh CCDX generation.
|
|
133
|
+
`;
|
|
134
|
+
const skillContent = skillBody.replace("# CCDX image generation", `<!-- ccdx:image-skill:${digest(skillBody)} -->\n# CCDX image generation`);
|
|
135
|
+
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`;
|
|
136
|
+
const helperContent = `// ccdx:image-helper:${digest(helperBody)}\n${helperBody}`;
|
|
137
|
+
if (previousSkill?.content === skillContent && previousHelper?.content === helperContent) {
|
|
138
|
+
return { changed: false, skillPath, rollback: () => {} };
|
|
139
|
+
}
|
|
140
|
+
atomicWriteFilePairSync(helperPath, helperContent, skillPath, skillContent, { mode: 0o600 });
|
|
141
|
+
touched.push([helperPath, previousHelper], [skillPath, previousSkill]);
|
|
142
|
+
return { changed: true, skillPath, rollback };
|
|
143
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { randomUUID } from "node:crypto";
|
|
5
|
+
|
|
6
|
+
const IMAGE_SIZES = new Set(["1024x1024", "1536x1024", "1024x1536"]);
|
|
7
|
+
const IMAGE_EXTENSIONS = new Map([["image/png", ".png"], ["image/jpeg", ".jpg"], ["image/webp", ".webp"]]);
|
|
8
|
+
const MAX_IMAGE_BYTES = 32 * 1024 * 1024;
|
|
9
|
+
const MAX_RESPONSE_BYTES = 48 * 1024 * 1024;
|
|
10
|
+
const USAGE = "Usage: generate.mjs --prompt <text> [--size 1024x1024|1536x1024|1024x1536] [--out <new-file>]";
|
|
11
|
+
|
|
12
|
+
export function parseImageToolArgs(args = []) {
|
|
13
|
+
if (args.length === 1 && ["--help", "-h"].includes(args[0])) return { help: true };
|
|
14
|
+
const values = {};
|
|
15
|
+
for (let index = 0; index < args.length; index += 2) {
|
|
16
|
+
const option = args[index];
|
|
17
|
+
if (!["--prompt", "--size", "--out"].includes(option) || option in values) throw new Error(USAGE);
|
|
18
|
+
if (typeof args[index + 1] !== "string" || !args[index + 1].trim()) throw new Error(`Missing value for ${option}`);
|
|
19
|
+
values[option] = args[index + 1];
|
|
20
|
+
}
|
|
21
|
+
const prompt = String(values["--prompt"] || "").trim();
|
|
22
|
+
if (!prompt || prompt.length > 16_000) throw new Error("Image prompt must contain 1 to 16000 characters");
|
|
23
|
+
const size = String(values["--size"] || "1024x1024").trim().toLowerCase();
|
|
24
|
+
if (!IMAGE_SIZES.has(size)) throw new Error("Unsupported image size");
|
|
25
|
+
return { prompt, size, out: values["--out"] };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function localImageEndpoint(value) {
|
|
29
|
+
const endpoint = new URL(value);
|
|
30
|
+
const host = endpoint.hostname.replace(/^\[|\]$/g, "");
|
|
31
|
+
const localAddresses = Object.values(os.networkInterfaces()).flat().filter(Boolean).map(({ address }) => address);
|
|
32
|
+
const local = host === "localhost" || host === "::1" || /^127\.\d+\.\d+\.\d+$/.test(host) || localAddresses.includes(host);
|
|
33
|
+
if (!local || endpoint.protocol !== "http:" || endpoint.username || endpoint.password
|
|
34
|
+
|| endpoint.pathname !== "/mcp/image" || endpoint.search || endpoint.hash) {
|
|
35
|
+
throw new Error("The CCDX image helper requires the configured local /mcp/image endpoint");
|
|
36
|
+
}
|
|
37
|
+
return endpoint.href;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function cleanError(value) {
|
|
41
|
+
return String(value || "Image generation failed").replace(/[\u0000-\u001f\u007f-\u009f]/g, " ").slice(0, 1000);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function readRpcResponse(response, expectedId) {
|
|
45
|
+
if (!response.ok) throw new Error(`CCDX image service returned HTTP ${response.status}`);
|
|
46
|
+
const declared = Number(response.headers.get("content-length"));
|
|
47
|
+
if (Number.isFinite(declared) && declared > MAX_RESPONSE_BYTES) {
|
|
48
|
+
await response.body?.cancel();
|
|
49
|
+
throw new Error("CCDX image response exceeds the size limit");
|
|
50
|
+
}
|
|
51
|
+
const chunks = [];
|
|
52
|
+
let bytes = 0;
|
|
53
|
+
for await (const chunk of response.body) {
|
|
54
|
+
bytes += chunk.byteLength;
|
|
55
|
+
if (bytes > MAX_RESPONSE_BYTES) throw new Error("CCDX image response exceeds the size limit");
|
|
56
|
+
chunks.push(Buffer.from(chunk));
|
|
57
|
+
}
|
|
58
|
+
let body;
|
|
59
|
+
try { body = JSON.parse(Buffer.concat(chunks, bytes).toString("utf8")); }
|
|
60
|
+
catch { throw new Error("CCDX image service returned invalid JSON"); }
|
|
61
|
+
if (body?.jsonrpc !== "2.0" || body.id !== expectedId) throw new Error("CCDX image service returned an invalid JSON-RPC response");
|
|
62
|
+
if (body.error) throw new Error(cleanError(body.error.message));
|
|
63
|
+
if (!body.result || typeof body.result !== "object") throw new Error("CCDX image service returned no result");
|
|
64
|
+
return body.result;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function assertNewOutput(filePath) {
|
|
68
|
+
try {
|
|
69
|
+
fs.lstatSync(filePath);
|
|
70
|
+
throw new Error(`Image output already exists: ${filePath}`);
|
|
71
|
+
} catch (error) {
|
|
72
|
+
if (error?.code !== "ENOENT") throw error;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export async function runImageToolClient({
|
|
77
|
+
endpoint,
|
|
78
|
+
args = process.argv.slice(2),
|
|
79
|
+
cwd = process.cwd(),
|
|
80
|
+
fetchImpl = fetch,
|
|
81
|
+
output = process.stdout,
|
|
82
|
+
signal = AbortSignal.timeout(210_000),
|
|
83
|
+
} = {}) {
|
|
84
|
+
const options = parseImageToolArgs(args);
|
|
85
|
+
if (options.help) {
|
|
86
|
+
output.write(`${USAGE}\n`);
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
const url = localImageEndpoint(endpoint);
|
|
90
|
+
const requestedOutput = options.out ? path.resolve(cwd, options.out) : null;
|
|
91
|
+
if (requestedOutput) assertNewOutput(requestedOutput);
|
|
92
|
+
const directory = requestedOutput ? path.dirname(requestedOutput) : path.resolve(cwd, "output", "imagegen");
|
|
93
|
+
fs.mkdirSync(directory, { recursive: true });
|
|
94
|
+
fs.accessSync(directory, fs.constants.W_OK);
|
|
95
|
+
const headers = { "Content-Type": "application/json", Accept: "application/json, text/event-stream" };
|
|
96
|
+
const post = (body) => fetchImpl(url, {
|
|
97
|
+
method: "POST", headers, body: JSON.stringify(body), redirect: "error", signal,
|
|
98
|
+
});
|
|
99
|
+
try {
|
|
100
|
+
const initialized = await post({
|
|
101
|
+
jsonrpc: "2.0", id: 1, method: "initialize",
|
|
102
|
+
params: { protocolVersion: "2025-06-18", capabilities: {}, clientInfo: { name: "ccdx-image-helper", version: "1" } },
|
|
103
|
+
});
|
|
104
|
+
const sessionId = initialized.headers.get("mcp-session-id");
|
|
105
|
+
const server = await readRpcResponse(initialized, 1);
|
|
106
|
+
if (server.serverInfo?.name !== "ccdx-image") throw new Error("The configured local service is not CCDX image generation");
|
|
107
|
+
headers["MCP-Protocol-Version"] = server.protocolVersion || "2025-06-18";
|
|
108
|
+
if (sessionId) headers["Mcp-Session-Id"] = sessionId;
|
|
109
|
+
const notified = await post({ jsonrpc: "2.0", method: "notifications/initialized" });
|
|
110
|
+
await notified.body?.cancel();
|
|
111
|
+
if (!notified.ok) throw new Error(`CCDX image initialization returned HTTP ${notified.status}`);
|
|
112
|
+
} catch (error) {
|
|
113
|
+
const code = error.cause?.code || error.code;
|
|
114
|
+
const detail = ["EPERM", "EACCES"].includes(code)
|
|
115
|
+
? "Local network access was denied. Request normal execution permission for this helper command."
|
|
116
|
+
: cleanError(error.message);
|
|
117
|
+
throw Object.assign(new Error(`Image generation has not started: ${detail}`), { code: "CCDX_IMAGE_NOT_STARTED" });
|
|
118
|
+
}
|
|
119
|
+
const result = await readRpcResponse(await post({
|
|
120
|
+
jsonrpc: "2.0", id: 2, method: "tools/call",
|
|
121
|
+
params: { name: "generate_image", arguments: { prompt: options.prompt, size: options.size } },
|
|
122
|
+
}), 2);
|
|
123
|
+
if (result.isError) {
|
|
124
|
+
throw new Error(cleanError(result.content?.find((part) => part.type === "text")?.text));
|
|
125
|
+
}
|
|
126
|
+
const image = result.content?.find((part) => part.type === "image");
|
|
127
|
+
const extension = IMAGE_EXTENSIONS.get(image?.mimeType);
|
|
128
|
+
if (!extension || typeof image.data !== "string" || !image.data
|
|
129
|
+
|| image.data.length > Math.ceil(MAX_IMAGE_BYTES / 3) * 4
|
|
130
|
+
|| !/^[A-Za-z0-9+/]+={0,2}$/.test(image.data)) {
|
|
131
|
+
throw new Error("CCDX image service returned an invalid image");
|
|
132
|
+
}
|
|
133
|
+
const bytes = Buffer.from(image.data, "base64");
|
|
134
|
+
if (!bytes.length || bytes.length > MAX_IMAGE_BYTES || bytes.toString("base64") !== image.data) {
|
|
135
|
+
throw new Error("CCDX image service returned an invalid image");
|
|
136
|
+
}
|
|
137
|
+
const filePath = requestedOutput || path.join(directory, `image-${randomUUID()}${extension}`);
|
|
138
|
+
fs.writeFileSync(filePath, bytes, { flag: "wx", mode: 0o600 });
|
|
139
|
+
output.write(`${filePath}\n`);
|
|
140
|
+
return filePath;
|
|
141
|
+
}
|