bananahub 0.1.2 → 0.1.3

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/README.md CHANGED
@@ -1,9 +1,17 @@
1
1
  # bananahub
2
2
 
3
- Template manager for [BananaHub Skill](https://github.com/bananahub-ai/bananahub-skill) — the agent-native Gemini image workflow.
3
+ Template manager for [BananaHub Skill](https://github.com/bananahub-ai/bananahub-skill) — the agent-native multi-provider image workflow.
4
4
 
5
5
  Install, manage, and share prompt or workflow modules for the BananaHub Skill workflow. BananaHub keeps the runtime lean and lets reusable prompt structures and guided SOPs travel as installable units.
6
6
 
7
+ ## Model Strategy
8
+
9
+ BananaHub now treats provider/model choice as a first-class interaction:
10
+
11
+ - Prefer `gpt-image-2` for new high-quality generation, launch visuals, README covers, information graphics, and premium first-pass outputs.
12
+ - Prefer Gemini / Nano Banana (`gemini-3-pro-image-preview` first) for edit-heavy, multi-reference, consistency-heavy, and previously validated template workflows.
13
+ - Templates can declare provider/model support explicitly, so `bananahub` can surface the recommended model instead of treating all image models as interchangeable.
14
+
7
15
  ## Installation
8
16
 
9
17
  ```bash
@@ -27,8 +35,8 @@ npx bananahub <command>
27
35
  Install template(s) from a GitHub repository, a specific template directory, or a known template collection.
28
36
 
29
37
  ```bash
30
- bananahub add user/bananahub-cyberpunk
31
- bananahub add bananahub-ai/bananahub-skill/cute-sticker
38
+ bananahub add user/bananahub-infographics
39
+ bananahub add bananahub-ai/templates/cute-sticker
32
40
  bananahub add user/multi-template-repo --template portrait
33
41
  ```
34
42
 
@@ -44,12 +52,13 @@ Uninstall an installed template.
44
52
  bananahub remove cyberpunk
45
53
  ```
46
54
 
47
- ### `list`
55
+ ### `list [--by-model]`
48
56
 
49
57
  List all installed templates.
50
58
 
51
59
  ```bash
52
60
  bananahub list
61
+ bananahub list --by-model
53
62
  ```
54
63
 
55
64
  ### `update [template-id]`
@@ -69,6 +78,18 @@ Show details about an installed template (metadata, version, source).
69
78
  bananahub info cyberpunk
70
79
  ```
71
80
 
81
+ `info` now shows provider/model support and suggests an explicit override when a template has a stronger recommended model, for example `gpt-image-2`.
82
+
83
+ ### `models [--remote]`
84
+
85
+ Show BananaHub's model support map from the local registry or the remote hub catalog.
86
+
87
+ ```bash
88
+ bananahub models
89
+ bananahub models --remote
90
+ bananahub models --provider openai
91
+ ```
92
+
72
93
  ### `search <keyword>`
73
94
 
74
95
  Search the hub catalog for prompt or workflow templates.
@@ -76,12 +97,17 @@ Search the hub catalog for prompt or workflow templates.
76
97
  ```bash
77
98
  bananahub search portrait
78
99
  bananahub search logo --curated
100
+ bananahub search logo --model gpt-image-2
101
+ bananahub search diagram --provider openai --capability generation
79
102
  ```
80
103
 
81
104
  Options:
82
105
  - `--limit <n>` — Limit the number of results (default: 8, max: 20)
83
106
  - `--curated` — Search only curated templates
84
107
  - `--discovered` — Search only discovered templates
108
+ - `--provider <id>` — Filter by provider, for example `openai` or `google-ai-studio`
109
+ - `--model <id>` — Filter by canonical model id or alias, for example `gpt-image-2` or `nano-banana-pro`
110
+ - `--capability <name>` — Filter by capability such as `generation`, `edit`, or `mask_edit`
85
111
 
86
112
  ### `trending`
87
113
 
package/bin/bananahub.js CHANGED
@@ -1,8 +1,9 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  import { bold, dim, cyan, yellow } from '../lib/color.js';
4
+ import { CLI_VERSION } from '../lib/version.js';
4
5
 
5
- const VERSION = '0.1.1';
6
+ const VERSION = CLI_VERSION;
6
7
 
7
8
  const HELP = `
8
9
  ${bold('bananahub')} ${dim(`v${VERSION}`)} — Template manager for BananaHub Skill
@@ -15,10 +16,11 @@ ${bold('COMMANDS')}
15
16
  --template <name> Pick one template from a multi-template directory
16
17
  --all Install all templates from a collection
17
18
  ${cyan('remove')} <template-id> Uninstall a template
18
- ${cyan('list')} List installed templates
19
+ ${cyan('list')} [--by-model] List installed templates
19
20
  ${cyan('update')} [template-id] Update one or all installed templates
20
21
  ${cyan('info')} <template-id> Show template details
21
- ${cyan('search')} <keyword> [--limit N] [--curated|--discovered]
22
+ ${cyan('models')} [--remote] [--provider P] Show provider/model support map
23
+ ${cyan('search')} <keyword> [--limit N] [--provider P] [--model M]
22
24
  Search hub for templates
23
25
  ${cyan('trending')} [--period 24h|7d] [--limit N]
24
26
  Show trending templates
@@ -31,12 +33,14 @@ ${bold('OPTIONS')}
31
33
  --version, -v Show version
32
34
 
33
35
  ${bold('EXAMPLES')}
34
- bananahub add user/bananahub-cyberpunk
35
- bananahub add bananahub-ai/bananahub-skill/cute-sticker
36
+ bananahub add user/bananahub-infographics
37
+ bananahub add bananahub-ai/templates/cute-sticker
36
38
  bananahub add user/multi-template-repo --template portrait
37
39
  bananahub search logo --curated
40
+ bananahub search logo --model gpt-image-2
41
+ bananahub models --remote
38
42
  bananahub trending --period 7d
39
- bananahub list
43
+ bananahub list --by-model
40
44
  bananahub validate ./my-template
41
45
  bananahub init
42
46
  bananahub init --type workflow
@@ -70,7 +74,7 @@ async function main() {
70
74
  }
71
75
  case 'list': {
72
76
  const { listCommand } = await import('../lib/commands/list.js');
73
- await listCommand();
77
+ await listCommand(cmdArgs);
74
78
  break;
75
79
  }
76
80
  case 'update': {
@@ -83,6 +87,11 @@ async function main() {
83
87
  await infoCommand(cmdArgs);
84
88
  break;
85
89
  }
90
+ case 'models': {
91
+ const { modelsCommand } = await import('../lib/commands/models.js');
92
+ await modelsCommand(cmdArgs);
93
+ break;
94
+ }
86
95
  case 'search': {
87
96
  const { searchCommand } = await import('../lib/commands/search.js');
88
97
  await searchCommand(cmdArgs);
@@ -8,7 +8,8 @@ import { extract } from 'tar';
8
8
  import { downloadTarball, getDefaultBranchInfo, getLatestSha } from '../github.js';
9
9
  import { validateTemplate } from '../validate.js';
10
10
  import { rebuildRegistry } from '../registry.js';
11
- import { TEMPLATES_DIR, CLI_VERSION, HUB_API, SKILL_COMMAND } from '../constants.js';
11
+ import { TEMPLATES_DIR, HUB_API, SKILL_COMMAND } from '../constants.js';
12
+ import { CLI_VERSION } from '../version.js';
12
13
  import { bold, green, red, yellow, cyan, dim } from '../color.js';
13
14
 
14
15
  const KNOWN_TEMPLATE_ROOTS = ['references/templates', 'templates'];
@@ -5,6 +5,7 @@ import { parseFrontmatter } from '../frontmatter.js';
5
5
  import { bold, dim, cyan, green } from '../color.js';
6
6
  import { red } from '../color.js';
7
7
  import { resolveInstalledTemplateDir } from '../paths.js';
8
+ import { MODEL_DISPLAY, normalizeProviders } from '../template-schema.js';
8
9
 
9
10
  export async function infoCommand(args) {
10
11
  const id = args[0];
@@ -58,7 +59,7 @@ export async function infoCommand(args) {
58
59
  }
59
60
 
60
61
  if (fm.models?.length) {
61
- console.log(` ${cyan('Models'.padEnd(12))}`);
62
+ console.log(` ${cyan('Legacy'.padEnd(12))}`);
62
63
  for (const m of fm.models) {
63
64
  const name = m.name || m;
64
65
  const quality = m.quality ? ` (${m.quality})` : '';
@@ -66,6 +67,28 @@ export async function infoCommand(args) {
66
67
  }
67
68
  }
68
69
 
70
+ const providers = normalizeProviders(fm);
71
+ if (providers.length) {
72
+ console.log(` ${cyan('Providers'.padEnd(12))}`);
73
+ for (const provider of providers) {
74
+ const family = provider.family ? dim(` [${provider.family}]`) : '';
75
+ console.log(` - ${provider.id}${family}`);
76
+ for (const model of provider.models) {
77
+ const quality = model.quality ? dim(` (${model.quality})`) : '';
78
+ const variant = model.prompt_variant ? dim(` via ${model.prompt_variant}`) : '';
79
+ const recommendation = MODEL_DISPLAY[model.id]?.tier === 'recommended' ? dim(' recommended') : '';
80
+ console.log(` - ${model.id}${quality}${variant}${recommendation}`);
81
+ }
82
+ }
83
+ }
84
+
85
+ if (fm.capabilities && typeof fm.capabilities === 'object') {
86
+ const capabilities = Object.entries(fm.capabilities)
87
+ .map(([key, value]) => `${key}=${value}`)
88
+ .join(', ');
89
+ console.log(` ${cyan('Capabilities'.padEnd(12))} ${capabilities}`);
90
+ }
91
+
69
92
  if (source) {
70
93
  console.log();
71
94
  console.log(dim(` Source: ${source.repo}`));
@@ -73,5 +96,22 @@ export async function infoCommand(args) {
73
96
  if (source.sha) console.log(dim(` SHA: ${source.sha.slice(0, 8)}`));
74
97
  }
75
98
 
76
- console.log(green(`\n Use: ${SKILL_COMMAND} use ${id}\n`));
99
+ console.log(green(`\n Use: ${SKILL_COMMAND} use ${id}`));
100
+ const recommended = findRecommendedProviderModel(providers);
101
+ if (recommended) {
102
+ console.log(dim(` Provider override: ${SKILL_COMMAND} use ${id} --provider ${recommended.provider} --model ${recommended.model}`));
103
+ }
104
+ console.log();
105
+ }
106
+
107
+ function findRecommendedProviderModel(providers) {
108
+ for (const provider of providers) {
109
+ const model = provider.models.find((entry) => entry.id === 'gpt-image-2');
110
+ if (model) return { provider: provider.id, model: model.id };
111
+ }
112
+ for (const provider of providers) {
113
+ const model = provider.models.find((entry) => entry.quality === 'best') || provider.models[0];
114
+ if (model) return { provider: provider.id, model: model.id };
115
+ }
116
+ return null;
77
117
  }
@@ -106,7 +106,19 @@ Reusable prompt block or instruction fragment for this workflow
106
106
  return `## Prompt Template
107
107
 
108
108
  \`\`\`
109
- Your prompt here with {{variable|default value}} slots
109
+ Provider-neutral base prompt with {{variable|default value}} slots. Keep this conservative and avoid model-specific hacks.
110
+ \`\`\`
111
+
112
+ ## Prompt Template: gemini
113
+
114
+ \`\`\`
115
+ Gemini/Nano Banana tuned prompt with {{variable|default value}} slots. Use descriptive scene language, preserve exact quoted labels when needed, and avoid generic quality tags.
116
+ \`\`\`
117
+
118
+ ## Prompt Template: gpt-image
119
+
120
+ \`\`\`
121
+ GPT Image tuned prompt with {{variable|default value}} slots. State exact constraints, what to avoid, and any text/label limits explicitly.
110
122
  \`\`\`
111
123
 
112
124
  ## Variables
@@ -117,7 +129,8 @@ Your prompt here with {{variable|default value}} slots
117
129
 
118
130
  ## Tips
119
131
 
120
- - Add tips for using this template
132
+ - Keep provider-specific tactics inside the matching prompt variant
133
+ - Do not reuse a Gemini-tuned prompt for GPT Image unless it has been tested and documented
121
134
  `;
122
135
  }
123
136
 
@@ -134,19 +147,20 @@ npx bananahub add your-username/${id}
134
147
 
135
148
  ## Verified Models
136
149
 
137
- - \`gemini-3-pro-image-preview\` validate the primary flow with a real sample before publishing
150
+ - \`gemini-3-pro-image-preview\` via \`gemini\` prompt variant validate with a real sample before publishing
138
151
 
139
152
  ## Supported Models
140
153
 
141
- - \`gemini-3.1-flash-image-preview\` — expected to work, not yet manually verified
154
+ - \`gemini-3.1-flash-image-preview\` via \`gemini\` prompt variant — expected to work, not yet manually verified
155
+ - \`gpt-image-2\` via \`gpt-image\` prompt variant — only list as verified after generating a real sample
142
156
 
143
157
  ## Sample Outputs
144
158
 
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 |
159
+ | File | Provider | Model | Prompt Variant |
160
+ |---|---|---|---|
161
+ | \`samples/sample-3-pro-01.png\` | \`google-ai-studio\` | \`gemini-3-pro-image-preview\` | \`gemini\` |
148
162
 
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\`.
163
+ Update this README after you generate real samples. Each sample should name the provider, exact model, and prompt variant used.
150
164
 
151
165
  ## License
152
166
 
@@ -186,7 +200,9 @@ export async function initCommand(args) {
186
200
 
187
201
  const sampleFrontmatter = type === 'prompt' ? `samples:
188
202
  - file: samples/sample-3-pro-01.png
203
+ provider: google-ai-studio
189
204
  model: gemini-3-pro-image-preview
205
+ prompt_variant: gemini
190
206
  prompt: "The exact prompt used to generate this sample"
191
207
  aspect: "16:9"` : `samples: []`;
192
208
 
@@ -207,6 +223,32 @@ models:
207
223
  - name: gemini-3.1-flash-image-preview
208
224
  tested: false
209
225
  quality: good
226
+ providers:
227
+ - id: google-ai-studio
228
+ family: gemini-image
229
+ models:
230
+ - id: gemini-3-pro-image-preview
231
+ aliases: [nano-banana-pro]
232
+ quality: best
233
+ prompt_variant: gemini
234
+ - id: gemini-3.1-flash-image-preview
235
+ aliases: [nano-banana-2]
236
+ quality: good
237
+ prompt_variant: gemini
238
+ - id: openai
239
+ family: gpt-image
240
+ models:
241
+ - id: gpt-image-2
242
+ quality: untested
243
+ prompt_variant: gpt-image
244
+ capabilities:
245
+ generation: true
246
+ edit: false
247
+ mask_edit: false
248
+ prompt_variants:
249
+ default: base
250
+ gemini: prompt-gemini
251
+ gpt-image: prompt-gpt-image
210
252
  aspect: "16:9"
211
253
  difficulty: ${difficulty}
212
254
  ${sampleFrontmatter}
@@ -1,7 +1,8 @@
1
1
  import { loadRegistry } from '../registry.js';
2
2
  import { bold, dim, cyan, yellow } from '../color.js';
3
+ import { collectModelSupport } from '../template-schema.js';
3
4
 
4
- export async function listCommand() {
5
+ export async function listCommand(args = []) {
5
6
  const registry = await loadRegistry();
6
7
  const templates = registry.templates || [];
7
8
 
@@ -11,6 +12,11 @@ export async function listCommand() {
11
12
  return;
12
13
  }
13
14
 
15
+ if (args.includes('--by-model') || args.includes('--by-provider')) {
16
+ printByModel(templates);
17
+ return;
18
+ }
19
+
14
20
  // Group by profile
15
21
  const groups = {};
16
22
  for (const t of templates) {
@@ -32,6 +38,27 @@ export async function listCommand() {
32
38
  if (t.tags?.length) {
33
39
  console.log(dim(` Tags: ${t.tags.join(', ')}`));
34
40
  }
41
+ if (t.providers?.length || t.models?.length) {
42
+ const providers = t.providers?.length ? t.providers.join(', ') : 'providers n/a';
43
+ const models = t.models?.length ? t.models.slice(0, 4).join(', ') : 'models n/a';
44
+ console.log(dim(` Support: ${providers} | ${models}`));
45
+ }
46
+ }
47
+ console.log();
48
+ }
49
+ }
50
+
51
+ function printByModel(templates) {
52
+ const models = collectModelSupport(templates);
53
+ console.log(bold(`\n Installed Templates by Model (${templates.length})\n`));
54
+
55
+ for (const model of models.sort((left, right) => right.template_count - left.template_count || left.id.localeCompare(right.id))) {
56
+ console.log(cyan(` ${model.id}`) + dim(` ${model.provider || 'provider n/a'}${model.family ? `/${model.family}` : ''}`));
57
+ for (const templateId of model.templates.slice(0, 12)) {
58
+ console.log(` - ${templateId}`);
59
+ }
60
+ if (model.templates.length > 12) {
61
+ console.log(dim(` ... ${model.templates.length - 12} more`));
35
62
  }
36
63
  console.log();
37
64
  }
@@ -0,0 +1,129 @@
1
+ import { bold, cyan, dim, green, red, yellow } from '../color.js';
2
+ import { fetchHubCatalog } from '../hub.js';
3
+ import { loadRegistry } from '../registry.js';
4
+ import {
5
+ MODEL_DISPLAY,
6
+ collectModelSupport,
7
+ templateSupportsProviderModel
8
+ } from '../template-schema.js';
9
+
10
+ export async function modelsCommand(args = []) {
11
+ const options = parseModelsArgs(args);
12
+ let templates;
13
+ let sourceLabel;
14
+
15
+ try {
16
+ if (options.remote) {
17
+ const catalog = await fetchHubCatalog();
18
+ templates = catalog.templates || [];
19
+ sourceLabel = 'hub catalog';
20
+ } else {
21
+ const registry = await loadRegistry();
22
+ templates = registry.templates || [];
23
+ sourceLabel = 'local registry';
24
+ }
25
+ } catch (error) {
26
+ console.error(red(`Error: ${error.message}`));
27
+ process.exit(1);
28
+ }
29
+
30
+ const filteredTemplates = templates.filter((template) => templateSupportsProviderModel(template, options));
31
+ const models = collectModelSupport(filteredTemplates)
32
+ .filter((model) => !options.provider || model.provider === options.provider)
33
+ .sort(compareModels);
34
+
35
+ console.log(bold(`\n BananaHub Model Map (${sourceLabel})\n`));
36
+ console.log(dim(' Use this to choose a provider/model before installing or activating templates.'));
37
+ console.log(dim(' Recommendation: use gpt-image-2 for new high-quality generation when OpenAI is available; use Gemini/Nano Banana for proven template/edit workflows.\n'));
38
+
39
+ if (models.length === 0) {
40
+ console.log(yellow(' No models matched the current filters.\n'));
41
+ return;
42
+ }
43
+
44
+ for (const model of models) {
45
+ printModel(model, options);
46
+ }
47
+
48
+ console.log(green(' Examples:'));
49
+ console.log(dim(' bananahub search logo --model gpt-image-2'));
50
+ console.log(dim(' bananahub search diagram --provider openai --capability generation'));
51
+ console.log(dim(' bananahub list --by-model\n'));
52
+ }
53
+
54
+ function parseModelsArgs(args) {
55
+ const options = {
56
+ remote: false,
57
+ provider: '',
58
+ model: '',
59
+ capability: ''
60
+ };
61
+
62
+ for (let index = 0; index < args.length; index++) {
63
+ const arg = args[index];
64
+ if (arg === '--remote' || arg === '--hub') {
65
+ options.remote = true;
66
+ continue;
67
+ }
68
+ if (arg === '--provider') {
69
+ options.provider = args[index + 1] || '';
70
+ index++;
71
+ continue;
72
+ }
73
+ if (arg === '--model') {
74
+ options.model = args[index + 1] || '';
75
+ index++;
76
+ continue;
77
+ }
78
+ if (arg === '--capability') {
79
+ options.capability = args[index + 1] || '';
80
+ index++;
81
+ continue;
82
+ }
83
+ }
84
+
85
+ return options;
86
+ }
87
+
88
+ function compareModels(left, right) {
89
+ const rankDiff = modelRank(left.id) - modelRank(right.id);
90
+ if (rankDiff !== 0) return rankDiff;
91
+ const countDiff = right.template_count - left.template_count;
92
+ if (countDiff !== 0) return countDiff;
93
+ return left.id.localeCompare(right.id);
94
+ }
95
+
96
+ function modelRank(modelId) {
97
+ if (modelId === 'gpt-image-2') return 0;
98
+ if (modelId === 'gemini-3-pro-image-preview') return 1;
99
+ if (modelId === 'gemini-3.1-flash-image-preview') return 2;
100
+ if (modelId.startsWith('gpt-image')) return 3;
101
+ if (modelId.startsWith('gemini')) return 4;
102
+ return 9;
103
+ }
104
+
105
+ function printModel(model, options) {
106
+ const display = MODEL_DISPLAY[model.id] || {};
107
+ const label = display.label || model.id;
108
+ const tier = display.tier ? dim(` ${display.tier}`) : '';
109
+ const provider = model.provider ? dim(` ${model.provider}`) : '';
110
+ const family = model.family ? dim(`/${model.family}`) : '';
111
+ const templates = model.templates.slice(0, 5).join(', ');
112
+
113
+ console.log(` ${bold(model.id)}${tier}`);
114
+ console.log(` ${cyan(label)}${provider}${family}`);
115
+ if (display.note) {
116
+ console.log(dim(` ${display.note}`));
117
+ }
118
+ if (model.aliases.length) {
119
+ console.log(dim(` Aliases: ${model.aliases.join(', ')}`));
120
+ }
121
+ if (model.capabilities.length) {
122
+ console.log(dim(` Capabilities: ${model.capabilities.join(', ')}`));
123
+ }
124
+ console.log(dim(` Templates: ${model.template_count}${templates ? ` (${templates}${model.templates.length > 5 ? ', ...' : ''})` : ''}`));
125
+ if (options.model && model.id !== options.model) {
126
+ console.log(dim(` Matched by alias/filter: ${options.model}`));
127
+ }
128
+ console.log();
129
+ }
@@ -1,13 +1,14 @@
1
1
  import { bold, cyan, dim, green, red, yellow } from '../color.js';
2
2
  import { fetchHubCatalog, fetchHubTrending, buildCatalogLookup, compareCatalogPriority, templateKey } from '../hub.js';
3
3
  import { HUB_SITE } from '../constants.js';
4
+ import { canonicalizeModelId, templateSupportsProviderModel } from '../template-schema.js';
4
5
 
5
6
  export async function searchCommand(args) {
6
7
  const options = parseSearchArgs(args);
7
8
  const keyword = options.terms.join(' ').trim();
8
9
 
9
10
  if (!keyword) {
10
- console.error(red('Usage: bananahub search <keyword> [--limit N] [--curated|--discovered]'));
11
+ console.error(red('Usage: bananahub search <keyword> [--limit N] [--curated|--discovered] [--provider P] [--model M] [--capability C]'));
11
12
  process.exit(1);
12
13
  }
13
14
 
@@ -81,7 +82,10 @@ function parseSearchArgs(args) {
81
82
  const options = {
82
83
  terms: [],
83
84
  limit: 8,
84
- source: 'all'
85
+ source: 'all',
86
+ provider: '',
87
+ model: '',
88
+ capability: ''
85
89
  };
86
90
 
87
91
  for (let index = 0; index < args.length; index++) {
@@ -99,6 +103,21 @@ function parseSearchArgs(args) {
99
103
  options.source = 'discovered';
100
104
  continue;
101
105
  }
106
+ if (arg === '--provider') {
107
+ options.provider = args[index + 1] || '';
108
+ index++;
109
+ continue;
110
+ }
111
+ if (arg === '--model') {
112
+ options.model = canonicalizeModelId(args[index + 1] || '');
113
+ index++;
114
+ continue;
115
+ }
116
+ if (arg === '--capability') {
117
+ options.capability = args[index + 1] || '';
118
+ index++;
119
+ continue;
120
+ }
102
121
  options.terms.push(arg);
103
122
  }
104
123
 
@@ -133,6 +152,7 @@ function rankTemplates(templates, keyword, options) {
133
152
 
134
153
  return templates
135
154
  .filter((template) => options.source === 'all' || template.catalog_source === options.source)
155
+ .filter((template) => templateSupportsProviderModel(template, options))
136
156
  .map((template) => ({
137
157
  template,
138
158
  score: scoreTemplate(template, query, terms)
@@ -155,17 +175,26 @@ function scoreTemplate(template, query, terms) {
155
175
  const description = normalize(template.description);
156
176
  const profile = normalize(template.profile);
157
177
  const tags = (template.tags || []).map((tag) => normalize(tag));
178
+ const providers = (template.providers || []).map((provider) => normalize(provider));
179
+ const models = (template.models || []).map((model) => normalize(model));
180
+ const capabilities = Object.entries(template.capabilities || {})
181
+ .filter(([, value]) => value === true || value === 'true')
182
+ .map(([key]) => normalize(key));
158
183
 
159
184
  let relevance = 0;
160
185
 
161
186
  if (id === query) relevance += 100;
162
187
  if (title === query || titleEn === query) relevance += 80;
163
188
  if (tags.includes(query)) relevance += 60;
189
+ if (providers.includes(query) || models.includes(query)) relevance += 50;
190
+ if (capabilities.includes(query)) relevance += 20;
164
191
 
165
192
  if (id.includes(query)) relevance += 24;
166
193
  if (title.includes(query) || titleEn.includes(query)) relevance += 24;
167
194
  if (description.includes(query)) relevance += 12;
168
195
  if (profile.includes(query)) relevance += 8;
196
+ if (providers.some((provider) => provider.includes(query))) relevance += 12;
197
+ if (models.some((model) => model.includes(query))) relevance += 12;
169
198
 
170
199
  for (const term of terms) {
171
200
  if (term === query) continue;
@@ -175,6 +204,9 @@ function scoreTemplate(template, query, terms) {
175
204
  if (title.includes(term) || titleEn.includes(term)) relevance += 5;
176
205
  if (description.includes(term)) relevance += 2;
177
206
  if (profile.includes(term)) relevance += 2;
207
+ if (providers.some((provider) => provider.includes(term))) relevance += 4;
208
+ if (models.some((model) => model.includes(term))) relevance += 4;
209
+ if (capabilities.includes(term)) relevance += 3;
178
210
  }
179
211
 
180
212
  if (relevance === 0) {
@@ -208,6 +240,42 @@ function normalize(value) {
208
240
  return String(value || '').trim().toLowerCase();
209
241
  }
210
242
 
243
+ function formatProviderIds(template) {
244
+ const ids = [];
245
+ for (const provider of template.providers || []) {
246
+ if (typeof provider === 'string') {
247
+ ids.push(provider);
248
+ } else if (provider?.id) {
249
+ ids.push(provider.id);
250
+ }
251
+ }
252
+ return [...new Set(ids.filter(Boolean))];
253
+ }
254
+
255
+ function formatModelIds(template) {
256
+ const ids = [];
257
+ for (const model of template.models || []) {
258
+ if (typeof model === 'string') {
259
+ ids.push(model);
260
+ } else if (model?.id || model?.name) {
261
+ ids.push(model.id || model.name);
262
+ }
263
+ }
264
+ for (const provider of template.providers || []) {
265
+ if (!provider || typeof provider === 'string') {
266
+ continue;
267
+ }
268
+ for (const model of provider.models || []) {
269
+ if (typeof model === 'string') {
270
+ ids.push(model);
271
+ } else if (model?.id || model?.name) {
272
+ ids.push(model.id || model.name);
273
+ }
274
+ }
275
+ }
276
+ return [...new Set(ids.filter(Boolean))];
277
+ }
278
+
211
279
  function printTemplateResult(index, template) {
212
280
  const title = template.title_en || template.title || template.id;
213
281
  const subtitle = template.title && template.title_en ? template.title : '';
@@ -226,6 +294,11 @@ function printTemplateResult(index, template) {
226
294
  if (template.tags?.length) {
227
295
  console.log(dim(` Tags: ${template.tags.slice(0, 6).join(', ')}`));
228
296
  }
297
+ if (template.providers?.length || template.models?.length) {
298
+ const providers = formatProviderIds(template).slice(0, 3).join(', ');
299
+ const models = formatModelIds(template).slice(0, 4).join(', ');
300
+ console.log(dim(` Support: ${providers || 'providers n/a'}${models ? ` | ${models}` : ''}`));
301
+ }
229
302
  if (primaryCmd) {
230
303
  console.log(dim(` ${commandLabel}: ${primaryCmd}`));
231
304
  }
package/lib/constants.js CHANGED
@@ -1,7 +1,6 @@
1
1
  import { homedir } from 'node:os';
2
2
  import { join } from 'node:path';
3
3
 
4
- export const CLI_VERSION = '0.1.1';
5
4
  export const CONFIG_HOME = join(homedir(), '.config', 'bananahub');
6
5
  export const TEMPLATES_DIR = join(CONFIG_HOME, 'templates');
7
6
  export const REGISTRY_FILE = '.registry.json';
@@ -1,6 +1,7 @@
1
1
  /**
2
2
  * Minimal YAML frontmatter parser.
3
- * Handles the subset used by template.md files (scalars, arrays, objects-in-arrays).
3
+ * Handles the subset used by template.md files (scalars, arrays, objects-in-arrays,
4
+ * and one nested object-array level for providers[].models[]).
4
5
  */
5
6
 
6
7
  export function parseFrontmatter(text) {
@@ -47,25 +48,52 @@ function parseYaml(text) {
47
48
 
48
49
  // Empty value — check for block array/object below
49
50
  if (value === '') {
50
- // Look ahead for block array (lines starting with " - ")
51
51
  const items = [];
52
52
  let j = i + 1;
53
53
  while (j < lines.length && lines[j].match(/^ - /)) {
54
54
  const itemLine = lines[j].replace(/^ - /, '').trim();
55
- // Check if this array item has sub-keys
56
55
  let k = j + 1;
57
56
  const subKeys = {};
58
57
  let hasSubKeys = false;
59
- while (k < lines.length && lines[k].match(/^ \w/)) {
58
+ while (k < lines.length && lines[k].match(/^ /)) {
59
+ if (lines[k].match(/^ /)) {
60
+ k++;
61
+ continue;
62
+ }
60
63
  const subMatch = lines[k].match(/^ (\w[\w_]*):\s*(.*)/);
61
64
  if (subMatch) {
62
65
  hasSubKeys = true;
66
+ const nestedKey = subMatch[1];
63
67
  let sv = subMatch[2].trim();
64
- if ((sv.startsWith('"') && sv.endsWith('"')) ||
65
- (sv.startsWith("'") && sv.endsWith("'"))) {
66
- sv = sv.slice(1, -1);
68
+
69
+ if (sv === '') {
70
+ const nestedItems = [];
71
+ k++;
72
+ while (k < lines.length && lines[k].match(/^ - /)) {
73
+ const nestedItemLine = lines[k].replace(/^ - /, '').trim();
74
+ const nestedObj = {};
75
+ const nestedFirstKey = nestedItemLine.match(/^(\w[\w_]*):\s*(.*)/);
76
+ if (nestedFirstKey) {
77
+ nestedObj[nestedFirstKey[1]] = coerceValue(nestedFirstKey[2].trim());
78
+ k++;
79
+ while (k < lines.length && lines[k].match(/^ \w/)) {
80
+ const attrMatch = lines[k].match(/^ (\w[\w_]*):\s*(.*)/);
81
+ if (attrMatch) {
82
+ nestedObj[attrMatch[1]] = coerceValue(attrMatch[2].trim());
83
+ }
84
+ k++;
85
+ }
86
+ nestedItems.push(nestedObj);
87
+ } else {
88
+ nestedItems.push(coerceValue(nestedItemLine));
89
+ k++;
90
+ }
91
+ }
92
+ subKeys[nestedKey] = nestedItems;
93
+ continue;
67
94
  }
68
- subKeys[subMatch[1]] = coerce(sv);
95
+
96
+ subKeys[nestedKey] = coerceValue(sv);
69
97
  }
70
98
  k++;
71
99
  }
@@ -74,17 +102,12 @@ function parseYaml(text) {
74
102
  // First line of block item may have a key: value too
75
103
  const firstKeyMatch = itemLine.match(/^(\w[\w_]*):\s*(.*)/);
76
104
  if (firstKeyMatch) {
77
- let fv = firstKeyMatch[2].trim();
78
- if ((fv.startsWith('"') && fv.endsWith('"')) ||
79
- (fv.startsWith("'") && fv.endsWith("'"))) {
80
- fv = fv.slice(1, -1);
81
- }
82
- subKeys[firstKeyMatch[1]] = coerce(fv);
105
+ subKeys[firstKeyMatch[1]] = coerceValue(firstKeyMatch[2].trim());
83
106
  }
84
107
  items.push(subKeys);
85
108
  j = k;
86
109
  } else {
87
- items.push(coerce(itemLine));
110
+ items.push(coerceValue(itemLine));
88
111
  j++;
89
112
  }
90
113
  }
@@ -95,13 +118,29 @@ function parseYaml(text) {
95
118
  continue;
96
119
  }
97
120
 
121
+ const obj = {};
122
+ j = i + 1;
123
+ while (j < lines.length && lines[j].match(/^ \w/)) {
124
+ const objMatch = lines[j].match(/^ (\w[\w_]*):\s*(.*)/);
125
+ if (objMatch) {
126
+ obj[objMatch[1]] = coerceValue(objMatch[2].trim());
127
+ }
128
+ j++;
129
+ }
130
+
131
+ if (Object.keys(obj).length > 0) {
132
+ result[key] = obj;
133
+ i = j;
134
+ continue;
135
+ }
136
+
98
137
  result[key] = '';
99
138
  i++;
100
139
  continue;
101
140
  }
102
141
 
103
142
  // Plain scalar
104
- result[key] = coerce(value);
143
+ result[key] = coerceValue(value);
105
144
  i++;
106
145
  }
107
146
 
@@ -120,7 +159,14 @@ function parseInlineArray(str) {
120
159
  }).filter(Boolean);
121
160
  }
122
161
 
123
- function coerce(val) {
162
+ function coerceValue(val) {
163
+ if ((val.startsWith('"') && val.endsWith('"')) ||
164
+ (val.startsWith("'") && val.endsWith("'"))) {
165
+ return val.slice(1, -1);
166
+ }
167
+ if (val.startsWith('[') && val.endsWith(']')) {
168
+ return parseInlineArray(val);
169
+ }
124
170
  if (val === 'true') return true;
125
171
  if (val === 'false') return false;
126
172
  if (val === 'null' || val === '~') return null;
package/lib/github.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { GITHUB_API } from './constants.js';
2
+ import { CLI_VERSION } from './version.js';
2
3
 
3
4
  /**
4
5
  * Fetch repo info from GitHub API.
@@ -70,7 +71,7 @@ export async function getLatestSha(repo, ref = 'HEAD') {
70
71
  function ghHeaders() {
71
72
  const headers = {
72
73
  'Accept': 'application/vnd.github.v3+json',
73
- 'User-Agent': 'bananahub-cli/0.1.1'
74
+ 'User-Agent': `bananahub-cli/${CLI_VERSION}`
74
75
  };
75
76
  if (process.env.GITHUB_TOKEN) {
76
77
  headers['Authorization'] = `token ${process.env.GITHUB_TOKEN}`;
package/lib/hub.js CHANGED
@@ -1,4 +1,5 @@
1
- import { CLI_VERSION, HUB_API, HUB_CATALOG_URL } from './constants.js';
1
+ import { HUB_API, HUB_CATALOG_URL } from './constants.js';
2
+ import { CLI_VERSION } from './version.js';
2
3
 
3
4
  export async function fetchHubCatalog() {
4
5
  const res = await fetch(HUB_CATALOG_URL, {
package/lib/registry.js CHANGED
@@ -3,6 +3,7 @@ import { join } from 'node:path';
3
3
  import { TEMPLATES_DIR, REGISTRY_FILE } from './constants.js';
4
4
  import { parseFrontmatter } from './frontmatter.js';
5
5
  import { ensurePrimaryTemplatesDir, getTemplateRoots } from './paths.js';
6
+ import { getProviderIds, getSupportedModelIds, normalizeProviders } from './template-schema.js';
6
7
 
7
8
  /**
8
9
  * Rebuild .registry.json by scanning all installed template directories.
@@ -42,7 +43,11 @@ export async function rebuildRegistry() {
42
43
  tags: fm.tags || [],
43
44
  difficulty: fm.difficulty || 'beginner',
44
45
  aspect: fm.aspect || '',
45
- models: Array.isArray(fm.models) ? fm.models.map((m) => m.name || m) : [],
46
+ models: getSupportedModelIds(fm),
47
+ providers: getProviderIds(fm),
48
+ provider_matrix: normalizeProviders(fm),
49
+ capabilities: fm.capabilities || {},
50
+ prompt_variants: fm.prompt_variants || {},
46
51
  source: source?.repo || '',
47
52
  version: fm.version || '0.0.0',
48
53
  installed_at: source?.installed_at || ''
@@ -55,7 +60,7 @@ export async function rebuildRegistry() {
55
60
  }
56
61
 
57
62
  const registry = {
58
- version: '1.0.0',
63
+ version: '2.0.0',
59
64
  generated_at: new Date().toISOString(),
60
65
  templates
61
66
  };
@@ -0,0 +1,276 @@
1
+ export const VALID_PROVIDER_IDS = [
2
+ 'google-ai-studio',
3
+ 'gemini-compatible',
4
+ 'vertex-ai',
5
+ 'openai',
6
+ 'openai-compatible',
7
+ 'chatgpt-compatible'
8
+ ];
9
+
10
+ export const MODEL_ALIASES = {
11
+ 'nano-banana-pro': 'gemini-3-pro-image-preview',
12
+ 'nano-banana-pro-preview': 'gemini-3-pro-image-preview',
13
+ 'nano-banana-2': 'gemini-3.1-flash-image-preview',
14
+ 'nano-banana-flash': 'gemini-3.1-flash-image-preview',
15
+ 'nano-banana': 'gemini-2.5-flash-image',
16
+ 'nano-banana-1': 'gemini-2.5-flash-image',
17
+ 'gpt-image2': 'gpt-image-2',
18
+ 'gpt image 2': 'gpt-image-2',
19
+ 'gpt-image': 'gpt-image-1',
20
+ 'gpt image': 'gpt-image-1',
21
+ 'gpt5.4': 'gpt-5.4',
22
+ 'gpt 5.4': 'gpt-5.4'
23
+ };
24
+
25
+ export const MODEL_DISPLAY = {
26
+ 'gpt-image-2': {
27
+ provider: 'openai',
28
+ family: 'gpt-image',
29
+ label: 'GPT Image 2',
30
+ tier: 'recommended',
31
+ note: 'Highest-priority default for new high-quality generation when an OpenAI key is available.'
32
+ },
33
+ 'gemini-3-pro-image-preview': {
34
+ provider: 'google-ai-studio',
35
+ family: 'gemini-image',
36
+ label: 'Gemini 3 Pro Image Preview',
37
+ tier: 'strong',
38
+ note: 'Best Nano Banana/Gemini path for template compatibility, editing flows, and conservative prompts.'
39
+ },
40
+ 'gemini-3.1-flash-image-preview': {
41
+ provider: 'google-ai-studio',
42
+ family: 'gemini-image',
43
+ label: 'Gemini 3.1 Flash Image Preview',
44
+ tier: 'fast',
45
+ note: 'Fast Gemini/Nano Banana option for simple templates and lower-cost iteration.'
46
+ },
47
+ 'gemini-2.5-flash-image': {
48
+ provider: 'google-ai-studio',
49
+ family: 'gemini-image',
50
+ label: 'Gemini 2.5 Flash Image',
51
+ tier: 'legacy',
52
+ note: 'Legacy Nano Banana-compatible model.'
53
+ },
54
+ 'gpt-image-1': {
55
+ provider: 'openai',
56
+ family: 'gpt-image',
57
+ label: 'GPT Image 1',
58
+ tier: 'fallback',
59
+ note: 'OpenAI GPT Image fallback where GPT Image 2 is unavailable.'
60
+ },
61
+ 'gpt-5.4': {
62
+ provider: 'chatgpt-compatible',
63
+ family: 'chat-image',
64
+ label: 'GPT 5.4 Chat Image',
65
+ tier: 'gateway',
66
+ note: 'Chat/completions-compatible image path; endpoint behavior is gateway-dependent.'
67
+ }
68
+ };
69
+
70
+ export const VALID_MODEL_FAMILIES = [
71
+ 'gemini-image',
72
+ 'gpt-image',
73
+ 'chat-image',
74
+ 'unknown-openai-compatible'
75
+ ];
76
+
77
+ export const VALID_MODEL_QUALITIES = [
78
+ 'best',
79
+ 'good',
80
+ 'ok',
81
+ 'untested',
82
+ 'expected-best'
83
+ ];
84
+
85
+ export const VALID_CAPABILITY_KEYS = [
86
+ 'generation',
87
+ 'edit',
88
+ 'mask_edit'
89
+ ];
90
+
91
+ export function getLegacyModelIds(fm) {
92
+ if (!Array.isArray(fm?.models)) return [];
93
+ return fm.models
94
+ .map((model) => {
95
+ if (typeof model === 'string') return model;
96
+ if (model && typeof model === 'object') return model.name || model.id || '';
97
+ return '';
98
+ })
99
+ .filter(Boolean);
100
+ }
101
+
102
+ export function normalizeProviders(fm) {
103
+ const providerEntries = Array.isArray(fm?.provider_matrix)
104
+ ? fm.provider_matrix
105
+ : (Array.isArray(fm?.providers) && fm.providers.some((provider) => provider && typeof provider === 'object')
106
+ ? fm.providers
107
+ : []);
108
+ if (!providerEntries.length) {
109
+ return normalizeFlatProviderModelSupport(fm);
110
+ }
111
+ return providerEntries
112
+ .filter((provider) => provider && typeof provider === 'object')
113
+ .map((provider) => ({
114
+ id: provider.id || '',
115
+ family: provider.family || '',
116
+ models: Array.isArray(provider.models)
117
+ ? provider.models
118
+ .filter((model) => model && typeof model === 'object')
119
+ .map((model) => ({
120
+ id: model.id || model.name || '',
121
+ aliases: Array.isArray(model.aliases) ? model.aliases : [],
122
+ quality: model.quality || 'untested',
123
+ prompt_variant: model.prompt_variant || ''
124
+ }))
125
+ : []
126
+ }));
127
+ }
128
+
129
+ function normalizeFlatProviderModelSupport(fm) {
130
+ const providerIds = Array.isArray(fm?.providers)
131
+ ? fm.providers.filter((provider) => typeof provider === 'string')
132
+ : [];
133
+ const modelIds = Array.isArray(fm?.models)
134
+ ? fm.models.map((model) => (typeof model === 'string' ? model : model?.id || model?.name || '')).filter(Boolean)
135
+ : [];
136
+ if (!providerIds.length || !modelIds.length) return [];
137
+ return providerIds.map((providerId) => ({
138
+ id: providerId,
139
+ family: inferFamily(providerId, modelIds),
140
+ models: modelIds
141
+ .filter((modelId) => modelBelongsToProvider(modelId, providerId))
142
+ .map((modelId) => ({
143
+ id: canonicalizeModelId(modelId),
144
+ aliases: [],
145
+ quality: 'untested',
146
+ prompt_variant: inferPromptVariant(providerId, modelId)
147
+ }))
148
+ })).filter((provider) => provider.models.length);
149
+ }
150
+
151
+ function inferFamily(providerId, modelIds) {
152
+ if (providerId === 'openai' || modelIds.some((modelId) => canonicalizeModelId(modelId).startsWith('gpt-image'))) return 'gpt-image';
153
+ if (providerId === 'chatgpt-compatible') return 'chat-image';
154
+ if (providerId.includes('gemini') || providerId.includes('google') || providerId === 'vertex-ai') return 'gemini-image';
155
+ return '';
156
+ }
157
+
158
+ function inferPromptVariant(providerId, modelId) {
159
+ const canonical = canonicalizeModelId(modelId);
160
+ if (canonical.startsWith('gpt-image')) return 'gpt-image';
161
+ if (providerId === 'chatgpt-compatible' || canonical === 'gpt-5.4') return 'chat-image';
162
+ if (canonical.startsWith('gemini')) return 'gemini';
163
+ return '';
164
+ }
165
+
166
+ function modelBelongsToProvider(modelId, providerId) {
167
+ const canonical = canonicalizeModelId(modelId);
168
+ if (providerId === 'openai') return canonical.startsWith('gpt-image');
169
+ if (providerId === 'chatgpt-compatible') return canonical === 'gpt-5.4';
170
+ if (providerId === 'google-ai-studio' || providerId === 'gemini-compatible' || providerId === 'vertex-ai') {
171
+ return canonical.startsWith('gemini');
172
+ }
173
+ return true;
174
+ }
175
+
176
+ export function canonicalizeModelId(value) {
177
+ const normalized = String(value || '').trim();
178
+ if (!normalized) return '';
179
+ return MODEL_ALIASES[normalized.toLowerCase()] || normalized;
180
+ }
181
+
182
+ export function providerMatches(providerId, requestedProvider) {
183
+ if (!requestedProvider) return true;
184
+ return String(providerId || '').toLowerCase() === String(requestedProvider || '').toLowerCase();
185
+ }
186
+
187
+ export function modelMatches(model, requestedModel) {
188
+ if (!requestedModel) return true;
189
+ const canonical = canonicalizeModelId(requestedModel).toLowerCase();
190
+ const ids = [model?.id, ...(model?.aliases || [])].map((item) => canonicalizeModelId(item).toLowerCase());
191
+ return ids.includes(canonical);
192
+ }
193
+
194
+ export function templateSupportsCapability(template, capability) {
195
+ if (!capability) return true;
196
+ const capabilities = template?.capabilities || {};
197
+ return capabilities[capability] === true || capabilities[capability] === 'true';
198
+ }
199
+
200
+ export function templateSupportsProviderModel(template, { provider = '', model = '', capability = '' } = {}) {
201
+ if (!templateSupportsCapability(template, capability)) return false;
202
+ const providers = normalizeProviders(template);
203
+ if (!providers.length) {
204
+ const models = getLegacyModelIds(template).map((id) => canonicalizeModelId(id).toLowerCase());
205
+ return !model || models.includes(canonicalizeModelId(model).toLowerCase());
206
+ }
207
+ return providers.some((entry) => {
208
+ if (!providerMatches(entry.id, provider)) return false;
209
+ return entry.models.some((entryModel) => modelMatches(entryModel, model));
210
+ });
211
+ }
212
+
213
+ export function collectModelSupport(templates) {
214
+ const support = new Map();
215
+ for (const template of templates || []) {
216
+ for (const provider of normalizeProviders(template)) {
217
+ for (const model of provider.models) {
218
+ const id = canonicalizeModelId(model.id);
219
+ if (!id) continue;
220
+ if (!support.has(id)) {
221
+ support.set(id, {
222
+ id,
223
+ provider: provider.id,
224
+ family: provider.family,
225
+ aliases: new Set(),
226
+ qualities: new Set(),
227
+ prompt_variants: new Set(),
228
+ templates: [],
229
+ capabilities: new Set()
230
+ });
231
+ }
232
+ const entry = support.get(id);
233
+ for (const alias of model.aliases || []) entry.aliases.add(alias);
234
+ if (model.quality) entry.qualities.add(model.quality);
235
+ if (model.prompt_variant) entry.prompt_variants.add(model.prompt_variant);
236
+ entry.templates.push(template.id);
237
+ for (const [key, value] of Object.entries(template.capabilities || {})) {
238
+ if (value === true || value === 'true') entry.capabilities.add(key);
239
+ }
240
+ }
241
+ }
242
+ }
243
+ return [...support.values()].map((entry) => ({
244
+ ...entry,
245
+ aliases: [...entry.aliases],
246
+ qualities: [...entry.qualities],
247
+ prompt_variants: [...entry.prompt_variants],
248
+ capabilities: [...entry.capabilities],
249
+ template_count: entry.templates.length
250
+ }));
251
+ }
252
+
253
+ export function getProviderModelIds(fm) {
254
+ return normalizeProviders(fm)
255
+ .flatMap((provider) => provider.models.map((model) => model.id))
256
+ .filter(Boolean);
257
+ }
258
+
259
+ export function getSupportedModelIds(fm) {
260
+ return Array.from(new Set([...getLegacyModelIds(fm), ...getProviderModelIds(fm)]));
261
+ }
262
+
263
+ export function getProviderIds(fm) {
264
+ return normalizeProviders(fm)
265
+ .map((provider) => provider.id)
266
+ .filter(Boolean);
267
+ }
268
+
269
+ export function hasPromptVariant(content, variantId) {
270
+ if (!variantId || variantId === 'base' || variantId === 'default') return true;
271
+ const escaped = variantId.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
272
+ return (
273
+ new RegExp(`^##\\s+Prompt Template:\\s*${escaped}\\s*$`, 'im').test(content) ||
274
+ new RegExp(`^#{2,4}\\s+Provider Variant:\\s*${escaped}\\s*$`, 'im').test(content)
275
+ );
276
+ }
package/lib/validate.js CHANGED
@@ -2,6 +2,15 @@ import { readFile, access, readdir } from 'node:fs/promises';
2
2
  import { dirname, join } from 'node:path';
3
3
  import { parseFrontmatter } from './frontmatter.js';
4
4
  import { VALID_PROFILES, VALID_DIFFICULTIES, VALID_TEMPLATE_TYPES } from './constants.js';
5
+ import {
6
+ VALID_CAPABILITY_KEYS,
7
+ VALID_MODEL_FAMILIES,
8
+ VALID_MODEL_QUALITIES,
9
+ VALID_PROVIDER_IDS,
10
+ getSupportedModelIds,
11
+ hasPromptVariant,
12
+ normalizeProviders
13
+ } from './template-schema.js';
5
14
 
6
15
  const SAMPLE_FILE_PATTERN = /^sample-[a-z0-9.]+(?:-[a-z0-9.]+)*-\d{2}\.(jpg|jpeg|png|webp)$/i;
7
16
  const DEFAULT_TEMPLATE_TYPE = 'prompt';
@@ -70,10 +79,12 @@ export async function validateTemplate(dirPath) {
70
79
  warnings.push('No license declared — add `license: CC-BY-4.0` or another SPDX/CC identifier');
71
80
  }
72
81
 
73
- if (!fm.models || !Array.isArray(fm.models) || fm.models.length === 0) {
74
- warnings.push('No models listed — users won\'t know which models are tested');
82
+ const supportedModelIds = getSupportedModelIds(fm);
83
+ if (supportedModelIds.length === 0) {
84
+ warnings.push('No models/providers listed — users won\'t know which models or providers are supported');
75
85
  }
76
86
 
87
+ validateProviderMetadata(fm, content, errors, warnings);
77
88
  validateBody(content, templateType, warnings);
78
89
  await validateLicenseFiles(dirPath, warnings);
79
90
  await validateSamplesDir(dirPath, templateType, warnings);
@@ -88,6 +99,63 @@ export async function validateTemplate(dirPath) {
88
99
  };
89
100
  }
90
101
 
102
+ function validateProviderMetadata(fm, content, errors, warnings) {
103
+ const providers = normalizeProviders(fm);
104
+ const hasProviderMatrix = Array.isArray(fm.providers) && fm.providers.length > 0;
105
+
106
+ if (!hasProviderMatrix) {
107
+ warnings.push('No provider matrix found — add `providers` for schema v2 multi-model compatibility');
108
+ return;
109
+ }
110
+
111
+ for (const provider of providers) {
112
+ if (!provider.id) {
113
+ errors.push('Provider entry is missing `id`');
114
+ continue;
115
+ }
116
+
117
+ if (!VALID_PROVIDER_IDS.includes(provider.id)) {
118
+ warnings.push(`Provider "${provider.id}" is not a known BananaHub provider id`);
119
+ }
120
+
121
+ if (provider.family && !VALID_MODEL_FAMILIES.includes(provider.family)) {
122
+ warnings.push(`Provider "${provider.id}" has unknown family "${provider.family}"`);
123
+ }
124
+
125
+ if (!provider.models.length) {
126
+ errors.push(`Provider "${provider.id}" must list at least one model`);
127
+ continue;
128
+ }
129
+
130
+ for (const model of provider.models) {
131
+ if (!model.id) {
132
+ errors.push(`Provider "${provider.id}" has a model entry without \`id\``);
133
+ continue;
134
+ }
135
+
136
+ if (!VALID_MODEL_QUALITIES.includes(model.quality)) {
137
+ warnings.push(`Model "${model.id}" has unusual quality "${model.quality}"`);
138
+ }
139
+
140
+ if (!model.prompt_variant) {
141
+ warnings.push(`Model "${model.id}" should declare prompt_variant to prevent cross-provider prompt misuse`);
142
+ } else if (!hasPromptVariant(content, model.prompt_variant)) {
143
+ errors.push(`Model "${model.id}" references missing prompt variant "${model.prompt_variant}"`);
144
+ }
145
+ }
146
+ }
147
+
148
+ if (!fm.capabilities || typeof fm.capabilities !== 'object' || Array.isArray(fm.capabilities)) {
149
+ warnings.push('No capabilities object found — add generation/edit/mask_edit flags for runtime routing');
150
+ } else {
151
+ for (const key of Object.keys(fm.capabilities)) {
152
+ if (!VALID_CAPABILITY_KEYS.includes(key)) {
153
+ warnings.push(`Unknown capability "${key}"`);
154
+ }
155
+ }
156
+ }
157
+ }
158
+
91
159
  function validateBody(content, templateType, warnings) {
92
160
  if (templateType === 'workflow') {
93
161
  const requiredSections = ['## Goal', '## Inputs', '## Steps', '## Prompt Blocks'];
@@ -168,12 +236,20 @@ async function validateSampleMetadata(dirPath, fm, warnings) {
168
236
  warnings.push(`Sample file "${sample.file}" should follow sample-{model-short}-{nn}.ext naming`);
169
237
  }
170
238
 
239
+ if (!sample.provider) {
240
+ warnings.push(`Sample "${sample.file}" is missing \`provider\``);
241
+ }
242
+
171
243
  if (!sample.model) {
172
244
  warnings.push(`Sample "${sample.file}" is missing \`model\``);
173
245
  } else if (!fileNameIncludesModel(fileName, sample.model)) {
174
246
  warnings.push(`Sample file "${sample.file}" should include the generating model shorthand for "${sample.model}"`);
175
247
  }
176
248
 
249
+ if (!sample.prompt_variant) {
250
+ warnings.push(`Sample "${sample.file}" is missing \`prompt_variant\``);
251
+ }
252
+
177
253
  if (!sample.prompt) {
178
254
  warnings.push(`Sample "${sample.file}" is missing \`prompt\``);
179
255
  }
package/lib/version.js ADDED
@@ -0,0 +1,8 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { dirname, join } from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+
5
+ const packageJsonPath = join(dirname(fileURLToPath(import.meta.url)), '..', 'package.json');
6
+ const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8'));
7
+
8
+ export const CLI_VERSION = packageJson.version;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bananahub",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
4
4
  "description": "Template manager for BananaHub Skill — installable Gemini workflow modules",
5
5
  "type": "module",
6
6
  "bin": {