Skip to content

Commit a02ae85

Browse files
syucreamclaude
andcommitted
feat: add convert command for JSON/YAML format conversion
Add a local-only convert command that converts workflow files between JSON and YAML formats. Supports --ids filtering, --tags filtering, --dry-run, --keep, and externalize threshold configuration. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> Signed-off-by: Ryo Okubo <syucream1031@gmail.com>
1 parent 9b25487 commit a02ae85

7 files changed

Lines changed: 594 additions & 12 deletions

File tree

.claude/skills/n8n-cli/SKILL.md

Lines changed: 43 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -459,11 +459,46 @@ Config file search order:
459459
# 3. Review error details and fix the workflow
460460
```
461461

462-
<<<<<<< HEAD
463-
### 9. Data Tables
464-
=======
465-
### 8. Data Tables
466-
>>>>>>> 2127c8ac10de74433e5bdbf0da7fc5a9437c9369
462+
### 9. Convert (Format Conversion)
463+
464+
**Convert workflow files between JSON and YAML formats (local-only, no API needed):**
465+
466+
```bash
467+
# Convert all JSON workflows to YAML
468+
./n8n-cli convert -d ./definitions --format yaml
469+
470+
# Convert specific workflows by ID
471+
./n8n-cli convert -d ./definitions --format json --ids <workflow-id>
472+
473+
# Preview without writing
474+
./n8n-cli convert -d ./definitions --format yaml --dry-run
475+
476+
# Keep original files
477+
./n8n-cli convert -d ./definitions --format yaml --keep
478+
479+
# Convert a single file
480+
./n8n-cli convert --format yaml definitions/<filename>.json
481+
```
482+
483+
**Options:**
484+
485+
| Option | Description |
486+
|--------|-------------|
487+
| `--format <format>` | Target format: `json`, `yaml` (required) |
488+
| `-d, --directory <dir>` | Directory to scan for workflow files |
489+
| `--ids <ids>` | Comma-separated workflow IDs to convert |
490+
| `--tags <tags>` | Filter by tags (comma-separated, AND condition) |
491+
| `-t, --threshold <n>` | Minimum lines for code externalization (JSON→YAML) |
492+
| `--dry-run` | Preview only |
493+
| `--keep` | Keep original files |
494+
495+
**Behavior:**
496+
- JSON→YAML: generates YAML + `_subfiles/` with externalized code
497+
- YAML→JSON: resolves `!include` refs and removes `_subfiles/`
498+
- Files already in target format are skipped
499+
- Original files removed after conversion unless `--keep`
500+
501+
### 10. Data Tables
467502

468503
**Manage data tables and rows:**
469504

@@ -525,8 +560,7 @@ Config file search order:
525560

526561
Supported column types: `string`, `number`, `boolean`, `date`, `json`.
527562

528-
<<<<<<< HEAD
529-
### 10. Trace (Data Flow Analysis)
563+
### 11. Trace (Data Flow Analysis)
530564

531565
**Analyze data flow and cardinality through a workflow:**
532566

@@ -570,7 +604,7 @@ When a node shows `?` for estimated items, it means cardinality could not be sta
570604
4. For `HTTP Request`, check if the response is an array or single object
571605
5. **Do not assume `?` means "many"** — it simply means "unknown at static analysis time"
572606

573-
### 11. Credential (Credential Management)
607+
### 12. Credential (Credential Management)
574608

575609
**Check available credentials:**
576610

@@ -589,7 +623,7 @@ When a node shows `?` for estimated items, it means cardinality could not be sta
589623
- If a node uses an external service, verify the corresponding credential exists
590624
- If a credential is missing, ask the user to create it in the n8n UI
591625

592-
### 12. Node Schema (Node Schema Reference)
626+
### 13. Node Schema (Node Schema Reference)
593627

594628
**Check node parameter definitions:**
595629

@@ -622,9 +656,6 @@ When a node shows `?` for estimated items, it means cardinality could not be sta
622656
|--------|-------------|
623657
| `--type <nodeType>` | Specific node type schema (e.g., `n8n-nodes-base.slack`) |
624658
| `-o, --output-dir <dir>` | Dump all nodes as individual files to directory |
625-
626-
=======
627-
>>>>>>> 2127c8ac10de74433e5bdbf0da7fc5a9437c9369
628659
## Typical Workflow Operations
629660

630661
### Editing Workflows

CONTRIBUTING.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ n8n-cli/
4444
│ ├── cli/ # CLI commands and output formatters
4545
│ ├── common/ # Shared utilities
4646
│ ├── config/ # Configuration loading
47+
│ ├── convert/ # Format conversion (JSON ↔ YAML)
4748
│ ├── formatter/ # Workflow formatting
4849
│ ├── git/ # Git integration
4950
│ ├── importer/ # Import command logic

README.md

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ A command-line interface for managing [n8n](https://n8n.io/) workflows as code.
55
## Features
66

77
- **Apply** - Deploy local workflow definitions (JSON/YAML) to an n8n server with dry-run support and conflict detection
8+
- **Convert** - Convert workflow files between JSON and YAML formats locally
89
- **Import** - Pull workflows from an n8n server to local files, with optional YAML conversion and code externalization
910
- **Lint** - Validate workflow definitions against configurable rules
1011
- **Format** - Auto-organize node positions for cleaner workflow layouts
@@ -109,6 +110,50 @@ n8n-cli apply [options]
109110
| `1` | Error detected |
110111
| `2` | Conflict detected (dry-run) or warning detected (non-force mode) |
111112

113+
### `convert`
114+
115+
Convert workflow files between formats (JSON ↔ YAML). This is a local-only operation that does not require an n8n server connection.
116+
117+
```bash
118+
n8n-cli convert [options] [files...]
119+
```
120+
121+
| Option | Description |
122+
|--------|-------------|
123+
| `--format <format>` | Target format: `json`, `yaml` (required) |
124+
| `-d, --directory <dir>` | Directory to scan for workflow files |
125+
| `--ids <ids>` | Comma-separated workflow IDs to convert |
126+
| `--tags <tags>` | Filter by tags (comma-separated, AND condition) |
127+
| `-t, --threshold <n>` | Minimum lines for code externalization (JSON→YAML) |
128+
| `--dry-run` | Preview conversions without writing files |
129+
| `--keep` | Keep original files after conversion |
130+
131+
**Examples:**
132+
133+
```bash
134+
# Convert all JSON workflows in a directory to YAML
135+
n8n-cli convert -d ./definitions --format yaml
136+
137+
# Convert specific workflows by ID
138+
n8n-cli convert -d ./definitions --format json --ids wf-100,wf-200
139+
140+
# Preview conversions without making changes
141+
n8n-cli convert -d ./definitions --format yaml --dry-run
142+
143+
# Convert but keep the original files
144+
n8n-cli convert -d ./definitions --format yaml --keep
145+
146+
# Convert a specific file
147+
n8n-cli convert --format yaml workflow__wf-100.json
148+
```
149+
150+
**Behavior:**
151+
152+
- **JSON → YAML**: Generates YAML with code externalization (`_subfiles/`) and `description.md`
153+
- **YAML → JSON**: Resolves `!include` directives (inlines external files) and removes `_subfiles/` directories
154+
- Files already in the target format are skipped
155+
- Original files are removed after conversion unless `--keep` is specified
156+
112157
### `import`
113158

114159
Import workflows from n8n to local files.

src/cli/commands/convert.ts

Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
import { readdirSync, statSync } from "node:fs";
2+
import path from "node:path";
3+
import type { Command } from "commander";
4+
import { WORKFLOW_EXTENSIONS } from "@/common/extensions.ts";
5+
import { hasAllTags, parseTagFilter } from "@/common/tags.ts";
6+
import { getEffectiveExternalizeThreshold, loadCLIConfig } from "@/config/claude-md.ts";
7+
import { convertWorkflowFile, type TargetFormat } from "@/convert/converter.ts";
8+
import { parseWorkflowFile } from "@/importer/scanner.ts";
9+
10+
/** registerConvertCommand registers the convert subcommand. */
11+
export function registerConvertCommand(program: Command): void {
12+
program
13+
.command("convert")
14+
.description("Convert workflow files between formats (JSON ↔ YAML)")
15+
.requiredOption("--format <format>", "Target format: json, yaml")
16+
.option("-d, --directory <dir>", "Directory to scan for workflow files")
17+
.option("--ids <ids>", "Comma-separated workflow IDs to convert")
18+
.option("--tags <tags>", "Filter by tags (comma-separated, AND condition)")
19+
.option("-t, --threshold <n>", "Minimum lines for code externalization (JSON→YAML)", "0")
20+
.option("--dry-run", "Preview conversions without writing files", false)
21+
.option("--keep", "Keep original files after conversion", false)
22+
.argument("[files...]", "Specific workflow files to convert")
23+
.action(
24+
async (
25+
files: string[],
26+
opts: {
27+
format: string;
28+
directory?: string;
29+
ids?: string;
30+
tags?: string;
31+
threshold: string;
32+
dryRun: boolean;
33+
keep: boolean;
34+
},
35+
) => {
36+
// Validate target format
37+
const targetFormat = opts.format as TargetFormat;
38+
if (targetFormat !== "json" && targetFormat !== "yaml") {
39+
console.error(`Error: unsupported format "${opts.format}". Use "json" or "yaml".`);
40+
process.exit(1);
41+
}
42+
43+
// Load config for threshold
44+
const cliConfig = loadCLIConfig();
45+
const threshold = getEffectiveExternalizeThreshold(
46+
Number.parseInt(opts.threshold, 10) || 0,
47+
cliConfig,
48+
);
49+
50+
// Collect target files
51+
const targetFiles = [...files];
52+
if (opts.directory) {
53+
targetFiles.push(...scanWorkflowFiles(opts.directory));
54+
}
55+
56+
if (targetFiles.length === 0) {
57+
console.error("Error: no files specified. Use -d <directory> or provide file paths.");
58+
process.exit(1);
59+
}
60+
61+
// Parse --ids filter
62+
const idsFilter = opts.ids
63+
? new Set(
64+
opts.ids
65+
.split(",")
66+
.map((s) => s.trim())
67+
.filter(Boolean),
68+
)
69+
: null;
70+
71+
// Parse --tags filter
72+
const filterByTags = parseTagFilter(opts.tags);
73+
74+
if (filterByTags.length > 0) {
75+
console.error(`Filtering by tags: ${filterByTags.join(", ")} (AND)`);
76+
}
77+
78+
let converted = 0;
79+
let skipped = 0;
80+
let errors = 0;
81+
82+
for (const filePath of targetFiles) {
83+
// ID filtering
84+
if (idsFilter) {
85+
try {
86+
const wf = parseWorkflowFile(filePath);
87+
if (!wf.id || !idsFilter.has(wf.id)) {
88+
continue;
89+
}
90+
} catch {
91+
// Cannot parse — skip
92+
continue;
93+
}
94+
}
95+
96+
// Tag filtering
97+
if (filterByTags.length > 0) {
98+
try {
99+
const wf = parseWorkflowFile(filePath);
100+
if (!hasAllTags(wf.tags, filterByTags)) {
101+
continue;
102+
}
103+
} catch {
104+
// Cannot parse — skip
105+
continue;
106+
}
107+
}
108+
109+
const directory = opts.directory ?? path.dirname(filePath);
110+
111+
const result = convertWorkflowFile(filePath, {
112+
targetFormat,
113+
directory,
114+
externalizeThreshold: threshold,
115+
dryRun: opts.dryRun,
116+
keepOriginal: opts.keep,
117+
});
118+
119+
if (result.error) {
120+
console.error(`Error: ${filePath}: ${result.error.message}`);
121+
errors++;
122+
continue;
123+
}
124+
125+
if (result.skipped) {
126+
console.error(`Skip: ${filePath}: ${result.skipReason}`);
127+
skipped++;
128+
continue;
129+
}
130+
131+
const dryRunLabel = opts.dryRun ? " (dry-run)" : "";
132+
console.log(`Converted${dryRunLabel}: ${filePath}${result.outputPath}`);
133+
134+
if (result.removedFiles.length > 0) {
135+
for (const removed of result.removedFiles) {
136+
console.log(` Removed: ${removed}`);
137+
}
138+
}
139+
140+
converted++;
141+
}
142+
143+
console.log(`\nConverted: ${converted}, Skipped: ${skipped}, Errors: ${errors}`);
144+
145+
if (errors > 0) {
146+
process.exit(1);
147+
}
148+
},
149+
);
150+
}
151+
152+
/** Recursively scans a directory for workflow files (.json, .yaml, .yml). */
153+
function scanWorkflowFiles(dir: string): string[] {
154+
const results: string[] = [];
155+
156+
try {
157+
const entries = readdirSync(dir);
158+
for (const entry of entries) {
159+
const fullPath = path.join(dir, entry);
160+
const stat = statSync(fullPath);
161+
162+
if (stat.isDirectory()) {
163+
if (entry.startsWith("_")) continue;
164+
results.push(...scanWorkflowFiles(fullPath));
165+
} else {
166+
const ext = path.extname(entry).toLowerCase();
167+
if (WORKFLOW_EXTENSIONS.has(ext)) {
168+
results.push(fullPath);
169+
}
170+
}
171+
}
172+
} catch {
173+
// Skip unreadable directories
174+
}
175+
176+
return results;
177+
}

0 commit comments

Comments
 (0)