comfyui-mcp
Version:
Local-first, agent-native control plane for ComfyUI — MCP server + autonomous sidebar agent that drives your live graph in natural language on ANY LLM: Claude/ChatGPT/Gemini on your subscription (no API key), free local models via Ollama (fully offline),
1,068 lines • 60.2 kB
JavaScript
import { z } from "zod";
import { appendFileSync, existsSync, readdirSync, readFileSync, statSync } from "node:fs";
import { mkdir, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { parse as parseYaml } from "yaml";
import { errorToToolResult, ValidationError } from "../utils/errors.js";
import { getComfyUIBaseUrl } from "../config.js";
import { comfyuiFetch, connectedPanelOriginsNow } from "../comfyui/fetch.js";
import { provenPanelOriginMatchesConfiguredTarget } from "../services/panel-fallback-target.js";
import { PanelComfyUIReadRelayError, requestPanelComfyUIRead, } from "../services/panel-image-relay.js";
import { PanelTemplateRelayError, requestPanelTemplateIndex } from "../services/panel-template-relay.js";
import { checkWorkflowRuntime, extractWorkflowClassTypes } from "../services/api-nodes.js";
import { extractWorkflowDependencies, installWorkflowDependencies, defaultWorkflowDepsDeps, } from "../services/workflow-deps.js";
import { generateSkillCached } from "../services/skill-cache.js";
import { resolvePackManifestFile } from "../services/manifest.js";
import { templateIndexScopeNote } from "../services/template-index-scope.js";
import { bodyPrefixOf, classifyNonJson, isNonJsonResponseError, looksLikeHtmlParsedAsJson, readComfyJson, redactErrorMessage, } from "../comfyui/json-guard.js";
// Optional, opt-in observability hook for the knowledge-parity smoke test: when
// COMFYUI_MCP_TOOL_TRACE points at a file, the knowledge tool appends a JSONL
// record of its invocation. No-op in normal operation (env unset). This is the
// only way an out-of-process harness can prove the agent actually CALLED
// list_packs (action:"skill_list"/"skill_read"/"read_workflow") on the headless
// comfyui stdio MCP, since those calls don't traverse the panel bridge.
//
// The record's `tool` is the LIVE tool name and the action rides in `args`, so a
// consumer (scripts/codex-knowledge-parity-smoke.mjs) reads exactly what was
// invoked. Only the six actions whose pre-0.50.0 tools traced are traced — adding
// the other four would change what the harness observes, which is behaviour, not
// surface.
function traceToolCall(tool, args) {
const path = process.env.COMFYUI_MCP_TOOL_TRACE;
if (!path)
return;
try {
appendFileSync(path, `${JSON.stringify({ tool, args, ts: Date.now() })}\n`);
}
catch {
// tracing is best-effort and must never affect the tool's result
}
}
/** Unchanged from the two dependency tools 0.50.0 slice 9 retired (see
* DEAD_NAMES): their shared input coercion, moved here with the actions
* action:"extract_deps" / action:"install_deps" that use it. */
function parseWorkflow(input) {
if (typeof input === "string") {
try {
const parsed = JSON.parse(input);
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
throw new ValidationError("Workflow JSON must be an object with node IDs as keys");
}
return parsed;
}
catch (err) {
if (err instanceof ValidationError)
throw err;
throw new ValidationError(`Invalid JSON string: ${err.message}`);
}
}
if (typeof input === "object" && input !== null && !Array.isArray(input)) {
return input;
}
throw new ValidationError("Workflow must be a JSON string or object");
}
// SKILL ACCESS — Codex↔Claude knowledge parity for the panel agent.
//
// Claude loads ALL plugin skills natively (the panel orchestrator passes
// plugins:[{type:"local",path:pluginPath}], skills:"all"), so it knows the
// per-family expertise (e.g. krea2-txt2img) and the installer-packs system out of
// the box. Codex has NO skill mechanism. The `list_packs` tool exposes the SAME
// bundled knowledge through the comfyui MCP that BOTH backends (and any MCP
// client) share, so Codex can discover and read a model family's skill on demand
// and prefer a ready pack over hand-building a generic graph from scratch.
//
// Resolution mirrors the orchestrator's plugin lookup (src/orchestrator/index.ts
// ~L246: "the bundled plugin (skills) ships alongside dist/ in the package root").
// This file compiles to dist/tools/skills-access.js, so the package root is two
// levels up.
/** Package root — dist/tools/skills-access.js → ../../ (the package root that
* ships both plugin/skills and packs/). */
function packageRoot() {
return fileURLToPath(new URL("../../", import.meta.url));
}
function skillsDir() {
return join(packageRoot(), "plugin", "skills");
}
function packsDir() {
return join(packageRoot(), "packs");
}
/** A safe single path segment: a skill / pack directory name with no traversal,
* separators, or oddities. Both name-taking actions validate the caller-supplied
* name against this AND against an actually-existing directory before reading. */
const SAFE_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
/** Split a SKILL.md (or any frontmatter doc) into { frontmatter, body }. The
* frontmatter is the YAML block between the leading `---` fences; the body is
* everything after. Tolerant of a missing/garbled block (returns {} + full text). */
function splitFrontmatter(text) {
// Normalize BOM + CRLF so the fence match is reliable cross-platform.
const norm = text.replace(/^/, "").replace(/\r\n/g, "\n");
const m = /^---\n([\s\S]*?)\n---\n?/.exec(norm);
if (!m)
return { frontmatter: {}, body: norm };
let fm = {};
try {
const parsed = parseYaml(m[1]);
if (parsed && typeof parsed === "object")
fm = parsed;
}
catch {
// malformed frontmatter — fall back to no metadata, keep the body
}
return { frontmatter: fm, body: norm.slice(m[0].length) };
}
/** Read a skill's SKILL.md, returning null when the dir/file is missing. */
function readSkillFile(name) {
const file = join(skillsDir(), name, "SKILL.md");
if (!existsSync(file))
return null;
try {
return readFileSync(file, "utf8");
}
catch {
return null;
}
}
/** Enumerate bundled skills as { name, description }. Tolerant of a missing
* plugin dir (returns []) and of skills with no/garbled frontmatter. */
function enumerateSkills() {
const dir = skillsDir();
if (!existsSync(dir))
return [];
const out = [];
for (const entry of readdirSync(dir)) {
if (!SAFE_NAME.test(entry))
continue;
let isDir = false;
try {
isDir = statSync(join(dir, entry)).isDirectory();
}
catch {
continue;
}
if (!isDir)
continue;
const text = readSkillFile(entry);
if (text == null)
continue; // no SKILL.md → not a skill
const { frontmatter } = splitFrontmatter(text);
const name = typeof frontmatter.name === "string" ? frontmatter.name : entry;
const description = typeof frontmatter.description === "string" ? frontmatter.description : "";
out.push({ name, description });
}
out.sort((a, b) => a.name.localeCompare(b.name));
return out;
}
/** The workflow filename a pack.yaml resolves to — the SINGLE derivation shared
* by action:"list" (has_workflow), resolvePackWorkflowFile() — which backs
* action:"read_workflow", action:"check_runtime" and also
* enqueue_workflow (action:"run_template") — and read_workflow's own message.
*
* This existed three times and one copy was different: action:"list" took
* `meta.workflow` verbatim while the resolver rejected any value that is not a
* single safe path segment and fell back to workflow.json. A pack declaring
* `workflow: "sub/graph.json"` therefore reported has_workflow: true from a file
* the resolver would never open, and read_workflow answered "workflow.json not
* found" — the catalog disagreeing with the filesystem (#2748). Deriving it once
* makes that divergence unrepresentable rather than merely absent from today's
* bundled packs. */
/** A pack.yaml must parse to a YAML MAPPING — pure so the trap below is
* directly testable.
*
* A top-level SEQUENCE is the trap: `parseYaml("[]")` is truthy AND
* `typeof === "object"`, so a `parsed && typeof parsed === "object"` check
* accepts it as a metadata record. Every field then reads `undefined`, and
* `workflow: undefined` is indistinguishable from a deliberate `workflow: null`
* — so a malformed bundle gets reported as an intentional installer-only pack.
* Array.isArray is the only thing separating metadata from "something else that
* happens to parse". */
export function coercePackMeta(parsed) {
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
return null;
return parsed;
}
/** Parse `packs/<name>/pack.yaml`, or null when absent/unreadable/not a mapping. */
function readPackMeta(packDir) {
const metaFile = join(packDir, "pack.yaml");
if (!existsSync(metaFile))
return null;
try {
return coercePackMeta(parseYaml(readFileSync(metaFile, "utf8")));
}
catch {
return null;
}
}
export function resolveWorkflowFileName(meta) {
const fallback = "workflow.json";
if (!meta || typeof meta !== "object")
return fallback;
const declared = meta.workflow;
if (typeof declared !== "string")
return fallback;
// A traversal, a separator or an empty value is not a usable filename.
if (!SAFE_NAME.test(declared))
return fallback;
return declared;
}
/** Enumerate installer packs as { name, family, kind, description, workflow,
* has_workflow, has_manifest }. Reads each packs/<name>/pack.yaml. */
export function enumeratePacks() {
const dir = packsDir();
if (!existsSync(dir))
return [];
const out = [];
for (const entry of readdirSync(dir)) {
if (!SAFE_NAME.test(entry))
continue;
const packDir = join(dir, entry);
let isDir = false;
try {
isDir = statSync(packDir).isDirectory();
}
catch {
continue;
}
if (!isDir)
continue;
const metaFile = join(packDir, "pack.yaml");
if (!existsSync(metaFile))
continue; // not a pack
// Same parse contract read_workflow/check_runtime use — a malformed or
// non-mapping pack.yaml still reports the pack with just its dir name.
const meta = readPackMeta(packDir) ?? {};
const workflowName = resolveWorkflowFileName(meta);
out.push({
name: entry,
family: meta.family ?? null,
kind: meta.kind ?? null,
display_name: meta.display_name ?? null,
description: typeof meta.description === "string" ? meta.description.trim() : "",
vram: meta.vram ?? null,
skill: meta.skill ?? null,
// Installer packs run on the user's OWN GPU (free) — none ship API-node
// graphs. pack.yaml may override with an explicit `runtime`, but the
// default is local/free. (Use action:"check_runtime" to verify a graph.)
runtime: typeof meta.runtime === "string" ? meta.runtime : "local",
has_workflow: existsSync(join(packDir, workflowName)),
has_manifest: existsSync(join(packDir, "manifest.yaml")),
manifest_path: existsSync(join(packDir, "manifest.yaml"))
? join(packDir, "manifest.yaml")
: null,
});
}
out.sort((a, b) => String(a.name).localeCompare(String(b.name)));
return out;
}
/** Pack names that actually HAVE a ready workflow — the only names worth
* suggesting after a workflow lookup fails.
*
* Not every bundled pack ships a graph: a pack may be installer-only, declaring
* `workflow: null` in pack.yaml because the upstream installer never shipped one
* (qwen-image, ltx-2.3). Suggesting those after a workflow miss advertises the
* very pack that was just refused, and re-calling with a name copied out of the
* list fails identically — which reads as the catalog disagreeing with the
* filesystem (#2748). `has_workflow` is the same existsSync the resolver uses,
* so this list and resolvePackWorkflowFile() cannot disagree.
*
* enqueue_workflow (action:"run_template") already filters its suggestions this
* way; this is the shared version of that filter. */
function packsWithReadyWorkflow() {
return enumeratePacks()
.filter((p) => p.has_workflow)
.map((p) => String(p.name));
}
/** True when `packs/<name>/` exists as a directory, regardless of whether it
* ships a workflow. Lets a workflow miss say "this pack ships no workflow"
* instead of the false "no pack named <name>". */
function packDirExists(name) {
if (!SAFE_NAME.test(name))
return false;
const packDir = join(packsDir(), name);
if (!packDir.startsWith(packsDir()))
return false;
try {
return statSync(packDir).isDirectory();
}
catch {
return false;
}
}
/** Render a suggestion list, or a clear note when nothing qualifies. */
function suggest(names) {
return names.length
? `Packs with a ready workflow: ${names.join(", ")}.`
: "No bundled pack ships a ready workflow.";
}
/** The "this directory exists but has no graph" sentence — the PURE half, so
* every branch is testable without planting fixture directories in packs/.
*
* FIVE distinct states share this exit, and collapsing them is the same defect
* this PR is fixing one level up: a message that asserts a cause nobody checked.
* "Installer-only" is a deliberate, working-as-intended state (pack.yaml
* `workflow: null`) — reporting a malformed pack.yaml, an absent pack.yaml, or
* `workflow: 42` as "installer-only" hides a broken bundle behind a reassuring
* explanation, and the has_workflow footnote is itself false when action:"list"
* does not list the directory at all (it requires a pack.yaml to consider one a
* pack). */
export function describeMissingWorkflow(name, state) {
// No pack.yaml → action:"list" does not consider this a pack at all, so the
// "has_workflow: false" footnote below would be a claim about a row that
// does not exist.
if (!state.hasPackYaml) {
return `"${name}" is a directory under packs/ with no pack.yaml, so it is not a bundled pack and action:"list" does not report it.`;
}
// Covers unreadable, malformed, empty, and "parsed to something that is not a
// mapping" alike — in none of them did anyone verify an installer-only intent.
if (state.meta === null) {
return `Pack "${name}" has a pack.yaml that did not parse to a YAML mapping, so no workflow filename could be resolved.`;
}
const declared = state.meta.workflow;
const footnote = ` action:"list" reports has_workflow: false for it.`;
if (typeof declared === "string") {
// Declared a name; the file is absent — a broken bundle, NOT installer-only.
return `Pack "${name}" ships no workflow — its declared workflow file (${resolveWorkflowFileName(state.meta)}) is missing from the pack.${footnote}`;
}
if (declared != null) {
return `Pack "${name}" ships no workflow — its pack.yaml sets \`workflow\` to a ${typeof declared} rather than a filename.${footnote}`;
}
// An OMITTED key is not a declaration. All 56 bundled packs write `workflow:`
// explicitly, so an absent key expresses no intent — the author may equally
// have meant workflow.json to be there. Report only what was checked and let
// the reader judge, rather than blessing a possibly-broken pack.
if (!("workflow" in state.meta)) {
return `Pack "${name}" ships no workflow — its pack.yaml has no \`workflow\` key, and the default workflow.json is not in the pack.${footnote}`;
}
// An EXPLICIT `workflow: null` — the declared installer-only shape
// (qwen-image, ltx-2.3).
return `Pack "${name}" ships no workflow — pack.yaml declares \`workflow: null\`, so it is installer-only.${footnote}`;
}
/** I/O half: read the pack's metadata state, then describe it. read_workflow and
* check_runtime share this one builder. Error path only. */
function noWorkflowSentence(name) {
const packDir = join(packsDir(), name);
return describeMissingWorkflow(name, {
hasPackYaml: existsSync(join(packDir, "pack.yaml")),
meta: readPackMeta(packDir),
});
}
/** Locate a pack's workflow.json file path (name-guarded, must exist). Returns
* null when the pack or its workflow is missing. Shared by action:"read_workflow"
* and action:"check_runtime" so they resolve the file identically. Also
* exported for enqueue_workflow (action:"run_template"), which resolves
* templates the same way. */
export function resolvePackWorkflowFile(packName) {
const name = packName.trim();
if (!SAFE_NAME.test(name))
return null;
const packDir = join(packsDir(), name);
if (!packDir.startsWith(packsDir()) || !existsSync(packDir) || !statSync(packDir).isDirectory()) {
return null;
}
const workflowName = resolveWorkflowFileName(readPackMeta(packDir));
const wfFile = join(packDir, workflowName);
if (!wfFile.startsWith(packDir) || !existsSync(wfFile))
return null;
return wfFile;
}
/**
* The nine knowledge tools collapsed into one action-parameterized `list_packs`
* tool (0.50.0 surface consolidation, slice 9) — bundled skills, installer packs,
* the connected server's workflow templates, and the two workflow-readiness
* checks (paid-API-node classification, custom-node dependency resolve/install).
*
* SHAPE: a FLAT object with an `action` enum — deliberately NOT a
* z.discriminatedUnion, which the MCP SDK renders as a schema with ZERO visible
* parameters, hiding every input from the model.
*
* REQUIREDNESS: only `action` can be schema-required — `name` is required for
* read_workflow/skill_read and meaningless elsewhere, `workflow` is required for
* extract_deps/install_deps, `source` for generate_skill. Every VALUE constraint
* the old tools had is unchanged at the zod layer (`name` keeps its `.min(1)`);
* the handler enforces per-action presence and names the missing field — the one
* deliberate behavioural difference a flat enum permits. Each branch calls the
* same function the old tool called, with the same arguments, and returns the
* identical content block (including generate_skill's `structuredContent`).
*
* MUTATION — TWO actions can change the user's machine, and neither may be a
* surprise under a tool whose name reads like a listing:
*
* action:"install_deps" INSTALLS custom-node packs through ComfyUI-Manager on
* the connected server, which downloads and RUNS third-party code. It is the
* only action that installs anything, and the switch below is the only route
* to that service.
*
* action:"generate_skill" WRITES TO DISK on every cache MISS — the generated
* SKILL.md is persisted into its read-through cache under the user's home
* (~/.comfyui-mcp/skill-cache, or COMFYUI_SKILL_CACHE_DIR) — and, when the
* caller passes `install_in`, ALSO creates that directory recursively and
* overwrites any SKILL.md in it. Both writes are exactly what its retired
* standalone tool did. "Read-only unless install_in" would be an overclaim:
* the cache write is unconditional on a miss and lands in configurable
* user-home state.
*
* The other eight actions read. Both mutations are stated in their own
* description bullets, because a read-only-looking tool that quietly installs or
* overwrites is a wrong-expectation defect — and so is a safety note that
* undercounts them.
*/
export function registerSkillsAccessTools(server) {
server.tool("list_packs", "Bundled ComfyUI knowledge — installer packs, model-family skills, workflow templates — plus the two workflow-readiness checks. Driven by the `action` parameter:\n" +
'- action:"list" — List the bundled installer packs under packs/: one-command setups for a model family (custom nodes + model weights via manifest.yaml) PLUS a ready workflow.json graph. Each entry reports its family/kind, its runtime (these packs are LOCAL-GPU / FREE — they run on the user\'s own GPU and never spend paid API credits), whether it has a ready workflow + manifest, and the manifest path for install_comfyui apply_manifest. When asked to "set up / build a <model-family> workflow", PREFER applying the matching pack and loading its ready workflow (panel_load_workflow pack:<name>) over building a generic graph from scratch. Read the ready graph with action:"read_workflow", and inspect its install manifest with action:"read_manifest".\n' +
'- action:"read_workflow" — Return a bundled pack\'s ready workflow.json graph by pack name (`name`; discover names + which packs have a workflow with action:"list"). This is the EXPERT graph for that model family — use it as the source of truth when setting up the family on the user\'s canvas: recreate it node-by-node with the panel_* tools (panel_add_node / panel_connect / panel_set_widget) so it lands on their live canvas, or enqueue it headlessly. Prefer this over inventing a graph from scratch. Names are validated (no path traversal) and must match an existing pack directory.\n' +
'- action:"read_manifest" — Return a bundled pack\'s install manifest (its manifest.yaml — the custom nodes + model weights apply_manifest would install) by pack name (`name`; discover names + which packs have a manifest with action:"list"). READ-ONLY — the way to INSPECT what a pack will install BEFORE calling the mutating apply_manifest. Names are validated (no path traversal) and must match an existing pack directory.\n' +
'- action:"list_templates" — List CUSTOM-NODE-contributed ComfyUI workflow templates on the connected ComfyUI, grouped by source (each pack\'s own example_workflows/*.json). Hits the live server\'s /api/workflow_templates index. SCOPE LIMIT: this endpoint does NOT include ComfyUI\'s own core bundled templates from the comfyui-workflow-templates package (e.g. "Flux.1 Inpaint") — those are served to the frontend as static assets via a separate code path this action cannot see, so a small/empty result here does NOT mean no official template exists, only that no custom-node pack contributed one. When asked to "set up / build a <model-family> workflow", check here for a custom-node-contributed starter AFTER checking the bundled skills + installer packs (action:"skill_list" / action:"list"), and also tell the user to check the ComfyUI frontend\'s own Templates browser directly for core templates, since this action cannot enumerate those. NOTE: this lists what\'s available; loading a template onto the canvas is done in the ComfyUI frontend\'s Templates browser (the panel agent cannot load a template graph headlessly yet) — surface the matching template name to the user.\n' +
'- action:"check_runtime" — Determine whether a workflow runs on the user\'s OWN GPU (LOCAL — free) or uses hosted API NODES (PAID api credits). Pass `pack` (a bundled pack name — always local/free) OR `graph` (a UI or API/prompt workflow JSON, as object or string). It scans the workflow\'s node class_types against the connected ComfyUI\'s API-node set (the same signal list_api_nodes uses) and returns { runtime: \'local\'|\'api\'|\'mixed\'|\'unknown\', usesApiNodes, apiNodes[], externalApiNodes[], unknownNodes[] } — \'unknown\' means some nodes couldn\'t be classified (could be paid), so treat it (and \'api\'/\'mixed\') as POSSIBLY PAID; only \'local\' is confirmed free. `externalApiNodes` is the THIRD-PARTY paid kind (a fal.ai-style pack, or any node taking a service credential): those are INSTALLED LOCALLY yet still cost money, billed by that provider on the user\'s own account with them rather than out of Comfy api credits — so when you ask the user, name the provider, not "Comfy credits" (`externalProviders` names it when recognised — e.g. ["fal.ai"]; it is absent when the node was flagged only by taking a service credential, which proves it authenticates somewhere but not to whom). ALWAYS call this before building OR loading a non-pack/ad-hoc workflow so you can ASK the user before spending paid API credits — never silently use API nodes.\n' +
'- action:"extract_deps" — Analyze a ComfyUI workflow (`workflow`, API JSON) and determine which custom node packs it requires. Maps each node class_type to its owning node pack using ComfyUI-Manager mappings and the server\'s installed node definitions, reporting which packs are installed vs missing. READ-ONLY — it installs nothing. Works remotely (HTTP only) — mirrors `comfy-cli node deps-in-workflow`.\n' +
'- action:"install_deps" — MUTATING: this is the ONE action on this tool that INSTALLS anything. Resolve and INSTALL the custom node packs a ComfyUI workflow (`workflow`) requires, via ComfyUI-Manager: it determines the missing packs, resets the Manager queue, QUEUES THE INSTALLS, starts the worker, and reports what was installed/already-present/unresolved. Installing a pack downloads and runs third-party code (and may pull large files) on the connected ComfyUI host — local OR remote --comfyui-url — and a ComfyUI restart is typically needed before new nodes load. Use action:"extract_deps" first if you only want to SEE what is missing. Mirrors `comfy-cli node install-deps`.\n' +
'- action:"skill_list" — List the bundled ComfyUI model-family + workflow skills shipped with comfyui-mcp (name + description for each). These encode per-family expertise (e.g. krea2-txt2img: native krea2 CLIPLoader, Qwen3-VL encoder, 8-step turbo settings) and the installer-packs system. Call this BEFORE hand-building a <model-family> workflow from scratch — if a matching skill exists, read its full guidance with action:"skill_read" and prefer a ready installer pack (action:"list") over a generic graph. Claude loads these natively; this action gives the SAME knowledge to any MCP client (e.g. the Codex backend).\n' +
'- action:"skill_read" — Return the full body of a bundled skill\'s SKILL.md by name (`name`; discover names with action:"skill_list"). Gives you the family\'s complete expertise on demand — model slots, node graph, recommended settings, and gotchas — so you can build the right workflow instead of guessing. Names are validated (no path traversal) and must match an existing skill directory.\n' +
'- action:"generate_skill" — MUTATING: it WRITES to the read-through skill cache on every cache miss, and when `install_in` is set it ALSO creates that directory and overwrites any SKILL.md in it. Generate a Claude skill (SKILL.md) documenting a ComfyUI custom node pack: its nodes, inputs/outputs, and example workflows. `source` accepts a ComfyUI Registry ID (resolved via api.comfy.org) or a GitHub repository URL. Uses a read-through cache under ~/.comfyui-mcp/skill-cache (override COMFYUI_SKILL_CACHE_DIR); set refresh:true to bypass it. On cache miss, fetches the repo README and scans its Python NODE_CLASS_MAPPINGS and example workflows over the network (uses GITHUB_TOKEN if set to avoid rate limits), so internet access is required. If a ComfyUI server is reachable it enriches node input/output types from /object_info, but the server is optional. Returns the SKILL.md markdown with structured cache metadata; if install_in is set, also creates that directory (recursively) and writes SKILL.md there, overwriting any existing file.', {
action: z
.enum([
"list",
"read_workflow",
"read_manifest",
"list_templates",
"check_runtime",
"extract_deps",
"install_deps",
"skill_list",
"skill_read",
"generate_skill",
])
.describe('Which knowledge operation to perform. "list", "list_templates" and "skill_list" take no other parameters; "read_workflow", "read_manifest" and "skill_read" require `name`; "check_runtime" takes `pack` OR `graph`; "extract_deps" and "install_deps" require `workflow` (and "install_deps" INSTALLS custom nodes — the only action here that installs, though "generate_skill" also WRITES to disk: its skill cache on every miss, plus `install_in` when set); "generate_skill" requires `source` (optional `install_in`/`refresh`).'),
name: z
.string()
.min(1)
.optional()
.describe('REQUIRED for action:"read_workflow" and action:"read_manifest" — the pack name (a directory under packs/, e.g. \'krea2-txt2img-manual\'), from action:"list". REQUIRED for action:"skill_read" — the skill name (a directory under plugin/skills/, e.g. \'krea2-txt2img\'), from action:"skill_list".'),
pack: z
.string()
.optional()
.describe('action:"check_runtime" — a bundled pack name (from action:"list"). Packs are local/free; this confirms it from the actual graph.'),
graph: z
.union([z.string(), z.record(z.string(), z.unknown())])
.optional()
.describe('action:"check_runtime" — a workflow graph to classify (UI or API/prompt format), as an object or a JSON string. Use this for ad-hoc/generated workflows.'),
workflow: z
.union([z.string(), z.record(z.string(), z.any())])
.optional()
.describe('REQUIRED for action:"extract_deps" and action:"install_deps" — a ComfyUI workflow in API format (JSON string or object).'),
source: z
.string()
.optional()
.describe('REQUIRED for action:"generate_skill" — a ComfyUI Registry node ID (e.g. \'comfyui-impact-pack\') or a GitHub repository URL.'),
install_in: z
.string()
.optional()
.describe('action:"generate_skill" — optional directory to write the generated SKILL.md into. Created recursively if missing; an existing SKILL.md is overwritten. Omit to only return the markdown without touching disk.'),
refresh: z
.boolean()
.optional()
.describe('action:"generate_skill" — bypass the read-through cache and rebuild the SKILL.md, overwriting the cached entry.'),
}, async (args) => {
try {
// `name`, `workflow` and `source` cannot be schema-required in a flat
// shape, so the handler enforces per-action presence and names the
// missing field — the same information the old per-tool schemas gave.
//
// ABSENCE only, never falsiness: `source: ""` passed z.string() before
// this consolidation and reached generateSkillCached, and `workflow: ""`
// passed the union and reached parseWorkflow, each answering with its own
// validation error. A `!x` guard would swallow those paths and substitute
// generic text. (`name` keeps its `.min(1)`, so an empty string is still
// rejected by zod exactly as before — the guard only covers absence.)
const requireName = (action, what) => {
if (args.name === undefined) {
throw new Error(`list_packs action:"${action}" requires \`name\` — ${what}.`);
}
return args.name;
};
const requireWorkflow = (action) => {
if (args.workflow === undefined) {
throw new Error(`list_packs action:"${action}" requires \`workflow\` — a ComfyUI workflow in API format (JSON string or object).`);
}
return args.workflow;
};
switch (args.action) {
case "list":
return listPacksAction();
case "read_workflow":
return readPackWorkflowAction(requireName("read_workflow", "the pack whose ready workflow.json to read"));
case "read_manifest":
return readPackManifestAction(requireName("read_manifest", "the pack whose install manifest.yaml to read"));
case "list_templates":
return await listWorkflowTemplatesAction();
case "check_runtime":
return await checkRuntimeAction({ pack: args.pack, graph: args.graph });
case "extract_deps":
return await extractDepsAction(requireWorkflow("extract_deps"));
case "install_deps":
// THE ONLY MUTATING BRANCH. Nothing above or below reaches
// installWorkflowDependencies, so no read action can install.
return await installDepsAction(requireWorkflow("install_deps"));
case "skill_list":
return listSkillsAction();
case "skill_read":
return readSkillAction(requireName("skill_read", "the bundled skill to read"));
case "generate_skill": {
if (args.source === undefined) {
throw new Error('list_packs action:"generate_skill" requires `source` — a ComfyUI Registry node ID or a GitHub repository URL.');
}
return await generateSkillAction({
source: args.source,
install_in: args.install_in,
refresh: args.refresh,
});
}
default: {
// Unreachable given the zod enum, but a clear runtime guard beats a
// silent undefined if the schema and switch ever drift apart.
const exhaustive = args.action;
throw new Error(`Unknown list_packs action "${String(exhaustive)}". Expected one of: list, read_workflow, read_manifest, list_templates, check_runtime, extract_deps, install_deps, skill_list, skill_read, generate_skill.`);
}
}
}
catch (err) {
return errorToToolResult(err);
}
});
}
/** action:"list" */
function listPacksAction() {
traceToolCall("list_packs", { action: "list" });
const packs = enumeratePacks();
return {
content: [
{
type: "text",
text: JSON.stringify({
count: packs.length,
note: "All bundled installer packs are LOCAL-GPU / FREE (no API nodes, no paid credits). Loading or running a pack workflow runs entirely on the user's GPU.",
packs,
}, null, 2),
},
],
};
}
/** action:"read_workflow" */
function readPackWorkflowAction(rawName) {
traceToolCall("list_packs", { action: "read_workflow", name: rawName });
const name = rawName.trim();
if (!SAFE_NAME.test(name)) {
return {
isError: true,
content: [
{
type: "text",
text: `Invalid pack name "${rawName}". Use a plain pack directory name from list_packs (action:"list").`,
},
],
};
}
const packDir = join(packsDir(), name);
if (!packDir.startsWith(packsDir()) || !existsSync(packDir) || !statSync(packDir).isDirectory()) {
return {
isError: true,
content: [
{
type: "text",
text: `No pack named "${name}". ${suggest(packsWithReadyWorkflow())}`,
},
],
};
}
// Same single derivation action:"list" and resolvePackWorkflowFile() use, so
// the filename this message names is the filename that was actually looked for.
const meta = readPackMeta(packDir);
const workflowName = resolveWorkflowFileName(meta);
const wfFile = join(packDir, workflowName);
if (!wfFile.startsWith(packDir) || !existsSync(wfFile)) {
return {
isError: true,
content: [
{
type: "text",
text: `${noWorkflowSentence(name)} ${suggest(packsWithReadyWorkflow())}`,
},
],
};
}
const text = readFileSync(wfFile, "utf8");
return { content: [{ type: "text", text }] };
}
/** action:"read_manifest" */
function readPackManifestAction(rawName) {
const name = rawName.trim();
if (!SAFE_NAME.test(name)) {
return {
isError: true,
content: [
{
type: "text",
text: `Invalid pack name "${rawName}". Use a plain pack directory name from list_packs (action:"list").`,
},
],
};
}
const packDir = join(packsDir(), name);
if (!packDir.startsWith(packsDir()) || !existsSync(packDir) || !statSync(packDir).isDirectory()) {
const known = enumeratePacks().map((p) => p.name);
return {
isError: true,
content: [
{
type: "text",
text: `No pack named "${name}". Available packs: ${known.join(", ") || "(none bundled)"}.`,
},
],
};
}
// Same resolver apply_manifest (pack:<name>) uses, so the manifest read here is
// exactly the one a later apply would install (.yaml or .yml, name-guarded).
const manifestFile = resolvePackManifestFile(name);
if (!manifestFile) {
return {
isError: true,
content: [
{
type: "text",
text: `Pack "${name}" has no install manifest (manifest.yaml not found).`,
},
],
};
}
const text = readFileSync(manifestFile, "utf8");
return { content: [{ type: "text", text }] };
}
function isNoPanelOrigin(error) {
return error instanceof PanelTemplateRelayError && error.code === "NO_PANEL_ORIGIN";
}
function templateIndexFromUnknown(value) {
if (!value || typeof value !== "object" || Array.isArray(value))
return undefined;
return Object.fromEntries(Object.entries(value));
}
/** Same bound-tab `fetch_comfyui_read` channel as get_history / panel_graph_outline. */
async function readBoundPanelTemplateIndex() {
try {
const read = await requestPanelComfyUIRead("workflow_templates");
if (!read)
return undefined;
return templateIndexFromUnknown(JSON.parse(read.body));
}
catch (error) {
if (error instanceof PanelComfyUIReadRelayError &&
(error.code === "STALE_TARGET" || error.code === "NO_LIVE_PANEL" || error.code === "AMBIGUOUS_REQUESTER")) {
throw error;
}
return undefined;
}
}
async function resolveWorkflowTemplateIndex() {
try {
const panelIndex = await requestPanelTemplateIndex();
if (panelIndex !== undefined)
return panelIndex;
}
catch (error) {
if (!isNoPanelOrigin(error))
throw error;
// Template-index HTTP is loopback-only. A live bound canvas still has the
// authenticated panel read relay used by mcp__panel__* tools (#2839).
const bound = await readBoundPanelTemplateIndex();
if (bound !== undefined)
return bound;
if (!provenPanelOriginMatchesConfiguredTarget(connectedPanelOriginsNow(), getComfyUIBaseUrl())) {
throw error;
}
// Unique published origin matches this child's configured target: listing
// that URL is not a new destination. Mixed/malformed/empty stays thrown.
return undefined;
}
return undefined;
}
/** action:"list_templates" */
async function listWorkflowTemplatesAction() {
traceToolCall("list_packs", { action: "list_templates" });
// A sidebar-backed child has an authenticated loopback route to the live
// panel's ComfyUI origin. Prefer it: the browser may be connected to the
// actual server while COMFYUI_URL is stale or unreachable. Standalone MCP
// children return undefined here and keep the established headless path.
// Once a panel route exists, relay failures stay fail-closed: falling back
// to COMFYUI_URL could silently list a different server's templates.
const panelIndex = await resolveWorkflowTemplateIndex();
if (panelIndex !== undefined)
return renderWorkflowTemplateIndex(panelIndex);
// Canonical base URL + auth headers — same connected-ComfyUI path
// enqueue_workflow (action:"template_schema") uses, so a proxied/authed
// remote resolves
// consistently between listing and schema lookup.
const base = getComfyUIBaseUrl();
const url = `${base}/api/workflow_templates`;
// #954: a bare `fetch` here rejected with the opaque `TypeError: fetch failed`
// and the reader had no way to see WHICH target was tried — the reported
// symptom, from a session where the panel bridge was connected and this
// headless address was not. comfyuiFetch names the target on a network throw,
// and it is the same auth path every other ComfyUI call uses.
//
// NO SIGNAL (#1415). comfyuiFetch applies COMFYUI_MCP_HTTP_TIMEOUT_S only
// when the caller passed none. A hard-coded AbortSignal.timeout(8000) always
// won, so raising the env for a slow remote still aborted this read at 8s.
const res = await comfyuiFetch(url);
if (!res.ok) {
// A NON-2xx may still be an HTML proxy/login page — say which, instead
// of blaming a possibly-fine ComfyUI version (#828).
const contentType = res.headers.get("content-type") ?? "";
// unknown-ok: "" is interpolated into an ERROR MESSAGE and nothing else — the
// HTTP status is reported either way, so an unreadable body costs detail in the
// text, never a wrong conclusion. Verified there is no branch on this value.
const body = await res.text().catch(() => "");
let parsedOk = false;
try {
JSON.parse(body);
parsedOk = true;
}
catch {
parsedOk = false;
}
return {
isError: true,
content: [
{
type: "text",
text: parsedOk
? // Do NOT attribute the status to ComfyUI: a JSON error body
// can just as easily be a gateway's own envelope that never
// reached it (#828). Offer both readings, assert neither.
// The body goes through the same credential redaction as the
// non-JSON path — a gateway that reflects the request could
// otherwise echo our ComfyUI token into this tool result.
`The request to ${url} returned ${res.status} with this JSON body: ${bodyPrefixOf(body)}. ` +
`If that is a ComfyUI error, the server may predate the workflow-templates endpoint; if it is a gateway's own error envelope, the request never reached ComfyUI.`
: classifyNonJson({ url, status: res.status, contentType, body }).message,
},
],
};
}
// A 200 whose body is an HTML document is the #828 case: the frontend's
// catch-all (or a proxy) answered a route it never forwarded to the API.
// readComfyJson names that instead of throwing "Unexpected token '<'".
const index = await readComfyJson(res, {
url,
expectShape: (v) => !!v && typeof v === "object" && !Array.isArray(v),
shapeHint: "the /api/workflow_templates index (an object keyed by source)",
});
return renderWorkflowTemplateIndex(index);
}
function renderWorkflowTemplateIndex(index) {
const groups = Object.keys(index);
const total = Object.values(index).reduce((n, v) => n + (Array.isArray(v) ? v.length : 0), 0);
return {
content: [
{
type: "text",
text: JSON.stringify({
source_count: groups.length,
template_count: total,
// #1454 — the counts above describe what the SERVER registers, not what is
// installed. Said on every listing, not only an empty one: the reporter's
// had 4 sources and 53 templates, and the pack they wanted was still absent.
index_scope: templateIndexScopeNote(),
templates: index,
}, null, 2),
},
],
};
}
/** action:"check_runtime" */
async function checkRuntimeAction(args) {
traceToolCall("list_packs", { action: "check_runtime", pack: args.pack });
let graph;
let bundledLocalPack = false;
// NOT a per-action requiredness guard: `pack` and `graph` were BOTH optional in
// the retired runtime-check tool's own schema, and it answered its own
// "provide either" error. This branch — truthiness on `pack`, `!= null` on
// `graph` — is that tool's pre-existing behaviour, carried over verbatim.
// Rewriting it as absence-checks would move `pack: ""` from the graph branch to
// the pack branch, which is a behaviour change, not a surface change.
if (args.pack) {
const wfFile = resolvePackWorkflowFile(args.pack);
if (!wfFile) {
// TWO different failures shared one message: a pack that does not exist,
// and a pack that exists but is installer-only. Saying `No pack named
// "qwen-image"` for a pack action:"list" plainly lists reads as the
// catalog contradicting itself (#2748) — name which one it actually is.
const packName = args.pack.trim();
const head = packDirExists(packName)
? `${noWorkflowSentence(packName)} There is no graph to runtime-check.`
: `No pack named "${args.pack}".`;
return {
isError: true,
content: [
{
type: "text",
text: `${head} ${suggest(packsWithReadyWorkflow())}`,
},
],
};
}
graph = JSON.parse(readFileSync(wfFile, "utf8"));
// Bundled packs are guaranteed local/free (action:"list" contract). Trust
// that so an uninstalled custom node doesn't read back as "unknown" and
// wrongly demand a paid-credits confirmation (#464).
bundledLocalPack = true;
}
else if (args.graph != null) {
graph = typeof args.graph === "string" ? JSON.parse(args.graph) : args.graph;
}
else {
return {
isError: true,
content: [
{
type: "text",
text: "Provide either `pack` (a bundled pack name) or `graph` (a workflow JSON).",
},
],
};
}
// Cheap, server-independent class_type extraction first — so we always
// return SOMETHING useful even if the live /object_info is unreachable.
const classTypes = extractWorkflowClassTypes(graph);
try {
const runtime = await checkWorkflowRuntime(graph, undefined, { bundledLocalPack });
// NAME THE PROVIDER (codex P2). "billed by that provider" is unactionable — the reader
// cannot check a balance they cannot name, and a user who goes looking at their Comfy
// credits, finds them untouched, and concludes the warning was wrong is exactly the
// failure this whole verdict exists to prevent. When only the credential signal fired
// we genuinely do not know WHO bills, and it says that instead of inventing a name.
const billedBy = runtime.externalProviders?.length
? runtime.externalProviders.join(" / ")
: "the service it authenticates to";
const guidance = runtime.runtime === "local"
? "Local-GPU / free — every node runs on the user's own GPU, no paid credits."
: runtime.runtime === "unknown"
? "UNKNOWN — some nodes aren't in this server's /object_info (uninstalled custom nodes, or possibly hosted API/partner nodes). Cannot confirm it's free. Treat as POSSIBLY PAID: ASK the user (free local GPU vs paid api credits) before building or loading it; prefer a local pack."
: // #1483 — NAME WHICH KIND OF PAID NODE WAS FOUND. A third-party service node
// (fal.ai and friends) is billed by that vendor on their own account, not out
// of Comfy api credits, so telling the reader to weigh "paid api credits" sends
// them to check the wrong balance — and a reader who finds their Comfy credits
// untouched may conclude the warning was wrong and proceed.
(runtime.externalApiNodes?.length
? runtime.apiNodes.length
? `This workflow uses BOTH hosted Comfy API nodes (PAID api credits) and third-party service nodes billed by ${billedBy}: ${runtime.externalApiNodes.join(", ")}. `
: `This workflow calls a PAID THIRD-PARTY SERVICE — ${runtime.externalApiNodes.join(", ")} — billed by ${billedBy} on the user's own account with them (NOT Comfy api credits), so it is NOT free even though every node is installed locally. `
: "This workflow uses hosted API nodes that consume PAID api credits. ") +
"ASK the user (free local GPU vs paid credits) BEFORE building or loading it; prefer a local pack unless they opt in.";
return {
content: [
{
type: "text",
text: JSON.stringify({ ...runtime, guidance }, null, 2),
},
],
};
}
catch (probeErr) {
// The classification failed. We can't authoritatively classify, but we
// still surface the node list so the agent can reason — AND we must not
// misattribute the cause. A server that answered with an HTML page was
// REACHED; calling that "could not reach the server" (#828) sends the
// user to check that ComfyUI is running when the real problem is a
// proxy or a sign-in gate in front of it.
// A diagnosed non-JSON response is the clearest case. But a RAW
// markup-parse failure also proves the server answered with something
// that is not JSON, even when the follow-up probe was inconclusive —
// filing that under "unavailable" loses the #828 diagnosis and sends
// the user to check that ComfyUI is running (codex gate, round 6).
// The message is redacted either way: it quotes the body it choked on.
const diagnosed = isNonJsonResponseError(probeErr);
const nonJson = diagnosed || looksLikeHtmlParsedAsJson(probeErr);
const detail = redactErrorMessage(probeErr);
return {
content: [
{
type: "text",
text: JSON.stringify({
runtime: "unknown",
usesApiNodes: null,
classTypes,
reason: nonJson ? "non_json_response" : "object_info_unavailable",
note: nonJson
? `Could not classify the nodes: the ComfyUI server was reached but /object_info did not return JSON. ${detail}${diagnosed ? "" : " (The response could not be re-probed, so what answered is not identified.)"} Until that is fixed the runtime is genuinely UNDETERMINED — treat this workflow as POSSIBLY paid and ask the user before spending credits.`
: `Could not classify the nodes: /object_info was unavailable (${detail}). API-node detection needs a reachable ComfyUI. If unsure, treat ad-hoc workflows as POSSIBLY paid and ask the user before spending credits.`,
}, null, 2),
},
],
};
}
}
/** action:"extract_deps" (READ-ONLY) */
async function extractDepsAction(input) {
const workflow = parseWorkflow(input);
const result = await extractWorkflowDependencies(workflow, defaultWorkflowDepsDeps());
const lines = [];
lines.push(`## Workflow dependencies (${result.classTypes.length} node type(s))`, "");
if (result.requiredPacks.length === 0) {
lines.push("All node types are core/built-in ComfyUI nodes. No custom node packs required.");
}
else {
lines.push(`### Required custom node packs (${result.requiredPacks.length})`);
for (const pack of result.requiredPacks) {
const missing = result.missingPacks.includes(pack);
lines.push(`- ${pack}${missing ? " — **NOT INSTALLED**" : " — installed"}`);
}
lines.push("");
}
if (result.missingPacks.length > 0) {
lines.push(`### Missing packs (${result.missingPacks.length})`, ...result.missingPacks.map((p) => `- ${p}`), "", 'Run `list_packs (action:"install_deps")` to install them on the connected ComfyUI via ComfyUI-Manager.', "");
}
if (result.unresolved.length > 0) {
// #1136 — extract_deps has its OWN signal: it never calls fetchManagerList,
// so catalogue_unavailable is not its fact. Its `unresolved` comes from the
// MAPPINGS endpoint, whose failure was previously caught, logged at warn and
// discarded -- we held the exception and asserted absence anyway.
if (result.mappings_unavailable)
lines.push(result.mappings_unavailable, "");
// panel#890 — rendered at every site the stronger caveats are, or `unresolved`
// still reads as "does not exist" wherever this one was forgotten.
if (result.catalogue_currency_unverified)
lines.push(result.catalogue_currency_unverified, "");
lines.push(`### Unresolved node types (${result.unresolved.length})`, "These class_types are neither installed nor known to ComfyUI-Manager:", ...result.unresolved.map((c) => `- ${c}`), "");
}
lines.push("### Per-node mapping");
for (const dep of result.dependencies) {
const where = dep.builtin
? "built-in"
: dep.pack
? `${dep.pack} (${dep.installed ? "installed" : "missing"})`
: dep.installed
? "installed, pack unknown"
: "UNRESOLVED";
lines.push(`- \`${dep.class_type}\` → ${where}`);
}
return { content: [{ type: "text", text: lines.join("\n") }] };
}
/** action:"install_deps" — THE ONLY MUTATING ACTION on this tool. */
async function installDepsAction(input) {
const workflow = parseWorkflow(input);
const result = await installWorkflowDependencies(workflow, defaultWorkflowDepsDeps());
const lines = [];
if (result.installed.length > 0) {
lines.push(`## Queued ${result.installed.length} node pack(s) for install`, ...result.installed.map((p) => `- ${p}`), "", "ComfyUI-Manager is processing the install queue. A ComfyUI restart is typically " +
"required before the new nodes become available.", "");
}
else {
lines.push("## No packs needed installation", "");
}
if (result.alreadyInstalled.length > 0) {
lines.push(`### Already installed (${result.alreadyInstalled.length})`, ...result.alreadyInstalled.map((p) => `- ${p}`), "");
}
if (result.unresolved.length > 0) {
// #1136 — say it BEFORE the list. A reader who has already read
// "not found in ComfyUI-Manager" has drawn the conclusion.
if (result.catalogue_unavailable)
lines.push(result.catalogue_unavailable, "");
// panel#890 (codex round 4) — the install reply can carry the analysis's mappings
// failure now, and a caveat that is set but never rendered is the same hole one
// layer down.
if (result.mappings_unavailable)
lines.push(result.mappings_unavailable, "");
if (result.catalogue_currency_unverified)
lines.push(result.catalogue_currency_unverified, "");
lines.push(`### Could not resolve (${result.unresolved.length})`, "Not found in ComfyUI-Manager — install manually:", ...result.unresolved.map((p) => `- ${p}`), "");
}
if (result.queue) {
const q = result.queue;
lines.push("### Manager queue status", `- total: ${q.total_count ?? "?"}, done: ${q.done_count ?? "?"}, ` +
`in progress: ${q.in_progress_count ?? "?"}, processing: ${q.is_processing ?? "?"}`);
}
return { content: [{ type: "text", text: lines.join("\n") }] };
}
/** action:"skill_list" */
function listSkillsAction() {
traceToolCall("list_packs", { action: "skill_list" });
const skills = enumerateSkills();
return {
content: [
{
type: "text",
text: JSON.stringify({ count: skills.length, skills }, null, 2),
},
],
};
}
/** action:"skill_read" */
function readSkillAction(rawName) {
traceToolCall("list_packs", { action: "skill_read", name: rawName });
const name = rawName.trim();
if (!SAFE_NAME.test(name)) {
return {
isError: true,
content: [
{
type: "text",
text: `Invalid skill name "${rawName}". Use a plain skill directory name (letters, digits, dot, dash, underscore) from list_packs (action:"skill_list").`,
},
],
};
}
// Must resolve to an existing skill dir (defense in depth alongside the regex).
const dir = join(skillsDir(), name);
const resolvedRoot = skillsDir();
if (!dir.startsWith(resolvedRoot) || !existsSync(dir) || !statSync(dir).isDirectory()) {
const known = enumerateSkills().map((s) => s.name);
return {
isError: true,
content: [
{
type: "text",
text: `No skill named "${name}". Available skills: ${known.join(", ") || "(none bundled)"}.`,
},
],
};
}
const text = readSkillFile(name);
if (text == null) {
return {
isError: true,
content: [{ type: "text", text: `Skill "${name}" has no readable SKILL.md.` }],
};
}
return { content: [{ type: "text", text }] };
}
/** action:"generate_skill". Returns `structuredContent` alongside the markdown,
* exactly as the retired tool did — the return shape is part of the contract. */
async function generateSkillAction(args) {
const result = await generateSkillCached(args.source, { refresh: args.refresh });
const markdown = result.markdown;
const structuredContent = {
cache_hit: result.cacheHit,
cache_key: result.safeKey,
cache_dir: result.cacheDir,
version: result.metadata.version,
};
// Optionally write to disk
if (args.install_in) {
const dir = args.install_in;
await mkdir(dir, { recursive: true });
const filePath = join(dir, "SKILL.md");
await writeFile(filePath, markdown, "utf-8");
return {
content: [
{
type: "text",
text: `Skill file written to ${filePath}\n\n${markdown}`,
},
],
structuredContent,
};
}
return {
content: [{ type: "text", text: markdown }],
structuredContent,
};
}
//# sourceMappingURL=skills-access.js.map