-
Notifications
You must be signed in to change notification settings - Fork 0
feat: add ComfyUI plugin #1
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,94 @@ | ||
| # opencli-comfyui | ||
|
|
||
| [ComfyUI](https://github.com/comfyanonymous/ComfyUI) plugin for [OpenCLI](https://github.com/jackwener/opencli). Manage your ComfyUI server from the command line — list nodes, explore models, run workflows, and monitor queue status. | ||
|
|
||
| ## Features | ||
|
|
||
| | Command | Description | | ||
| |---------|-------------| | ||
| | `comfyui nodes` | List all registered node types (700+) | | ||
| | `comfyui node-info <name>` | Detailed definition of a node (inputs, types, defaults, constraints) | | ||
| | `comfyui search-node <query>` | Search nodes by name or field | | ||
| | `comfyui models [--model_type <type>]` | List available model files | | ||
| | `comfyui system-stats` | Server status and system information | | ||
| | `comfyui run <json\|file>` | Execute a workflow | | ||
| | `comfyui queue` | Queue status (running / pending) | | ||
| | `comfyui history [--limit N]` | Execution history | | ||
|
|
||
| ## Installation | ||
|
|
||
| ```bash | ||
| # Install the plugin | ||
| cd opencli-comfyui | ||
| npm link | ||
|
|
||
| # Or symlink manually | ||
| opencli plugin install <path-to-this-directory> | ||
| ``` | ||
|
|
||
| ## Configuration | ||
|
|
||
| Set your ComfyUI server address: | ||
|
|
||
| ```bash | ||
| export COMFYUI_HOST=http://127.0.0.1:8188 | ||
| ``` | ||
|
|
||
| | Variable | Default | Description | | ||
| |----------|---------|-------------| | ||
| | `COMFYUI_HOST` | `http://127.0.0.1:8188` | ComfyUI server URL | | ||
|
|
||
| ## Usage Examples | ||
|
|
||
| ```bash | ||
| # Check server status | ||
| opencli comfyui system-stats | ||
|
|
||
| # List all nodes | ||
| opencli comfyui nodes --limit 20 | ||
|
|
||
| # Search for a node | ||
| opencli comfyui search-node sampler | ||
|
|
||
| # View node details | ||
| opencli comfyui node-info KSampler | ||
|
|
||
| # List models | ||
| opencli comfyui models | ||
| opencli comfyui models --model_type loras | ||
|
|
||
| # Run a workflow from JSON | ||
| opencli comfyui run '{"4":{"class_type":"EmptyLatentImage","inputs":{"width":512,"height":512,"batch_size":1}}}' | ||
|
|
||
| # Run a workflow from file | ||
| opencli comfyui run my-workflow.json | ||
|
|
||
| # Check queue | ||
| opencli comfyui queue | ||
|
|
||
| # View history | ||
| opencli comfyui history --limit 5 | ||
| opencli comfyui history --prompt_id <id> | ||
| ``` | ||
|
|
||
| ## ComfyUI API Endpoints Used | ||
|
|
||
| | Endpoint | Method | Purpose | | ||
| |----------|--------|---------| | ||
| | `/api/object_info` | GET | List all node definitions | | ||
| | `/api/models` | GET | List model directories | | ||
| | `/api/models/<type>` | GET | List models of a type | | ||
| | `/api/system_stats` | GET | Server information | | ||
| | `/prompt` | POST | Queue a workflow for execution | | ||
| | `/api/queue` | GET | Queue status | | ||
| | `/api/history` | GET | Execution history | | ||
|
|
||
| ## Requirements | ||
|
|
||
| - [OpenCLI](https://github.com/jackwener/opencli) >= 1.6.0 | ||
| - A running [ComfyUI](https://github.com/comfyanonymous/ComfyUI) server | ||
| - Node.js 18+ | ||
|
|
||
| ## License | ||
|
|
||
| MIT |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,19 @@ | ||
| /** | ||
| * Shared config for the ComfyUI plugin. | ||
| * | ||
| * Configuration via environment variables: | ||
| * COMFYUI_HOST — ComfyUI server base URL (default: http://127.0.0.1:8188) | ||
| * | ||
| * Example: | ||
| * export COMFYUI_HOST=http://192.168.1.100:8008 | ||
| * opencli comfyui system-stats | ||
| */ | ||
|
|
||
| export const COMFYUI_HOST = typeof process !== 'undefined' | ||
| ? (process.env.COMFYUI_HOST || 'http://127.0.0.1:8188') | ||
| : 'http://127.0.0.1:8188'; | ||
|
|
||
| export function url(path) { | ||
| const host = COMFYUI_HOST.replace(/\/+$/, ''); | ||
| return `${host}${path.startsWith('/') ? path : '/' + path}`; | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,51 @@ | ||
| import { cli, Strategy } from '../../registry.js'; | ||
| import { url } from './config.js'; | ||
| import { CommandExecutionError } from '@jackwener/opencli/errors'; | ||
|
|
||
| cli({ | ||
| site: 'comfyui', | ||
| name: 'history', | ||
| description: 'View ComfyUI workflow execution history', | ||
| strategy: Strategy.PUBLIC, | ||
| args: [ | ||
| { name: 'limit', type: 'int', default: 10 }, | ||
| { name: 'prompt_id', type: 'str', default: '' }, | ||
| ], | ||
| columns: ['prompt_id', 'status', 'nodes', 'output_nodes'], | ||
| func: async (page, kwargs) => { | ||
| const res = await fetch(url('/api/history')); | ||
| const data = await res.json(); | ||
|
|
||
| if (kwargs.prompt_id) { | ||
| const entry = data[kwargs.prompt_id]; | ||
| if (!entry) { | ||
| throw new CommandExecutionError(`History entry not found for prompt_id: ${kwargs.prompt_id}`); | ||
| } | ||
| const prompt = entry.prompt || []; | ||
| const promptData = prompt[2] || {}; | ||
| return [{ | ||
| prompt_id: kwargs.prompt_id, | ||
| status: entry.status?.status_str || 'unknown', | ||
| nodes: Object.keys(promptData).length, | ||
| output_nodes: entry.outputs ? Object.keys(entry.outputs).join(', ') : '-', | ||
| }]; | ||
| } | ||
|
|
||
| const entries = Object.entries(data) | ||
| .sort((a, b) => b[0].localeCompare(a[0])) | ||
| .slice(0, kwargs.limit); | ||
|
|
||
| return entries.map(([id, entry], index) => { | ||
| const prompt = entry.prompt || []; | ||
| const promptData = prompt[2] || {}; | ||
| const outputs = entry.outputs || {}; | ||
| return { | ||
| rank: index + 1, | ||
| prompt_id: id, | ||
| status: entry.status?.status_str || 'unknown', | ||
| nodes: Object.keys(promptData).length, | ||
| output_nodes: Object.keys(outputs).length > 0 ? Object.keys(outputs).join(', ') : '-', | ||
| }; | ||
| }); | ||
| }, | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,40 @@ | ||
| import { cli, Strategy } from '../../registry.js'; | ||
| import { url } from './config.js'; | ||
|
|
||
| cli({ | ||
| site: 'comfyui', | ||
| name: 'models', | ||
| description: 'List available model files in ComfyUI', | ||
| strategy: Strategy.PUBLIC, | ||
| args: [ | ||
| { name: 'model_type', type: 'str', default: 'all' }, | ||
| ], | ||
| columns: ['rank', 'type', 'name', 'count'], | ||
| func: async (page, kwargs) => { | ||
| const typesRes = await fetch(url('/api/models')); | ||
| const types = await typesRes.json(); | ||
|
|
||
| if (kwargs.model_type !== 'all') { | ||
| const modelRes = await fetch(url(`/api/models/${kwargs.model_type}`)); | ||
| const models = await modelRes.json(); | ||
| return typeof models === 'object' && models.length !== undefined | ||
| ? models.map((m, idx) => ({ rank: idx + 1, type: kwargs.model_type, name: typeof m === 'string' ? m : m.name })) | ||
| : [{ rank: 1, type: kwargs.model_type, name: typeof models === 'string' ? models : JSON.stringify(models) }]; | ||
| } | ||
|
|
||
| const results = []; | ||
| let idx = 0; | ||
| for (const t of types) { | ||
| const modelRes = await fetch(url(`/api/models/${t}`)); | ||
| const models = await modelRes.json(); | ||
| idx++; | ||
| results.push({ | ||
| rank: idx, | ||
| type: t, | ||
| count: Array.isArray(models) ? models.length : 0, | ||
| name: Array.isArray(models) ? models.slice(0, 3).join(', ') : JSON.stringify(models), | ||
| }); | ||
| } | ||
| return results; | ||
| }, | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,54 @@ | ||
| import { cli, Strategy } from '../../registry.js'; | ||
| import { url } from './config.js'; | ||
| import { CommandExecutionError } from '@jackwener/opencli/errors'; | ||
|
|
||
| cli({ | ||
| site: 'comfyui', | ||
| name: 'node-info', | ||
| description: 'Show detailed definition of a ComfyUI node (inputs, types, defaults, constraints)', | ||
| strategy: Strategy.PUBLIC, | ||
| args: [ | ||
| { name: 'node', type: 'str', required: true, positional: true }, | ||
| ], | ||
| columns: ['field', 'mode', 'type', 'default', 'range'], | ||
| func: async (page, kwargs) => { | ||
| const res = await fetch(url('/api/object_info')); | ||
| const data = await res.json(); | ||
| const info = data[kwargs.node]; | ||
|
|
||
| if (!info) { | ||
| const keys = Object.keys(data); | ||
| const similar = keys.filter(n => n.toLowerCase().includes(kwargs.node.toLowerCase())); | ||
| const hint = similar.length ? `Similar nodes: ${similar.slice(0, 5).join(', ')}` : 'No similar nodes found'; | ||
| throw new CommandExecutionError(`Node not found: "${kwargs.node}" — ${hint}`); | ||
| } | ||
|
|
||
| const result = []; | ||
| const inputs = info.input || {}; | ||
| const required = inputs.required || {}; | ||
| const optional = inputs.optional || {}; | ||
|
|
||
| for (const [field, config] of Object.entries(required)) { | ||
| const [typeDef, opts = {}] = config; | ||
| result.push({ | ||
| field, | ||
| mode: 'required', | ||
| type: Array.isArray(typeDef) ? `choice[${typeDef.join(', ')}]` : String(typeDef), | ||
| default: opts.default != null ? String(opts.default) : '-', | ||
| range: (opts.min != null && opts.max != null) ? `${opts.min}~${opts.max}` : '', | ||
| }); | ||
| } | ||
| for (const [field, config] of Object.entries(optional)) { | ||
| const [typeDef, opts = {}] = config; | ||
| result.push({ | ||
| field, | ||
| mode: 'optional', | ||
| type: Array.isArray(typeDef) ? `choice[${typeDef.join(', ')}]` : String(typeDef), | ||
| default: opts.default != null ? String(opts.default) : '-', | ||
| range: (opts.min != null && opts.max != null) ? `${opts.min}~${opts.max}` : '', | ||
| }); | ||
| } | ||
|
|
||
| return result.map((item, index) => ({ rank: index + 1, ...item })); | ||
| }, | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,26 @@ | ||
| import { cli, Strategy } from '../../registry.js'; | ||
| import { url } from './config.js'; | ||
|
|
||
| cli({ | ||
| site: 'comfyui', | ||
| name: 'nodes', | ||
| description: 'List all registered node types in ComfyUI', | ||
| strategy: Strategy.PUBLIC, | ||
| args: [ | ||
| { name: 'limit', type: 'int', default: 9999 }, | ||
| { name: 'detail', type: 'str', default: 'none' }, | ||
| ], | ||
| columns: ['rank', 'name', 'inputs'], | ||
| func: async (page, kwargs) => { | ||
| const res = await fetch(url('/api/object_info')); | ||
| const data = await res.json(); | ||
| const names = Object.keys(data).sort((a, b) => a.localeCompare(b)); | ||
|
|
||
| return names.slice(0, kwargs.limit).map((name, index) => { | ||
| const info = data[name]; | ||
| const inputs = info.input || {}; | ||
| const totalInputs = Object.keys(inputs.required || {}).length + Object.keys(inputs.optional || {}).length; | ||
| return { rank: index + 1, name, inputs: totalInputs }; | ||
| }); | ||
| }, | ||
| }); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,41 @@ | ||
| import { cli, Strategy } from '../../registry.js'; | ||
| import { url } from './config.js'; | ||
|
|
||
| cli({ | ||
| site: 'comfyui', | ||
| name: 'queue', | ||
| description: 'Show currently running and pending ComfyUI queue tasks', | ||
| strategy: Strategy.PUBLIC, | ||
| columns: ['status', 'queue_number', 'prompt_id', 'nodes'], | ||
| func: async () => { | ||
| const res = await fetch(url('/api/queue')); | ||
| const data = await res.json(); | ||
| const results = []; | ||
|
|
||
| const running = data.queue_running || []; | ||
| for (const q of running) { | ||
| results.push({ | ||
| status: 'running', | ||
| queue_number: q[0], | ||
| prompt_id: q[1], | ||
| nodes: typeof q[2] === 'object' ? Object.keys(q[2]).length : '-', | ||
| }); | ||
| } | ||
|
|
||
| const pending = data.queue_pending || []; | ||
| for (const q of pending) { | ||
| results.push({ | ||
| status: 'pending', | ||
| queue_number: q[0], | ||
| prompt_id: q[1], | ||
| nodes: typeof q[2] === 'object' ? Object.keys(q[2]).length : '-', | ||
| }); | ||
| } | ||
|
|
||
| if (results.length === 0) { | ||
| return [{ status: 'empty', queue_number: 0, prompt_id: '-', nodes: 'Queue is empty' }]; | ||
| } | ||
|
|
||
| return results.map((item, index) => ({ rank: index + 1, ...item })); | ||
| }, | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,48 @@ | ||
| import { cli, Strategy } from '../../registry.js'; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
This new built-in command is added as a Useful? React with 👍 / 👎. |
||
| import { url } from './config.js'; | ||
| import { CommandExecutionError } from '@jackwener/opencli/errors'; | ||
|
|
||
| cli({ | ||
| site: 'comfyui', | ||
| name: 'run', | ||
| description: 'Execute a ComfyUI workflow (pass a JSON workflow prompt string or a path to a JSON file)', | ||
| strategy: Strategy.PUBLIC, | ||
| args: [ | ||
| { name: 'prompt', type: 'str', required: true, positional: true, description: 'Workflow prompt as JSON string or path to a JSON file' }, | ||
| { name: 'client_id', type: 'str', default: 'opencli', description: 'Client ID for tracking the task' }, | ||
| ], | ||
| columns: ['status', 'prompt_id', 'queue_number'], | ||
| func: async (page, kwargs) => { | ||
| let promptObj; | ||
| try { | ||
| promptObj = JSON.parse(kwargs.prompt); | ||
| } catch (e) { | ||
| try { | ||
| const { readFileSync } = await import('fs'); | ||
| promptObj = JSON.parse(readFileSync(kwargs.prompt, 'utf-8')); | ||
| } catch (fileErr) { | ||
| throw new CommandExecutionError(`Failed to parse prompt: ${e.message}, and could not read file: ${kwargs.prompt}`); | ||
| } | ||
| } | ||
|
|
||
| const res = await fetch(url('/prompt'), { | ||
| method: 'POST', | ||
| headers: { 'Content-Type': 'application/json' }, | ||
| body: JSON.stringify({ prompt: promptObj, client_id: kwargs.client_id }), | ||
| }); | ||
| const data = await res.json(); | ||
|
|
||
| if (data.error) { | ||
| throw new CommandExecutionError(`${data.error.type}: ${data.error.message}`); | ||
| } | ||
|
|
||
| return [{ | ||
| status: 'submitted', | ||
| prompt_id: data.prompt_id || 'unknown', | ||
| queue_number: data.number ?? '-', | ||
| node_errors: Object.keys(data.node_errors || {}).length > 0 | ||
| ? Object.keys(data.node_errors).join(', ') | ||
| : 'none', | ||
| }]; | ||
| }, | ||
| }); | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This adapter imports
../../registry.js, but that path does not exist in either the repo root or built package layout (the registry is exported as@jackwener/opencli/registry), so module loading fails withERR_MODULE_NOT_FOUNDwhen discovery imports the file and thecomfyuicommands are never registered.Useful? React with 👍 / 👎.