@lhi/n8m 0.2.4 → 0.3.1

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.
@@ -1,14 +1,52 @@
1
1
  import { Args, Command, Flags } from '@oclif/core';
2
2
  import { theme } from '../utils/theme.js';
3
+ import path from 'node:path';
4
+ async function findWorkflowFiles(rootDir) {
5
+ const fs = await import('node:fs/promises');
6
+ const choices = [];
7
+ async function scan(dir) {
8
+ let entries;
9
+ try {
10
+ entries = await fs.readdir(dir, { withFileTypes: true });
11
+ }
12
+ catch {
13
+ return;
14
+ }
15
+ for (const entry of entries) {
16
+ const fullPath = path.join(dir, entry.name);
17
+ if (entry.isDirectory()) {
18
+ await scan(fullPath);
19
+ }
20
+ else if (entry.name.endsWith('.json')) {
21
+ const rel = path.relative(rootDir, fullPath);
22
+ // Try to read workflow name from JSON
23
+ let label = rel;
24
+ try {
25
+ const raw = await fs.readFile(fullPath, 'utf-8');
26
+ const parsed = JSON.parse(raw);
27
+ if (parsed.name)
28
+ label = `${parsed.name} (${rel})`;
29
+ }
30
+ catch {
31
+ // use rel path as label
32
+ }
33
+ choices.push({ name: label, value: fullPath });
34
+ }
35
+ }
36
+ }
37
+ await scan(rootDir);
38
+ return choices;
39
+ }
3
40
  export default class Deploy extends Command {
4
41
  static args = {
5
42
  workflow: Args.string({
6
- description: 'Path to the workflow file or workflow ID',
7
- required: true,
43
+ description: 'Path to workflow JSON file (omit for interactive menu)',
44
+ required: false,
8
45
  }),
9
46
  };
10
47
  static description = 'Push workflows to n8n instance via API';
11
48
  static examples = [
49
+ '<%= config.bin %> <%= command.id %>',
12
50
  '<%= config.bin %> <%= command.id %> ./workflows/slack-notifier.json',
13
51
  ];
14
52
  static flags = {
@@ -22,27 +60,43 @@ export default class Deploy extends Command {
22
60
  default: false,
23
61
  description: 'Activate workflow after deployment',
24
62
  }),
63
+ dir: Flags.string({
64
+ char: 'd',
65
+ description: 'Directory to scan for workflows (default: ./workflows)',
66
+ }),
25
67
  };
26
68
  async run() {
27
69
  this.log(theme.brand());
28
70
  const { args, flags } = await this.parse(Deploy);
29
71
  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
72
  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);
73
+ let workflowPath = args.workflow;
74
+ if (!workflowPath) {
75
+ const { default: select } = await import('@inquirer/select');
76
+ const workflowsDir = flags.dir ?? path.join(process.cwd(), 'workflows');
77
+ this.log(theme.agent(`Scanning ${theme.secondary(workflowsDir)} for workflows...`));
78
+ const choices = await findWorkflowFiles(workflowsDir);
79
+ if (choices.length === 0) {
80
+ this.error(`No workflow JSON files found in ${workflowsDir}. Pass a file path directly or use --dir to specify another directory.`);
81
+ }
82
+ workflowPath = await select({
83
+ message: 'Select a workflow to deploy:',
84
+ choices,
85
+ pageSize: 15,
86
+ });
42
87
  }
