@chriswiegman/hugo-tools 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.
@@ -0,0 +1,29 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ /**
5
+ * Generates a default .hugo-tools.json config file in the current Hugo site root.
6
+ * Usage:
7
+ * config
8
+ */
9
+
10
+ const fs = require('fs');
11
+ const path = require('path');
12
+ const { requireHugoSite, DEFAULTS } = require('./config');
13
+
14
+ function main() {
15
+ requireHugoSite();
16
+
17
+ const dest = path.join(process.cwd(), '.hugo-tools.json');
18
+
19
+ if (fs.existsSync(dest)) {
20
+ process.stderr.write('Error: .hugo-tools.json already exists.\n');
21
+ process.exit(1);
22
+ }
23
+
24
+ fs.writeFileSync(dest, JSON.stringify(DEFAULTS, null, 2) + '\n', 'utf8');
25
+ console.log(`Created ${dest}`);
26
+ console.log('Edit the values to match your Hugo site layout.');
27
+ }
28
+
29
+ main();
package/src/config.js ADDED
@@ -0,0 +1,41 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+
6
+ const DEFAULTS = {
7
+ timezone: 'America/Chicago',
8
+ postsDir: 'content/posts',
9
+ draftsDir: 'content/drafts',
10
+ notesDir: 'content/notes',
11
+ booksDir: 'content/books',
12
+ imagesDir: 'assets/images',
13
+ vscodeDir: '.vscode',
14
+ };
15
+
16
+ let override = {};
17
+ const configPath = path.join(process.cwd(), '.hugo-tools.json');
18
+ try {
19
+ if (fs.existsSync(configPath)) {
20
+ override = JSON.parse(fs.readFileSync(configPath, 'utf8'));
21
+ }
22
+ } catch (e) {
23
+ process.stderr.write(`Warning: could not read .hugo-tools.json: ${e.message}\n`);
24
+ }
25
+
26
+ const HUGO_CONFIGS = [
27
+ 'hugo.toml', 'hugo.yaml', 'hugo.yml', 'hugo.json',
28
+ 'config.toml', 'config.yaml', 'config.yml', 'config.json',
29
+ 'config',
30
+ ];
31
+
32
+ function requireHugoSite() {
33
+ const cwd = process.cwd();
34
+ const found = HUGO_CONFIGS.some(name => fs.existsSync(path.join(cwd, name)));
35
+ if (!found) {
36
+ process.stderr.write('Error: no Hugo config file found. Run this command from the root of your Hugo site.\n');
37
+ process.exit(1);
38
+ }
39
+ }
40
+
41
+ module.exports = { ...DEFAULTS, ...override, requireHugoSite, DEFAULTS };
package/src/draft.js ADDED
@@ -0,0 +1,77 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ /**
5
+ * Creates a new Hugo draft using the standard drafts archetype.
6
+ * Usage:
7
+ * new-draft
8
+ *
9
+ * Generates a timestamp-based filename (YYYYMMDD-HHMMSS.md) in the configured
10
+ * drafts directory, writes the archetype front matter without a date: line,
11
+ * then opens the file in VS Code.
12
+ */
13
+
14
+ const fs = require('fs');
15
+ const path = require('path');
16
+ const { execFileSync } = require('child_process');
17
+ const config = require('./config');
18
+
19
+ function timestampFilename() {
20
+ const now = new Date();
21
+ const parts = new Intl.DateTimeFormat('en-CA', {
22
+ timeZone: config.timezone,
23
+ year: 'numeric',
24
+ month: '2-digit',
25
+ day: '2-digit',
26
+ hour: '2-digit',
27
+ minute: '2-digit',
28
+ second: '2-digit',
29
+ hour12: false,
30
+ }).formatToParts(now);
31
+ const get = (type) => parts.find(p => p.type === type).value;
32
+ let hour = get('hour');
33
+ if (hour === '24') hour = '00';
34
+ return `${get('year')}${get('month')}${get('day')}-${hour}${get('minute')}${get('second')}`;
35
+ }
36
+
37
+ function buildArchetype() {
38
+ return [
39
+ '---',
40
+ 'title: ""',
41
+ 'description: ""',
42
+ 'draft: true',
43
+ 'images:',
44
+ ' -',
45
+ 'categories:',
46
+ ' -',
47
+ 'tags:',
48
+ ' -',
49
+ '---',
50
+ '',
51
+ ].join('\n');
52
+ }
53
+
54
+ function main() {
55
+ config.requireHugoSite();
56
+
57
+ const stamp = timestampFilename();
58
+ const draftsDir = path.join(process.cwd(), config.draftsDir);
59
+ const destPath = path.join(draftsDir, `${stamp}.md`);
60
+
61
+ fs.mkdirSync(draftsDir, { recursive: true });
62
+ fs.writeFileSync(destPath, buildArchetype(), 'utf8');
63
+
64
+ console.log(destPath);
65
+
66
+ try {
67
+ execFileSync('code', [destPath], { stdio: 'ignore' });
68
+ } catch {
69
+ // VS Code not available — silently continue
70
+ }
71
+ }
72
+
73
+ if (require.main === module) {
74
+ main();
75
+ }
76
+
77
+ module.exports = { buildArchetype, timestampFilename };
@@ -0,0 +1,77 @@
1
+ import assert from 'node:assert/strict';
2
+ import { test } from 'node:test';
3
+ import { createRequire } from 'node:module';
4
+
5
+ const require = createRequire(import.meta.url);
6
+ const { buildArchetype, timestampFilename } = require('./draft.js');
7
+
8
+ // ---------------------------------------------------------------------------
9
+ // buildArchetype
10
+ // ---------------------------------------------------------------------------
11
+
12
+ test('buildArchetype: starts with front matter delimiter', () => {
13
+ assert.ok(buildArchetype().startsWith('---\n'));
14
+ });
15
+
16
+ test('buildArchetype: contains blank title', () => {
17
+ assert.ok(buildArchetype().includes('title: ""'));
18
+ });
19
+
20
+ test('buildArchetype: contains blank description', () => {
21
+ assert.ok(buildArchetype().includes('description: ""'));
22
+ });
23
+
24
+ test('buildArchetype: sets draft true', () => {
25
+ assert.ok(buildArchetype().includes('draft: true'));
26
+ });
27
+
28
+ test('buildArchetype: contains images placeholder', () => {
29
+ const out = buildArchetype();
30
+ assert.ok(out.includes('images:\n -'));
31
+ });
32
+
33
+ test('buildArchetype: contains categories placeholder', () => {
34
+ const out = buildArchetype();
35
+ assert.ok(out.includes('categories:\n -'));
36
+ });
37
+
38
+ test('buildArchetype: contains tags placeholder', () => {
39
+ const out = buildArchetype();
40
+ assert.ok(out.includes('tags:\n -'));
41
+ });
42
+
43
+ test('buildArchetype: does not contain a date key', () => {
44
+ assert.ok(!buildArchetype().includes('date:'));
45
+ });
46
+
47
+ test('buildArchetype: ends with closing delimiter and trailing newline', () => {
48
+ assert.ok(buildArchetype().endsWith('---\n'));
49
+ });
50
+
51
+ // ---------------------------------------------------------------------------
52
+ // timestampFilename
53
+ // ---------------------------------------------------------------------------
54
+
55
+ test('timestampFilename: matches YYYYMMDD-HHMMSS format', () => {
56
+ assert.match(timestampFilename(), /^\d{8}-\d{6}$/);
57
+ });
58
+
59
+ test('timestampFilename: date portion is a plausible date', () => {
60
+ const stamp = timestampFilename();
61
+ const year = parseInt(stamp.slice(0, 4), 10);
62
+ const month = parseInt(stamp.slice(4, 6), 10);
63
+ const day = parseInt(stamp.slice(6, 8), 10);
64
+ assert.ok(year >= 2024);
65
+ assert.ok(month >= 1 && month <= 12);
66
+ assert.ok(day >= 1 && day <= 31);
67
+ });
68
+
69
+ test('timestampFilename: time portion is a plausible time', () => {
70
+ const stamp = timestampFilename();
71
+ const hour = parseInt(stamp.slice(9, 11), 10);
72
+ const minute = parseInt(stamp.slice(11, 13), 10);
73
+ const second = parseInt(stamp.slice(13, 15), 10);
74
+ assert.ok(hour >= 0 && hour <= 23);
75
+ assert.ok(minute >= 0 && minute <= 59);
76
+ assert.ok(second >= 0 && second <= 59);
77
+ });
@@ -0,0 +1,234 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Extracts all unique categories and tags from published Hugo posts.
5
+ * Outputs a JSON file that can be used for autocomplete/reference.
6
+ */
7
+
8
+ const fs = require('fs');
9
+ const path = require('path');
10
+ const config = require('./config');
11
+
12
+ const CONTENT_DIR = path.join(process.cwd(), config.postsDir);
13
+ const OUTPUT_FILE = path.join(process.cwd(), config.vscodeDir, 'tags-categories.json');
14
+ const SNIPPETS_FILE = path.join(process.cwd(), config.vscodeDir, 'markdown.code-snippets');
15
+
16
+ /**
17
+ * Extracts frontmatter from a markdown file
18
+ */
19
+ function extractFrontmatter(content) {
20
+ const match = content.match(/^---\n([\s\S]*?)\n---/);
21
+ return match ? match[1] : null;
22
+ }
23
+
24
+ /**
25
+ * Parses YAML-style list items from frontmatter
26
+ */
27
+ function parseListItems(frontmatter, key) {
28
+ const items = [];
29
+ const blockRegex = new RegExp(`^${key}:\\s*\\n((\\s*-\\s*.+\\n)+)`, 'm');
30
+ const match = frontmatter.match(blockRegex);
31
+
32
+ if (match) {
33
+ const lines = match[1].split('\n');
34
+ for (const line of lines) {
35
+ const itemMatch = line.match(/^\s*-\s*(.+)\s*$/);
36
+ if (itemMatch) {
37
+ const value = itemMatch[1].trim().replace(/^["']|["']$/g, '');
38
+ if (value) {
39
+ items.push(value);
40
+ }
41
+ }
42
+ }
43
+ }
44
+
45
+ return items;
46
+ }
47
+
48
+ /**
49
+ * Recursively finds all markdown files
50
+ */
51
+ function findMarkdownFiles(dir) {
52
+ const files = [];
53
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
54
+
55
+ for (const entry of entries) {
56
+ const fullPath = path.join(dir, entry.name);
57
+ if (entry.isDirectory()) {
58
+ files.push(...findMarkdownFiles(fullPath));
59
+ } else if (entry.isFile() && entry.name.endsWith('.md')) {
60
+ files.push(fullPath);
61
+ }
62
+ }
63
+
64
+ return files;
65
+ }
66
+
67
+ /**
68
+ * Main extraction logic
69
+ */
70
+ function extractTagsAndCategories() {
71
+ const categories = new Set();
72
+ const tags = new Set();
73
+ const categoryCount = {};
74
+ const tagCount = {};
75
+
76
+ const files = findMarkdownFiles(CONTENT_DIR);
77
+ console.log(`Found ${files.length} markdown files`);
78
+
79
+ for (const file of files) {
80
+ const content = fs.readFileSync(file, 'utf8');
81
+ const frontmatter = extractFrontmatter(content);
82
+
83
+ if (!frontmatter) continue;
84
+
85
+ // Extract categories
86
+ const fileCategories = parseListItems(frontmatter, 'categories');
87
+ for (const cat of fileCategories) {
88
+ categories.add(cat);
89
+ categoryCount[cat] = (categoryCount[cat] || 0) + 1;
90
+ }
91
+
92
+ // Extract tags
93
+ const fileTags = parseListItems(frontmatter, 'tags');
94
+ for (const tag of fileTags) {
95
+ tags.add(tag);
96
+ tagCount[tag] = (tagCount[tag] || 0) + 1;
97
+ }
98
+ }
99
+
100
+ // Sort by usage count (most used first)
101
+ const sortedCategories = Array.from(categories).sort((a, b) => {
102
+ return (categoryCount[b] || 0) - (categoryCount[a] || 0);
103
+ });
104
+
105
+ const sortedTags = Array.from(tags).sort((a, b) => {
106
+ return (tagCount[b] || 0) - (tagCount[a] || 0);
107
+ });
108
+
109
+ return {
110
+ categories: sortedCategories,
111
+ tags: sortedTags,
112
+ stats: {
113
+ categoryCount,
114
+ tagCount,
115
+ totalCategories: categories.size,
116
+ totalTags: tags.size,
117
+ totalFiles: files.length,
118
+ generatedAt: new Date().toISOString()
119
+ }
120
+ };
121
+ }
122
+
123
+ /**
124
+ * Generate snippet prefixes for a tag/category
125
+ */
126
+ function generatePrefixes(name) {
127
+ const lower = name.toLowerCase();
128
+ const prefixes = [lower];
129
+
130
+ // Add concatenated version for multi-word items (e.g., "Digital Life" -> "digitallife")
131
+ if (name.includes(' ')) {
132
+ const concat = lower.replace(/\s+/g, '');
133
+ if (concat !== lower) {
134
+ prefixes.push(concat);
135
+ }
136
+ }
137
+
138
+ // Add common abbreviations
139
+ const abbrevMap = {
140
+ 'Technology': ['tech'],
141
+ 'Personal': ['pers'],
142
+ 'WordPress': ['wp'],
143
+ 'Infrastructure': ['infra'],
144
+ 'Open Source': ['oss'],
145
+ 'Reflection': ['reflect'],
146
+ 'Web Development': ['webdev', 'web'],
147
+ 'Development': ['dev'],
148
+ 'Digital Life': ['digital'],
149
+ 'Social Media': ['social'],
150
+ 'Education': ['edu'],
151
+ 'Self-hosting': ['selfhost'],
152
+ 'Content Management Systems': ['cms', 'contentmanagement']
153
+ };
154
+
155
+ if (abbrevMap[name]) {
156
+ prefixes.push(...abbrevMap[name]);
157
+ }
158
+
159
+ // Return single string if only one prefix, otherwise return array
160
+ return prefixes.length === 1 ? prefixes[0] : prefixes;
161
+ }
162
+
163
+ /**
164
+ * Generate VSCode snippets from tags and categories
165
+ */
166
+ function generateSnippets(data) {
167
+ const snippets = {};
168
+
169
+ // Generate category snippets
170
+ for (const category of data.categories) {
171
+ snippets[`Category: ${category}`] = {
172
+ prefix: generatePrefixes(category),
173
+ body: category,
174
+ description: `Category: ${category}`
175
+ };
176
+ }
177
+
178
+ // Generate tag snippets
179
+ for (const tag of data.tags) {
180
+ snippets[`Tag: ${tag}`] = {
181
+ prefix: generatePrefixes(tag),
182
+ body: tag,
183
+ description: `Tag: ${tag}`
184
+ };
185
+ }
186
+
187
+ return snippets;
188
+ }
189
+
190
+ /**
191
+ * Main execution
192
+ */
193
+ function main() {
194
+ config.requireHugoSite();
195
+
196
+ console.log('Extracting tags and categories from published posts...');
197
+
198
+ const data = extractTagsAndCategories();
199
+
200
+ // Ensure .vscode directory exists
201
+ const vscodePath = path.dirname(OUTPUT_FILE);
202
+ if (!fs.existsSync(vscodePath)) {
203
+ fs.mkdirSync(vscodePath, { recursive: true });
204
+ }
205
+
206
+ // Write JSON data file
207
+ fs.writeFileSync(OUTPUT_FILE, JSON.stringify(data, null, 2));
208
+
209
+ // Generate and write snippets file
210
+ const snippets = generateSnippets(data);
211
+ fs.writeFileSync(SNIPPETS_FILE, JSON.stringify(snippets, null, 2));
212
+
213
+ console.log('\nResults:');
214
+ console.log(` Categories: ${data.stats.totalCategories}`);
215
+ console.log(` Tags: ${data.stats.totalTags}`);
216
+ console.log(` Files processed: ${data.stats.totalFiles}`);
217
+ console.log(`\nOutput written to:`);
218
+ console.log(` - ${OUTPUT_FILE}`);
219
+ console.log(` - ${SNIPPETS_FILE}`);
220
+ console.log('\nMost used categories:');
221
+ data.categories.slice(0, 5).forEach(cat => {
222
+ console.log(` - ${cat} (${data.stats.categoryCount[cat]} posts)`);
223
+ });
224
+ console.log('\nMost used tags:');
225
+ data.tags.slice(0, 10).forEach(tag => {
226
+ console.log(` - ${tag} (${data.stats.tagCount[tag]} posts)`);
227
+ });
228
+ }
229
+
230
+ if (require.main === module) {
231
+ main();
232
+ }
233
+
234
+ module.exports = { extractFrontmatter, parseListItems, generatePrefixes };
@@ -0,0 +1,121 @@
1
+ import assert from 'node:assert/strict';
2
+ import { test } from 'node:test';
3
+ import { createRequire } from 'node:module';
4
+
5
+ const require = createRequire(import.meta.url);
6
+ const { extractFrontmatter, parseListItems, generatePrefixes } = require('./extract-tags.js');
7
+
8
+ // ---------------------------------------------------------------------------
9
+ // extractFrontmatter
10
+ // ---------------------------------------------------------------------------
11
+
12
+ test('extractFrontmatter: returns frontmatter content between --- delimiters', () => {
13
+ const content = '---\ntitle: My Post\ndraft: true\n---\nBody text here.';
14
+ assert.equal(extractFrontmatter(content), 'title: My Post\ndraft: true');
15
+ });
16
+
17
+ test('extractFrontmatter: returns null when no frontmatter', () => {
18
+ assert.equal(extractFrontmatter('Just body text with no delimiters.'), null);
19
+ });
20
+
21
+ test('extractFrontmatter: returns null when only one --- delimiter', () => {
22
+ assert.equal(extractFrontmatter('---\ntitle: foo\nno closing delimiter'), null);
23
+ });
24
+
25
+ test('extractFrontmatter: handles empty frontmatter block', () => {
26
+ assert.equal(extractFrontmatter('---\n\n---\nBody.'), '');
27
+ });
28
+
29
+ test('extractFrontmatter: does not include body content', () => {
30
+ const content = '---\ntitle: foo\n---\nThis body should not appear.';
31
+ const result = extractFrontmatter(content);
32
+ assert.ok(!result.includes('This body'));
33
+ });
34
+
35
+ test('extractFrontmatter: handles --- appearing in body without confusion', () => {
36
+ const content = '---\ntitle: foo\n---\nBody line.\n---\nAnother section.';
37
+ const result = extractFrontmatter(content);
38
+ assert.equal(result, 'title: foo');
39
+ });
40
+
41
+ // ---------------------------------------------------------------------------
42
+ // parseListItems
43
+ // ---------------------------------------------------------------------------
44
+
45
+ test('parseListItems: parses a simple list', () => {
46
+ const fm = 'tags:\n - JavaScript\n - Go\n';
47
+ assert.deepEqual(parseListItems(fm, 'tags'), ['JavaScript', 'Go']);
48
+ });
49
+
50
+ test('parseListItems: strips double quotes from values', () => {
51
+ const fm = 'categories:\n - "Technology"\n - "Open Source"\n';
52
+ assert.deepEqual(parseListItems(fm, 'categories'), ['Technology', 'Open Source']);
53
+ });
54
+
55
+ test('parseListItems: strips single quotes from values', () => {
56
+ const fm = "tags:\n - 'JavaScript'\n";
57
+ assert.deepEqual(parseListItems(fm, 'tags'), ['JavaScript']);
58
+ });
59
+
60
+ test('parseListItems: returns empty array when key is not present', () => {
61
+ assert.deepEqual(parseListItems('title: My Post\n', 'tags'), []);
62
+ });
63
+
64
+ test('parseListItems: returns empty array for empty file', () => {
65
+ assert.deepEqual(parseListItems('', 'tags'), []);
66
+ });
67
+
68
+ test('parseListItems: handles multiple spaces before list items', () => {
69
+ const fm = 'tags:\n - foo\n - bar\n';
70
+ assert.deepEqual(parseListItems(fm, 'tags'), ['foo', 'bar']);
71
+ });
72
+
73
+ test('parseListItems: does not confuse tags and categories', () => {
74
+ const fm = 'categories:\n - Tech\ntags:\n - Go\n';
75
+ assert.deepEqual(parseListItems(fm, 'tags'), ['Go']);
76
+ assert.deepEqual(parseListItems(fm, 'categories'), ['Tech']);
77
+ });
78
+
79
+ test('parseListItems: single item list', () => {
80
+ const fm = 'tags:\n - solo\n';
81
+ assert.deepEqual(parseListItems(fm, 'tags'), ['solo']);
82
+ });
83
+
84
+ // ---------------------------------------------------------------------------
85
+ // generatePrefixes
86
+ // ---------------------------------------------------------------------------
87
+
88
+ test('generatePrefixes: single word returns a string', () => {
89
+ assert.equal(generatePrefixes('Go'), 'go');
90
+ });
91
+
92
+ test('generatePrefixes: single word with no abbreviation returns lowercase string', () => {
93
+ assert.equal(generatePrefixes('JavaScript'), 'javascript');
94
+ });
95
+
96
+ test('generatePrefixes: multi-word returns array including concatenated form', () => {
97
+ const result = generatePrefixes('Open Source');
98
+ assert.ok(Array.isArray(result));
99
+ assert.ok(result.includes('open source'));
100
+ assert.ok(result.includes('opensource'));
101
+ });
102
+
103
+ test('generatePrefixes: known abbreviation is included', () => {
104
+ const result = generatePrefixes('Technology');
105
+ assert.ok(Array.isArray(result));
106
+ assert.ok(result.includes('technology'));
107
+ assert.ok(result.includes('tech'));
108
+ });
109
+
110
+ test('generatePrefixes: multi-word with abbreviation includes all forms', () => {
111
+ const result = generatePrefixes('Web Development');
112
+ assert.ok(result.includes('web development'));
113
+ assert.ok(result.includes('webdevelopment'));
114
+ assert.ok(result.includes('webdev'));
115
+ assert.ok(result.includes('web'));
116
+ });
117
+
118
+ test('generatePrefixes: Open Source includes oss abbreviation', () => {
119
+ const result = generatePrefixes('Open Source');
120
+ assert.ok(result.includes('oss'));
121
+ });
@@ -0,0 +1,80 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ // Lists images in the configured images directory for the current year/month
5
+ // and copies the selected path to the clipboard.
6
+
7
+ const fs = require('fs');
8
+ const path = require('path');
9
+ const readline = require('readline');
10
+ const { execSync, execFileSync } = require('child_process');
11
+ const config = require('./config');
12
+
13
+ config.requireHugoSite();
14
+
15
+ const IMAGE_EXTS = new Set(['.jpg', '.jpeg', '.png', '.webp', '.gif']);
16
+
17
+ const now = new Date();
18
+ const year = now.getFullYear().toString();
19
+ const month = String(now.getMonth() + 1).padStart(2, '0');
20
+ const imageDir = path.join(process.cwd(), config.imagesDir, year, month);
21
+ const imagePath = `/${config.imagesDir}/${year}/${month}`;
22
+
23
+ if (!fs.existsSync(imageDir)) {
24
+ console.error(`No image directory found at ${imageDir}`);
25
+ process.exit(1);
26
+ }
27
+
28
+ const files = fs.readdirSync(imageDir)
29
+ .filter(f => IMAGE_EXTS.has(path.extname(f).toLowerCase()))
30
+ .sort();
31
+
32
+ if (files.length === 0) {
33
+ console.error(`No images found in ${imageDir}`);
34
+ process.exit(1);
35
+ }
36
+
37
+ console.log(`\nImages in ${imagePath}/:\n`);
38
+ files.forEach((f, i) => console.log(` ${i + 1}) ${f}`));
39
+ console.log('');
40
+
41
+ function copyToClipboard(text) {
42
+ const platform = process.platform;
43
+ if (platform === 'darwin') {
44
+ execSync(`echo ${JSON.stringify(text)} | pbcopy`);
45
+ return;
46
+ }
47
+ if (platform === 'win32') {
48
+ execFileSync('clip', { input: text });
49
+ return;
50
+ }
51
+ // Linux: try common clipboard tools in order
52
+ const tools = [
53
+ ['xclip', ['-selection', 'clipboard']],
54
+ ['xsel', ['--clipboard', '--input']],
55
+ ['wl-copy', []],
56
+ ];
57
+ for (const [cmd, args] of tools) {
58
+ try {
59
+ execFileSync(cmd, args, { input: text });
60
+ return;
61
+ } catch {
62
+ // try next
63
+ }
64
+ }
65
+ console.log('(clipboard unavailable — copy the path above manually)');
66
+ }
67
+
68
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
69
+ rl.question(`Select image (1-${files.length}): `, answer => {
70
+ rl.close();
71
+ const choice = parseInt(answer, 10);
72
+ if (choice >= 1 && choice <= files.length) {
73
+ const result = `${imagePath}/${files[choice - 1]}`;
74
+ copyToClipboard(result);
75
+ console.log(`\nCopied to clipboard: ${result}`);
76
+ } else {
77
+ console.error('Invalid selection');
78
+ process.exit(1);
79
+ }
80
+ });