Compare commits

...
1 Commits
Author SHA1 Message Date
Ivor StrelarandClaude Opus 5 0c3830097b feat: add SF Claude Code node
Runs prompts through the locally installed Claude Code CLI, so it uses the
user's existing Claude login rather than an Anthropic API key. Accepts a
system prompt and up to 16 images, which are written to a temp working
directory and read back by the agent's Read tool.

Image inputs grow on demand: connecting one reveals the next empty slot, and
unused trailing slots are trimmed.

Closes stdin on the subprocess, which otherwise costs 3s per call while the
CLI waits for input that never arrives.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 08:50:24 +02:00
4 changed files with 355 additions and 1 deletions
+28
View File
@@ -59,6 +59,34 @@ Chat with large language models directly inside your ComfyUI workflows, with opt
|---|---|
| **SF LLM Chat** | Multi-model chat supporting Claude (Opus, Sonnet, Haiku), Gemini 3 (Pro, Flash), and OpenAI GPT-5 series. Supports image input and reports token usage. |
| **SF Text Analyzer** | Analyze and process text strings within workflows. |
| **SF Claude Code** | Send text and images to Claude through the Claude Code CLI on your own machine, using your existing Claude login instead of an API key. Image slots appear as you fill them. |
#### Using SF Claude Code
This node shells out to the **Claude Code CLI**, so it bills against your Claude
subscription rather than an Anthropic API key.
**Requirements:** [Claude Code](https://claude.com/claude-code) must be installed *and signed
in* on the machine running ComfyUI. The node searches your `PATH` plus the usual install
locations, and gives a clear error if it can't find it.
| Input | Purpose |
|---|---|
| `prompt` | What you want Claude to do |
| `system_prompt` | Shapes the reply, e.g. *"Reply with only the prompt text, no preamble."* Leave blank to skip |
| `model` | `default`, `opus`, `sonnet`, or `haiku` |
| `timeout_seconds` | Give up after this long (default 300) |
| `seed` | Not sent to Claude — change it to force a fresh reply instead of ComfyUI's cached one |
| `image_1…` | Connect an image and the next slot appears, up to 16 |
Worth knowing before you build a big batch around it:
- **Roughly 5–6 seconds per call minimum.** Claude Code is an agent that reads each image as a
tool call, so it's slower than a direct vision API request.
- **Subscription rate limits apply.** Heavy batch use will hit them; `SF LLM Chat` with an API
key is the pay-per-token alternative that won't.
- **Not portable.** Anyone else installing this pack needs their own Claude Code install and
login for this particular node. Every other node in the pack is unaffected.
---
+241
View File
@@ -0,0 +1,241 @@
# ABOUTME: Runs a prompt through the locally installed Claude Code CLI, using its own login
# ABOUTME: Accepts text plus any number of images and returns Claude's reply as a string
import json
import os
import shutil
import subprocess
import tempfile
from .vertexai_utils import tensor_to_pil
MAX_IMAGES = 16
# Aliases rather than pinned model IDs, so this keeps working as models are updated.
MODEL_CHOICES = ["default", "opus", "sonnet", "haiku"]
BINARY_CANDIDATES = [
"~/.local/bin/claude",
"~/.claude/local/claude",
"/opt/homebrew/bin/claude",
"/usr/local/bin/claude",
]
def find_claude_binary():
"""Locates the Claude Code CLI, which may not be on ComfyUI's PATH."""
found = shutil.which("claude")
if found:
return found
for candidate in BINARY_CANDIDATES:
path = os.path.expanduser(candidate)
if os.path.isfile(path) and os.access(path, os.X_OK):
return path
return None
def save_images(image_inputs, directory):
"""Writes every frame of every connected IMAGE input to disk. Returns filenames."""
names = []
for slot, tensor in image_inputs:
if tensor is None:
continue
batch = tensor if tensor.dim() == 4 else tensor.unsqueeze(0)
for frame in range(batch.shape[0]):
pil = tensor_to_pil(batch[frame])
if pil is None:
continue
name = f"image_{slot}.png" if batch.shape[0] == 1 else f"image_{slot}_{frame + 1}.png"
pil.convert("RGB").save(os.path.join(directory, name))
names.append(name)
return names
def build_prompt(prompt, image_names):
"""Prefixes the request with instructions to read the images off disk."""
if not image_names:
return prompt
listing = ", ".join(image_names)
plural = "image" if len(image_names) == 1 else "images"
return (
f"The following {plural} are in your current working directory: {listing}\n"
f"Use the Read tool on each of them first, then respond to this request:\n\n"
f"{prompt}"
)
class SFClaudeCode:
"""
Sends a prompt (and any attached images) to Claude through the Claude Code
CLI installed on this machine, so it runs on your existing Claude login
rather than a separate API key.
Connect an image and another image slot appears automatically.
"""
@classmethod
def INPUT_TYPES(cls):
optional = {
f"image_{i}": ("IMAGE",) for i in range(1, MAX_IMAGES + 1)
}
return {
"required": {
"prompt": (
"STRING",
{
"multiline": True,
"default": "",
"tooltip": "What you want Claude to do.",
},
),
"system_prompt": (
"STRING",
{
"multiline": True,
"default": "",
"tooltip": (
"Extra instructions shaping how Claude responds, e.g. "
"'Reply with only the prompt text, no preamble.' Leave blank to skip."
),
},
),
"model": (
MODEL_CHOICES,
{
"default": "default",
"tooltip": "Which Claude model to use. 'default' uses whatever Claude Code is set to.",
},
),
"timeout_seconds": (
"INT",
{
"default": 300,
"min": 30,
"max": 3600,
"step": 30,
"tooltip": "Give up if Claude has not replied within this long.",
},
),
"seed": (
"INT",
{
"default": 0,
"min": 0,
"max": 0xFFFFFFFF,
"control_after_generate": True,
"tooltip": (
"Not sent to Claude. Change it to force a fresh reply instead "
"of reusing ComfyUI's cached result."
),
},
),
},
"optional": optional,
}
RETURN_TYPES = ("STRING",)
RETURN_NAMES = ("text",)
FUNCTION = "run"
CATEGORY = "Stillfront/LLM"
DESCRIPTION = """
Sends text and images to Claude via the **Claude Code CLI** on this machine,
using your existing Claude login — no API key needed.
Requires Claude Code to be installed and signed in on whichever computer runs
ComfyUI. Connect an image and a further image slot appears automatically.
Change **seed** to force a fresh reply; otherwise ComfyUI reuses the cached one.
"""
def run(self, prompt, system_prompt, model, timeout_seconds, seed, **kwargs):
if not prompt.strip():
raise ValueError("prompt is empty — give Claude something to do.")
binary = find_claude_binary()
if not binary:
raise RuntimeError(
"Claude Code CLI not found. Install it from "
"https://claude.com/claude-code and sign in, then restart ComfyUI."
)
image_inputs = [
(i, kwargs.get(f"image_{i}"))
for i in range(1, MAX_IMAGES + 1)
if kwargs.get(f"image_{i}") is not None
]
workdir = tempfile.mkdtemp(prefix="sf_claude_")
try:
image_names = save_images(image_inputs, workdir)
command = [
binary,
"-p",
build_prompt(prompt, image_names),
"--output-format",
"json",
]
if system_prompt.strip():
command += ["--append-system-prompt", system_prompt.strip()]
if model != "default":
command += ["--model", model]
if image_names:
command += ["--allowedTools", "Read"]
print(
f"[SF Claude Code] Running {model} with {len(image_names)} image(s), "
f"timeout {timeout_seconds}s."
)
try:
completed = subprocess.run(
command,
cwd=workdir,
capture_output=True,
text=True,
timeout=timeout_seconds,
# Without this the CLI spends 3s waiting on stdin it will never get.
stdin=subprocess.DEVNULL,
)
except subprocess.TimeoutExpired:
raise RuntimeError(
f"Claude did not reply within {timeout_seconds}s. Raise timeout_seconds "
f"or simplify the request."
)
if completed.returncode != 0:
detail = (completed.stderr or completed.stdout or "").strip()[:500]
raise RuntimeError(
f"Claude Code exited with code {completed.returncode}. {detail}"
)
try:
payload = json.loads(completed.stdout)
except json.JSONDecodeError:
raise RuntimeError(
f"Could not parse Claude's response: {completed.stdout.strip()[:500]}"
)
if payload.get("is_error"):
raise RuntimeError(
f"Claude reported an error: {payload.get('result', 'unknown')}"
)
text = payload.get("result", "")
if not text:
raise RuntimeError("Claude returned an empty response.")
print(
f"[SF Claude Code] Got {len(text)} characters in "
f"{payload.get('duration_ms', '?')}ms."
)
return (text,)
finally:
shutil.rmtree(workdir, ignore_errors=True)
NODE_CLASS_MAPPINGS = {"SFClaudeCode": SFClaudeCode}
NODE_DISPLAY_NAME_MAPPINGS = {"SFClaudeCode": "SF Claude Code"}
+1 -1
View File
@@ -1,7 +1,7 @@
[project]
name = "comfyui-sf-nodes"
description = "Custom nodes for ComfyUI including LLM Chat with support for Claude, Gemini, and OpenAI models, WaveSpeed AI image/video generation, VertexAI integration, and workflow utilities"
version = "1.0.10"
version = "1.1.0"
license = { file = "LICENSE" }
dependencies = [
"requests>=2.31.0",
+85
View File
@@ -0,0 +1,85 @@
/**
* SF Claude Code — grows the image input list on demand.
*
* The Python side declares image_1..image_16 as optional inputs. This keeps
* only as many slots visible as are in use, plus one spare to connect into.
*/
import { app } from "../../../scripts/app.js";
const NODE_NAME = "SFClaudeCode";
const MAX_IMAGES = 16;
function imageSlotIndex(node, n) {
return (node.inputs ?? []).findIndex((input) => input.name === `image_${n}`);
}
function syncImageSlots(node) {
if (!node.inputs) {
return;
}
// Highest numbered image slot that currently has something plugged in.
let lastUsed = 0;
for (const input of node.inputs) {
const match = input.name?.match(/^image_(\d+)$/);
if (match && input.link != null) {
lastUsed = Math.max(lastUsed, parseInt(match[1], 10));
}
}
// Show every used slot, plus one empty spare to drag into.
const wanted = Math.min(lastUsed + 1, MAX_IMAGES);
for (let n = 1; n <= wanted; n++) {
if (imageSlotIndex(node, n) === -1) {
node.addInput(`image_${n}`, "IMAGE");
}
}
// Drop trailing spares, but never one that is still connected.
for (let n = MAX_IMAGES; n > wanted; n--) {
const index = imageSlotIndex(node, n);
if (index !== -1 && node.inputs[index].link == null) {
node.removeInput(index);
}
}
node.setSize(node.computeSize());
node.setDirtyCanvas(true, true);
}
app.registerExtension({
name: "Stillfront.ClaudeCode",
async beforeRegisterNodeDef(nodeType, nodeData) {
if (nodeData.name !== NODE_NAME) {
return;
}
const onNodeCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function () {
const result = onNodeCreated ? onNodeCreated.apply(this, arguments) : undefined;
syncImageSlots(this);
return result;
};
// Re-sync after a saved workflow restores its connections.
const onConfigure = nodeType.prototype.onConfigure;
nodeType.prototype.onConfigure = function () {
const result = onConfigure ? onConfigure.apply(this, arguments) : undefined;
requestAnimationFrame(() => syncImageSlots(this));
return result;
};
const onConnectionsChange = nodeType.prototype.onConnectionsChange;
nodeType.prototype.onConnectionsChange = function (type, index, connected, linkInfo, ioSlot) {
const result = onConnectionsChange
? onConnectionsChange.apply(this, arguments)
: undefined;
// Let litegraph finish updating link state before recounting.
requestAnimationFrame(() => syncImageSlots(this));
return result;
};
},
});