43
- else {
44
- throw new Error("Local JSON file path required for currently active bridge.");
88
+ const resolvedPath = workflowPath;
89
+ this.log(theme.subHeader('Context Analysis'));
90
+ this.log(`${theme.label('Workflow')} ${theme.value(resolvedPath)}`);
91
+ this.log(`${theme.label('Instance')} ${theme.value(flags.instance)}`);
92
+ this.log(`${theme.label('Auto-Activate')} ${theme.value(flags.activate)}`);
93
+ this.log(theme.divider(40));
94
+ if (!resolvedPath.endsWith('.json')) {
95
+ throw new Error('Selected file must be a .json workflow file.');
45
96
  }
97
+ const fs = await import('node:fs/promises');
98
+ const content = await fs.readFile(resolvedPath, 'utf-8');
99
+ const workflowData = JSON.parse(content);
46
100
  this.log(theme.info('Authenticating...'));
47
101
  const { ConfigManager } = await import('../utils/config.js');
48
102
  const config = await ConfigManager.load();
@@ -54,12 +108,58 @@ export default class Deploy extends Command {
54
108
  const { N8nClient } = await import('../utils/n8nClient.js');
55
109
  const client = new N8nClient({ apiUrl: n8nUrl, apiKey: n8nKey });
56
110
  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) {
111
+ const saveIdToFile = async (id) => {
112
+ if (workflowData.id !== id) {
113
+ workflowData.id = id;
114
+ await fs.writeFile(resolvedPath, JSON.stringify(workflowData, null, 2), 'utf-8');
115
+ this.log(theme.muted(` Local file updated with ID: ${id}`));
116
+ }
117
+ };
118
+ let deployedId;
119
+ if (workflowData.id) {
120
+ let existsRemotely = false;
121
+ try {
122
+ await client.getWorkflow(workflowData.id);
123
+ existsRemotely = true;
124
+ }
125
+ catch {
126
+ // not found — will create
127
+ }
128
+ if (existsRemotely) {
129
+ const { default: select } = await import('@inquirer/select');
130
+ const action = await select({
131
+ message: `Workflow "${workflowData.name}" already exists in n8n (ID: ${workflowData.id}). What would you like to do?`,
132
+ choices: [
133
+ { name: 'Update existing workflow', value: 'update' },
134
+ { name: 'Create as new workflow', value: 'create' },
135
+ ],
136
+ });
137
+ if (action === 'update') {
138
+ await client.updateWorkflow(workflowData.id, workflowData);
139
+ deployedId = workflowData.id;
140
+ }
141
+ else {
142
+ const result = await client.createWorkflow(workflowData.name || 'n8m-deployment', workflowData);
143
+ deployedId = result.id;
144
+ await saveIdToFile(deployedId);
145
+ }
146
+ }
147
+ else {
148
+ const result = await client.createWorkflow(workflowData.name || 'n8m-deployment', workflowData);
149
+ deployedId = result.id;
150
+ await saveIdToFile(deployedId);
151
+ }
152
+ }
153
+ else {
154
+ const result = await client.createWorkflow(workflowData.name || 'n8m-deployment', workflowData);
155
+ deployedId = result.id;
156
+ await saveIdToFile(deployedId);
157
+ }
158
+ if (flags.activate && deployedId) {
59
159
  this.log(theme.warn('Activation request queued.'));
60
160
  }
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))}`);
161
+ this.log(theme.done(`Deployment Successful. [ID: ${theme.primary(deployedId)}]`));
162
+ this.log(`${theme.label('Public Link')} ${theme.secondary(client.getWorkflowLink(deployedId))}`);
63
163
  }
64
164
  catch (error) {
65
165
  this.error(`Operation aborted: ${error.message}`);
@@ -215,12 +215,31 @@ export default class Fixture extends Command {
215
215
  return;
216
216
  }
217
217
  const fixtureManager = new FixtureManager();
218
- const fixturePath = path.join(process.cwd(), '.n8m', 'fixtures', `${resolvedId}.json`);
218
+ const defaultName = latest.status === 'success' ? 'happy-path' : 'error-case';
219
+ const { fixtureName } = await inquirer.prompt([{
220
+ type: 'input',
221
+ name: 'fixtureName',
222
+ message: 'Name this fixture (e.g. happy-path, missing-field, bad-auth):',
223
+ default: defaultName,
224
+ }]);
225
+ const { expectedOutcome } = await inquirer.prompt([{
226
+ type: 'select',
227
+ name: 'expectedOutcome',
228
+ message: 'Expected test outcome for this case:',
229
+ choices: [
230
+ { name: 'pass — execution should succeed', value: 'pass' },
231
+ { name: 'fail — execution should fail (testing error handling)', value: 'fail' },
232
+ ],
233
+ default: latest.status === 'success' ? 'pass' : 'fail',
234
+ }]);
235
+ const fixtureDir = path.join(process.cwd(), '.n8m', 'fixtures', resolvedId);
236
+ const safeName = fixtureName.replace(/[^a-z0-9_-]/gi, '-').replace(/-+/g, '-').toLowerCase();
237
+ const fixturePath = path.join(fixtureDir, `${safeName}.json`);
219
238
  if (existsSync(fixturePath)) {
220
239
  const { overwrite } = await inquirer.prompt([{
221
240
  type: 'confirm',
222
241
  name: 'overwrite',
223
- message: `Fixture already exists for workflow ${resolvedId}. Overwrite?`,
242
+ message: `Fixture "${safeName}" already exists. Overwrite?`,
224
243
  default: true,
225
244
  }]);
226
245
  if (!overwrite) {
@@ -228,11 +247,13 @@ export default class Fixture extends Command {
228
247
  return;
229
248
  }
230
249
  }
231
- await fixtureManager.save({
250
+ await fixtureManager.saveNamed({
232
251
  version: '1.0',
233
252
  capturedAt: new Date().toISOString(),
234
253
  workflowId: resolvedId,
235
254
  workflowName: resolvedName ?? resolvedId,
255
+ description: fixtureName,
256
+ expectedOutcome: expectedOutcome,
236
257
  workflow,
237
258
  execution: {
238
259
  id: fullExec.id,
@@ -245,14 +266,16 @@ export default class Fixture extends Command {
245
266
  },
246
267
  },
247
268
  },
248
- });
269
+ }, safeName);
249
270
  const nodeCount = Object.keys(fullExec.data?.resultData?.runData ?? {}).length;
250
- this.log(theme.success(`Fixture saved to .n8m/fixtures/${resolvedId}.json`));
271
+ this.log(theme.success(`Fixture saved to .n8m/fixtures/${resolvedId}/${safeName}.json`));
251
272
  this.log(theme.muted(` Workflow: ${resolvedName}`));
252
273
  this.log(theme.muted(` Execution: ${fullExec.status} · ${nodeCount} node(s) captured`));
274
+ this.log(theme.muted(` Expected outcome: ${expectedOutcome}`));
253
275
  this.log('');
254
- this.log(theme.muted(' To test with this fixture:'));
255
- this.log(theme.muted(` n8m test --fixture .n8m/fixtures/${resolvedId}.json`));
256
- this.log(theme.muted(` Or just run: n8m test (auto-detected by workflow ID)`));
276
+ this.log(theme.muted(' To run this fixture:'));
277
+ this.log(theme.muted(` n8m test --fixture .n8m/fixtures/${resolvedId}/${safeName}.json`));
278
+ this.log(theme.muted(' To run all fixtures for this workflow:'));
279
+ this.log(theme.muted(` n8m test --fixture .n8m/fixtures/${resolvedId}`));
257
280
  }
258
281
  }
@@ -0,0 +1,19 @@
1
+ import { Command } from '@oclif/core';
2
+ export default class Learn extends Command {
3
+ static args: {
4
+ workflow: import("@oclif/core/interfaces").Arg<string | undefined, Record<string, unknown>>;
5
+ };
6
+ static description: string;
7
+ static examples: string[];
8
+ static flags: {
9
+ dir: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
10
+ all: import("@oclif/core/interfaces").BooleanFlag<boolean>;
11
+ github: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
12
+ 'github-path': import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
13
+ token: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
14
+ };
15
+ run(): Promise<void>;
16
+ private runGitHubImport;
17
+ private collectMdFiles;
18
+ private processWorkflow;
19
+ }
@@ -0,0 +1,277 @@
1
+ import { Args, Command, Flags } from '@oclif/core';
2
+ import { theme } from '../utils/theme.js';
3
+ import path from 'node:path';
4
+ import fs from 'node:fs/promises';
5
+ import { existsSync } from 'node:fs';
6
+ async function listGitHubPatterns(owner, repo, dirPath, branch, token) {
7
+ const url = `https://api.github.com/repos/${owner}/${repo}/contents/${dirPath}${branch ? `?ref=${branch}` : ''}`;
8
+ const headers = { 'User-Agent': 'n8m-cli', Accept: 'application/vnd.github.v3+json' };
9
+ if (token)
10
+ headers['Authorization'] = `Bearer ${token}`;
11
+ const res = await fetch(url, { headers });
12
+ if (!res.ok) {
13
+ const body = await res.text();
14
+ throw new Error(`GitHub API error ${res.status}: ${body}`);
15
+ }
16
+ const entries = await res.json();
17
+ return entries;
18
+ }
19
+ async function fetchRaw(url, token) {
20
+ const headers = { 'User-Agent': 'n8m-cli' };
21
+ if (token)
22
+ headers['Authorization'] = `Bearer ${token}`;
23
+ const res = await fetch(url, { headers });
24
+ if (!res.ok)
25
+ throw new Error(`Failed to fetch ${url}: ${res.status}`);
26
+ return res.text();
27
+ }
28
+ async function findWorkflowFiles(rootDir) {
29
+ const choices = [];
30
+ async function scan(dir) {
31
+ let entries;
32
+ try {
33
+ entries = await fs.readdir(dir, { withFileTypes: true });
34
+ }
35
+ catch {
36
+ return;
37
+ }
38
+ for (const entry of entries) {
39
+ const fullPath = path.join(dir, entry.name);
40
+ if (entry.isDirectory()) {
41
+ await scan(fullPath);
42
+ }
43
+ else if (entry.name.endsWith('.json')) {
44
+ const rel = path.relative(rootDir, fullPath);
45
+ let label = rel;
46
+ try {
47
+ const raw = await fs.readFile(fullPath, 'utf-8');
48
+ const parsed = JSON.parse(raw);
49
+ if (parsed.name)
50
+ label = `${parsed.name} (${rel})`;
51
+ }
52
+ catch {
53
+ // use rel path as label
54
+ }
55
+ choices.push({ name: label, value: fullPath });
56
+ }
57
+ }
58
+ }
59
+ await scan(rootDir);
60
+ return choices;
61
+ }
62
+ export default class Learn extends Command {
63
+ static args = {
64
+ workflow: Args.string({
65
+ description: 'Path to workflow JSON file (omit for interactive menu)',
66
+ required: false,
67
+ }),
68
+ };
69
+ static description = 'Extract reusable patterns from a validated workflow into the pattern library';
70
+ static examples = [
71
+ '<%= config.bin %> <%= command.id %>',
72
+ '<%= config.bin %> <%= command.id %> ./workflows/my-workflow/workflow.json',
73
+ '<%= config.bin %> <%= command.id %> --github owner/repo',
74
+ '<%= config.bin %> <%= command.id %> --github owner/repo --github-path patterns/google',
75
+ ];
76
+ static flags = {
77
+ dir: Flags.string({
78
+ char: 'd',
79
+ description: 'Directory to scan for workflows (default: ./workflows)',
80
+ }),
81
+ all: Flags.boolean({
82
+ description: 'Generate patterns for all workflows in the directory',
83
+ default: false,
84
+ }),
85
+ github: Flags.string({
86
+ description: 'Import patterns from a GitHub repo (format: owner/repo or owner/repo@branch)',
87
+ }),
88
+ 'github-path': Flags.string({
89
+ description: 'Path within the GitHub repo to import from (default: patterns)',
90
+ default: 'patterns',
91
+ }),
92
+ token: Flags.string({
93
+ description: 'GitHub personal access token (increases rate limit for public repos)',
94
+ env: 'GITHUB_TOKEN',
95
+ }),
96
+ };
97
+ async run() {
98
+ this.log(theme.brand());
99
+ const { args, flags } = await this.parse(Learn);
100
+ this.log(theme.header('PATTERN LEARNING'));
101
+ const patternsDir = path.join(process.cwd(), '.n8m', 'patterns');
102
+ await fs.mkdir(patternsDir, { recursive: true });
103
+ // GitHub import mode
104
+ if (flags.github) {
105
+ await this.runGitHubImport(flags.github, flags['github-path'], flags.token, patternsDir);
106
+ return;
107
+ }
108
+ // Local workflow → AI generation mode
109
+ const { AIService } = await import('../services/ai.service.js');
110
+ const aiService = AIService.getInstance();
111
+ const workflowsDir = flags.dir ?? path.join(process.cwd(), 'workflows');
112
+ let workflowPaths = [];
113
+ if (args.workflow) {
114
+ workflowPaths = [args.workflow];
115
+ }
116
+ else if (flags.all) {
117
+ this.log(theme.agent(`Scanning ${theme.secondary(workflowsDir)} for workflows...`));
118
+ const choices = await findWorkflowFiles(workflowsDir);
119
+ if (choices.length === 0) {
120
+ this.error(`No workflow JSON files found in ${workflowsDir}.`);
121
+ }
122
+ workflowPaths = choices.map(c => c.value);
123
+ this.log(theme.info(`Found ${workflowPaths.length} workflow(s).`));
124
+ }
125
+ else {
126
+ const { default: select } = await import('@inquirer/select');
127
+ this.log(theme.agent(`Scanning ${theme.secondary(workflowsDir)} for workflows...`));
128
+ const choices = await findWorkflowFiles(workflowsDir);
129
+ if (choices.length === 0) {
130
+ this.error(`No workflow JSON files found in ${workflowsDir}. Pass a file path directly or use --dir to specify another directory.`);
131
+ }
132
+ const selected = await select({
133
+ message: 'Select a workflow to learn from:',
134
+ choices,
135
+ pageSize: 15,
136
+ });
137
+ workflowPaths = [selected];
138
+ }
139
+ for (const workflowPath of workflowPaths) {
140
+ await this.processWorkflow(workflowPath, patternsDir, aiService);
141
+ }
142
+ }
143
+ async runGitHubImport(repoArg, dirPath, token, patternsDir) {
144
+ // Parse owner/repo@branch
145
+ const [repoPart, branch = ''] = repoArg.split('@');
146
+ const [owner, repo] = repoPart.split('/');
147
+ if (!owner || !repo) {
148
+ this.error('--github must be in the format owner/repo or owner/repo@branch');
149
+ }
150
+ this.log(theme.agent(`Fetching pattern list from ${theme.secondary(`github.com/${owner}/${repo}/${dirPath}`)}...`));
151
+ let entries;
152
+ try {
153
+ entries = await listGitHubPatterns(owner, repo, dirPath, branch, token);
154
+ }
155
+ catch (err) {
156
+ this.error(`Could not reach GitHub: ${err.message}`);
157
+ }
158
+ // Collect all .md files, recursing into subdirectories
159
+ const mdFiles = await this.collectMdFiles(entries, owner, repo, branch, token);
160
+ if (mdFiles.length === 0) {
161
+ this.log(theme.warn(`No .md pattern files found at ${dirPath} in ${owner}/${repo}.`));
162
+ return;
163
+ }
164
+ this.log(theme.info(`Found ${mdFiles.length} pattern(s).`));
165
+ const { checkbox } = await import('inquirer');
166
+ const selected = await checkbox({
167
+ message: 'Select patterns to import:',
168
+ choices: mdFiles.map(f => ({ name: f.path, value: f.path, checked: true })),
169
+ pageSize: 20,
170
+ });
171
+ if (selected.length === 0) {
172
+ this.log(theme.muted('Nothing selected.'));
173
+ return;
174
+ }
175
+ const toDownload = mdFiles.filter(f => selected.includes(f.path));
176
+ for (const file of toDownload) {
177
+ if (!file.download_url)
178
+ continue;
179
+ this.log(theme.agent(`Downloading ${theme.secondary(file.name)}...`));
180
+ const content = await fetchRaw(file.download_url, token);
181
+ const outPath = path.join(patternsDir, file.name);
182
+ if (existsSync(outPath)) {
183
+ const { default: select } = await import('@inquirer/select');
184
+ const action = await select({
185
+ message: `"${file.name}" already exists locally. Overwrite?`,
186
+ choices: [
187
+ { name: 'Overwrite', value: 'overwrite' },
188
+ { name: 'Skip', value: 'skip' },
189
+ ],
190
+ });
191
+ if (action === 'skip') {
192
+ this.log(theme.muted(`Skipped ${file.name}.`));
193
+ continue;
194
+ }
195
+ }
196
+ await fs.writeFile(outPath, content, 'utf-8');
197
+ this.log(theme.done(`Saved: ${theme.primary(outPath)}`));
198
+ }
199
+ this.log(theme.done(`Import complete. ${toDownload.length} pattern(s) added to ${theme.primary(patternsDir)}`));
200
+ }
201
+ async collectMdFiles(entries, owner, repo, branch, token) {
202
+ const result = [];
203
+ for (const entry of entries) {
204
+ if (entry.type === 'file' && entry.name.endsWith('.md')) {
205
+ result.push(entry);
206
+ }
207
+ else if (entry.type === 'dir') {
208
+ try {
209
+ const sub = await listGitHubPatterns(owner, repo, entry.path, branch, token);
210
+ const subFiles = await this.collectMdFiles(sub, owner, repo, branch, token);
211
+ result.push(...subFiles);
212
+ }
213
+ catch {
214
+ // skip unreadable subdirs
215
+ }
216
+ }
217
+ }
218
+ return result;
219
+ }
220
+ async processWorkflow(workflowPath, patternsDir, aiService) {
221
+ this.log(theme.divider(40));
222
+ this.log(`${theme.label('Workflow')} ${theme.value(workflowPath)}`);
223
+ let workflowJson;
224
+ try {
225
+ const raw = await fs.readFile(workflowPath, 'utf-8');
226
+ workflowJson = JSON.parse(raw);
227
+ }
228
+ catch {
229
+ this.log(theme.fail(`Could not read or parse ${workflowPath} — skipping.`));
230
+ return;
231
+ }
232
+ this.log(theme.agent(`Analyzing ${theme.secondary(workflowJson.name || 'workflow')}...`));
233
+ const { content, slug } = await aiService.generatePattern(workflowJson);
234
+ // Show preview
235
+ this.log(theme.subHeader('Generated Pattern Preview'));
236
+ const previewLines = content.split('\n').slice(0, 20);
237
+ previewLines.forEach((line) => this.log(theme.muted(line)));
238
+ if (content.split('\n').length > 20) {
239
+ this.log(theme.muted(` ... (${content.split('\n').length - 20} more lines)`));
240
+ }
241
+ this.log('');
242
+ const outPath = path.join(patternsDir, `${slug}.md`);
243
+ const alreadyExists = existsSync(outPath);
244
+ const { default: select } = await import('@inquirer/select');
245
+ const action = await select({
246
+ message: alreadyExists
247
+ ? `Pattern file "${slug}.md" already exists. What would you like to do?`
248
+ : `Save pattern as "${slug}.md"?`,
249
+ choices: alreadyExists
250
+ ? [
251
+ { name: 'Overwrite existing pattern', value: 'save' },
252
+ { name: 'Save with a new name', value: 'rename' },
253
+ { name: 'Skip', value: 'skip' },
254
+ ]
255
+ : [
256
+ { name: 'Save pattern', value: 'save' },
257
+ { name: 'Save with a different name', value: 'rename' },
258
+ { name: 'Skip', value: 'skip' },
259
+ ],
260
+ });
261
+ if (action === 'skip') {
262
+ this.log(theme.muted('Skipped.'));
263
+ return;
264
+ }
265
+ let finalPath = outPath;
266
+ if (action === 'rename') {
267
+ const { default: input } = await import('@inquirer/input');
268
+ const customName = await input({
269
+ message: 'Enter filename (without .md):',
270
+ default: slug,
271
+ });
272
+ finalPath = path.join(patternsDir, `${customName.replace(/\.md$/, '')}.md`);
273
+ }
274
+ await fs.writeFile(finalPath, content, 'utf-8');
275
+ this.log(theme.done(`Pattern saved: ${theme.primary(finalPath)}`));
276
+ }
277
+ }