@lhi/n8m 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +247 -0
- package/bin/dev.js +5 -0
- package/bin/run.js +6 -0
- package/dist/agentic/checkpointer.d.ts +2 -0
- package/dist/agentic/checkpointer.js +14 -0
- package/dist/agentic/graph.d.ts +483 -0
- package/dist/agentic/graph.js +100 -0
- package/dist/agentic/nodes/architect.d.ts +6 -0
- package/dist/agentic/nodes/architect.js +51 -0
- package/dist/agentic/nodes/engineer.d.ts +11 -0
- package/dist/agentic/nodes/engineer.js +182 -0
- package/dist/agentic/nodes/qa.d.ts +5 -0
- package/dist/agentic/nodes/qa.js +151 -0
- package/dist/agentic/nodes/reviewer.d.ts +5 -0
- package/dist/agentic/nodes/reviewer.js +111 -0
- package/dist/agentic/nodes/supervisor.d.ts +6 -0
- package/dist/agentic/nodes/supervisor.js +18 -0
- package/dist/agentic/state.d.ts +51 -0
- package/dist/agentic/state.js +26 -0
- package/dist/commands/config.d.ts +13 -0
- package/dist/commands/config.js +47 -0
- package/dist/commands/create.d.ts +14 -0
- package/dist/commands/create.js +182 -0
- package/dist/commands/deploy.d.ts +13 -0
- package/dist/commands/deploy.js +68 -0
- package/dist/commands/modify.d.ts +13 -0
- package/dist/commands/modify.js +276 -0
- package/dist/commands/prune.d.ts +9 -0
- package/dist/commands/prune.js +98 -0
- package/dist/commands/resume.d.ts +8 -0
- package/dist/commands/resume.js +39 -0
- package/dist/commands/test.d.ts +27 -0
- package/dist/commands/test.js +619 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/services/ai.service.d.ts +51 -0
- package/dist/services/ai.service.js +421 -0
- package/dist/services/n8n.service.d.ts +17 -0
- package/dist/services/n8n.service.js +81 -0
- package/dist/services/node-definitions.service.d.ts +36 -0
- package/dist/services/node-definitions.service.js +102 -0
- package/dist/utils/config.d.ts +15 -0
- package/dist/utils/config.js +25 -0
- package/dist/utils/multilinePrompt.d.ts +1 -0
- package/dist/utils/multilinePrompt.js +52 -0
- package/dist/utils/n8nClient.d.ts +97 -0
- package/dist/utils/n8nClient.js +440 -0
- package/dist/utils/sandbox.d.ts +13 -0
- package/dist/utils/sandbox.js +34 -0
- package/dist/utils/theme.d.ts +23 -0
- package/dist/utils/theme.js +92 -0
- package/oclif.manifest.json +331 -0
- package/package.json +95 -0
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { Command, Flags } from '@oclif/core';
|
|
2
|
+
import { theme } from '../utils/theme.js';
|
|
3
|
+
import { ConfigManager } from '../utils/config.js';
|
|
4
|
+
export default class Config extends Command {
|
|
5
|
+
static description = 'Manage n8m configuration';
|
|
6
|
+
static flags = {
|
|
7
|
+
'n8n-url': Flags.string({ description: 'Set n8n Instance URL' }),
|
|
8
|
+
'n8n-key': Flags.string({ description: 'Set n8n API Key' }),
|
|
9
|
+
'ai-key': Flags.string({ description: 'Set AI API Key (used for all AI features)' }),
|
|
10
|
+
'ai-provider': Flags.string({ description: 'Set AI provider (openai, anthropic, gemini)' }),
|
|
11
|
+
'ai-model': Flags.string({ description: 'Set AI model name (e.g. gpt-4o, claude-sonnet-4-6)' }),
|
|
12
|
+
'ai-base-url': Flags.string({ description: 'Set custom AI base URL (for OpenAI-compatible endpoints)' }),
|
|
13
|
+
};
|
|
14
|
+
async run() {
|
|
15
|
+
this.log(theme.brand());
|
|
16
|
+
const { flags } = await this.parse(Config);
|
|
17
|
+
const config = await ConfigManager.load();
|
|
18
|
+
if (Object.keys(flags).length === 0) {
|
|
19
|
+
this.log(theme.header('CURRENT CONFIGURATION'));
|
|
20
|
+
this.log(theme.label('β n8n β'));
|
|
21
|
+
this.log(`${theme.label('n8n URL')} ${config.n8nUrl ? theme.value(config.n8nUrl) : theme.muted('Not set')}`);
|
|
22
|
+
this.log(`${theme.label('n8n Key')} ${config.n8nKey ? theme.value('********') : theme.muted('Not set')}`);
|
|
23
|
+
this.log(theme.label('β AI β'));
|
|
24
|
+
this.log(`${theme.label('AI Provider')} ${config.aiProvider ? theme.value(config.aiProvider) : theme.muted('Not set (defaults to openai)')}`);
|
|
25
|
+
this.log(`${theme.label('AI Key')} ${config.aiKey ? theme.value('********') : theme.muted('Not set')}`);
|
|
26
|
+
this.log(`${theme.label('AI Model')} ${config.aiModel ? theme.value(config.aiModel) : theme.muted('Not set (uses provider default)')}`);
|
|
27
|
+
this.log(`${theme.label('AI Base URL')} ${config.aiBaseUrl ? theme.value(config.aiBaseUrl) : theme.muted('Not set')}`);
|
|
28
|
+
this.log(theme.divider(40));
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
// Update config
|
|
32
|
+
if (flags['n8n-url'])
|
|
33
|
+
config.n8nUrl = flags['n8n-url'];
|
|
34
|
+
if (flags['n8n-key'])
|
|
35
|
+
config.n8nKey = flags['n8n-key'];
|
|
36
|
+
if (flags['ai-key'])
|
|
37
|
+
config.aiKey = flags['ai-key'];
|
|
38
|
+
if (flags['ai-provider'])
|
|
39
|
+
config.aiProvider = flags['ai-provider'];
|
|
40
|
+
if (flags['ai-model'])
|
|
41
|
+
config.aiModel = flags['ai-model'];
|
|
42
|
+
if (flags['ai-base-url'])
|
|
43
|
+
config.aiBaseUrl = flags['ai-base-url'];
|
|
44
|
+
await ConfigManager.save(config);
|
|
45
|
+
this.log(theme.done('Configuration updated successfully'));
|
|
46
|
+
}
|
|
47
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { Command } from '@oclif/core';
|
|
2
|
+
export default class Create extends Command {
|
|
3
|
+
static args: {
|
|
4
|
+
description: import("@oclif/core/interfaces").Arg<string | undefined, Record<string, unknown>>;
|
|
5
|
+
};
|
|
6
|
+
static description: string;
|
|
7
|
+
static examples: string[];
|
|
8
|
+
static flags: {
|
|
9
|
+
deploy: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
10
|
+
output: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
11
|
+
multiline: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
12
|
+
};
|
|
13
|
+
run(): Promise<void>;
|
|
14
|
+
}
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import { Args, Command, Flags } from '@oclif/core';
|
|
2
|
+
import { theme } from '../utils/theme.js';
|
|
3
|
+
import { runAgenticWorkflowStream } from '../agentic/graph.js';
|
|
4
|
+
import * as path from 'node:path';
|
|
5
|
+
import * as fs from 'node:fs/promises';
|
|
6
|
+
import { existsSync } from 'node:fs';
|
|
7
|
+
import inquirer from 'inquirer';
|
|
8
|
+
import { randomUUID } from 'node:crypto';
|
|
9
|
+
import { graph, resumeAgenticWorkflow } from '../agentic/graph.js';
|
|
10
|
+
import { promptMultiline } from '../utils/multilinePrompt.js';
|
|
11
|
+
export default class Create extends Command {
|
|
12
|
+
static args = {
|
|
13
|
+
description: Args.string({
|
|
14
|
+
description: 'Natural language description of the workflow',
|
|
15
|
+
required: false,
|
|
16
|
+
}),
|
|
17
|
+
};
|
|
18
|
+
static description = 'Generate n8n workflows from natural language using Gemini AI Agent';
|
|
19
|
+
static examples = [
|
|
20
|
+
'<%= config.bin %> <%= command.id %> "Send a telegram alert when I receive an email"',
|
|
21
|
+
'echo "Slack to Discord sync" | <%= config.bin %> <%= command.id %>',
|
|
22
|
+
'<%= config.bin %> <%= command.id %> --output ./my-workflow.json',
|
|
23
|
+
];
|
|
24
|
+
static flags = {
|
|
25
|
+
deploy: Flags.boolean({
|
|
26
|
+
char: 'd',
|
|
27
|
+
description: 'Deploy the generated workflow to n8n instance (Not yet fully integrated with agent)',
|
|
28
|
+
default: false,
|
|
29
|
+
hidden: true,
|
|
30
|
+
}),
|
|
31
|
+
output: Flags.string({
|
|
32
|
+
char: 'o',
|
|
33
|
+
description: 'Path to save the generated workflow JSON',
|
|
34
|
+
}),
|
|
35
|
+
multiline: Flags.boolean({
|
|
36
|
+
char: 'm',
|
|
37
|
+
description: 'Open editor for multiline workflow description',
|
|
38
|
+
default: false,
|
|
39
|
+
}),
|
|
40
|
+
};
|
|
41
|
+
async run() {
|
|
42
|
+
this.log(theme.brand());
|
|
43
|
+
const { args, flags } = await this.parse(Create);
|
|
44
|
+
// 1. INPUT
|
|
45
|
+
let description = args.description;
|
|
46
|
+
// Handle piped input
|
|
47
|
+
if (!description && !process.stdin.isTTY) {
|
|
48
|
+
const chunks = [];
|
|
49
|
+
for await (const chunk of process.stdin) {
|
|
50
|
+
chunks.push(chunk);
|
|
51
|
+
}
|
|
52
|
+
description = Buffer.concat(chunks).toString('utf-8').trim();
|
|
53
|
+
}
|
|
54
|
+
// Handle multiline flag
|
|
55
|
+
if (!description && flags.multiline) {
|
|
56
|
+
const response = await inquirer.prompt([{
|
|
57
|
+
type: 'editor',
|
|
58
|
+
name: 'description',
|
|
59
|
+
message: 'Describe the workflow you want to build (opens editor):',
|
|
60
|
+
validate: (d) => d.trim().length > 0
|
|
61
|
+
}]);
|
|
62
|
+
description = response.description;
|
|
63
|
+
}
|
|
64
|
+
// Prompt if still empty
|
|
65
|
+
if (!description) {
|
|
66
|
+
description = await promptMultiline();
|
|
67
|
+
}
|
|
68
|
+
// Strip backticks if passed as a single block in argument or piped input
|
|
69
|
+
if (description && description.startsWith('```') && description.endsWith('```')) {
|
|
70
|
+
description = description.slice(3, -3).trim();
|
|
71
|
+
}
|
|
72
|
+
if (!description) {
|
|
73
|
+
this.error('Description is required.');
|
|
74
|
+
}
|
|
75
|
+
// 2. AGENTIC EXECUTION
|
|
76
|
+
const threadId = randomUUID();
|
|
77
|
+
this.log(theme.info(`\nInitializing Agentic Workflow for: "${description}" (Session: ${threadId})`));
|
|
78
|
+
let lastWorkflowJson = null;
|
|
79
|
+
let lastSpec = null;
|
|
80
|
+
try {
|
|
81
|
+
const stream = await runAgenticWorkflowStream(description, threadId);
|
|
82
|
+
for await (const event of stream) {
|
|
83
|
+
// event keys correspond to node names that just finished
|
|
84
|
+
const nodeName = Object.keys(event)[0];
|
|
85
|
+
const stateUpdate = event[nodeName];
|
|
86
|
+
if (nodeName === 'architect') {
|
|
87
|
+
this.log(theme.agent(`ποΈ Architect: Blueprint designed.`));
|
|
88
|
+
if (stateUpdate.spec?.suggestedName) {
|
|
89
|
+
this.log(` Goal: ${theme.value(stateUpdate.spec.suggestedName)}`);
|
|
90
|
+
lastSpec = stateUpdate.spec;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
else if (nodeName === 'engineer') {
|
|
94
|
+
this.log(theme.agent(`βοΈ Engineer: Workflow code generated/updated.`));
|
|
95
|
+
if (stateUpdate.workflowJson) {
|
|
96
|
+
lastWorkflowJson = stateUpdate.workflowJson;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
else if (nodeName === 'qa') {
|
|
100
|
+
const status = stateUpdate.validationStatus;
|
|
101
|
+
if (status === 'passed') {
|
|
102
|
+
this.log(theme.success(`π§ͺ QA: Validation Passed!`));
|
|
103
|
+
}
|
|
104
|
+
else {
|
|
105
|
+
this.log(theme.fail(`π§ͺ QA: Validation Failed.`));
|
|
106
|
+
if (stateUpdate.validationErrors && stateUpdate.validationErrors.length > 0) {
|
|
107
|
+
stateUpdate.validationErrors.forEach((e) => this.log(theme.error(` - ${e}`)));
|
|
108
|
+
}
|
|
109
|
+
this.log(theme.warn(` Looping back to Engineer for repairs...`));
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
// Check for interrupt/pause
|
|
114
|
+
const snapshot = await graph.getState({ configurable: { thread_id: threadId } });
|
|
115
|
+
if (snapshot.next.length > 0) {
|
|
116
|
+
this.log(theme.warn(`\nβΈοΈ Workflow Paused at step: ${snapshot.next.join(', ')}`));
|
|
117
|
+
const { resume } = await inquirer.prompt([{
|
|
118
|
+
type: 'confirm',
|
|
119
|
+
name: 'resume',
|
|
120
|
+
message: 'Review completed. Resume workflow execution?',
|
|
121
|
+
default: true
|
|
122
|
+
}]);
|
|
123
|
+
if (resume) {
|
|
124
|
+
this.log(theme.agent("Resuming..."));
|
|
125
|
+
// Resume recursively/iteratively?
|
|
126
|
+
// For now, simple resume call. ideally we'd stream again.
|
|
127
|
+
// But wait, resumeAgenticWorkflow returns the FINAL result, not a stream.
|
|
128
|
+
// We should probably loop if we want to stream again, but let's just create a simple resume handling here.
|
|
129
|
+
// Or we can just call resumeAgenticWorkflow and print the final result.
|
|
130
|
+
const result = await resumeAgenticWorkflow(threadId);
|
|
131
|
+
if (result.validationStatus === 'passed') {
|
|
132
|
+
this.log(theme.success(`π§ͺ QA (Resumed): Validation Passed!`));
|
|
133
|
+
if (result.workflowJson)
|
|
134
|
+
lastWorkflowJson = result.workflowJson;
|
|
135
|
+
}
|
|
136
|
+
else {
|
|
137
|
+
this.log(theme.fail(`π§ͺ QA (Resumed): Final Status: ${result.validationStatus}`));
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
else {
|
|
141
|
+
this.log(theme.info(`Session persisted. Resume later with: n8m resume ${threadId}`));
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
catch (error) {
|
|
147
|
+
this.error(`Agent ran into an unrecoverable error: ${error.message}`);
|
|
148
|
+
}
|
|
149
|
+
if (!lastWorkflowJson) {
|
|
150
|
+
this.error('Agent finished but no workflow JSON was produced.');
|
|
151
|
+
}
|
|
152
|
+
// 3. SAVE
|
|
153
|
+
// Normalize to array
|
|
154
|
+
const workflows = lastWorkflowJson.workflows || [lastWorkflowJson];
|
|
155
|
+
const savedResources = [];
|
|
156
|
+
for (const workflow of workflows) {
|
|
157
|
+
const workflowName = workflow.name || (lastSpec && lastSpec.suggestedName) || 'generated-workflow';
|
|
158
|
+
const sanitizedName = workflowName.replace(/[^a-z0-9]+/gi, '-').replace(/^-+|-+$/g, '').toLowerCase();
|
|
159
|
+
let targetFile = flags.output;
|
|
160
|
+
// If multiple workflows and output provided, append name to avoid overwrite, unless it's a directory
|
|
161
|
+
if (workflows.length > 1 && targetFile && !targetFile.endsWith('.json')) {
|
|
162
|
+
targetFile = path.join(targetFile, `${sanitizedName}.json`);
|
|
163
|
+
}
|
|
164
|
+
else if (workflows.length > 1 && targetFile) {
|
|
165
|
+
// If specific file given but we have multiple, suffix it
|
|
166
|
+
targetFile = targetFile.replace('.json', `-${sanitizedName}.json`);
|
|
167
|
+
}
|
|
168
|
+
else if (!targetFile) {
|
|
169
|
+
const targetDir = path.join(process.cwd(), 'workflows');
|
|
170
|
+
if (!existsSync(targetDir)) {
|
|
171
|
+
await fs.mkdir(targetDir, { recursive: true });
|
|
172
|
+
}
|
|
173
|
+
targetFile = path.join(targetDir, `${sanitizedName}.json`);
|
|
174
|
+
}
|
|
175
|
+
await fs.writeFile(targetFile, JSON.stringify(workflow, null, 2));
|
|
176
|
+
savedResources.push({ path: targetFile, name: workflowName, original: workflow });
|
|
177
|
+
this.log(theme.success(`\nWorkflow saved to: ${targetFile}`));
|
|
178
|
+
}
|
|
179
|
+
this.log(theme.done('Agentic Workflow Complete.'));
|
|
180
|
+
process.exit(0);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { Command } from '@oclif/core';
|
|
2
|
+
export default class Deploy extends Command {
|
|
3
|
+
static args: {
|
|
4
|
+
workflow: import("@oclif/core/interfaces").Arg<string, Record<string, unknown>>;
|
|
5
|
+
};
|
|
6
|
+
static description: string;
|
|
7
|
+
static examples: string[];
|
|
8
|
+
static flags: {
|
|
9
|
+
instance: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
|
|
10
|
+
activate: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
11
|
+
};
|
|
12
|
+
run(): Promise<void>;
|
|
13
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { Args, Command, Flags } from '@oclif/core';
|
|
2
|
+
import { theme } from '../utils/theme.js';
|
|
3
|
+
export default class Deploy extends Command {
|
|
4
|
+
static args = {
|
|
5
|
+
workflow: Args.string({
|
|
6
|
+
description: 'Path to the workflow file or workflow ID',
|
|
7
|
+
required: true,
|
|
8
|
+
}),
|
|
9
|
+
};
|
|
10
|
+
static description = 'Push workflows to n8n instance via API';
|
|
11
|
+
static examples = [
|
|
12
|
+
'<%= config.bin %> <%= command.id %> ./workflows/slack-notifier.json',
|
|
13
|
+
];
|
|
14
|
+
static flags = {
|
|
15
|
+
instance: Flags.string({
|
|
16
|
+
char: 'i',
|
|
17
|
+
default: 'production',
|
|
18
|
+
description: 'n8n instance name (from config)',
|
|
19
|
+
}),
|
|
20
|
+
activate: Flags.boolean({
|
|
21
|
+
char: 'a',
|
|
22
|
+
default: false,
|
|
23
|
+
description: 'Activate workflow after deployment',
|
|
24
|
+
}),
|
|
25
|
+
};
|
|
26
|
+
async run() {
|
|
27
|
+
this.log(theme.brand());
|
|
28
|
+
const { args, flags } = await this.parse(Deploy);
|
|
29
|
+
this.log(theme.header('WORKFLOW DEPLOYMENT'));
|
|
30
|
+
this.log(theme.subHeader('Context Analysis'));
|
|
31
|
+
this.log(`${theme.label('Workflow')} ${theme.value(args.workflow)}`);
|
|
32
|
+
this.log(`${theme.label('Instance')} ${theme.value(flags.instance)}`);
|
|
33
|
+
this.log(`${theme.label('Auto-Activate')} ${theme.value(flags.activate)}`);
|
|
34
|
+
this.log(theme.divider(40));
|
|
35
|
+
try {
|
|
36
|
+
this.log(theme.agent('Scanning environment for local n8n instance...'));
|
|
37
|
+
let workflowData;
|
|
38
|
+
if (args.workflow.endsWith('.json')) {
|
|
39
|
+
const fs = await import('node:fs/promises');
|
|
40
|
+
const content = await fs.readFile(args.workflow, 'utf-8');
|
|
41
|
+
workflowData = JSON.parse(content);
|
|
42
|
+
}
|
|
43
|
+
else {
|
|
44
|
+
throw new Error("Local JSON file path required for currently active bridge.");
|
|
45
|
+
}
|
|
46
|
+
this.log(theme.info('Authenticating...'));
|
|
47
|
+
const { ConfigManager } = await import('../utils/config.js');
|
|
48
|
+
const config = await ConfigManager.load();
|
|
49
|
+
const n8nUrl = config.n8nUrl || process.env.N8N_API_URL;
|
|
50
|
+
const n8nKey = config.n8nKey || process.env.N8N_API_KEY;
|
|
51
|
+
if (!n8nUrl || !n8nKey) {
|
|
52
|
+
throw new Error('Missing n8n credentials. Run \'n8m config\' to set them.');
|
|
53
|
+
}
|
|
54
|
+
const { N8nClient } = await import('../utils/n8nClient.js');
|
|
55
|
+
const client = new N8nClient({ apiUrl: n8nUrl, apiKey: n8nKey });
|
|
56
|
+
this.log(theme.agent(`Transmitting bytecode to ${theme.secondary(n8nUrl)}`));
|
|
57
|
+
const result = await client.createWorkflow(workflowData.name || 'n8m-deployment', workflowData);
|
|
58
|
+
if (flags.activate && result.id) {
|
|
59
|
+
this.log(theme.warn('Activation request queued.'));
|
|
60
|
+
}
|
|
61
|
+
this.log(theme.done(`Deployment Successful. [ID: ${theme.primary(result.id)}]`));
|
|
62
|
+
this.log(`${theme.label('Public Link')} ${theme.secondary(client.getWorkflowLink(result.id))}`);
|
|
63
|
+
}
|
|
64
|
+
catch (error) {
|
|
65
|
+
this.error(`Operation aborted: ${error.message}`);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { Command } from '@oclif/core';
|
|
2
|
+
export default class Modify extends Command {
|
|
3
|
+
static args: {
|
|
4
|
+
workflow: import("@oclif/core/interfaces").Arg<string | undefined, Record<string, unknown>>;
|
|
5
|
+
instruction: import("@oclif/core/interfaces").Arg<string | undefined, Record<string, unknown>>;
|
|
6
|
+
};
|
|
7
|
+
static description: string;
|
|
8
|
+
static flags: {
|
|
9
|
+
multiline: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
10
|
+
output: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
11
|
+
};
|
|
12
|
+
run(): Promise<void>;
|
|
13
|
+
}
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
import inquirer from 'inquirer';
|
|
2
|
+
import { Args, Command, Flags } from '@oclif/core';
|
|
3
|
+
import { theme } from '../utils/theme.js';
|
|
4
|
+
import { N8nClient } from '../utils/n8nClient.js';
|
|
5
|
+
import { ConfigManager } from '../utils/config.js';
|
|
6
|
+
import { graph, resumeAgenticWorkflow } from '../agentic/graph.js';
|
|
7
|
+
import * as path from 'path';
|
|
8
|
+
import * as fs from 'fs/promises';
|
|
9
|
+
import { existsSync } from 'fs';
|
|
10
|
+
import { randomUUID } from 'node:crypto';
|
|
11
|
+
import { promptMultiline } from '../utils/multilinePrompt.js';
|
|
12
|
+
export default class Modify extends Command {
|
|
13
|
+
static args = {
|
|
14
|
+
workflow: Args.string({
|
|
15
|
+
description: 'Path or Name of the workflow to modify',
|
|
16
|
+
required: false,
|
|
17
|
+
}),
|
|
18
|
+
instruction: Args.string({
|
|
19
|
+
description: 'Modification instructions',
|
|
20
|
+
required: false,
|
|
21
|
+
}),
|
|
22
|
+
};
|
|
23
|
+
static description = 'Modify existing n8n workflows using Gemini AI Agent';
|
|
24
|
+
static flags = {
|
|
25
|
+
multiline: Flags.boolean({
|
|
26
|
+
char: 'm',
|
|
27
|
+
description: 'Open editor for multiline modification instructions',
|
|
28
|
+
default: false,
|
|
29
|
+
}),
|
|
30
|
+
output: Flags.string({
|
|
31
|
+
char: 'o',
|
|
32
|
+
description: 'Path to save the modified workflow JSON (defaults to overwriting if local file)',
|
|
33
|
+
}),
|
|
34
|
+
};
|
|
35
|
+
async run() {
|
|
36
|
+
const { args, flags } = await this.parse(Modify);
|
|
37
|
+
this.log(theme.brand());
|
|
38
|
+
this.log(theme.header('WORKFLOW MODIFICATION'));
|
|
39
|
+
// 1. Load Credentials & Client
|
|
40
|
+
const config = await ConfigManager.load();
|
|
41
|
+
const n8nUrl = config.n8nUrl || process.env.N8N_API_URL;
|
|
42
|
+
const n8nKey = config.n8nKey || process.env.N8N_API_KEY;
|
|
43
|
+
if (!n8nUrl || !n8nKey) {
|
|
44
|
+
this.error('Credentials missing. Configure environment via \'n8m config\'.');
|
|
45
|
+
}
|
|
46
|
+
const client = new N8nClient({ apiUrl: n8nUrl, apiKey: n8nKey });
|
|
47
|
+
// 1a. Fetch Valid Node Types
|
|
48
|
+
let validNodeTypes = [];
|
|
49
|
+
try {
|
|
50
|
+
validNodeTypes = await client.getNodeTypes();
|
|
51
|
+
if (validNodeTypes.length > 0) {
|
|
52
|
+
this.log(theme.muted(`β Loaded ${validNodeTypes.length} valid node types for validation.`));
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
catch (e) {
|
|
56
|
+
this.log(theme.warn(`β Failed to fetch node types: ${e.message}`));
|
|
57
|
+
}
|
|
58
|
+
// 2. Resolve Workflow
|
|
59
|
+
let workflowData;
|
|
60
|
+
let workflowName = 'Untitled';
|
|
61
|
+
let originalPath = undefined;
|
|
62
|
+
let remoteId = undefined;
|
|
63
|
+
if (args.workflow && existsSync(args.workflow)) {
|
|
64
|
+
// Direct file path
|
|
65
|
+
originalPath = path.resolve(args.workflow);
|
|
66
|
+
const content = await fs.readFile(originalPath, 'utf-8');
|
|
67
|
+
workflowData = JSON.parse(content);
|
|
68
|
+
workflowName = workflowData.name || path.basename(args.workflow, '.json');
|
|
69
|
+
}
|
|
70
|
+
else {
|
|
71
|
+
// Selection Logic (similar to test.ts)
|
|
72
|
+
this.log(theme.info('Searching for local and remote workflows...'));
|
|
73
|
+
const localChoices = [];
|
|
74
|
+
const workflowsDir = path.join(process.cwd(), 'workflows');
|
|
75
|
+
const searchDirs = [workflowsDir, process.cwd()];
|
|
76
|
+
for (const dir of searchDirs) {
|
|
77
|
+
if (existsSync(dir)) {
|
|
78
|
+
const files = await fs.readdir(dir);
|
|
79
|
+
for (const file of files) {
|
|
80
|
+
if (file.endsWith('.json')) {
|
|
81
|
+
localChoices.push({
|
|
82
|
+
name: `${theme.value('[LOCAL]')} ${file}`,
|
|
83
|
+
value: { type: 'local', path: path.join(dir, file) }
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
const remoteWorkflows = await client.getWorkflows();
|
|
90
|
+
const remoteChoices = remoteWorkflows
|
|
91
|
+
.map(w => ({
|
|
92
|
+
name: `${theme.info('[n8n]')} ${w.name} (${w.id}) ${w.active ? '[Active]' : ''}`,
|
|
93
|
+
value: { type: 'remote', id: w.id, data: w }
|
|
94
|
+
}));
|
|
95
|
+
const choices = [
|
|
96
|
+
...(localChoices.length > 0 ? [new inquirer.Separator('--- Local Files ---'), ...localChoices] : []),
|
|
97
|
+
...(remoteChoices.length > 0 ? [new inquirer.Separator('--- n8n Instance ---'), ...remoteChoices] : []),
|
|
98
|
+
];
|
|
99
|
+
if (choices.length === 0)
|
|
100
|
+
this.error('No workflows found locally or on n8n instance.');
|
|
101
|
+
const { selection } = await inquirer.prompt([{
|
|
102
|
+
type: 'select',
|
|
103
|
+
name: 'selection',
|
|
104
|
+
message: 'Select a workflow to modify:',
|
|
105
|
+
choices,
|
|
106
|
+
pageSize: 15
|
|
107
|
+
}]);
|
|
108
|
+
if (selection.type === 'local') {
|
|
109
|
+
originalPath = selection.path;
|
|
110
|
+
const content = await fs.readFile(originalPath, 'utf-8');
|
|
111
|
+
workflowData = JSON.parse(content);
|
|
112
|
+
workflowName = workflowData.name || path.basename(originalPath, '.json');
|
|
113
|
+
}
|
|
114
|
+
else {
|
|
115
|
+
remoteId = selection.id;
|
|
116
|
+
workflowData = await client.getWorkflow(remoteId);
|
|
117
|
+
workflowName = workflowData.name || 'Remote Workflow';
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
// 3. Get Instruction
|
|
121
|
+
let instruction = args.instruction;
|
|
122
|
+
if (!instruction && flags.multiline) {
|
|
123
|
+
const response = await inquirer.prompt([{
|
|
124
|
+
type: 'editor',
|
|
125
|
+
name: 'instruction',
|
|
126
|
+
message: 'Describe the modifications you want to apply (opens editor):',
|
|
127
|
+
validate: (d) => d.trim().length > 0
|
|
128
|
+
}]);
|
|
129
|
+
instruction = response.instruction;
|
|
130
|
+
}
|
|
131
|
+
if (!instruction) {
|
|
132
|
+
instruction = await promptMultiline('Describe the modifications you want to apply:');
|
|
133
|
+
}
|
|
134
|
+
if (!instruction) {
|
|
135
|
+
this.error('Modification instructions are required.');
|
|
136
|
+
}
|
|
137
|
+
// 4. AGENTIC EXECUTION
|
|
138
|
+
const threadId = randomUUID();
|
|
139
|
+
this.log(theme.info(`\nInitializing Agentic Modification for: "${workflowName}"`));
|
|
140
|
+
let lastWorkflowJson = workflowData;
|
|
141
|
+
const goal = `Modify the provided workflow based on these instructions: ${instruction}`;
|
|
142
|
+
const initialState = {
|
|
143
|
+
userGoal: goal,
|
|
144
|
+
messages: [],
|
|
145
|
+
validationErrors: [],
|
|
146
|
+
workflowJson: workflowData,
|
|
147
|
+
availableNodeTypes: validNodeTypes
|
|
148
|
+
};
|
|
149
|
+
try {
|
|
150
|
+
const stream = await graph.stream({
|
|
151
|
+
...initialState,
|
|
152
|
+
revisionCount: 0,
|
|
153
|
+
}, {
|
|
154
|
+
configurable: { thread_id: threadId }
|
|
155
|
+
});
|
|
156
|
+
for await (const event of stream) {
|
|
157
|
+
const nodeName = Object.keys(event)[0];
|
|
158
|
+
const stateUpdate = event[nodeName];
|
|
159
|
+
if (nodeName === 'architect') {
|
|
160
|
+
this.log(theme.agent(`ποΈ Architect: Analysis complete.`));
|
|
161
|
+
if (stateUpdate.spec) {
|
|
162
|
+
this.log(` Plan: ${theme.value(stateUpdate.spec.suggestedName || 'Modifying structure')}`);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
else if (nodeName === 'engineer') {
|
|
166
|
+
this.log(theme.agent(`βοΈ Engineer: Applying changes to workflow...`));
|
|
167
|
+
if (stateUpdate.workflowJson) {
|
|
168
|
+
lastWorkflowJson = stateUpdate.workflowJson;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
else if (nodeName === 'qa') {
|
|
172
|
+
const status = stateUpdate.validationStatus;
|
|
173
|
+
if (status === 'passed') {
|
|
174
|
+
this.log(theme.success(`π§ͺ QA: Modification Validated.`));
|
|
175
|
+
}
|
|
176
|
+
else {
|
|
177
|
+
this.log(theme.fail(`π§ͺ QA: Validation Issues Found.`));
|
|
178
|
+
if (stateUpdate.validationErrors && stateUpdate.validationErrors.length > 0) {
|
|
179
|
+
stateUpdate.validationErrors.forEach((e) => this.log(theme.error(` - ${e}`)));
|
|
180
|
+
}
|
|
181
|
+
this.log(theme.warn(` Looping back for refinements...`));
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
// HITL Pause
|
|
186
|
+
const snapshot = await graph.getState({ configurable: { thread_id: threadId } });
|
|
187
|
+
if (snapshot.next.length > 0) {
|
|
188
|
+
this.log(theme.warn(`\nβΈοΈ Modification Paused at step: ${snapshot.next.join(', ')}`));
|
|
189
|
+
const { resume } = await inquirer.prompt([{
|
|
190
|
+
type: 'confirm',
|
|
191
|
+
name: 'resume',
|
|
192
|
+
message: 'Review pending changes. Proceed with finalization?',
|
|
193
|
+
default: true
|
|
194
|
+
}]);
|
|
195
|
+
if (resume) {
|
|
196
|
+
this.log(theme.agent("Finalizing..."));
|
|
197
|
+
const result = await resumeAgenticWorkflow(threadId);
|
|
198
|
+
if (result.workflowJson)
|
|
199
|
+
lastWorkflowJson = result.workflowJson;
|
|
200
|
+
}
|
|
201
|
+
else {
|
|
202
|
+
this.log(theme.info(`Session persisted. Thread: ${threadId}`));
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
catch (error) {
|
|
208
|
+
this.error(`Agent encountered an error: ${error.message}`);
|
|
209
|
+
}
|
|
210
|
+
// 5. POST-MODIFICATION ACTIONS
|
|
211
|
+
const modifiedWorkflow = lastWorkflowJson.workflows ? lastWorkflowJson.workflows[0] : lastWorkflowJson;
|
|
212
|
+
// Self-Healing: Ensure settings and staticData exist for API compatibility
|
|
213
|
+
if (!modifiedWorkflow.settings)
|
|
214
|
+
modifiedWorkflow.settings = { executionOrder: 'v1' };
|
|
215
|
+
if (!modifiedWorkflow.staticData)
|
|
216
|
+
modifiedWorkflow.staticData = null;
|
|
217
|
+
// Preserve ID if it existed in the original and is missing in the new
|
|
218
|
+
if (workflowData.id) {
|
|
219
|
+
modifiedWorkflow.id = workflowData.id;
|
|
220
|
+
}
|
|
221
|
+
// Standardize naming
|
|
222
|
+
if (!modifiedWorkflow.name.toLowerCase().includes('modified')) {
|
|
223
|
+
// maybe add a suffix? Or just keep it.
|
|
224
|
+
}
|
|
225
|
+
const { action } = await inquirer.prompt([{
|
|
226
|
+
type: 'select',
|
|
227
|
+
name: 'action',
|
|
228
|
+
message: 'Modification complete. What would you like to do?',
|
|
229
|
+
choices: [
|
|
230
|
+
{ name: 'Save locally', value: 'save' },
|
|
231
|
+
{ name: 'Deploy to n8n instance', value: 'deploy' },
|
|
232
|
+
{ name: 'Run ephemeral test (n8m test)', value: 'test' },
|
|
233
|
+
{ name: 'Discard changes', value: 'discard' }
|
|
234
|
+
]
|
|
235
|
+
}]);
|
|
236
|
+
if (action === 'save') {
|
|
237
|
+
const defaultPath = flags.output || originalPath || path.join(process.cwd(), 'workflows', `${workflowName}-modified.json`);
|
|
238
|
+
const { targetPath } = await inquirer.prompt([{
|
|
239
|
+
type: 'input',
|
|
240
|
+
name: 'targetPath',
|
|
241
|
+
message: 'Save modified workflow to:',
|
|
242
|
+
default: defaultPath
|
|
243
|
+
}]);
|
|
244
|
+
await fs.mkdir(path.dirname(targetPath), { recursive: true });
|
|
245
|
+
await fs.writeFile(targetPath, JSON.stringify(modifiedWorkflow, null, 2));
|
|
246
|
+
this.log(theme.success(`β Saved to ${targetPath}`));
|
|
247
|
+
}
|
|
248
|
+
else if (action === 'deploy') {
|
|
249
|
+
if (remoteId) {
|
|
250
|
+
this.log(theme.info(`Updating remote workflow ${remoteId}...`));
|
|
251
|
+
await client.updateWorkflow(remoteId, modifiedWorkflow);
|
|
252
|
+
this.log(theme.success(`β Remote workflow updated.`));
|
|
253
|
+
}
|
|
254
|
+
else {
|
|
255
|
+
this.log(theme.info(`Creating new workflow on instance...`));
|
|
256
|
+
const payload = { ...modifiedWorkflow };
|
|
257
|
+
// Remove ID to ensure a fresh creation
|
|
258
|
+
delete payload.id;
|
|
259
|
+
const result = await client.createWorkflow(payload.name, payload);
|
|
260
|
+
this.log(theme.success(`β Created workflow [ID: ${result.id}]`));
|
|
261
|
+
this.log(`${theme.label('Link')} ${theme.secondary(client.getWorkflowLink(result.id))}`);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
else if (action === 'test') {
|
|
265
|
+
// Automatically run the test command
|
|
266
|
+
const tempPath = path.join(process.cwd(), '.n8m-temp-modified.json');
|
|
267
|
+
await fs.writeFile(tempPath, JSON.stringify(modifiedWorkflow, null, 2));
|
|
268
|
+
this.log(theme.info(`Workflow staged. Running ephemeral test...`));
|
|
269
|
+
// Execute Test command
|
|
270
|
+
// We import Test to avoid circular dependency issues if we just used runCommand with string?
|
|
271
|
+
// Actually runCommand is cleaner.
|
|
272
|
+
await this.config.runCommand('test', [tempPath]);
|
|
273
|
+
}
|
|
274
|
+
this.log(theme.done('Modification Process Complete.'));
|
|
275
|
+
}
|
|
276
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { Command } from '@oclif/core';
|
|
2
|
+
export default class Prune extends Command {
|
|
3
|
+
static description: string;
|
|
4
|
+
static flags: {
|
|
5
|
+
force: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
6
|
+
'dry-run': import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
7
|
+
};
|
|
8
|
+
run(): Promise<void>;
|
|
9
|
+
}
|