Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
94 changes: 94 additions & 0 deletions clis/comfyui/README.md
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
19 changes: 19 additions & 0 deletions clis/comfyui/config.js
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}`;
}
51 changes: 51 additions & 0 deletions clis/comfyui/history.js
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(', ') : '-',
};
});
},
});
40 changes: 40 additions & 0 deletions clis/comfyui/models.js
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;
},
});
54 changes: 54 additions & 0 deletions clis/comfyui/node-info.js
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 }));
},
});
26 changes: 26 additions & 0 deletions clis/comfyui/nodes.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { cli, Strategy } from '../../registry.js';
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Import registry via package export path

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 with ERR_MODULE_NOT_FOUND when discovery imports the file and the comfyui commands are never registered.

Useful? React with 👍 / 👎.

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 };
});
},
});
41 changes: 41 additions & 0 deletions clis/comfyui/queue.js
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 }));
},
});
48 changes: 48 additions & 0 deletions clis/comfyui/run.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { cli, Strategy } from '../../registry.js';
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Keep built-in adapters as TS sources

This new built-in command is added as a .js file under clis/, but the build pipeline compiles clis/**/*.ts and publishes from dist/clis; without a copy step for source .js adapters, the generated manifest can reference dist/clis/comfyui/*.js files that are not emitted, causing opencli comfyui ... commands to fail in installed builds.

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',
}];
},
});
Loading