@n8n-as-code/n8nac 2026.3.1-next.12

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/CHANGELOG.md ADDED
@@ -0,0 +1,7 @@
1
+ # @n8n-as-code/n8nac
2
+
3
+ ## [2026.3.1](https://github.com/EtienneLescot/n8n-as-code/compare/@n8n-as-code/n8nac@v2026.3.0...@n8n-as-code/n8nac@v2026.3.1) (2026-03-13)
4
+
5
+ ### Documentation
6
+
7
+ * align editor and integration release messaging ([e1d6198](https://github.com/EtienneLescot/n8n-as-code/commit/e1d6198c3c6c942afe024f34b4ad419005ed991c))
package/README.md ADDED
@@ -0,0 +1,163 @@
1
+ # @n8n-as-code/n8nac
2
+
3
+ **OpenClaw-native access to the `n8n-as-code` workflow stack.**
4
+
5
+ Use OpenClaw to build, update, validate, and manage n8n workflows with the same `n8nac` CLI and AI context model used across the wider `n8n-as-code` project.
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ openclaw plugins install @n8n-as-code/n8nac
11
+ ```
12
+
13
+ If you previously installed `@n8n-as-code/openclaw-plugin`, remove the old install first so OpenClaw re-registers the plugin cleanly under `n8nac`:
14
+
15
+ ```bash
16
+ openclaw plugins uninstall n8nac
17
+ openclaw plugins install @n8n-as-code/n8nac
18
+ ```
19
+
20
+ Restart the gateway, then run the setup wizard:
21
+
22
+ ```bash
23
+ openclaw n8nac:setup
24
+ ```
25
+
26
+ The wizard asks for your n8n host URL and API key once, saves them via
27
+ `n8nac init-auth`, selects your project, and generates an AI context file
28
+ (`AGENTS.md`) in the workspace (`~/.openclaw/n8nac/`).
29
+
30
+ ## Usage
31
+
32
+ Once setup is done, just talk to OpenClaw:
33
+
34
+ > "Create an n8n workflow that sends a Slack message when a GitHub issue is opened"
35
+
36
+ > "Pull workflow 42 and add an error handler to it"
37
+
38
+ > "What operations does the Google Sheets node support?"
39
+
40
+ The plugin injects the full n8n-architect instructions into every conversation
41
+ so the AI knows the exact `n8nac` workflow (init-check → pull → edit → push → verify).
42
+
43
+ ## CLI commands
44
+
45
+ | Command | Description |
46
+ |---|---|
47
+ | `openclaw n8nac:setup` | Interactive setup wizard |
48
+ | `openclaw n8nac:status` | Show workspace status |
49
+
50
+ Options for `n8nac:setup`:
51
+
52
+ ```
53
+ --host <url> n8n host URL (skip prompt)
54
+ --api-key <key> n8n API key (skip prompt)
55
+ --project-index <n> Project to select non-interactively
56
+ ```
57
+
58
+ ## Workspace
59
+
60
+ All files live in `~/.openclaw/n8nac/`:
61
+
62
+ ```
63
+ ~/.openclaw/n8nac/
64
+ n8nac-config.json ← project binding (written by n8nac init-project)
65
+ AGENTS.md ← AI context (written by n8nac update-ai)
66
+ workflows/ ← .workflow.ts files (your n8n workflows)
67
+ ```
68
+
69
+ ## Agent tool
70
+
71
+ The plugin registers the `n8nac` tool with these actions:
72
+
73
+ | Action | Description |
74
+ |---|---|
75
+ | `setup_check` | Check initialization state |
76
+ | `init_auth` | Save n8n credentials |
77
+ | `init_project` | Select n8n project |
78
+ | `list` | List all workflows |
79
+ | `pull` | Download a workflow by ID |
80
+ | `push` | Upload a workflow file |
81
+ | `verify` | Validate live workflow against schema |
82
+ | `skills` | Run any `npx n8nac skills` subcommand |
83
+ | `validate` | Validate a local `.workflow.ts` file |
84
+
85
+ ## Local development
86
+
87
+ This section covers how to load the plugin from source during development so
88
+ that changes take effect immediately without an npm publish cycle.
89
+
90
+ ### 1. Link the plugin directory
91
+
92
+ OpenClaw's `--link` flag registers a local path instead of installing a copy.
93
+ jiti is used to run TypeScript directly, so no build step is needed.
94
+
95
+ ```bash
96
+ openclaw plugins install --link \
97
+ /home/etienne/repos/n8n-as-code/plugins/openclaw/n8n-as-code
98
+ ```
99
+
100
+ What this does:
101
+ - Adds the path to `plugins.load.paths` in `~/.openclaw/openclaw.json`
102
+ - Registers a `source: "path"` install record bound to the plugin ID `n8nac`
103
+ - No file copy — OpenClaw loads `index.ts` directly from the source tree
104
+
105
+ ### 2. Verify the plugin is registered
106
+
107
+ ```bash
108
+ openclaw plugins info n8nac
109
+ ```
110
+
111
+ You should see status `loaded` and the tool `n8nac` in the tools list.
112
+
113
+ ### 3. Run the setup wizard
114
+
115
+ ```bash
116
+ openclaw n8nac:setup
117
+ ```
118
+
119
+ Enter your n8n host and API key when prompted. The wizard writes
120
+ `~/.openclaw/n8nac/n8nac-config.json` and generates `AGENTS.md`.
121
+
122
+ ### 4. Iterate on the code
123
+
124
+ - Edit any `.ts` file in `plugins/openclaw/n8n-as-code/`
125
+ - **Restart the gateway** to reload: `openclaw stop && openclaw start` (or the
126
+ equivalent service restart on your setup)
127
+ - The `before_prompt_build` hook, tool schema, and CLI commands all reload on
128
+ gateway start
129
+
130
+ ### 5. Check gateway logs
131
+
132
+ ```bash
133
+ tail -f ~/.openclaw/logs/openclaw-$(date +%Y-%m-%d).log | grep n8nac
134
+ ```
135
+
136
+ The plugin prefixes all `api.logger` calls with `[n8nac]`.
137
+
138
+ ### 6. Inspect the n8nac workspace
139
+
140
+ ```
141
+ ~/.openclaw/n8nac/
142
+ n8nac-config.json ← written by init-project
143
+ AGENTS.md ← written by update-ai
144
+ workflows/ ← .workflow.ts files
145
+ ```
146
+
147
+ To reset and redo setup from scratch:
148
+
149
+ ```bash
150
+ rm -rf ~/.openclaw/n8nac && openclaw n8nac:setup
151
+ ```
152
+
153
+ ### 7. Unlink when done
154
+
155
+ ```bash
156
+ openclaw plugins uninstall n8nac
157
+ ```
158
+
159
+ ---
160
+
161
+ ## Source
162
+
163
+ Part of the [n8n-as-code](https://github.com/EtienneLescot/n8n-as-code) monorepo.
package/index.ts ADDED
@@ -0,0 +1,147 @@
1
+ import { existsSync, mkdirSync, readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
4
+ import { registerN8nAcCli } from "./src/cli.js";
5
+ import { createN8nAcTool } from "./src/tool.js";
6
+ import { getWorkspaceDir, isWorkspaceInitialized } from "./src/workspace.js";
7
+
8
+ // ---------------------------------------------------------------------------
9
+ // AGENTS.md context injection — populated once on service start
10
+ // ---------------------------------------------------------------------------
11
+
12
+ const BOOTSTRAP_CONTEXT = `\
13
+ ## n8n-as-code — Bootstrap
14
+
15
+ The n8n-as-code plugin is installed but the workspace has not been initialized yet.
16
+
17
+ **Tell the user:**
18
+ > "To start building n8n workflows I need your n8n host URL and API key."
19
+
20
+ Once you have both, call the \`n8nac\` tool with \`action: "init_auth"\`, then
21
+ \`action: "init_project"\` to finish setup.
22
+ `;
23
+
24
+ const MISSING_AGENTS_CONTEXT = `\
25
+ ## n8n-as-code — AI Context Missing
26
+
27
+ The workspace is initialized, but the generated AI context file (\`AGENTS.md\`) is missing or unreadable.
28
+
29
+ **Tell the user:**
30
+ > "Your n8n-as-code workspace is connected, but the AI context needs to be regenerated before I can safely guide workflow changes."
31
+
32
+ Ask the user to run \`npx --yes n8nac update-ai\` in the OpenClaw workspace, or rerun
33
+ \`openclaw n8nac:setup\` if they want the setup wizard to repair it.
34
+ `;
35
+
36
+ let agentsContext: string | null = null;
37
+
38
+ function readConfig(workspaceDir: string): Record<string, string> {
39
+ try {
40
+ const raw = readFileSync(join(workspaceDir, "n8nac-config.json"), "utf-8");
41
+ return JSON.parse(raw) as Record<string, string>;
42
+ } catch {
43
+ return {};
44
+ }
45
+ }
46
+
47
+ function buildStatusHeader(workspaceDir: string): string {
48
+ const cfg = readConfig(workspaceDir);
49
+ const host = cfg.host ?? "(unknown)";
50
+ const project = cfg.projectName ?? cfg.projectId ?? "(unknown)";
51
+ return [
52
+ "## ✅ n8n-as-code Workspace Status",
53
+ "",
54
+ "**The workspace is already fully initialized. Do NOT ask the user for credentials.**",
55
+ "",
56
+ `- Workspace directory: \`${workspaceDir}\``,
57
+ `- n8n host: \`${host}\``,
58
+ `- Active project: \`${project}\``,
59
+ "",
60
+ "Skip the 'Workspace Bootstrap' section below — setup is complete.",
61
+ "Proceed directly to the user's request using the `n8nac` tool.",
62
+ "",
63
+ "---",
64
+ "",
65
+ ].join("\n");
66
+ }
67
+
68
+ function loadAgentsContext(workspaceDir: string): string | null {
69
+ const p = join(workspaceDir, "AGENTS.md");
70
+ if (!existsSync(p)) return null;
71
+ try {
72
+ const raw = readFileSync(p, "utf-8");
73
+ return buildStatusHeader(workspaceDir) + raw;
74
+ } catch {
75
+ return null;
76
+ }
77
+ }
78
+
79
+ // ---------------------------------------------------------------------------
80
+ // Plugin
81
+ // ---------------------------------------------------------------------------
82
+
83
+ const n8nAcPlugin = {
84
+ id: "n8nac",
85
+ name: "n8n-as-code",
86
+ description:
87
+ "Create and manage n8n workflows from OpenClaw using n8n-as-code (n8nac). " +
88
+ "Guides through workspace initialization, workflow CRUD, and AI-powered node schema lookup.",
89
+
90
+ register(api: OpenClawPluginApi) {
91
+ const workspaceDir = getWorkspaceDir();
92
+
93
+ // Ensure the plugin workspace directory always exists.
94
+ mkdirSync(workspaceDir, { recursive: true });
95
+
96
+ // -- Context injection ---------------------------------------------------
97
+ // Prepend n8n-architect instructions to every prompt build.
98
+ api.on("before_prompt_build", () => {
99
+ const initialized = isWorkspaceInitialized(workspaceDir);
100
+ // Lazy-load: setup may have run after the gateway started, so the
101
+ // service start() missed it. Re-attempt on every prompt until loaded.
102
+ // The status header embeds host + project, so re-read on every call
103
+ // when not yet cached to pick up fresh config after setup.
104
+ if (agentsContext === null && initialized) {
105
+ agentsContext = loadAgentsContext(workspaceDir);
106
+ }
107
+ const context = agentsContext ?? (initialized ? MISSING_AGENTS_CONTEXT : BOOTSTRAP_CONTEXT);
108
+ if (!context) return;
109
+ return { prependContext: context };
110
+ });
111
+
112
+ // -- Agent tool ----------------------------------------------------------
113
+ api.registerTool(createN8nAcTool({ workspaceDir }));
114
+
115
+ // -- CLI wizard ----------------------------------------------------------
116
+ api.registerCli(
117
+ ({ program }) => registerN8nAcCli({ program, workspaceDir }),
118
+ { commands: ["n8nac:setup", "n8nac:status"] },
119
+ );
120
+
121
+ // -- Service -------------------------------------------------------------
122
+ // On gateway start: refresh the AGENTS.md cache so the agent always has
123
+ // up-to-date node knowledge.
124
+ api.registerService({
125
+ id: "n8nac-context",
126
+ start: async () => {
127
+ // Invalidate so next before_prompt_build re-reads from disk.
128
+ agentsContext = null;
129
+ if (isWorkspaceInitialized(workspaceDir)) {
130
+ agentsContext = loadAgentsContext(workspaceDir);
131
+ if (agentsContext) {
132
+ api.logger.info("[n8nac] Workspace ready — AI context loaded.");
133
+ } else {
134
+ api.logger.warn("[n8nac] Workspace ready, but AGENTS.md is missing or unreadable.");
135
+ }
136
+ } else {
137
+ api.logger.info("[n8nac] Workspace not initialized. Run `openclaw n8nac:setup`.");
138
+ }
139
+ },
140
+ stop: async () => {
141
+ agentsContext = null;
142
+ },
143
+ });
144
+ },
145
+ };
146
+
147
+ export default n8nAcPlugin;
@@ -0,0 +1,16 @@
1
+ {
2
+ "id": "n8nac",
3
+ "name": "n8n-as-code",
4
+ "description": "Create and manage n8n workflows from OpenClaw. Guides the AI through n8nac initialization and provides workflow tools.",
5
+ "configSchema": {
6
+ "type": "object",
7
+ "additionalProperties": false,
8
+ "properties": {
9
+ "enabled": {
10
+ "type": "boolean",
11
+ "default": false,
12
+ "description": "Enable the n8n-as-code integration"
13
+ }
14
+ }
15
+ }
16
+ }
package/package.json ADDED
@@ -0,0 +1,52 @@
1
+ {
2
+ "name": "@n8n-as-code/n8nac",
3
+ "version": "2026.3.1-next.12",
4
+ "description": "OpenClaw plugin for n8n-as-code — create and manage n8n workflows from OpenClaw",
5
+ "keywords": [
6
+ "n8n",
7
+ "workflow",
8
+ "automation",
9
+ "openclaw",
10
+ "openclaw-plugin",
11
+ "n8nac"
12
+ ],
13
+ "repository": {
14
+ "type": "git",
15
+ "url": "git+https://github.com/EtienneLescot/n8n-as-code.git",
16
+ "directory": "plugins/openclaw/n8n-as-code"
17
+ },
18
+ "license": "Apache-2.0",
19
+ "type": "module",
20
+ "scripts": {
21
+ "build": "npm run typecheck",
22
+ "clean": "rm -rf node_modules/.vitest",
23
+ "test": "vitest run",
24
+ "typecheck": "tsc --noEmit"
25
+ },
26
+ "dependencies": {
27
+ "@clack/prompts": "^1.0.1",
28
+ "@sinclair/typebox": "0.34.48"
29
+ },
30
+ "devDependencies": {
31
+ "@types/node": "^22.0.0",
32
+ "openclaw": "^2026.2.26",
33
+ "typescript": "^5.8.0",
34
+ "vitest": "^1.6.1"
35
+ },
36
+ "files": [
37
+ "CHANGELOG.md",
38
+ "README.md",
39
+ "index.ts",
40
+ "openclaw.plugin.json",
41
+ "src/",
42
+ "tsconfig.json"
43
+ ],
44
+ "peerDependencies": {
45
+ "openclaw": ">=2026.2.0"
46
+ },
47
+ "openclaw": {
48
+ "extensions": [
49
+ "./index.ts"
50
+ ]
51
+ }
52
+ }
package/src/cli.ts ADDED
@@ -0,0 +1,266 @@
1
+ import { mkdirSync } from "node:fs";
2
+ import { spawn } from "node:child_process";
3
+ import type { ChildProcess, ChildProcessWithoutNullStreams } from "node:child_process";
4
+ import * as p from "@clack/prompts";
5
+ import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
6
+ import { isWorkspaceInitialized } from "./workspace.js";
7
+
8
+ type CliProgram = Parameters<Parameters<OpenClawPluginApi["registerCli"]>[0]>[0]["program"];
9
+
10
+ type CliOpts = {
11
+ program: CliProgram;
12
+ workspaceDir: string;
13
+ };
14
+
15
+ type RunResult = {
16
+ stdout: string;
17
+ stderr: string;
18
+ exitCode: number;
19
+ timedOut: boolean;
20
+ };
21
+
22
+ function runN8nac(
23
+ args: string[],
24
+ opts: {
25
+ cwd: string;
26
+ timeout: number;
27
+ env?: NodeJS.ProcessEnv;
28
+ stdio?: "pipe" | "inherit";
29
+ },
30
+ ): Promise<RunResult> {
31
+ return new Promise((resolve) => {
32
+ const baseOptions = {
33
+ cwd: opts.cwd,
34
+ env: { ...process.env, ...opts.env },
35
+ };
36
+
37
+ const child: ChildProcess | ChildProcessWithoutNullStreams =
38
+ opts.stdio === "inherit"
39
+ ? spawn("npx", ["--yes", "n8nac", ...args], { ...baseOptions, stdio: "inherit" })
40
+ : spawn("npx", ["--yes", "n8nac", ...args], { ...baseOptions, stdio: "pipe" });
41
+
42
+ let stdout = "";
43
+ let stderr = "";
44
+ let timedOut = false;
45
+ let settled = false;
46
+ let killTimer: NodeJS.Timeout | undefined;
47
+
48
+ const finish = (result: RunResult) => {
49
+ if (settled) return;
50
+ settled = true;
51
+ clearTimeout(timer);
52
+ if (killTimer) clearTimeout(killTimer);
53
+ resolve(result);
54
+ };
55
+
56
+ if ("stdout" in child && child.stdout) {
57
+ child.stdout.on("data", (chunk: Buffer) => {
58
+ stdout += chunk.toString();
59
+ });
60
+ }
61
+
62
+ if ("stderr" in child && child.stderr) {
63
+ child.stderr.on("data", (chunk: Buffer) => {
64
+ stderr += chunk.toString();
65
+ });
66
+ }
67
+
68
+ const timer = setTimeout(() => {
69
+ timedOut = true;
70
+ child.kill("SIGTERM");
71
+ killTimer = setTimeout(() => {
72
+ if (settled) return;
73
+ child.kill("SIGKILL");
74
+ finish({ stdout, stderr: stderr || "Process timed out.", exitCode: 1, timedOut: true });
75
+ }, 2_000);
76
+ }, opts.timeout);
77
+
78
+ child.on("error", (error: Error) => {
79
+ finish({ stdout, stderr: error.message || stderr, exitCode: 1, timedOut });
80
+ });
81
+
82
+ child.on("close", (code: number | null) => {
83
+ finish({ stdout, stderr, exitCode: code ?? 1, timedOut });
84
+ });
85
+ });
86
+ }
87
+
88
+ export function registerN8nAcCli({ program, workspaceDir }: CliOpts): void {
89
+ // -------------------------------------------------------------------------
90
+ // n8nac:status — quick health check
91
+ // -------------------------------------------------------------------------
92
+ program
93
+ .command("n8nac:status")
94
+ .description("Show n8n-as-code workspace status")
95
+ .action(() => {
96
+ const initialized = isWorkspaceInitialized(workspaceDir);
97
+ console.log(`\nn8n-as-code workspace: ${workspaceDir}`);
98
+ console.log(`Status: ${initialized ? "✓ Initialized" : "✗ Not initialized"}`);
99
+ if (!initialized) {
100
+ console.log("\nRun `openclaw n8nac:setup` to connect your n8n instance.");
101
+ }
102
+ console.log();
103
+ });
104
+
105
+ // -------------------------------------------------------------------------
106
+ // n8nac:setup — interactive wizard
107
+ // -------------------------------------------------------------------------
108
+ program
109
+ .command("n8nac:setup")
110
+ .description("Initialize or reconfigure the n8n-as-code workspace")
111
+ .option("--host <url>", "n8n host URL (skip prompt)")
112
+ .option("--api-key <key>", "n8n API key (skip prompt)")
113
+ .option("--project-index <n>", "Project index to select non-interactively")
114
+ .action(async (opts: { host?: string; apiKey?: string; projectIndex?: string }) => {
115
+ p.intro("n8n-as-code setup");
116
+
117
+ // Ensure workspace dir exists.
118
+ mkdirSync(workspaceDir, { recursive: true });
119
+ p.log.info(`Workspace: ${workspaceDir}`);
120
+
121
+ // ------------------------------------------------------------------
122
+ // Step 1: Collect credentials
123
+ // ------------------------------------------------------------------
124
+ let host = opts.host ?? "";
125
+ if (!host) {
126
+ const answer = await p.text({
127
+ message: "n8n host URL",
128
+ placeholder: "https://your-n8n.example.com",
129
+ validate: (v) => (v && v.startsWith("http") ? undefined : "Must start with http:// or https://"),
130
+ });
131
+ if (p.isCancel(answer)) {
132
+ p.cancel("Setup cancelled.");
133
+ process.exit(0);
134
+ }
135
+ host = answer as string;
136
+ }
137
+
138
+ let apiKey = opts.apiKey ?? "";
139
+ if (!apiKey) {
140
+ const answer = await p.password({
141
+ message: "n8n API key",
142
+ validate: (v) => (v && v.length > 0 ? undefined : "API key cannot be empty"),
143
+ });
144
+ if (p.isCancel(answer)) {
145
+ p.cancel("Setup cancelled.");
146
+ process.exit(0);
147
+ }
148
+ apiKey = answer as string;
149
+ }
150
+
151
+ // ------------------------------------------------------------------
152
+ // Step 2: init-auth
153
+ // ------------------------------------------------------------------
154
+ const authSpinner = p.spinner();
155
+ authSpinner.start("Saving credentials…");
156
+
157
+ const authResult = await runN8nac(["init-auth", "--host", host], {
158
+ cwd: workspaceDir,
159
+ timeout: 60_000,
160
+ env: { N8N_API_KEY: apiKey },
161
+ });
162
+
163
+ if (authResult.exitCode !== 0) {
164
+ authSpinner.stop("Failed to save credentials.");
165
+ if (authResult.timedOut) {
166
+ p.log.error("n8nac init-auth timed out.");
167
+ }
168
+ p.log.error(authResult.stderr || authResult.stdout || "Unknown error.");
169
+ p.outro("Setup failed. Check your host URL and API key and try again.");
170
+ process.exit(1);
171
+ }
172
+ authSpinner.stop("Credentials saved ✓");
173
+
174
+ // ------------------------------------------------------------------
175
+ // Step 3: init-project
176
+ // If the user passed --project-index, run non-interactively.
177
+ // Otherwise inherit stdio so n8nac's own interactive project picker can appear.
178
+ // ------------------------------------------------------------------
179
+ const projectSpinner = p.spinner();
180
+ const projectArgs = ["init-project", "--sync-folder", "workflows"];
181
+ let projectResult: RunResult;
182
+
183
+ if (opts.projectIndex) {
184
+ const projectIdx = Number.parseInt(opts.projectIndex, 10);
185
+ if (!Number.isInteger(projectIdx) || projectIdx < 1) {
186
+ p.log.error("--project-index must be a positive integer.");
187
+ p.outro("Setup failed.");
188
+ process.exit(1);
189
+ }
190
+ projectSpinner.start("Selecting project…");
191
+ projectResult = await runN8nac([...projectArgs, "--project-index", String(projectIdx)], {
192
+ cwd: workspaceDir,
193
+ timeout: 120_000,
194
+ });
195
+ } else {
196
+ projectSpinner.start("Opening project picker…");
197
+ projectSpinner.stop("Project picker ready");
198
+ projectResult = await runN8nac(projectArgs, {
199
+ cwd: workspaceDir,
200
+ timeout: 120_000,
201
+ stdio: "inherit",
202
+ });
203
+ }
204
+
205
+ if (projectResult.exitCode !== 0) {
206
+ projectSpinner.stop("Failed to select project.");
207
+ if (projectResult.timedOut) {
208
+ p.log.error("n8nac init-project timed out.");
209
+ }
210
+ p.log.error(projectResult.stderr || projectResult.stdout || "Unknown error.");
211
+ p.log.info("If you have multiple projects, rerun with --project-index <n>.");
212
+ p.outro("Setup failed.");
213
+ process.exit(1);
214
+ }
215
+ projectSpinner.stop("Project selected ✓");
216
+
217
+ // ------------------------------------------------------------------
218
+ // Step 4: update-ai — generate AGENTS.md
219
+ // ------------------------------------------------------------------
220
+ const aiSpinner = p.spinner();
221
+ aiSpinner.start("Generating AI context (AGENTS.md)…");
222
+ const aiResult = await runN8nac(["update-ai"], {
223
+ cwd: workspaceDir,
224
+ timeout: 60_000,
225
+ });
226
+
227
+ if (aiResult.exitCode !== 0) {
228
+ aiSpinner.stop("Failed to generate AI context.");
229
+ if (aiResult.timedOut) {
230
+ p.log.error("n8nac update-ai timed out.");
231
+ }
232
+ p.log.error(aiResult.stderr || aiResult.stdout || "Unknown error.");
233
+ p.outro(
234
+ "Setup partially completed: credentials and project were saved, but AGENTS.md generation failed. " +
235
+ "Run `npx --yes n8nac update-ai` after fixing the issue.",
236
+ );
237
+ process.exit(1);
238
+ }
239
+ aiSpinner.stop("AI context ready ✓");
240
+
241
+ p.log.step("What's next?");
242
+ p.log.message(
243
+ [
244
+ " 1. Restart the OpenClaw gateway to activate the plugin:",
245
+ " openclaw gateway restart",
246
+ "",
247
+ " 2. Ask OpenClaw to create a workflow in plain language, for example:",
248
+ ' "Create an n8n workflow that sends a Slack message every morning"',
249
+ "",
250
+ " 3. Useful commands:",
251
+ " openclaw n8nac:status — check workspace + connection health",
252
+ " openclaw n8nac:setup — reconfigure host / API key",
253
+ "",
254
+ " 4. Manage workflows directly:",
255
+ " npx n8nac list — list local & remote workflows",
256
+ " npx n8nac pull <id> — download a workflow from n8n",
257
+ " npx n8nac push <file> — upload a workflow to n8n",
258
+ ].join("\n"),
259
+ );
260
+
261
+ p.outro(
262
+ `Setup complete!\n` +
263
+ `Workspace: ${workspaceDir}`,
264
+ );
265
+ });
266
+ }
package/src/tool.ts ADDED
@@ -0,0 +1,363 @@
1
+ import { spawn } from "node:child_process";
2
+ import { Type } from "@sinclair/typebox";
3
+ import { isWorkspaceInitialized } from "./workspace.js";
4
+
5
+ // ---------------------------------------------------------------------------
6
+ // Schema
7
+ // ---------------------------------------------------------------------------
8
+
9
+ const ACTIONS = [
10
+ "setup_check",
11
+ "init_auth",
12
+ "init_project",
13
+ "list",
14
+ "pull",
15
+ "push",
16
+ "verify",
17
+ "skills",
18
+ "validate",
19
+ ] as const;
20
+
21
+ const LIST_SCOPES = ["all", "local", "remote", "distant"] as const;
22
+
23
+ const N8nAcToolSchema = Type.Object({
24
+ action: Type.Unsafe<(typeof ACTIONS)[number]>({
25
+ type: "string",
26
+ enum: [...ACTIONS],
27
+ description: [
28
+ "Action to perform:",
29
+ " setup_check — check whether the workspace is initialized.",
30
+ " init_auth — save n8n credentials. Requires n8nHost and n8nApiKey.",
31
+ " init_project — select the n8n project. Optionally pass projectId, projectName, or projectIndex (1-based, default 1).",
32
+ " list — list all workflows with their sync status.",
33
+ " pull — download a workflow from n8n. Requires workflowId.",
34
+ " push — upload a local workflow file. Requires filename (e.g. my-flow.workflow.ts).",
35
+ " verify — fetch a workflow from n8n and validate it. Requires workflowId.",
36
+ " skills — run any n8nac skills subcommand. Requires skillsArgs (e.g. 'search telegram' or 'node-info googleSheets').",
37
+ " validate — validate a local workflow file. Requires validateFile.",
38
+ ].join("\n"),
39
+ }),
40
+ // init_auth
41
+ n8nHost: Type.Optional(
42
+ Type.String({ description: "n8n host URL (for init_auth). Example: https://your-n8n.example.com" }),
43
+ ),
44
+ n8nApiKey: Type.Optional(Type.String({ description: "n8n API key (for init_auth)" })),
45
+ // init_project
46
+ projectId: Type.Optional(Type.String({ description: "n8n project ID (for init_project)" })),
47
+ projectName: Type.Optional(Type.String({ description: "n8n project name (for init_project)" })),
48
+ projectIndex: Type.Optional(
49
+ Type.Number({ description: "n8n project index, 1-based (for init_project, default: 1)" }),
50
+ ),
51
+ listScope: Type.Optional(
52
+ Type.Unsafe<(typeof LIST_SCOPES)[number]>({
53
+ type: "string",
54
+ enum: [...LIST_SCOPES],
55
+ description: "Workflow list scope (for list). One of: all, local, remote, distant.",
56
+ }),
57
+ ),
58
+ // pull / verify
59
+ workflowId: Type.Optional(Type.String({ description: "Workflow ID (for pull, verify)" })),
60
+ // push
61
+ filename: Type.Optional(
62
+ Type.String({
63
+ description:
64
+ "Workflow filename including .workflow.ts extension (for push). " +
65
+ "Example: my-flow.workflow.ts. Do NOT pass a path.",
66
+ }),
67
+ ),
68
+ // skills
69
+ skillsArgs: Type.Optional(
70
+ Type.String({
71
+ description:
72
+ "Arguments for the n8nac skills subcommand (for skills action). " +
73
+ "Examples: 'search telegram', 'node-info googleSheets', 'examples search slack', 'docs OpenAI'",
74
+ }),
75
+ ),
76
+ skillsArgv: Type.Optional(
77
+ Type.Array(Type.String(), {
78
+ description:
79
+ "Array form of arguments for the n8nac skills subcommand (preferred when values contain spaces). " +
80
+ 'Example: ["examples", "search", "slack notification"]',
81
+ }),
82
+ ),
83
+ // validate
84
+ validateFile: Type.Optional(
85
+ Type.String({ description: "Workflow file path to validate (for validate action)" }),
86
+ ),
87
+ });
88
+
89
+ // ---------------------------------------------------------------------------
90
+ // Helpers
91
+ // ---------------------------------------------------------------------------
92
+
93
+ type RunResult = {
94
+ stdout: string;
95
+ stderr: string;
96
+ exitCode: number;
97
+ timedOut: boolean;
98
+ };
99
+
100
+ function runNpx(
101
+ args: string[],
102
+ cwd: string,
103
+ env?: NodeJS.ProcessEnv,
104
+ ): Promise<RunResult> {
105
+ return new Promise((resolve) => {
106
+ const child = spawn("npx", ["--yes", "n8nac", ...args], {
107
+ cwd,
108
+ env: { ...process.env, ...env },
109
+ stdio: "pipe",
110
+ });
111
+
112
+ let stdout = "";
113
+ let stderr = "";
114
+ let timedOut = false;
115
+ let settled = false;
116
+ let killTimer: NodeJS.Timeout | undefined;
117
+
118
+ const finish = (result: RunResult) => {
119
+ if (settled) return;
120
+ settled = true;
121
+ clearTimeout(timer);
122
+ if (killTimer) clearTimeout(killTimer);
123
+ resolve(result);
124
+ };
125
+
126
+ child.stdout.on("data", (chunk) => {
127
+ stdout += chunk.toString();
128
+ });
129
+
130
+ child.stderr.on("data", (chunk) => {
131
+ stderr += chunk.toString();
132
+ });
133
+
134
+ const timer = setTimeout(() => {
135
+ timedOut = true;
136
+ child.kill("SIGTERM");
137
+ killTimer = setTimeout(() => {
138
+ if (settled) return;
139
+ child.kill("SIGKILL");
140
+ finish({ stdout, stderr: stderr || "Process timed out.", exitCode: 1, timedOut: true });
141
+ }, 2_000);
142
+ }, 120_000);
143
+
144
+ child.on("error", (error) => {
145
+ finish({ stdout, stderr: error.message || stderr, exitCode: 1, timedOut });
146
+ });
147
+
148
+ child.on("close", (code) => {
149
+ finish({ stdout, stderr, exitCode: code ?? 1, timedOut });
150
+ });
151
+ });
152
+ }
153
+
154
+ function ok(data: unknown) {
155
+ return {
156
+ content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }],
157
+ details: data,
158
+ };
159
+ }
160
+
161
+ function str(v: unknown): string {
162
+ return typeof v === "string" ? v.trim() : "";
163
+ }
164
+
165
+ export function splitArgv(input: string): string[] | null {
166
+ const args: string[] = [];
167
+ let current = "";
168
+ let quote: '"' | "'" | null = null;
169
+ let escaping = false;
170
+
171
+ for (const ch of input) {
172
+ if (escaping) {
173
+ current += ch;
174
+ escaping = false;
175
+ continue;
176
+ }
177
+
178
+ if (ch === "\\" && quote !== "'") {
179
+ escaping = true;
180
+ continue;
181
+ }
182
+
183
+ if (quote) {
184
+ if (ch === quote) {
185
+ quote = null;
186
+ } else {
187
+ current += ch;
188
+ }
189
+ continue;
190
+ }
191
+
192
+ if (ch === '"' || ch === "'") {
193
+ quote = ch;
194
+ continue;
195
+ }
196
+
197
+ if (/\s/.test(ch)) {
198
+ if (current) {
199
+ args.push(current);
200
+ current = "";
201
+ }
202
+ continue;
203
+ }
204
+
205
+ current += ch;
206
+ }
207
+
208
+ if (escaping) current += "\\";
209
+ if (quote) return null;
210
+ if (current) args.push(current);
211
+ return args;
212
+ }
213
+
214
+ // ---------------------------------------------------------------------------
215
+ // Tool factory
216
+ // ---------------------------------------------------------------------------
217
+
218
+ export function createN8nAcTool(opts: { workspaceDir: string }) {
219
+ const { workspaceDir } = opts;
220
+
221
+ return {
222
+ name: "n8nac",
223
+ label: "n8n-as-code",
224
+ description:
225
+ "Create and manage n8n workflows using n8n-as-code. " +
226
+ "Handles workspace initialization (init_auth → init_project), " +
227
+ "workflow sync (list, pull, push, verify), and AI knowledge lookup (skills, validate). " +
228
+ "Always call setup_check first to determine initialization state.",
229
+ parameters: N8nAcToolSchema,
230
+
231
+ async execute(_toolCallId: string, params: Record<string, unknown>) {
232
+ const action = str(params.action);
233
+
234
+ // ---- setup_check --------------------------------------------------
235
+ if (action === "setup_check") {
236
+ const initialized = isWorkspaceInitialized(workspaceDir);
237
+ return ok({
238
+ initialized,
239
+ workspaceDir,
240
+ next: initialized
241
+ ? "Workspace is ready. Use list, pull, push, verify, or skills."
242
+ : "Workspace not initialized. Ask the user for their n8n host URL and API key, then call init_auth.",
243
+ });
244
+ }
245
+
246
+ // ---- init_auth ----------------------------------------------------
247
+ if (action === "init_auth") {
248
+ const host = str(params.n8nHost);
249
+ const key = str(params.n8nApiKey);
250
+ if (!host || !key) {
251
+ return ok({ error: "n8nHost and n8nApiKey are required for init_auth" });
252
+ }
253
+ const r = await runNpx(["init-auth", "--host", host], workspaceDir, { N8N_API_KEY: key });
254
+ if (r.exitCode !== 0) {
255
+ return ok({ error: r.stderr || r.stdout, exitCode: r.exitCode });
256
+ }
257
+ return ok({
258
+ ok: true,
259
+ output: r.stdout,
260
+ next: "Credentials saved. Now call init_project. If you need to inspect remote workflows first, use list with listScope: 'remote'.",
261
+ });
262
+ }
263
+
264
+ // ---- init_project -------------------------------------------------
265
+ if (action === "init_project") {
266
+ const id = str(params.projectId);
267
+ const name = str(params.projectName);
268
+ const idx = typeof params.projectIndex === "number" ? params.projectIndex : 1;
269
+ const args: string[] = ["init-project", "--sync-folder", "workflows"];
270
+ if (id) args.push("--project-id", id);
271
+ else if (name) args.push("--project-name", name);
272
+ else args.push("--project-index", String(idx));
273
+
274
+ const r = await runNpx(args, workspaceDir);
275
+ if (r.exitCode !== 0) {
276
+ return ok({ error: r.stderr || r.stdout, exitCode: r.exitCode });
277
+ }
278
+ // Refresh AGENTS.md after successful init
279
+ const ai = await runNpx(["update-ai"], workspaceDir);
280
+ if (ai.exitCode !== 0) {
281
+ return ok({
282
+ ok: true,
283
+ output: r.stdout,
284
+ warning: ai.stderr || ai.stdout || "AGENTS.md regeneration failed.",
285
+ next:
286
+ "Workspace initialized, but AI context regeneration failed. Run `npx --yes n8nac update-ai` before relying on agent-guided workflow work.",
287
+ });
288
+ }
289
+ return ok({
290
+ ok: true,
291
+ output: r.stdout,
292
+ next: "Workspace initialized. AGENTS.md regenerated. You can now list, pull, push, and verify workflows.",
293
+ });
294
+ }
295
+
296
+ // ---- list ---------------------------------------------------------
297
+ if (action === "list") {
298
+ const scope = str(params.listScope) || "all";
299
+ const args = ["list"];
300
+ if (scope === "local" || scope === "remote" || scope === "distant") {
301
+ args.push(`--${scope}`);
302
+ }
303
+ const r = await runNpx(args, workspaceDir);
304
+ return ok({ exitCode: r.exitCode, output: r.stdout, error: r.stderr || undefined });
305
+ }
306
+
307
+ // ---- pull ---------------------------------------------------------
308
+ if (action === "pull") {
309
+ const id = str(params.workflowId);
310
+ if (!id) return ok({ error: "workflowId is required for pull" });
311
+ const r = await runNpx(["pull", id], workspaceDir);
312
+ return ok({ exitCode: r.exitCode, output: r.stdout, error: r.stderr || undefined });
313
+ }
314
+
315
+ // ---- push ---------------------------------------------------------
316
+ if (action === "push") {
317
+ const file = str(params.filename);
318
+ if (!file) return ok({ error: "filename is required for push (e.g. my-flow.workflow.ts)" });
319
+ const r = await runNpx(["push", file, "--verify"], workspaceDir);
320
+ return ok({ exitCode: r.exitCode, output: r.stdout, error: r.stderr || undefined });
321
+ }
322
+
323
+ // ---- verify -------------------------------------------------------
324
+ if (action === "verify") {
325
+ const id = str(params.workflowId);
326
+ if (!id) return ok({ error: "workflowId is required for verify" });
327
+ const r = await runNpx(["verify", id], workspaceDir);
328
+ return ok({ exitCode: r.exitCode, output: r.stdout, error: r.stderr || undefined });
329
+ }
330
+
331
+ // ---- skills -------------------------------------------------------
332
+ if (action === "skills") {
333
+ const skillsArgv = Array.isArray(params.skillsArgv)
334
+ ? params.skillsArgv.filter((value): value is string => typeof value === "string" && value.trim().length > 0)
335
+ : [];
336
+ const skillsArgs = str(params.skillsArgs);
337
+ if (!skillsArgv.length && !skillsArgs) {
338
+ return ok({
339
+ error:
340
+ "skillsArgv or skillsArgs is required. Examples: skillsArgv: ['examples', 'search', 'slack notification']",
341
+ });
342
+ }
343
+ const parsedArgs = skillsArgv.length ? skillsArgv : splitArgv(skillsArgs);
344
+ if (!parsedArgs) {
345
+ return ok({ error: "skillsArgs contains an unterminated quote. Prefer skillsArgv when values contain spaces." });
346
+ }
347
+ const args = ["skills", ...parsedArgs];
348
+ const r = await runNpx(args, workspaceDir);
349
+ return ok({ exitCode: r.exitCode, output: r.stdout, error: r.stderr || undefined });
350
+ }
351
+
352
+ // ---- validate -----------------------------------------------------
353
+ if (action === "validate") {
354
+ const file = str(params.validateFile);
355
+ if (!file) return ok({ error: "validateFile is required for validate" });
356
+ const r = await runNpx(["skills", "validate", file], workspaceDir);
357
+ return ok({ exitCode: r.exitCode, output: r.stdout, error: r.stderr || undefined });
358
+ }
359
+
360
+ return ok({ error: `Unknown action: ${action}` });
361
+ },
362
+ };
363
+ }
@@ -0,0 +1,31 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+
5
+ /**
6
+ * Fixed workspace directory for V1.
7
+ * All n8nac files (n8nac-config.json, AGENTS.md, workflows/) live here.
8
+ */
9
+ export function getWorkspaceDir(): string {
10
+ return join(homedir(), ".openclaw", "n8nac");
11
+ }
12
+
13
+ /**
14
+ * Returns true when n8nac has been initialized in the given directory,
15
+ * meaning the config exists and contains a selected project + sync folder.
16
+ */
17
+ export function isWorkspaceInitialized(workspaceDir: string): boolean {
18
+ const configPath = join(workspaceDir, "n8nac-config.json");
19
+ if (!existsSync(configPath)) return false;
20
+
21
+ try {
22
+ const raw = readFileSync(configPath, "utf-8");
23
+ const config = JSON.parse(raw) as Record<string, unknown>;
24
+ const projectId = typeof config.projectId === "string" ? config.projectId.trim() : "";
25
+ const projectName = typeof config.projectName === "string" ? config.projectName.trim() : "";
26
+ const syncFolder = typeof config.syncFolder === "string" ? config.syncFolder.trim() : "";
27
+ return projectId.length > 0 && projectName.length > 0 && syncFolder.length > 0;
28
+ } catch {
29
+ return false;
30
+ }
31
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,18 @@
1
+ {
2
+ "compilerOptions": {
3
+ "allowImportingTsExtensions": true,
4
+ "allowSyntheticDefaultImports": true,
5
+ "esModuleInterop": true,
6
+ "forceConsistentCasingInFileNames": true,
7
+ "lib": ["DOM", "DOM.Iterable", "ES2023"],
8
+ "module": "NodeNext",
9
+ "moduleResolution": "NodeNext",
10
+ "noEmit": true,
11
+ "resolveJsonModule": true,
12
+ "skipLibCheck": true,
13
+ "strict": true,
14
+ "target": "es2023"
15
+ },
16
+ "include": ["index.ts", "src/**/*", "tests/**/*.ts"],
17
+ "exclude": ["node_modules", "dist"]
18
+ }