bananahub 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,74 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import { TEMPLATES_DIR, SOURCE_FILE } from '../constants.js';
4
+ import { parseFrontmatter } from '../frontmatter.js';
5
+ import { bold, dim, cyan, green } from '../color.js';
6
+ import { red } from '../color.js';
7
+
8
+ export async function infoCommand(args) {
9
+ const id = args[0];
10
+ if (!id) {
11
+ console.error(red('Usage: bananahub info <template-id>'));
12
+ process.exit(1);
13
+ }
14
+
15
+ let content;
16
+ try {
17
+ content = await readFile(join(TEMPLATES_DIR, id, 'template.md'), 'utf8');
18
+ } catch {
19
+ console.error(red(`Template "${id}" is not installed.`));
20
+ process.exit(1);
21
+ }
22
+
23
+ const fm = parseFrontmatter(content);
24
+ if (!fm) {
25
+ console.error(red('Could not parse template frontmatter.'));
26
+ process.exit(1);
27
+ }
28
+
29
+ let source = null;
30
+ try {
31
+ const raw = await readFile(join(TEMPLATES_DIR, id, SOURCE_FILE), 'utf8');
32
+ source = JSON.parse(raw);
33
+ } catch { /* ok */ }
34
+
35
+ console.log(bold(`\n ${fm.title || id}`));
36
+ if (fm.title_en) console.log(dim(` ${fm.title_en}`));
37
+ console.log();
38
+
39
+ const fields = [
40
+ ['ID', fm.id || id],
41
+ ['Type', fm.type || 'prompt'],
42
+ ['Version', fm.version || '-'],
43
+ ['Author', fm.author || '-'],
44
+ ['Profile', fm.profile || '-'],
45
+ ['Aspect', fm.aspect || '-'],
46
+ ['Difficulty', fm.difficulty || '-'],
47
+ ];
48
+
49
+ for (const [k, v] of fields) {
50
+ console.log(` ${cyan(k.padEnd(12))} ${v}`);
51
+ }
52
+
53
+ if (fm.tags?.length) {
54
+ console.log(` ${cyan('Tags'.padEnd(12))} ${fm.tags.join(', ')}`);
55
+ }
56
+
57
+ if (fm.models?.length) {
58
+ console.log(` ${cyan('Models'.padEnd(12))}`);
59
+ for (const m of fm.models) {
60
+ const name = m.name || m;
61
+ const quality = m.quality ? ` (${m.quality})` : '';
62
+ console.log(` - ${name}${dim(quality)}`);
63
+ }
64
+ }
65
+
66
+ if (source) {
67
+ console.log();
68
+ console.log(dim(` Source: ${source.repo}`));
69
+ console.log(dim(` Installed: ${source.installed_at}`));
70
+ if (source.sha) console.log(dim(` SHA: ${source.sha.slice(0, 8)}`));
71
+ }
72
+
73
+ console.log(green(`\n Use: /nanobanana use ${id}\n`));
74
+ }
@@ -0,0 +1,214 @@
1
+ import { mkdir, writeFile } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import { createInterface } from 'node:readline';
4
+ import { VALID_PROFILES, VALID_DIFFICULTIES, VALID_TEMPLATE_TYPES } from '../constants.js';
5
+ import { bold, green, cyan, dim } from '../color.js';
6
+
7
+ async function collectInput(typeHint) {
8
+ if (process.stdin.isTTY) {
9
+ return collectInteractive(typeHint);
10
+ }
11
+ return collectPiped(typeHint);
12
+ }
13
+
14
+ async function collectInteractive(typeHint) {
15
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
16
+ const ask = (q) => new Promise(resolve => rl.question(q, resolve));
17
+
18
+ try {
19
+ let type = typeHint || 'prompt';
20
+ if (!typeHint) {
21
+ console.log(dim(` Template types: ${VALID_TEMPLATE_TYPES.join(', ')}`));
22
+ type = (await ask(cyan(' Type: '))).trim() || 'prompt';
23
+ if (!VALID_TEMPLATE_TYPES.includes(type)) {
24
+ console.log(dim(` Using "prompt" (invalid type: "${type}")`));
25
+ type = 'prompt';
26
+ }
27
+ }
28
+
29
+ const id = (await ask(cyan(' Template ID (lowercase, hyphens): '))).trim().toLowerCase().replace(/\s+/g, '-') || 'my-template';
30
+ const title = (await ask(cyan(' Title (Chinese): '))).trim() || '我的模板';
31
+ const titleEn = (await ask(cyan(' Title (English): '))).trim() || 'My Template';
32
+
33
+ console.log(dim(` Profiles: ${VALID_PROFILES.join(', ')}`));
34
+ let profile = (await ask(cyan(' Profile: '))).trim() || 'general';
35
+ if (!VALID_PROFILES.includes(profile)) {
36
+ console.log(dim(` Using "general" (invalid profile: "${profile}")`));
37
+ profile = 'general';
38
+ }
39
+
40
+ console.log(dim(` Levels: ${VALID_DIFFICULTIES.join(', ')}`));
41
+ let difficulty = (await ask(cyan(' Difficulty: '))).trim() || 'beginner';
42
+ if (!VALID_DIFFICULTIES.includes(difficulty)) difficulty = 'beginner';
43
+
44
+ return { type, id, title, titleEn, profile, difficulty };
45
+ } finally {
46
+ rl.close();
47
+ }
48
+ }
49
+
50
+ async function collectPiped(typeHint) {
51
+ const lines = [];
52
+ const rl = createInterface({ input: process.stdin, terminal: false });
53
+ for await (const line of rl) {
54
+ lines.push(line.trim());
55
+ }
56
+
57
+ const pipedType = VALID_TEMPLATE_TYPES.includes(lines[0]) ? lines[0] : null;
58
+ const offset = pipedType ? 1 : 0;
59
+
60
+ return {
61
+ type: VALID_TEMPLATE_TYPES.includes(typeHint) ? typeHint : (pipedType || 'prompt'),
62
+ id: (lines[offset] || 'my-template').toLowerCase().replace(/\s+/g, '-'),
63
+ title: lines[offset + 1] || '我的模板',
64
+ titleEn: lines[offset + 2] || 'My Template',
65
+ profile: VALID_PROFILES.includes(lines[offset + 3]) ? lines[offset + 3] : 'general',
66
+ difficulty: VALID_DIFFICULTIES.includes(lines[offset + 4]) ? lines[offset + 4] : 'beginner'
67
+ };
68
+ }
69
+
70
+ function buildTemplateBody(type) {
71
+ if (type === 'workflow') {
72
+ return `## Goal
73
+
74
+ Describe the workflow outcome this template should help the agent produce.
75
+
76
+ ## When To Use
77
+
78
+ - Describe the situations where this workflow is a strong fit
79
+
80
+ ## Inputs
81
+
82
+ | Input | Required | Description |
83
+ |-------|----------|-------------|
84
+ | \`input_name\` | yes | What the agent needs before starting |
85
+
86
+ ## Steps
87
+
88
+ 1. Describe the first step the agent should take.
89
+ 2. Describe the next decision or generation step.
90
+ 3. Describe how to iterate or stop.
91
+
92
+ ## Prompt Blocks
93
+
94
+ ### Starter Prompt
95
+
96
+ \`\`\`text
97
+ Reusable prompt block or instruction fragment for this workflow
98
+ \`\`\`
99
+
100
+ ## Success Checks
101
+
102
+ - Add checks that confirm the workflow result is usable
103
+ `;
104
+ }
105
+
106
+ return `## Prompt Template
107
+
108
+ \`\`\`
109
+ Your prompt here with {{variable|default value}} slots
110
+ \`\`\`
111
+
112
+ ## Variables
113
+
114
+ | Variable | Default | Description |
115
+ |----------|---------|-------------|
116
+ | \`variable\` | default value | Description |
117
+
118
+ ## Tips
119
+
120
+ - Add tips for using this template
121
+ `;
122
+ }
123
+
124
+ function buildReadme(titleEn, id, profile, type) {
125
+ return `# ${titleEn}
126
+
127
+ A Nanobanana ${type} template for ${profile} workflows.
128
+
129
+ ## Install
130
+
131
+ \`\`\`bash
132
+ npx bananahub add your-username/${id}
133
+ \`\`\`
134
+
135
+ ## Verified Models
136
+
137
+ - \`gemini-3-pro-image-preview\` — validate the primary flow with a real sample before publishing
138
+
139
+ ## Supported Models
140
+
141
+ - \`gemini-3.1-flash-image-preview\` — expected to work, not yet manually verified
142
+
143
+ ## Sample Outputs
144
+
145
+ | File | Model | Prompt / Variant |
146
+ |---|---|---|
147
+ | \`samples/sample-3-pro-01.png\` | \`gemini-3-pro-image-preview\` | Replace with your real sample or representative workflow output |
148
+
149
+ Update this README after you generate real samples. Each sample filename should include the generating model shorthand, for example \`sample-3-pro-01.png\` or \`sample-3.1-flash-01.png\`.
150
+ `;
151
+ }
152
+
153
+ export async function initCommand(args) {
154
+ console.log(bold('\n BananaHub Template Scaffolding\n'));
155
+
156
+ const typeFlag = args.indexOf('--type');
157
+ const typeHint = typeFlag !== -1 ? args[typeFlag + 1] : null;
158
+ if (typeHint && !VALID_TEMPLATE_TYPES.includes(typeHint)) {
159
+ throw new Error(`Invalid template type: ${typeHint}`);
160
+ }
161
+
162
+ const { type, id, title, titleEn, profile, difficulty } = await collectInput(typeHint);
163
+
164
+ const outDir = join(process.cwd(), id);
165
+ await mkdir(join(outDir, 'samples'), { recursive: true });
166
+
167
+ const sampleFrontmatter = type === 'prompt' ? `samples:
168
+ - file: samples/sample-3-pro-01.png
169
+ model: gemini-3-pro-image-preview
170
+ prompt: "The exact prompt used to generate this sample"
171
+ aspect: "16:9"` : `samples: []`;
172
+
173
+ const templateMd = `---
174
+ type: ${type}
175
+ id: ${id}
176
+ title: ${title}
177
+ title_en: ${titleEn}
178
+ author: your-github-username
179
+ version: 1.0.0
180
+ profile: ${profile}
181
+ tags: []
182
+ models:
183
+ - name: gemini-3-pro-image-preview
184
+ tested: true
185
+ quality: best
186
+ - name: gemini-3.1-flash-image-preview
187
+ tested: false
188
+ quality: good
189
+ aspect: "16:9"
190
+ difficulty: ${difficulty}
191
+ ${sampleFrontmatter}
192
+ created: ${new Date().toISOString().split('T')[0]}
193
+ updated: ${new Date().toISOString().split('T')[0]}
194
+ ---
195
+
196
+ ${buildTemplateBody(type)}
197
+ `;
198
+
199
+ await writeFile(join(outDir, 'template.md'), templateMd);
200
+ await writeFile(join(outDir, 'samples', '.gitkeep'), '');
201
+ await writeFile(join(outDir, 'README.md'), buildReadme(titleEn, id, profile, type));
202
+
203
+ console.log(green(`\n Created: ${bold(id)}/`));
204
+ console.log(dim(` ${id}/template.md`));
205
+ console.log(dim(` ${id}/samples/.gitkeep`));
206
+ console.log(dim(` ${id}/README.md`));
207
+ console.log(cyan('\n Next steps:'));
208
+ console.log(dim(` 1. Edit template.md — add your ${type === 'workflow' ? 'workflow sections and prompt blocks' : 'prompt and variables'}`));
209
+ console.log(dim(' 2. Add sample images to samples/ and include the model in each filename'));
210
+ console.log(dim(' 3. Update README.md with verified/supported models and sample mappings'));
211
+ console.log(dim(' 4. Create a GitHub repo and push'));
212
+ console.log(dim(' 5. Others install: npx bananahub add <user>/' + id));
213
+ console.log();
214
+ }
@@ -0,0 +1,38 @@
1
+ import { loadRegistry } from '../registry.js';
2
+ import { bold, dim, cyan, yellow } from '../color.js';
3
+
4
+ export async function listCommand() {
5
+ const registry = await loadRegistry();
6
+ const templates = registry.templates || [];
7
+
8
+ if (templates.length === 0) {
9
+ console.log(dim('\n No templates installed.'));
10
+ console.log(dim(' Install one: bananahub add <user/repo>\n'));
11
+ return;
12
+ }
13
+
14
+ // Group by profile
15
+ const groups = {};
16
+ for (const t of templates) {
17
+ const profile = t.profile || 'general';
18
+ if (!groups[profile]) groups[profile] = [];
19
+ groups[profile].push(t);
20
+ }
21
+
22
+ console.log(bold(`\n Installed Templates (${templates.length})\n`));
23
+
24
+ for (const [profile, items] of Object.entries(groups).sort()) {
25
+ console.log(cyan(` [${profile}]`));
26
+ for (const t of items) {
27
+ const title = t.title_en || t.title || t.id;
28
+ const type = dim(` [${t.type || 'prompt'}]`);
29
+ const version = t.version ? dim(` v${t.version}`) : '';
30
+ const author = t.author ? dim(` by ${t.author}`) : '';
31
+ console.log(` ${bold(t.id)}${type} ${title}${version}${author}`);
32
+ if (t.tags?.length) {
33
+ console.log(dim(` Tags: ${t.tags.join(', ')}`));
34
+ }
35
+ }
36
+ console.log();
37
+ }
38
+ }
@@ -0,0 +1,15 @@
1
+ import { rebuildRegistry } from '../registry.js';
2
+ import { bold, green, dim } from '../color.js';
3
+
4
+ export async function registryCommand(args) {
5
+ const sub = args[0];
6
+ if (sub !== 'rebuild') {
7
+ console.log(` Usage: bananahub registry rebuild`);
8
+ process.exit(1);
9
+ }
10
+
11
+ console.log(dim(' Rebuilding registry...'));
12
+ const registry = await rebuildRegistry();
13
+ const count = registry.templates?.length || 0;
14
+ console.log(green(` Registry rebuilt: ${bold(String(count))} template(s) indexed.\n`));
15
+ }
@@ -0,0 +1,25 @@
1
+ import { rm, access } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import { TEMPLATES_DIR } from '../constants.js';
4
+ import { rebuildRegistry } from '../registry.js';
5
+ import { bold, green, red } from '../color.js';
6
+
7
+ export async function removeCommand(args) {
8
+ const id = args[0];
9
+ if (!id) {
10
+ console.error(red('Usage: bananahub remove <template-id>'));
11
+ process.exit(1);
12
+ }
13
+
14
+ const dir = join(TEMPLATES_DIR, id);
15
+ try {
16
+ await access(dir);
17
+ } catch {
18
+ console.error(red(`Template "${id}" is not installed.`));
19
+ process.exit(1);
20
+ }
21
+
22
+ await rm(dir, { recursive: true, force: true });
23
+ await rebuildRegistry();
24
+ console.log(green(` Removed: ${bold(id)}`));
25
+ }
@@ -0,0 +1,277 @@
1
+ import { bold, cyan, dim, green, red, yellow } from '../color.js';
2
+ import { fetchHubCatalog, fetchHubTrending, buildCatalogLookup, compareCatalogPriority, templateKey } from '../hub.js';
3
+ import { HUB_SITE } from '../constants.js';
4
+
5
+ export async function searchCommand(args) {
6
+ const options = parseSearchArgs(args);
7
+ const keyword = options.terms.join(' ').trim();
8
+
9
+ if (!keyword) {
10
+ console.error(red('Usage: bananahub search <keyword> [--limit N] [--curated|--discovered]'));
11
+ process.exit(1);
12
+ }
13
+
14
+ let catalog;
15
+ try {
16
+ catalog = await fetchHubCatalog();
17
+ } catch (error) {
18
+ console.error(red(`Error: ${error.message}`));
19
+ console.log(dim(`Browse the hub directly: ${HUB_SITE}`));
20
+ process.exit(1);
21
+ }
22
+
23
+ const matches = rankTemplates(catalog.templates || [], keyword, options).slice(0, options.limit);
24
+
25
+ if (matches.length === 0) {
26
+ console.log(yellow(`\n No hub templates matched "${keyword}".`));
27
+ console.log(dim(` Browse the full catalog: ${HUB_SITE}\n`));
28
+ return;
29
+ }
30
+
31
+ const sourceHint = options.source === 'all' ? '' : dim(` (${options.source})`);
32
+ console.log(bold(`\n Hub Search Results${sourceHint}\n`));
33
+
34
+ for (const [index, template] of matches.entries()) {
35
+ printTemplateResult(index + 1, template);
36
+ }
37
+
38
+ console.log(green(' Install with: bananahub add <install_target>\n'));
39
+ }
40
+
41
+ export async function trendingCommand(args = []) {
42
+ const options = parseTrendingArgs(args);
43
+ if (!['24h', '7d'].includes(options.period)) {
44
+ console.error(red('Usage: bananahub trending [--period 24h|7d] [--limit N]'));
45
+ process.exit(1);
46
+ }
47
+
48
+ let catalog;
49
+ let trending;
50
+
51
+ try {
52
+ [catalog, trending] = await Promise.all([
53
+ fetchHubCatalog(),
54
+ fetchHubTrending({ period: options.period, limit: options.limit })
55
+ ]);
56
+ } catch (error) {
57
+ console.error(red(`Error: ${error.message}`));
58
+ console.log(dim(`Browse the hub directly: ${HUB_SITE}`));
59
+ process.exit(1);
60
+ }
61
+
62
+ const lookup = buildCatalogLookup(catalog);
63
+ const items = trending.templates || [];
64
+
65
+ if (items.length === 0) {
66
+ console.log(yellow(`\n No trending installs found for ${options.period}.\n`));
67
+ return;
68
+ }
69
+
70
+ console.log(bold(`\n Trending Templates (${options.period})\n`));
71
+
72
+ for (const [index, item] of items.entries()) {
73
+ const template = lookup.get(templateKey(item.repo, item.template_id));
74
+ printTrendingResult(index + 1, item, template);
75
+ }
76
+
77
+ console.log(green(' Install with: bananahub add <install_target>\n'));
78
+ }
79
+
80
+ function parseSearchArgs(args) {
81
+ const options = {
82
+ terms: [],
83
+ limit: 8,
84
+ source: 'all'
85
+ };
86
+
87
+ for (let index = 0; index < args.length; index++) {
88
+ const arg = args[index];
89
+ if (arg === '--limit') {
90
+ options.limit = clampLimit(args[index + 1], 8, 20);
91
+ index++;
92
+ continue;
93
+ }
94
+ if (arg === '--curated') {
95
+ options.source = 'curated';
96
+ continue;
97
+ }
98
+ if (arg === '--discovered') {
99
+ options.source = 'discovered';
100
+ continue;
101
+ }
102
+ options.terms.push(arg);
103
+ }
104
+
105
+ return options;
106
+ }
107
+
108
+ function parseTrendingArgs(args) {
109
+ const options = {
110
+ limit: 10,
111
+ period: '7d'
112
+ };
113
+
114
+ for (let index = 0; index < args.length; index++) {
115
+ const arg = args[index];
116
+ if (arg === '--limit') {
117
+ options.limit = clampLimit(args[index + 1], 10, 20);
118
+ index++;
119
+ continue;
120
+ }
121
+ if (arg === '--period') {
122
+ options.period = args[index + 1] || '';
123
+ index++;
124
+ }
125
+ }
126
+
127
+ return options;
128
+ }
129
+
130
+ function rankTemplates(templates, keyword, options) {
131
+ const query = normalize(keyword);
132
+ const terms = buildTerms(query);
133
+
134
+ return templates
135
+ .filter((template) => options.source === 'all' || template.catalog_source === options.source)
136
+ .map((template) => ({
137
+ template,
138
+ score: scoreTemplate(template, query, terms)
139
+ }))
140
+ .filter((entry) => entry.score > 0)
141
+ .sort((left, right) => {
142
+ const scoreDiff = right.score - left.score;
143
+ if (scoreDiff !== 0) {
144
+ return scoreDiff;
145
+ }
146
+ return compareCatalogPriority(left.template, right.template);
147
+ })
148
+ .map((entry) => entry.template);
149
+ }
150
+
151
+ function scoreTemplate(template, query, terms) {
152
+ const id = normalize(template.id);
153
+ const title = normalize(template.title);
154
+ const titleEn = normalize(template.title_en);
155
+ const description = normalize(template.description);
156
+ const profile = normalize(template.profile);
157
+ const tags = (template.tags || []).map((tag) => normalize(tag));
158
+
159
+ let relevance = 0;
160
+
161
+ if (id === query) relevance += 100;
162
+ if (title === query || titleEn === query) relevance += 80;
163
+ if (tags.includes(query)) relevance += 60;
164
+
165
+ if (id.includes(query)) relevance += 24;
166
+ if (title.includes(query) || titleEn.includes(query)) relevance += 24;
167
+ if (description.includes(query)) relevance += 12;
168
+ if (profile.includes(query)) relevance += 8;
169
+
170
+ for (const term of terms) {
171
+ if (term === query) continue;
172
+ if (tags.includes(term)) relevance += 10;
173
+ if (tags.some((tag) => tag.includes(term))) relevance += 6;
174
+ if (id.includes(term)) relevance += 5;
175
+ if (title.includes(term) || titleEn.includes(term)) relevance += 5;
176
+ if (description.includes(term)) relevance += 2;
177
+ if (profile.includes(term)) relevance += 2;
178
+ }
179
+
180
+ if (relevance === 0) {
181
+ return 0;
182
+ }
183
+
184
+ let score = relevance;
185
+ if (template.pinned) score += 12;
186
+ if (template.featured) score += 8;
187
+ if (template.catalog_source === 'curated') score += 4;
188
+ if (template.official) score += 2;
189
+
190
+ return score;
191
+ }
192
+
193
+ function buildTerms(query) {
194
+ const parts = query
195
+ .split(/\s+/)
196
+ .map((part) => part.trim())
197
+ .filter(Boolean)
198
+ .filter((part) => part.length > 1 || /[^\u0000-\u007f]/.test(part));
199
+
200
+ if (query && !parts.includes(query)) {
201
+ parts.unshift(query);
202
+ }
203
+
204
+ return [...new Set(parts)];
205
+ }
206
+
207
+ function normalize(value) {
208
+ return String(value || '').trim().toLowerCase();
209
+ }
210
+
211
+ function printTemplateResult(index, template) {
212
+ const title = template.title_en || template.title || template.id;
213
+ const subtitle = template.title && template.title_en ? template.title : '';
214
+ const flags = buildFlags(template);
215
+
216
+ console.log(` ${cyan(String(index).padStart(2, ' '))}. ${bold(template.id)}${dim(` [${template.type || 'prompt'}]`)} ${title}`);
217
+ if (subtitle) {
218
+ console.log(dim(` ${subtitle}`));
219
+ }
220
+ console.log(dim(` ${template.profile || 'general'} | ${template.difficulty || 'beginner'}${flags ? ` | ${flags}` : ''}`));
221
+ if (template.description) {
222
+ console.log(dim(` ${clip(template.description, 140)}`));
223
+ }
224
+ if (template.tags?.length) {
225
+ console.log(dim(` Tags: ${template.tags.slice(0, 6).join(', ')}`));
226
+ }
227
+ if (template.install_cmd) {
228
+ console.log(dim(` Install: ${template.install_cmd}`));
229
+ }
230
+ console.log();
231
+ }
232
+
233
+ function printTrendingResult(index, item, template) {
234
+ const id = template?.id || item.template_id;
235
+ const type = template?.type || 'template';
236
+ const title = template?.title_en || template?.title || id;
237
+ const subtitle = template?.title && template?.title_en ? template.title : '';
238
+ const flags = buildFlags(template);
239
+ const installCmd = template?.install_cmd || `bananahub add ${item.repo}`;
240
+
241
+ console.log(` ${cyan(String(index).padStart(2, ' '))}. ${bold(id)}${dim(` [${type}]`)} ${title}`);
242
+ if (subtitle) {
243
+ console.log(dim(` ${subtitle}`));
244
+ }
245
+ console.log(dim(` ${item.installs} installs | ${item.repo}${flags ? ` | ${flags}` : ''}`));
246
+ console.log(dim(` Install: ${installCmd}`));
247
+ console.log();
248
+ }
249
+
250
+ function buildFlags(template) {
251
+ if (!template) {
252
+ return '';
253
+ }
254
+
255
+ const flags = [];
256
+ if (template.catalog_source) flags.push(template.catalog_source);
257
+ if (template.official) flags.push('official');
258
+ if (template.pinned) flags.push('pinned');
259
+ if (template.featured) flags.push('featured');
260
+ return flags.join(', ');
261
+ }
262
+
263
+ function clip(text, maxLength) {
264
+ const value = String(text || '').trim();
265
+ if (value.length <= maxLength) {
266
+ return value;
267
+ }
268
+ return `${value.slice(0, maxLength - 3)}...`;
269
+ }
270
+
271
+ function clampLimit(rawValue, fallback, max) {
272
+ const parsed = parseInt(rawValue || String(fallback), 10);
273
+ if (Number.isNaN(parsed) || parsed < 1) {
274
+ return fallback;
275
+ }
276
+ return Math.min(parsed, max);
277
+ }
@@ -0,0 +1,48 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import { TEMPLATES_DIR, SOURCE_FILE } from '../constants.js';
4
+ import { loadRegistry } from '../registry.js';
5
+ import { addCommand } from './add.js';
6
+ import { bold, dim, yellow, green, red } from '../color.js';
7
+
8
+ export async function updateCommand(args) {
9
+ const targetId = args[0];
10
+ const registry = await loadRegistry();
11
+ const templates = registry.templates || [];
12
+
13
+ if (templates.length === 0) {
14
+ console.log(dim(' No templates installed.'));
15
+ return;
16
+ }
17
+
18
+ let toUpdate = templates;
19
+ if (targetId) {
20
+ toUpdate = templates.filter((template) => template.id === targetId);
21
+ if (toUpdate.length === 0) {
22
+ console.error(red(`Template "${targetId}" is not installed.`));
23
+ process.exit(1);
24
+ }
25
+ }
26
+
27
+ for (const template of toUpdate) {
28
+ let source;
29
+ try {
30
+ const raw = await readFile(join(TEMPLATES_DIR, template.id, SOURCE_FILE), 'utf8');
31
+ source = JSON.parse(raw);
32
+ } catch {
33
+ console.log(yellow(` Skipping ${bold(template.id)}: no source info (locally created?)`));
34
+ continue;
35
+ }
36
+
37
+ const installTarget = source.install_target || (source.template_path ? `${source.repo}/${source.template_path}` : source.repo);
38
+ if (!installTarget) {
39
+ console.log(yellow(` Skipping ${bold(template.id)}: no source repo recorded`));
40
+ continue;
41
+ }
42
+
43
+ console.log(dim(` Updating ${bold(template.id)} from ${installTarget}...`));
44
+ await addCommand([installTarget]);
45
+ }
46
+
47
+ console.log(green('\n Update complete.\n'));
48
+ }