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 +30 -4
- package/bin/bananahub.js +16 -7
- package/lib/commands/add.js +2 -1
- package/lib/commands/info.js +42 -2
- package/lib/commands/init.js +50 -8
- package/lib/commands/list.js +28 -1
- package/lib/commands/models.js +129 -0
- package/lib/commands/search.js +75 -2
- package/lib/constants.js +0 -1
- package/lib/frontmatter.js +63 -17
- package/lib/github.js +2 -1
- package/lib/hub.js +2 -1
- package/lib/registry.js +7 -2
- package/lib/template-schema.js +276 -0
- package/lib/validate.js +78 -2
- package/lib/version.js +8 -0
- package/package.json +1 -1
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
|
|
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-
|
|
31
|
-
bananahub add bananahub-ai/
|
|
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 =
|
|
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')}
|
|
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('
|
|
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-
|
|
35
|
-
bananahub add bananahub-ai/
|
|
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);
|
package/lib/commands/add.js
CHANGED
|
@@ -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,
|
|
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'];
|
package/lib/commands/info.js
CHANGED
|
@@ -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('
|
|
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}
|
|
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
|
}
|
package/lib/commands/init.js
CHANGED
|
@@ -106,7 +106,19 @@ Reusable prompt block or instruction fragment for this workflow
|
|
|
106
106
|
return `## Prompt Template
|
|
107
107
|
|
|
108
108
|
\`\`\`
|
|
109
|
-
|
|
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
|
-
-
|
|
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\`
|
|
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
|
|
146
|
-
|
|
147
|
-
| \`samples/sample-3-pro-01.png\` | \`gemini-3-pro-image-preview\` |
|
|
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
|
|
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}
|
package/lib/commands/list.js
CHANGED
|
@@ -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
|
+
}
|
package/lib/commands/search.js
CHANGED
|
@@ -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';
|
package/lib/frontmatter.js
CHANGED
|
@@ -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(/^
|
|
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
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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] =
|
|
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
|
|
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':
|
|
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
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:
|
|
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: '
|
|
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
|
-
|
|
74
|
-
|
|
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;
|