Initial release: generic CLI agent nodes for Nextcloud Node-RED

This commit is contained in:
n3wk
2026-08-24 19:06:08 +02:00
commit b9249dd9ca
16 changed files with 1437 additions and 0 deletions
+30
View File
@@ -0,0 +1,30 @@
# Built artifacts
node_modules/
npm-debug.log*
# IDE
.vscode/
.idea/
*.swp
# OS
.DS_Store
Thumbs.db
# Git
.git/
.gitignore
# Test
coverage/
.nyc_output/
# Logs
*.log
# Local config
.env
.env.local
# Docker
docker-compose.override.yml
+18
View File
@@ -0,0 +1,18 @@
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [1.0.0] - 2024-08-24
### Added
- Initial release
- `agent-config` - Configuration node for CLI agent
- `agent-exec` - Execute agent commands (run, version, help, etc.)
- `agent-file` - Read/write/list files in agent workspace
- `agent-process` - Manage persistent agent processes
- Integration script for existing `nextcloud-node-red` Docker setup
- Bootstrap installer for VM-agnostic deployment
- Example flows demonstrating Nextcloud + agent integration
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2024 hannesahlin
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+148
View File
@@ -0,0 +1,148 @@
# node-red-contrib-nextcloud-agent
Node-RED nodes for **generic CLI agent** integration with Nextcloud.
Works alongside your existing [`nextcloud-nodered-ocs-api`](https://kode.hannesahlin.se/n3wk/nextcloud-nodered-ocs-api) and [`nextcloud-collabora-nodered`](https://kode.hannesahlin.se/n3wk/nextcloud-collabora-nodered) setup.
## Nodes
| Node | Category | Description |
|------|----------|-------------|
| `agent-config` | config | Shared configuration (CLI path, workspace, API endpoint) |
| `agent-exec` | agent | Execute agent CLI commands (`run`, `version`, `help`, etc.) |
| `agent-file` | agent | Read/write/list files in the agent workspace |
| `agent-process` | agent | Manage persistent agent processes |
## Quick Install
```bash
# Clone this repo
git clone https://kode.hannesahlin.se/n3wk/nextcloud-nodered-agent.git
cd nextcloud-nodered-agent
# Integrate into your existing nextcloud-node-red
NODERED_DIR=/path/to/nextcloud-node-red ./integrate-into-nodered.sh
# Rebuild and restart
cd /path/to/nextcloud-node-red
docker compose build --no-cache
docker compose up -d
```
## Bootstrap Install (VM-agnostic)
```bash
curl -fsSL https://kode.hannesahlin.se/n3wk/nextcloud-nodered-agent/raw/branch/main/bootstrap-install.sh | sudo -u ncdeploy bash
```
This auto-detects your `nextcloud-node-red` installation and patches it.
## Configuration
In Node-RED, add an **agent config** node:
- **CLI Command**: `agent` (install your agent in the Docker container)
- **Workspace Directory**: `/data/agent-workspace` (shared Docker volume)
- **API Endpoint**: `http://localhost:3000` (if agent runs in server mode)
- **Default Arguments**: `--verbose` (space-separated defaults)
- **Max Concurrent Processes**: `3`
**Important**: You must edit the Dockerfile after integration to install YOUR specific agent CLI.
## Example Flows
Import `flows/example-flows.json` for:
1. **Run Task** - Use `agent-exec` to execute a one-off task
2. **Persistent Process** - Start a process, send follow-up inputs, check status
3. **Nextcloud + Agent** - List Nextcloud files → process with agent → save back
## Docker Requirements
The integration script adds to your `nextcloud-node-red`:
- **You must add agent CLI installation** to Dockerfile (script adds placeholder)
- `agent-workspace` Docker volume mounted at `/data/agent-workspace`
- Nodes copied to `/opt/nextcloud-nodes/node-red-contrib-nextcloud-agent` and installed at container start
## Architecture
```
┌─────────────────────────────────────────────────────────────────┐
│ Nextcloud (https://cloud.example.com) │
│ ┌───────────────────────────────────────────────────────────┐ │
│ │ nodered_embed app → iframe → Node-RED :1880 │ │
│ │ ┌─────────────────────────────────────────────────────┐ │ │
│ │ │ agent nodes │ │ │
│ │ │ ┌──────────┐ ┌──────────┐ ┌──────────────┐ │ │ │
│ │ │ │ exec │ │ file │ │ process │ │ │ │
│ │ │ └────┬─────┘ └────┬─────┘ └──────┬───────┘ │ │ │
│ │ │ │ │ │ │ │ │
│ │ │ ▼ ▼ ▼ │ │ │
│ │ │ ┌─────────────────────────────────────────┐ │ │ │
│ │ │ │ Your CLI Agent (in container) │ │ │ │
│ │ │ │ Workspace: /data/agent-workspace │ │ │ │
│ │ │ └─────────────────────────────────────────┘ │ │ │
│ │ └─────────────────────────────────────────────────────┘ │ │
│ └───────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
```
## Usage Examples
### Execute Task with agent-exec
```javascript
// Inject node
msg.prompt = "Process all files in /data/input and generate summary";
return msg;
```
Connect to `agent-exec` with command `run`.
### Persistent Process
```javascript
// Start process
msg.action = "start";
msg.processId = "my-processor";
msg.payload = "Watch /data/input for new files and process them";
return msg;
// Send follow-up
msg.action = "send";
msg.processId = "my-processor";
msg.payload = "Also generate CSV export";
return msg;
```
### Read/Write Files in Workspace
```javascript
// Write agent output to file
msg.filePath = "results/output.txt";
msg.payload = msg.payload; // from agent-exec
msg.operation = "write";
return msg;
```
## Integration with Nextcloud Nodes
Combine with your existing `file-operations`, `ocs-api`, `collectives` nodes:
1. **Read from Nextcloud** → `file-operations` (list/download)
2. **Process with agent** → `agent-exec` (transform, analyze, generate)
3. **Write to workspace** → `agent-file` (save intermediate)
4. **Upload to Nextcloud** → `file-operations` (upload)
## Troubleshooting
| Issue | Solution |
|-------|----------|
| `agent: command not found` | Edit Dockerfile to install your agent CLI, then rebuild |
| Permission denied on workspace | Ensure `agent-workspace` volume has correct permissions |
| Process won't start | Check `docker logs nextcloud-node-red` for agent errors |
| Nodes not appearing | Restart Node-RED container; check `/data/node_modules/node-red-contrib-nextcloud-agent` |
## License
MIT — see [LICENSE](LICENSE)
+52
View File
@@ -0,0 +1,52 @@
#!/usr/bin/env bash
set -euo pipefail
# Bootstrap installer for node-red-contrib-nextcloud-agent
# Clones repo and integrates into existing nextcloud-node-red
REPO_URL="https://kode.hannesahlin.se/n3wk/nextcloud-nodered-agent.git"
INSTALL_DIR="${INSTALL_DIR:-/opt/node-red-contrib-nextcloud-agent}"
NODERED_DIR="${NODERED_DIR:-}"
echo "=== Nextcloud Agent Node-RED Bootstrap Installer ==="
# Auto-detect nextcloud-node-red directory if not set
if [[ -z "$NODERED_DIR" ]]; then
for dir in \
"/opt/nextcloud-node-red" \
"/home/ncdeploy/nextcloud-node-red" \
"/root/nextcloud-node-red" \
"$(docker inspect -f '{{.Config.Labels}}' nextcloud-node-red 2>/dev/null | grep -o 'com.docker.compose.project.working_dir=[^ ]*' | cut -d= -f2)"; do
if [[ -d "$dir" && -f "$dir/Dockerfile" ]]; then
NODERED_DIR="$dir"
break
fi
done
fi
if [[ -z "$NODERED_DIR" ]]; then
echo "ERROR: Could not auto-detect nextcloud-node-red directory."
echo "Set NODERED_DIR environment variable."
exit 1
fi
echo "Target Node-RED: $NODERED_DIR"
# Clone or update repo
if [[ -d "$INSTALL_DIR/.git" ]]; then
echo "Updating existing repo..."
cd "$INSTALL_DIR"
git pull
else
echo "Cloning repo..."
git clone "$REPO_URL" "$INSTALL_DIR"
cd "$INSTALL_DIR"
fi
# Run integration
export NODERED_DIR
"$INSTALL_DIR/integrate-into-nodered.sh"
echo ""
echo "=== Bootstrap Complete ==="
echo "Rebuild with: cd $NODERED_DIR && docker compose build --no-cache && docker compose up -d"
+266
View File
@@ -0,0 +1,266 @@
[
{
"id": "agent-config",
"type": "agent-config",
"name": "Agent Config",
"cliPath": "agent",
"workspaceDir": "/data/agent-workspace",
"apiEndpoint": "http://localhost:3000",
"defaultArgs": "",
"maxConcurrent": 3,
"autoStart": true
},
{
"id": "run-task",
"type": "inject",
"name": "Run Task",
"props": [
{"p": "payload"},
{"p": "prompt", "v": "Process all files in /data/input and generate summary report", "vt": "str"}
],
"repeat": "",
"crontab": "",
"once": false,
"onceDelay": 0.1,
"topic": "",
"x": 200,
"y": 200,
"wires": [["agent-exec-run"]]
},
{
"id": "agent-exec-run",
"type": "agent-exec",
"name": "Agent Exec",
"agentConfig": "agent-config",
"command": "run",
"args": "",
"workingDir": "/data/agent-workspace",
"timeout": "120000",
"outputFormat": "text",
"x": 450,
"y": 200,
"wires": [["write-result", "debug-output"]]
},
{
"id": "write-result",
"type": "agent-file",
"name": "Write Result",
"agentConfig": "agent-config",
"operation": "write",
"filePath": "results/summary.txt",
"encoding": "utf8",
"createDirs": true,
"x": 700,
"y": 200,
"wires": [["list-results"]]
},
{
"id": "list-results",
"type": "agent-file",
"name": "List Results",
"agentConfig": "agent-config",
"operation": "list",
"filePath": "results",
"encoding": "utf8",
"x": 950,
"y": 200,
"wires": [["debug-list"]]
},
{
"id": "debug-output",
"type": "debug",
"name": "Debug Output",
"active": true,
"tosidebar": true,
"console": false,
"tostatus": false,
"complete": "payload",
"targetType": "msg",
"x": 700,
"y": 140,
"wires": []
},
{
"id": "debug-list",
"type": "debug",
"name": "Debug File List",
"active": true,
"tosidebar": true,
"console": false,
"tostatus": false,
"complete": "payload",
"targetType": "msg",
"x": 1200,
"y": 200,
"wires": []
},
{
"id": "start-process",
"type": "inject",
"name": "Start Process",
"props": [
{"p": "action", "v": "start", "vt": "str"},
{"p": "processId", "v": "data-processor", "vt": "str"},
{"p": "payload", "v": "Watch /data/input for new files and process them", "vt": "str"}
],
"repeat": "",
"crontab": "",
"once": false,
"onceDelay": 0.1,
"topic": "",
"x": 200,
"y": 400,
"wires": [["agent-process"]]
},
{
"id": "send-input",
"type": "inject",
"name": "Send Input",
"props": [
{"p": "action", "v": "send", "vt": "str"},
{"p": "processId", "v": "data-processor", "vt": "str"},
{"p": "payload", "v": "Also generate CSV export", "vt": "str"}
],
"repeat": "",
"crontab": "",
"once": false,
"onceDelay": 0.1,
"topic": "",
"x": 200,
"y": 460,
"wires": [["agent-process"]]
},
{
"id": "check-status",
"type": "inject",
"name": "Check Status",
"props": [
{"p": "action", "v": "status", "vt": "str"},
{"p": "processId", "v": "data-processor", "vt": "str"}
],
"repeat": "",
"crontab": "",
"once": false,
"onceDelay": 0.1,
"topic": "",
"x": 200,
"y": 520,
"wires": [["agent-process"]]
},
{
"id": "agent-process",
"type": "agent-process",
"name": "Data Processor",
"agentConfig": "agent-config",
"processId": "data-processor",
"action": "start",
"workingDir": "/data/agent-workspace",
"autoRestart": true,
"x": 450,
"y": 460,
"wires": [["debug-process"]]
},
{
"id": "debug-process",
"type": "debug",
"name": "Debug Process",
"active": true,
"tosidebar": true,
"console": false,
"tostatus": false,
"complete": "payload",
"targetType": "msg",
"x": 700,
"y": 460,
"wires": []
},
{
"id": "nextcloud-flow",
"type": "inject",
"name": "Nextcloud + Agent Flow",
"props": [
{"p": "topic", "v": "nextcloud-agent", "vt": "str"}
],
"repeat": "",
"crontab": "",
"once": false,
"onceDelay": 0.1,
"topic": "",
"x": 200,
"y": 650,
"wires": [["nc-list-files"]]
},
{
"id": "nc-list-files",
"type": "file-operations",
"name": "List Nextcloud Files",
"nextcloud": "nextcloud-config",
"operation": "list",
"path": "/Documents",
"x": 450,
"y": 650,
"wires": [["filter-files"]]
},
{
"id": "filter-files",
"type": "function",
"name": "Filter Files",
"func": "const files = msg.payload.ocs.data.filter(f => f.name.endsWith('.txt') || f.name.endsWith('.md'));\nmsg.payload = files.map(f => f.name);\nreturn msg;",
"outputs": 1,
"x": 700,
"y": 650,
"wires": [["agent-process-files"]]
},
{
"id": "agent-process-files",
"type": "agent-exec",
"name": "Process with Agent",
"agentConfig": "agent-config",
"command": "run",
"args": "",
"workingDir": "/data/agent-workspace",
"timeout": "180000",
"outputFormat": "text",
"x": 950,
"y": 650,
"wires": [["save-processed"]]
},
{
"id": "save-processed",
"type": "agent-file",
"name": "Save Processed Output",
"agentConfig": "agent-config",
"operation": "write",
"filePath": "processed/output.txt",
"encoding": "utf8",
"createDirs": true,
"x": 1200,
"y": 650,
"wires": [["nc-upload-processed"]]
},
{
"id": "nc-upload-processed",
"type": "file-operations",
"name": "Upload to Nextcloud",
"nextcloud": "nextcloud-config",
"operation": "upload",
"path": "/Documents/processed/output.txt",
"x": 1450,
"y": 650,
"wires": [["debug-upload"]]
},
{
"id": "debug-upload",
"type": "debug",
"name": "Debug Upload",
"active": true,
"tosidebar": true,
"console": false,
"tostatus": false,
"complete": "payload",
"targetType": "msg",
"x": 1700,
"y": 650,
"wires": []
}
]
+119
View File
@@ -0,0 +1,119 @@
#!/usr/bin/env bash
set -euo pipefail
# Integration script for node-red-contrib-nextcloud-agent
# Patches agent nodes into existing nextcloud-node-red Docker image
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
NODERED_DIR="${NODERED_DIR:-/path/to/nextcloud-node-red}"
echo "=== Nextcloud Agent Node-RED Integration ==="
echo "Source: $SCRIPT_DIR"
echo "Target: $NODERED_DIR"
if [[ ! -d "$NODERED_DIR" ]]; then
echo "ERROR: NODERED_DIR not found: $NODERED_DIR"
echo "Set NODERED_DIR environment variable or edit this script"
exit 1
fi
if [[ ! -f "$NODERED_DIR/Dockerfile" ]]; then
echo "ERROR: No Dockerfile found in $NODERED_DIR"
exit 1
fi
# 1. Copy nodes to nextcloud-node-red nodes directory
NODES_TARGET="$NODERED_DIR/nodes/nextcloud-ocs"
echo "Copying agent nodes to $NODES_TARGET..."
mkdir -p "$NODES_TARGET"
cp "$SCRIPT_DIR/nodes"/*.js "$NODES_TARGET/"
cp "$SCRIPT_DIR/nodes"/*.html "$NODES_TARGET/"
echo "Nodes copied."
# 2. Patch Dockerfile to install agent CLI
DOCKERFILE="$NODERED_DIR/Dockerfile"
echo "Patching Dockerfile..."
if grep -q "agent-cli" "$DOCKERFILE"; then
echo "Dockerfile already contains agent CLI installation"
else
cp "$DOCKERFILE" "$DOCKERFILE.bak.$(date +%s)"
sed -i '/COPY nodes\//i\
# Install agent CLI (adjust for your specific agent)\
RUN npm install -g your-agent-cli@latest || \\\
(curl -fsSL https://your-agent.example.com/install.sh | bash && \\\
ln -sf ~/.agent/bin/agent /usr/local/bin/agent)\
' "$DOCKERFILE"
echo "Dockerfile patched with agent CLI installation (EDIT THE INSTALL COMMAND)."
fi
# 3. Patch entrypoint.sh to include agent nodes
ENTRYPOINT="$NODERED_DIR/entrypoint.sh"
echo "Patching entrypoint.sh..."
if [[ -f "$ENTRYPOINT" ]]; then
cp "$ENTRYPOINT" "$ENTRYPOINT.bak.$(date +%s)"
if ! grep -q "node-red-contrib-nextcloud-agent" "$ENTRYPOINT"; then
sed -i '/copy nodes/a\
# Install agent nodes\
if [ -d "/opt/nextcloud-nodes/node-red-contrib-nextcloud-agent" ]; then \
cp -r /opt/nextcloud-nodes/node-red-contrib-nextcloud-agent/* /data/node_modules/ 2>/dev/null || true \
fi' "$ENTRYPOINT"
echo "entrypoint.sh patched."
else
echo "entrypoint.sh already contains agent nodes."
fi
else
echo "WARNING: entrypoint.sh not found at $ENTRYPOINT"
fi
# 4. Create agent nodes package in /opt for entrypoint to copy
OPT_NODES_DIR="$NODERED_DIR/opt/nextcloud-nodes"
echo "Creating agent nodes package in $OPT_NODES_DIR..."
mkdir -p "$OPT_NODES_DIR/node-red-contrib-nextcloud-agent/nodes"
cp "$SCRIPT_DIR/nodes"/*.js "$OPT_NODES_DIR/node-red-contrib-nextcloud-agent/nodes/"
cp "$SCRIPT_DIR/nodes"/*.html "$OPT_NODES_DIR/node-red-contrib-nextcloud-agent/nodes/"
cp "$SCRIPT_DIR/package.json" "$OPT_NODES_DIR/node-red-contrib-nextcloud-agent/package.json"
echo "Agent nodes package created."
# 5. Update docker-compose.yml to add agent workspace volume
COMPOSE="$NODERED_DIR/docker-compose.yml"
if [[ -f "$COMPOSE" ]]; then
echo "Checking docker-compose.yml for agent workspace volume..."
if ! grep -q "agent-workspace" "$COMPOSE"; then
cp "$COMPOSE" "$COMPOSE.bak.$(date +%s)"
sed -i '/volumes:/a\
- agent-workspace:/data/agent-workspace' "$COMPOSE"
sed -i '/^volumes:/a\
agent-workspace:' "$COMPOSE"
echo "docker-compose.yml updated with agent-workspace volume."
else
echo "docker-compose.yml already has agent-workspace volume."
fi
fi
echo ""
echo "=== Integration Complete ==="
echo ""
echo "IMPORTANT: Edit the Dockerfile to install YOUR specific agent CLI:"
echo " $NODERED_DIR/Dockerfile"
echo ""
echo "Next steps:"
echo "1. Rebuild the Docker image:"
echo " cd $NODERED_DIR && docker compose build --no-cache"
echo ""
echo "2. Restart the container:"
echo " docker compose up -d"
echo ""
echo "3. In Node-RED, configure an 'agent config' node with:"
echo " - CLI Command: agent (your installed agent binary)"
echo " - Workspace Directory: /data/agent-workspace"
echo " - API Endpoint: http://localhost:3000 (if using agent server mode)"
echo ""
echo "4. Import example flows from: $SCRIPT_DIR/flows/"
+56
View File
@@ -0,0 +1,56 @@
<script type="text/html" data-template-name="agent-config">
<div class="form-row">
<label for="node-input-cliPath"><i class="fa fa-terminal"></i> CLI Command</label>
<input type="text" id="node-input-cliPath" placeholder="agent (or full path like /usr/local/bin/agent)">
<span class="help-text">Command or full path to the CLI agent binary. Must be in PATH or absolute path.</span>
</div>
<div class="form-row">
<label for="node-input-workspaceDir"><i class="fa fa-folder"></i> Workspace Directory</label>
<input type="text" id="node-input-workspaceDir" placeholder="/data/agent-workspace">
<span class="help-text">Directory where the agent will operate. Should be accessible from Node-RED container.</span>
</div>
<div class="form-row">
<label for="node-input-apiEndpoint"><i class="fa fa-server"></i> API Endpoint</label>
<input type="text" id="node-input-apiEndpoint" placeholder="http://localhost:3000">
<span class="help-text">Agent REST API endpoint (if running in server mode).</span>
</div>
<div class="form-row">
<label for="node-input-apiKey"><i class="fa fa-key"></i> API Key</label>
<input type="password" id="node-input-apiKey" placeholder="Optional API key for agent server">
<span class="help-text">API key if agent server requires authentication.</span>
</div>
<div class="form-row">
<label for="node-input-defaultArgs"><i class="fa fa-list"></i> Default Arguments</label>
<input type="text" id="node-input-defaultArgs" placeholder="--verbose --model default">
<span class="help-text">Default arguments passed to the agent (space-separated).</span>
</div>
<div class="form-row">
<label for="node-input-maxConcurrent"><i class="fa fa-tasks"></i> Max Concurrent Processes</label>
<input type="number" id="node-input-maxConcurrent" min="1" max="10" value="3" style="width: 80px;">
<span class="help-text">Maximum number of concurrent agent processes.</span>
</div>
<div class="form-row">
<label for="node-input-autoStart"><i class="fa fa-play"></i> Auto-start Process</label>
<input type="checkbox" id="node-input-autoStart" checked>
<span class="help-text">Automatically start agent process when node receives input.</span>
</div>
</script>
<script type="text/html" data-help-name="agent-config">
<p>Configuration node for CLI agent integration.</p>
<p>This node stores connection settings for a CLI agent. Other agent nodes reference this config.</p>
<h3>Settings</h3>
<ul>
<li><b>CLI Command</b> - Command or full path to the agent binary. Default: <code>agent</code> (must be in PATH).</li>
<li><b>Workspace Directory</b> - Directory where agent will read/write files. Should be a shared volume accessible from both Node-RED and agent.</li>
<li><b>API Endpoint</b> - If running agent in server mode, the REST API endpoint.</li>
<li><b>API Key</b> - Optional authentication key for agent server mode.</li>
<li><b>Default Arguments</b> - Default arguments passed to the agent.</li>
<li><b>Max Concurrent Processes</b> - Limit concurrent agent processes.</li>
<li><b>Auto-start Process</b> - Start agent automatically on input.</li>
</ul>
<h3>Docker Setup</h3>
<p>When running in Docker, ensure the workspace directory is mounted as a volume in both Node-RED and agent containers:</p>
<pre><code>volumes:
- agent-workspace:/data/agent-workspace</code></pre>
</script>
+20
View File
@@ -0,0 +1,20 @@
'use strict';
module.exports = function (RED) {
function AgentConfigNode(n) {
RED.nodes.createNode(this, n);
this.cliPath = n.cliPath || 'agent';
this.workspaceDir = n.workspaceDir || '/data/agent-workspace';
this.apiEndpoint = (n.apiEndpoint || 'http://localhost:3000').replace(/\/$/, '');
this.apiKey = this.credentials.apiKey || '';
this.autoStart = n.autoStart !== false;
this.defaultArgs = n.defaultArgs || '';
this.maxConcurrent = parseInt(n.maxConcurrent) || 3;
}
RED.nodes.registerType('agent-config', AgentConfigNode, {
credentials: {
apiKey: { type: 'password' }
}
});
};
+70
View File
@@ -0,0 +1,70 @@
<script type="text/html" data-template-name="agent-exec">
<div class="form-row">
<label for="node-input-agentConfig"><i class="fa fa-cog"></i> Agent Config</label>
<input type="text" id="node-input-agentConfig" class="node-config-input" placeholder="Select agent config node">
<span class="help-text">Select the agent configuration node.</span>
</div>
<div class="form-row">
<label for="node-input-command"><i class="fa fa-terminal"></i> Command</label>
<select id="node-input-command">
<option value="run">run - Execute a task</option>
<option value="version">version - Show version</option>
<option value="config">config - Show configuration</option>
<option value="help">help - Show help</option>
</select>
<span class="help-text">Agent subcommand to execute.</span>
</div>
<div class="form-row">
<label for="node-input-args"><i class="fa fa-list"></i> Arguments</label>
<input type="text" id="node-input-args" placeholder="--verbose --output json">
<span class="help-text">Additional arguments (space-separated). Can be overridden by msg.args.</span>
</div>
<div class="form-row">
<label for="node-input-workingDir"><i class="fa fa-folder"></i> Working Directory</label>
<input type="text" id="node-input-workingDir" placeholder="/data/agent-workspace">
<span class="help-text">Working directory for agent. Overrides config. Can be overridden by msg.workingDir.</span>
</div>
<div class="form-row">
<label for="node-input-timeout"><i class="fa fa-clock"></i> Timeout (ms)</label>
<input type="number" id="node-input-timeout" min="5000" max="600000" value="120000" style="width: 100px;">
<span class="help-text">Maximum execution time in milliseconds.</span>
</div>
<div class="form-row">
<label for="node-input-outputFormat"><i class="fa fa-code"></i> Output Format</label>
<select id="node-input-outputFormat">
<option value="text">Text (raw stdout)</option>
<option value="json">JSON (parse stdout as JSON)</option>
</select>
<span class="help-text">How to parse the output.</span>
</div>
</script>
<script type="text/html" data-help-name="agent-exec">
<p>Execute CLI agent commands from Node-RED flows.</p>
<h3>Usage</h3>
<p>This node runs agent commands and returns the output. The most common use is <code>agent run</code> with a prompt/input.</p>
<h3>Input Message Properties</h3>
<ul>
<li><code>msg.command</code> - Override the command (e.g., "run", "version")</li>
<li><code>msg.args</code> - Override arguments (array or space-separated string)</li>
<li><code>msg.prompt</code> - Input text for <code>run</code> command</li>
<li><code>msg.workingDir</code> - Override working directory</li>
<li><code>msg.timeout</code> - Override timeout in ms</li>
</ul>
<h3>Output Message Properties</h3>
<ul>
<li><code>msg.payload</code> - Command output (text or parsed JSON)</li>
<li><code>msg.stderr</code> - Standard error output</li>
<li><code>msg.exitCode</code> - Process exit code</li>
<li><code>msg.duration</code> - Execution time in milliseconds</li>
<li><code>msg.command</code> - Full command that was executed</li>
<li><code>msg.workingDir</code> - Working directory used</li>
<li><code>msg.error</code> - Error message if failed</li>
</ul>
<h3>Example Flow</h3>
<pre><code>// Inject node with:
msg.payload = "Process the files in /data/input and generate a report";
msg.prompt = msg.payload;
return msg;</code></pre>
<p>Connect to agent-exec with command "run" to execute the task.</p>
</script>
+133
View File
@@ -0,0 +1,133 @@
'use strict';
const { spawn } = require('child_process');
const path = require('path');
module.exports = function (RED) {
function AgentExecNode(config) {
RED.nodes.createNode(this, config);
const node = this;
this.configNode = RED.nodes.getNode(config.agentConfig);
this.command = config.command || 'run';
this.args = config.args || '';
this.workingDir = config.workingDir || '';
this.timeout = parseInt(config.timeout) || 120000;
this.outputFormat = config.outputFormat || 'text';
if (!this.configNode) {
node.error('No agent config node selected');
return;
}
node.on('input', async function (msg) {
const cfg = node.configNode;
const startTime = Date.now();
const cliCmd = cfg.cliPath || 'agent';
const workspaceDir = msg.workingDir || node.workingDir || cfg.workspaceDir || '/data/agent-workspace';
const command = msg.command || node.command;
const args = msg.args ? (Array.isArray(msg.args) ? msg.args : msg.args.split(' ').filter(Boolean)) : (node.args ? node.args.split(' ').filter(Boolean) : []);
const timeout = msg.timeout || node.timeout;
const fullArgs = [command, ...args];
if (command === 'run' && msg.prompt) {
fullArgs.push('--prompt', msg.prompt);
}
if (cfg.defaultArgs) {
fullArgs.unshift(...cfg.defaultArgs.split(' ').filter(Boolean));
}
node.status({ fill: 'blue', shape: 'dot', text: `Running: ${command}` });
try {
const result = await runAgent(cliCmd, fullArgs, {
cwd: workspaceDir,
timeout,
env: {
...process.env,
AGENT_WORKSPACE: workspaceDir,
AGENT_API_ENDPOINT: cfg.apiEndpoint,
AGENT_API_KEY: cfg.apiKey || ''
}
});
const duration = Date.now() - startTime;
node.status({ fill: 'green', shape: 'dot', text: `Done (${duration}ms)` });
msg.payload = node.outputFormat === 'json' ? parseOutput(result.stdout) : result.stdout;
msg.stderr = result.stderr;
msg.exitCode = result.exitCode;
msg.duration = duration;
msg.command = `${cliCmd} ${fullArgs.join(' ')}`;
msg.workingDir = workspaceDir;
node.send(msg);
} catch (err) {
const duration = Date.now() - startTime;
node.status({ fill: 'red', shape: 'ring', text: `Error (${duration}ms)` });
msg.error = err.message;
msg.stderr = err.stderr || '';
msg.exitCode = err.exitCode || -1;
msg.duration = duration;
node.error(`agent ${command} failed: ${err.message}`, msg);
}
});
}
function runAgent(cmd, args, options) {
return new Promise((resolve, reject) => {
let stdout = '';
let stderr = '';
let exitCode = 0;
const child = spawn(cmd, args, {
cwd: options.cwd,
env: options.env,
timeout: options.timeout,
windowsHide: true
});
child.stdout.on('data', (data) => { stdout += data.toString(); });
child.stderr.on('data', (data) => { stderr += data.toString(); });
child.on('close', (code) => {
exitCode = code || 0;
if (exitCode === 0) {
resolve({ stdout, stderr, exitCode });
} else {
const err = new Error(`Process exited with code ${exitCode}`);
err.exitCode = exitCode;
err.stderr = stderr;
reject(err);
}
});
child.on('error', (err) => {
err.exitCode = -1;
reject(err);
});
child.on('timeout', () => {
child.kill('SIGTERM');
const err = new Error(`Process timed out after ${options.timeout}ms`);
err.exitCode = -2;
err.stderr = stderr;
reject(err);
});
});
}
function parseOutput(text) {
try {
const trimmed = text.trim();
if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
return JSON.parse(trimmed);
}
return text;
} catch {
return text;
}
}
RED.nodes.registerType('agent-exec', AgentExecNode);
};
+79
View File
@@ -0,0 +1,79 @@
<script type="text/html" data-template-name="agent-file">
<div class="form-row">
<label for="node-input-agentConfig"><i class="fa fa-cog"></i> Agent Config</label>
<input type="text" id="node-input-agentConfig" class="node-config-input" placeholder="Select agent config node">
<span class="help-text">Select the agent configuration node (provides workspace directory).</span>
</div>
<div class="form-row">
<label for="node-input-operation"><i class="fa fa-tasks"></i> Operation</label>
<select id="node-input-operation">
<option value="read">Read File</option>
<option value="write">Write File</option>
<option value="append">Append to File</option>
<option value="delete">Delete File</option>
<option value="exists">Check Exists</option>
<option value="list">List Directory</option>
<option value="stat">File Stats</option>
<option value="mkdir">Create Directory</option>
</select>
<span class="help-text">File operation to perform.</span>
</div>
<div class="form-row">
<label for="node-input-filePath"><i class="fa fa-file"></i> File Path</label>
<input type="text" id="node-input-filePath" placeholder="output/result.txt or /data/agent-workspace/output/result.txt">
<span class="help-text">Path relative to workspace directory, or absolute path. Can be overridden by msg.filePath.</span>
</div>
<div class="form-row">
<label for="node-input-encoding"><i class="fa fa-font"></i> Encoding</label>
<select id="node-input-encoding">
<option value="utf8">UTF-8</option>
<option value="ascii">ASCII</option>
<option value="latin1">Latin1</option>
<option value="base64">Base64</option>
<option value="hex">Hex</option>
</select>
<span class="help-text">Text encoding for read/write operations.</span>
</div>
<div class="form-row">
<label for="node-input-createDirs"><i class="fa fa-folder-plus"></i> Create Parent Directories</label>
<input type="checkbox" id="node-input-createDirs" checked>
<span class="help-text">Automatically create parent directories for write/append operations.</span>
</div>
</script>
<script type="text/html" data-help-name="agent-file">
<p>Read, write, and manage files in the agent workspace directory.</p>
<h3>Operations</h3>
<ul>
<li><b>Read</b> - Returns file content as string (or Buffer for binary encodings)</li>
<li><b>Write</b> - Creates/overwrites file with msg.payload content</li>
<li><b>Append</b> - Appends msg.payload to existing file</li>
<li><b>Delete</b> - Removes file</li>
<li><b>Exists</b> - Returns {exists: true/false}</li>
<li><b>List</b> - Returns array of directory entries with name, path, isDirectory, isFile</li>
<li><b>Stat</b> - Returns file stats (size, timestamps, type)</li>
<li><b>Mkdir</b> - Creates directory recursively</li>
</ul>
<h3>Input Message Properties</h3>
<ul>
<li><code>msg.operation</code> - Override operation</li>
<li><code>msg.filePath</code> - Override file path</li>
<li><code>msg.payload</code> - Content for write/append operations</li>
<li><code>msg.encoding</code> - Override encoding</li>
</ul>
<h3>Output Message Properties</h3>
<ul>
<li><code>msg.payload</code> - Operation result (content, stats, or status object)</li>
<li><code>msg.filePath</code> - File path used</li>
<li><code>msg.operation</code> - Operation performed</li>
<li><code>msg.error</code> - Error message if failed</li>
</ul>
<h3>Security</h3>
<p>Paths are restricted to the configured workspace directory. Path traversal attempts are blocked.</p>
<h3>Example: Write agent output to file</h3>
<pre><code>// After agent-exec generates output:
msg.filePath = "results/output.txt";
msg.payload = msg.payload; // output from agent-exec
msg.operation = "write";
return msg;</code></pre>
</script>
+133
View File
@@ -0,0 +1,133 @@
'use strict';
const fs = require('fs').promises;
const path = require('path');
module.exports = function (RED) {
function AgentFileNode(config) {
RED.nodes.createNode(this, config);
const node = this;
this.configNode = RED.nodes.getNode(config.agentConfig);
this.operation = config.operation || 'read';
this.filePath = config.filePath || '';
this.encoding = config.encoding || 'utf8';
this.createDirs = config.createDirs !== false;
if (!this.configNode) {
node.error('No agent config node selected');
return;
}
node.on('input', async function (msg) {
const cfg = node.configNode;
const workspaceDir = cfg.workspaceDir || '/data/agent-workspace';
const operation = msg.operation || node.operation;
const filePath = msg.filePath || node.filePath;
const encoding = msg.encoding || node.encoding;
if (!filePath) {
node.error('No file path specified', msg);
return;
}
const fullPath = path.isAbsolute(filePath)
? filePath
: path.join(workspaceDir, filePath);
const resolvedWorkspace = path.resolve(workspaceDir);
const resolvedPath = path.resolve(fullPath);
if (!resolvedPath.startsWith(resolvedWorkspace)) {
node.error('Path traversal attempt blocked', msg);
return;
}
node.status({ fill: 'blue', shape: 'dot', text: operation });
try {
let result;
switch (operation) {
case 'read':
result = await fs.readFile(resolvedPath, encoding);
break;
case 'write':
const content = msg.payload;
if (node.createDirs) {
await fs.mkdir(path.dirname(resolvedPath), { recursive: true });
}
await fs.writeFile(resolvedPath, content, encoding);
result = { written: true, path: filePath, bytes: Buffer.byteLength(content, encoding) };
break;
case 'append':
const appendContent = msg.payload;
if (node.createDirs) {
await fs.mkdir(path.dirname(resolvedPath), { recursive: true });
}
await fs.appendFile(resolvedPath, appendContent, encoding);
result = { appended: true, path: filePath, bytes: Buffer.byteLength(appendContent, encoding) };
break;
case 'delete':
await fs.unlink(resolvedPath);
result = { deleted: true, path: filePath };
break;
case 'exists':
try {
await fs.access(resolvedPath);
result = { exists: true, path: filePath };
} catch {
result = { exists: false, path: filePath };
}
break;
case 'list':
const entries = await fs.readdir(resolvedPath, { withFileTypes: true });
result = entries.map(e => ({
name: e.name,
path: path.join(filePath, e.name),
isDirectory: e.isDirectory(),
isFile: e.isFile()
}));
break;
case 'stat':
const stats = await fs.stat(resolvedPath);
result = {
path: filePath,
size: stats.size,
isDirectory: stats.isDirectory(),
isFile: stats.isFile(),
created: stats.birthtime,
modified: stats.mtime
};
break;
case 'mkdir':
await fs.mkdir(resolvedPath, { recursive: true });
result = { created: true, path: filePath };
break;
default:
throw new Error(`Unknown operation: ${operation}`);
}
node.status({ fill: 'green', shape: 'dot', text: 'Done' });
msg.payload = result;
msg.filePath = filePath;
msg.operation = operation;
node.send(msg);
} catch (err) {
node.status({ fill: 'red', shape: 'ring', text: 'Error' });
msg.error = err.message;
msg.filePath = filePath;
msg.operation = operation;
node.error(`File ${operation} failed: ${err.message}`, msg);
}
});
}
RED.nodes.registerType('agent-file', AgentFileNode);
};
+83
View File
@@ -0,0 +1,83 @@
<script type="text/html" data-template-name="agent-process">
<div class="form-row">
<label for="node-input-agentConfig"><i class="fa fa-cog"></i> Agent Config</label>
<input type="text" id="node-input-agentConfig" class="node-config-input" placeholder="Select agent config node">
<span class="help-text">Select the agent configuration node.</span>
</div>
<div class="form-row">
<label for="node-input-processId"><i class="fa fa-id-badge"></i> Process ID</label>
<input type="text" id="node-input-processId" placeholder="default">
<span class="help-text">Unique identifier for this process instance.</span>
</div>
<div class="form-row">
<label for="node-input-action"><i class="fa fa-play"></i> Action</label>
<select id="node-input-action">
<option value="start">Start Process</option>
<option value="stop">Stop Process</option>
<option value="restart">Restart Process</option>
<option value="send">Send Input</option>
<option value="status">Get Status</option>
</select>
<span class="help-text">Action to perform on the process.</span>
</div>
<div class="form-row">
<label for="node-input-initialInput"><i class="fa fa-comment"></i> Initial Input</label>
<input type="text" id="node-input-initialInput" placeholder="Process the files in /data/input...">
<span class="help-text">Initial input for the process (used on start). Can be overridden by msg.payload or msg.initialInput.</span>
</div>
<div class="form-row">
<label for="node-input-workingDir"><i class="fa fa-folder"></i> Working Directory</label>
<input type="text" id="node-input-workingDir" placeholder="/data/agent-workspace">
<span class="help-text">Workspace directory for this process. Overrides config.</span>
</div>
<div class="form-row">
<label for="node-input-autoRestart"><i class="fa fa-redo"></i> Auto Restart</label>
<input type="checkbox" id="node-input-autoRestart" checked>
<span class="help-text">Automatically restart process if it crashes.</span>
</div>
</script>
<script type="text/html" data-help-name="agent-process">
<p>Manage persistent CLI agent processes.</p>
<h3>Actions</h3>
<ul>
<li><b>Start</b> - Launch a new persistent agent process</li>
<li><b>Stop</b> - Gracefully terminate the process</li>
<li><b>Restart</b> - Stop and start the process</li>
<li><b>Send</b> - Send input to a running process via stdin</li>
<li><b>Status</b> - Get current process status (running, pid, uptime)</li>
</ul>
<h3>Input Message Properties</h3>
<ul>
<li><code>msg.action</code> - Override action</li>
<li><code>msg.processId</code> - Override process ID</li>
<li><code>msg.payload</code> - Input for start/send actions</li>
<li><code>msg.initialInput</code> - Alternative to msg.payload for start action</li>
<li><code>msg.workingDir</code> - Override workspace directory</li>
</ul>
<h3>Output Message Properties</h3>
<ul>
<li><code>msg.payload</code> - Status object with process info</li>
<li><code>msg.processId</code> - Process ID</li>
<li><code>msg.error</code> - Error message if failed</li>
</ul>
<h3>Process Lifecycle</h3>
<p>Processes run as persistent child processes. They maintain state across interactions. Use <b>Send</b> action to continue interactions with a running process.</p>
<h3>Example: Continuous processing session</h3>
<pre><code>// Start process with initial context
msg.action = "start";
msg.processId = "data-processor";
msg.payload = "Watch /data/input for new files and process them";
return msg;
// Later, send follow-up commands
msg.action = "send";
msg.processId = "data-processor";
msg.payload = "Now also generate summary report";
return msg;
// Check status
msg.action = "status";
msg.processId = "data-processor";
return msg;</code></pre>
</script>
+180
View File
@@ -0,0 +1,180 @@
'use strict';
const { spawn } = require('child_process');
module.exports = function (RED) {
const agentSessions = new Map();
function AgentProcessNode(config) {
RED.nodes.createNode(this, config);
const node = this;
this.configNode = RED.nodes.getNode(config.agentConfig);
this.processId = config.processId || 'default';
this.action = config.action || 'start';
this.initialInput = config.initialInput || '';
this.workingDir = config.workingDir || '';
this.autoRestart = config.autoRestart !== false;
if (!this.configNode) {
node.error('No agent config node selected');
return;
}
node.on('input', async function (msg) {
const cfg = node.configNode;
const action = msg.action || node.action;
const processId = msg.processId || node.processId;
const workspaceDir = msg.workingDir || node.workingDir || cfg.workspaceDir || '/data/agent-workspace';
try {
switch (action) {
case 'start':
await startProcess(node, cfg, processId, workspaceDir, msg);
break;
case 'stop':
await stopProcess(node, processId);
break;
case 'restart':
await stopProcess(node, processId);
await startProcess(node, cfg, processId, workspaceDir, msg);
break;
case 'send':
await sendToProcess(node, processId, msg.payload || msg.initialInput);
break;
case 'status':
await getProcessStatus(node, processId);
break;
default:
throw new Error(`Unknown action: ${action}`);
}
} catch (err) {
node.error(`Process ${action} failed: ${err.message}`, msg);
}
});
node.on('close', function (removed, done) {
if (removed) {
stopProcess(node, node.processId).finally(done);
} else {
done();
}
});
}
async function startProcess(node, cfg, processId, workspaceDir, msg) {
if (agentSessions.has(processId)) {
node.warn(`Process ${processId} already running`);
msg.payload = { status: 'running', processId };
node.send(msg);
return;
}
const cliCmd = cfg.cliPath || 'agent';
const initialInput = msg.initialInput || msg.payload || node.initialInput;
const defaultArgs = cfg.defaultArgs ? cfg.defaultArgs.split(' ').filter(Boolean) : [];
const args = ['run', ...defaultArgs];
if (initialInput) args.push('--input', initialInput);
node.status({ fill: 'yellow', shape: 'dot', text: `Starting ${processId}...` });
const child = spawn(cliCmd, args, {
cwd: workspaceDir,
env: {
...process.env,
AGENT_WORKSPACE: workspaceDir,
AGENT_API_ENDPOINT: cfg.apiEndpoint,
AGENT_API_KEY: cfg.apiKey || ''
},
windowsHide: true
});
let stdout = '';
let stderr = '';
child.stdout.on('data', (data) => { stdout += data.toString(); });
child.stderr.on('data', (data) => { stderr += data.toString(); });
const session = {
id: processId,
process: child,
workspaceDir,
startTime: Date.now(),
stdout,
stderr
};
agentSessions.set(processId, session);
child.on('close', (code) => {
agentSessions.delete(processId);
node.status({ fill: 'red', shape: 'ring', text: `Stopped (${code})` });
node.send({
payload: { status: 'stopped', processId, exitCode: code, stdout, stderr },
processId
});
});
child.on('error', (err) => {
agentSessions.delete(processId);
node.status({ fill: 'red', shape: 'ring', text: 'Error' });
node.error(`Process ${processId} error: ${err.message}`);
});
await new Promise(r => setTimeout(r, 2000));
node.status({ fill: 'green', shape: 'dot', text: `Running: ${processId}` });
msg.payload = { status: 'started', processId, pid: child.pid };
msg.processId = processId;
node.send(msg);
}
async function stopProcess(node, processId) {
const session = agentSessions.get(processId);
if (!session) {
node.warn(`Process ${processId} not found`);
return;
}
session.process.kill('SIGTERM');
await new Promise(r => setTimeout(r, 1000));
if (!session.process.killed) {
session.process.kill('SIGKILL');
}
}
async function sendToProcess(node, processId, input) {
const session = agentSessions.get(processId);
if (!session) {
throw new Error(`Process ${processId} not running`);
}
if (session.process.stdin.writable) {
session.process.stdin.write(input + '\n');
}
node.status({ fill: 'blue', shape: 'dot', text: `Sent to ${processId}` });
node.send({ payload: { sent: true, processId, input }, processId });
}
async function getProcessStatus(node, processId) {
const session = agentSessions.get(processId);
if (!session) {
node.send({ payload: { status: 'not running', processId }, processId });
return;
}
node.send({
payload: {
status: 'running',
processId,
pid: session.process.pid,
uptime: Date.now() - session.startTime,
workspaceDir: session.workspaceDir
},
processId
});
}
RED.nodes.registerType('agent-process', AgentProcessNode);
};
+29
View File
@@ -0,0 +1,29 @@
{
"name": "node-red-contrib-nextcloud-agent",
"version": "1.0.0",
"description": "Node-RED nodes for CLI agent integration with Nextcloud",
"license": "MIT",
"author": "hannesahlin",
"keywords": [
"node-red",
"nextcloud",
"cli",
"agent",
"automation"
],
"node-red": {
"version": ">=3.0.0",
"nodes": {
"agent-config": "nodes/agent-config.js",
"agent-exec": "nodes/agent-exec.js",
"agent-file": "nodes/agent-file.js",
"agent-process": "nodes/agent-process.js"
}
},
"dependencies": {
"ws": "^8.16.0"
},
"engines": {
"node": ">=18"
}
}