@myvillage/cli 1.64.7 → 1.66.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@myvillage/cli",
3
- "version": "1.64.7",
3
+ "version": "1.66.0",
4
4
  "description": "MyVillageOS CLI for community developers",
5
5
  "type": "module",
6
6
  "bin": {
@@ -21,44 +21,53 @@ export async function agentLoop(agentName, { signal }) {
21
21
  const configPath = join(agentDir, 'agent.config.yaml');
22
22
  const config = parseYaml(readFileSync(configPath, 'utf-8'));
23
23
 
24
- // Determine provider and resolve API key
25
- const provider = config.brain?.provider || 'anthropic';
24
+ // Determine provider + model, honoring the app-wide default model in
25
+ // ~/.myvillage/config.json (written by MyVillage Studio's Settings). Precedence:
26
+ // per-agent brain.* > app-wide defaultModel > built-in default.
26
27
  const globalConfigPath = join(homedir(), '.myvillage', 'config.json');
27
- let apiKey;
28
+ let globalConfig = {};
29
+ if (existsSync(globalConfigPath)) {
30
+ try {
31
+ globalConfig = JSON.parse(readFileSync(globalConfigPath, 'utf-8'));
32
+ } catch { /* ignore */ }
33
+ }
34
+ const defaultModel = globalConfig.defaultModel || {};
35
+
36
+ const provider = config.brain?.provider || defaultModel.provider || 'anthropic';
37
+ const DEFAULT_MODEL_ID = {
38
+ anthropic: 'claude-sonnet-4-5-20250929',
39
+ openai: 'gpt-4o',
40
+ 'self-hosted': '',
41
+ };
42
+ const modelId =
43
+ config.brain?.model || defaultModel.model || DEFAULT_MODEL_ID[provider] || DEFAULT_MODEL_ID.anthropic;
28
44
 
45
+ // Create provider and model
46
+ let model;
29
47
  if (provider === 'openai') {
30
- apiKey = process.env.OPENAI_API_KEY;
31
- if (!apiKey && existsSync(globalConfigPath)) {
32
- try {
33
- const globalConfig = JSON.parse(readFileSync(globalConfigPath, 'utf-8'));
34
- apiKey = globalConfig.openaiApiKey;
35
- } catch { /* ignore */ }
36
- }
48
+ const apiKey = process.env.OPENAI_API_KEY || globalConfig.openaiApiKey;
37
49
  if (!apiKey) {
38
50
  logActivity(agentDir, { type: 'error', error: 'No OpenAI API key configured' });
39
- throw new Error('No OpenAI API key configured. Set OPENAI_API_KEY or run: myvillage agent start ' + agentName);
51
+ throw new Error('No OpenAI API key configured. Set OPENAI_API_KEY or configure a model in MyVillage Studio settings.');
40
52
  }
41
- } else {
42
- apiKey = process.env.ANTHROPIC_API_KEY;
43
- if (!apiKey && existsSync(globalConfigPath)) {
44
- try {
45
- const globalConfig = JSON.parse(readFileSync(globalConfigPath, 'utf-8'));
46
- apiKey = globalConfig.anthropicApiKey;
47
- } catch { /* ignore */ }
53
+ const openai = createOpenAI({ apiKey });
54
+ model = openai(modelId);
55
+ } else if (provider === 'self-hosted') {
56
+ // Self-hosted = any OpenAI-compatible endpoint.
57
+ const baseURL = config.brain?.baseURL || defaultModel.baseURL;
58
+ if (!baseURL) {
59
+ logActivity(agentDir, { type: 'error', error: 'No base URL configured for self-hosted model' });
60
+ throw new Error('No base URL configured for the self-hosted model. Set it in MyVillage Studio settings.');
48
61
  }
62
+ const apiKey = process.env.SELF_HOSTED_API_KEY || globalConfig.selfHostedApiKey || 'not-needed';
63
+ const openai = createOpenAI({ apiKey, baseURL });
64
+ model = openai(modelId);
65
+ } else {
66
+ const apiKey = process.env.ANTHROPIC_API_KEY || globalConfig.anthropicApiKey;
49
67
  if (!apiKey) {
50
68
  logActivity(agentDir, { type: 'error', error: 'No Anthropic API key configured' });
51
- throw new Error('No Anthropic API key configured. Set ANTHROPIC_API_KEY or run: myvillage agent start ' + agentName);
69
+ throw new Error('No Anthropic API key configured. Set ANTHROPIC_API_KEY or configure a model in MyVillage Studio settings.');
52
70
  }
53
- }
54
-
55
- // Create provider and model
56
- const modelId = config.brain?.model || (provider === 'openai' ? 'gpt-4o' : 'claude-sonnet-4-5-20250929');
57
- let model;
58
- if (provider === 'openai') {
59
- const openai = createOpenAI({ apiKey });
60
- model = openai(modelId);
61
- } else {
62
71
  const anthropic = createAnthropic({ apiKey });
63
72
  model = anthropic(modelId);
64
73
  }
@@ -156,7 +156,12 @@ export async function getMCPTools(agentDir, agentConfig) {
156
156
  }
157
157
 
158
158
  activeClients.push(client);
159
- const tools = await client.tools();
159
+ // Per-server tool allowlist (least privilege). When `allowed_tools` is a
160
+ // non-empty array in tools.yaml, expose ONLY those tools to the agent and
161
+ // withhold everything else this server offers (e.g. grant `network_feed`
162
+ // but not `wallet_send`). Absent/empty → all tools, the backward-compatible
163
+ // default.
164
+ const tools = applyToolAllowlist(await client.tools(), server.allowed_tools, name);
160
165
  const toolNames = Object.keys(tools);
161
166
  Object.assign(allTools, tools);
162
167
  servers.push({
@@ -182,6 +187,29 @@ export async function getMCPTools(agentDir, agentConfig) {
182
187
  return { tools: allTools, servers };
183
188
  }
184
189
 
190
+ /**
191
+ * Restrict a server's tools to an allowlist. `allowed` is the `allowed_tools`
192
+ * array from a tools.yaml server entry; when it's missing or empty, all tools
193
+ * pass through unchanged (backward-compatible). Allowlisted names that the
194
+ * server doesn't actually expose are logged so a typo or a removed/renamed tool
195
+ * is visible rather than silently dropping the scope to nothing.
196
+ */
197
+ export function applyToolAllowlist(tools, allowed, serverName) {
198
+ if (!Array.isArray(allowed) || allowed.length === 0) return tools;
199
+ const allow = new Set(allowed);
200
+ const filtered = {};
201
+ for (const [name, tool] of Object.entries(tools)) {
202
+ if (allow.has(name)) filtered[name] = tool;
203
+ }
204
+ const unknown = allowed.filter((n) => !(n in tools));
205
+ if (unknown.length > 0) {
206
+ console.error(
207
+ `[mcp-client] ${serverName}: allowed_tools lists ${unknown.length} tool(s) this server doesn't expose: ${unknown.join(', ')}`,
208
+ );
209
+ }
210
+ return filtered;
211
+ }
212
+
185
213
  export async function cleanupMCPClients() {
186
214
  for (const client of activeClients) {
187
215
  try { await client.close(); } catch { /* ignore */ }
@@ -8,54 +8,117 @@ import inquirer from 'inquirer';
8
8
  import { isAuthenticated } from '../utils/auth.js';
9
9
  import { createGameProject, createUnityGameProject } from '../utils/templates.js';
10
10
 
11
+ // ── Option normalizers (for non-interactive / Studio-driven creation) ────────
12
+ function normOrientation(v) {
13
+ if (!v) return null;
14
+ v = String(v).toLowerCase();
15
+ return v === 'portrait' || v === 'landscape' ? v : null;
16
+ }
17
+ function normEngine(v) {
18
+ if (!v) return null;
19
+ v = String(v).toLowerCase();
20
+ if (v === 'threejs' || v === 'three' || v === 'js') return 'threejs';
21
+ if (v === 'unity') return 'unity';
22
+ return null;
23
+ }
24
+ function normType(v) {
25
+ if (!v) return null;
26
+ v = String(v).toLowerCase();
27
+ return ['quiz', 'exploration', 'narrative', 'custom'].includes(v) ? v : null;
28
+ }
29
+ function normAge(v) {
30
+ if (!v) return null;
31
+ v = String(v).toLowerCase();
32
+ return ['under-5', '6-12', '13-and-over'].includes(v) ? v : null;
33
+ }
34
+ function normCategory(v) {
35
+ if (!v) return null;
36
+ v = String(v).toLowerCase();
37
+ return ['education', 'action', 'adventure', 'puzzle', 'other'].includes(v) ? v : null;
38
+ }
39
+ function fail(json, msg) {
40
+ if (json) console.log(JSON.stringify({ ok: false, error: msg }));
41
+ else console.log(chalk.red(`\n ✗ ${msg}`));
42
+ }
43
+
11
44
  export async function createGameCommand(options = {}) {
12
- // Check authentication
45
+ const json = !!options.json;
46
+ // In JSON/non-interactive mode we never prompt — missing required flags error out.
47
+ const nonInteractive = json || !!options.yes || !!options.nonInteractive;
48
+
13
49
  if (!isAuthenticated()) {
14
- console.log(chalk.red(' \u2717 Authentication required. Run \'myvillage login\' first.'));
15
- return;
50
+ return fail(json, "Authentication required. Run 'myvillage login' first.");
16
51
  }
17
52
 
18
53
  // Orientation first — it decides where the game can play.
19
- // Portrait → muni mobile app + web (mvp-video-games)
20
- // Landscape → web only (mvp-video-games)
21
- const { orientation } = await inquirer.prompt([
22
- {
23
- type: 'list',
24
- name: 'orientation',
25
- message: 'Where will this game play?',
26
- choices: [
27
- { name: 'Portrait — muni mobile app + web (plays everywhere)', value: 'portrait' },
28
- { name: 'Landscape — web only (mvp-video-games)', value: 'landscape' },
29
- ],
30
- },
31
- ]);
54
+ let orientation = normOrientation(options.orientation);
55
+ if (!orientation) {
56
+ if (nonInteractive) return fail(json, 'Missing --orientation (portrait|landscape)');
57
+ orientation = (
58
+ await inquirer.prompt([
59
+ {
60
+ type: 'list',
61
+ name: 'orientation',
62
+ message: 'Where will this game play?',
63
+ choices: [
64
+ { name: 'Portrait — muni mobile app + web (plays everywhere)', value: 'portrait' },
65
+ { name: 'Landscape — web only (mvp-video-games)', value: 'landscape' },
66
+ ],
67
+ },
68
+ ])
69
+ ).orientation;
70
+ }
32
71
 
33
72
  // Engine choice
34
- const { engine } = await inquirer.prompt([
35
- {
36
- type: 'list',
37
- name: 'engine',
38
- message: 'Which engine are you using?',
39
- choices: [
40
- { name: 'JS / Three.js — web build (runs in a browser / WebView)', value: 'threejs' },
73
+ let engine = normEngine(options.engine);
74
+ if (!engine) {
75
+ if (nonInteractive) return fail(json, 'Missing --engine (threejs|unity)');
76
+ engine = (
77
+ await inquirer.prompt([
41
78
  {
42
- name: orientation === 'landscape'
43
- ? 'Unity — WebGL build (runs in browser)'
44
- : 'Unity — native build for muni (can also build WebGL for web)',
45
- value: 'unity',
79
+ type: 'list',
80
+ name: 'engine',
81
+ message: 'Which engine are you using?',
82
+ choices: [
83
+ { name: 'JS / Three.js — web build (runs in a browser / WebView)', value: 'threejs' },
84
+ {
85
+ name:
86
+ orientation === 'landscape'
87
+ ? 'Unity — WebGL build (runs in browser)'
88
+ : 'Unity — native build for muni (can also build WebGL for web)',
89
+ value: 'unity',
90
+ },
91
+ ],
46
92
  },
47
- ],
48
- },
49
- ]);
93
+ ])
94
+ ).engine;
95
+ }
50
96
 
51
97
  if (engine === 'unity') {
52
98
  return createUnityGame({ ...options, orientation });
53
99
  }
54
100
 
55
- // ── Three.js path (existing flow) ──
101
+ // ── Three.js path ──
102
+ const provided = {
103
+ type: normType(options.type),
104
+ name: options.name && String(options.name).trim() ? String(options.name).trim() : null,
105
+ description: options.description,
106
+ ageGroup: normAge(options.targetAge || options.ageGroup),
107
+ };
108
+
109
+ const needType = !provided.type;
110
+ const needName = !provided.name;
111
+ const needAge = !provided.ageGroup;
112
+ if (nonInteractive && (needType || needName || needAge)) {
113
+ const miss = [needType && '--type', needName && '--name', needAge && '--target-age']
114
+ .filter(Boolean)
115
+ .join(', ');
116
+ return fail(json, `Missing required option(s): ${miss}`);
117
+ }
56
118
 
57
- const answers = await inquirer.prompt([
58
- {
119
+ const prompts = [];
120
+ if (needType)
121
+ prompts.push({
59
122
  type: 'list',
60
123
  name: 'type',
61
124
  message: 'What type of game would you like to create?',
@@ -65,24 +128,24 @@ export async function createGameCommand(options = {}) {
65
128
  { name: 'Narrative Game - Story-driven experience', value: 'narrative' },
66
129
  { name: 'Custom - Start with base Three.js template', value: 'custom' },
67
130
  ],
68
- },
69
- {
131
+ });
132
+ if (needName)
133
+ prompts.push({
70
134
  type: 'input',
71
135
  name: 'name',
72
136
  message: 'Game name:',
73
137
  default: 'My Game',
74
- validate: (input) => {
75
- if (!input.trim()) return 'Game name is required.';
76
- return true;
77
- },
78
- },
79
- {
138
+ validate: (input) => (input.trim() ? true : 'Game name is required.'),
139
+ });
140
+ if (provided.description === undefined && !nonInteractive)
141
+ prompts.push({
80
142
  type: 'input',
81
143
  name: 'description',
82
144
  message: 'Game description:',
83
145
  default: 'An educational game built with Three.js',
84
- },
85
- {
146
+ });
147
+ if (needAge)
148
+ prompts.push({
86
149
  type: 'list',
87
150
  name: 'ageGroup',
88
151
  message: 'Target age group:',
@@ -91,81 +154,113 @@ export async function createGameCommand(options = {}) {
91
154
  { name: '6-12', value: '6-12' },
92
155
  { name: '13 and Over', value: '13-and-over' },
93
156
  ],
94
- },
95
- ]);
157
+ });
158
+
159
+ const answers = prompts.length ? await inquirer.prompt(prompts) : {};
160
+ const game = {
161
+ type: provided.type || answers.type,
162
+ name: provided.name || answers.name,
163
+ description: provided.description ?? answers.description ?? 'An educational game built with Three.js',
164
+ ageGroup: provided.ageGroup || answers.ageGroup,
165
+ };
96
166
 
97
- const slug = answers.name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '');
98
- const targetDir = resolve(process.cwd(), slug);
167
+ const slug = game.name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '');
168
+ const targetDir = resolve(options.dir ? resolve(options.dir) : process.cwd(), slug);
99
169
 
100
170
  if (existsSync(targetDir)) {
101
- console.log(chalk.red(`\n \u2717 Directory "${slug}" already exists. Choose a different name or remove the existing directory.`));
102
- return;
171
+ return fail(json, `Directory "${slug}" already exists. Choose a different name or remove it.`);
103
172
  }
104
173
 
105
- const spinner = villageSpinner('Creating game project...').start();
174
+ const spinner = json ? null : villageSpinner('Creating game project...').start();
106
175
 
107
176
  try {
108
177
  createGameProject(targetDir, {
109
- name: answers.name,
110
- description: answers.description,
111
- type: answers.type,
112
- ageGroup: answers.ageGroup,
178
+ name: game.name,
179
+ description: game.description,
180
+ type: game.type,
181
+ ageGroup: game.ageGroup,
113
182
  orientation,
114
183
  });
115
184
 
116
- spinner.text = 'Installing dependencies...';
185
+ if (spinner) spinner.text = 'Installing dependencies...';
186
+ execSync('npm install', { cwd: targetDir, stdio: 'pipe' });
187
+ if (spinner) spinner.succeed(`Game "${game.name}" created successfully!`);
117
188
 
118
- execSync('npm install', {
119
- cwd: targetDir,
120
- stdio: 'pipe',
121
- });
122
-
123
- spinner.succeed(`Game "${answers.name}" created successfully!`);
189
+ if (json) {
190
+ console.log(
191
+ JSON.stringify({
192
+ ok: true,
193
+ slug,
194
+ dir: targetDir,
195
+ name: game.name,
196
+ type: game.type,
197
+ orientation,
198
+ ageGroup: game.ageGroup,
199
+ engine: 'threejs',
200
+ }),
201
+ );
202
+ return;
203
+ }
124
204
 
125
205
  console.log();
126
- console.log(brand.green(` \u2713 Game "${answers.name}" created successfully!`));
206
+ console.log(brand.green(` ✓ Game "${game.name}" created successfully!`));
127
207
  console.log();
128
208
  console.log(brand.teal(' Next steps:'));
129
209
  console.log();
130
210
  console.log(` ${brand.gold('cd')} ${slug}`);
131
- console.log(` ${brand.gold('npm install')} ${brand.teal('- Install dependencies (already run for you; re-run after cloning)')}`);
132
211
  console.log(` ${brand.gold('npm run dev')} ${brand.teal('- Start development server')}`);
133
212
  console.log(` ${brand.gold('npm run build')} ${brand.teal('- Build for production')}`);
134
213
  console.log(` ${brand.gold('myvillage deploy')} ${brand.teal('- Deploy to MyVillageOS')}`);
135
214
  console.log();
136
- console.log(brand.teal(` Game type: ${answers.type} | Orientation: ${orientation} | Age group: ${answers.ageGroup}`));
215
+ console.log(brand.teal(` Game type: ${game.type} | Orientation: ${orientation} | Age group: ${game.ageGroup}`));
137
216
  console.log(brand.teal(` Cover art: drop thumbnail.png / banner.png in ${slug}/assets/ before deploy.`));
138
217
  console.log();
139
218
  } catch (err) {
140
- spinner.fail('Failed to create game project.');
141
- console.error(chalk.red(` ${err.message}`));
219
+ if (spinner) spinner.fail('Failed to create game project.');
220
+ fail(json, err.message);
142
221
  }
143
222
  }
144
223
 
145
224
  // ── Unity path ──
146
-
147
225
  async function createUnityGame(options = {}) {
148
- // orientation is decided in the top-level wizard; default portrait for Unity.
149
- const orientation = options.orientation || 'portrait';
226
+ const json = !!options.json;
227
+ const nonInteractive = json || !!options.yes || !!options.nonInteractive;
228
+ const orientation = normOrientation(options.orientation) || 'portrait';
229
+
230
+ const provided = {
231
+ name: options.name && String(options.name).trim() ? String(options.name).trim() : null,
232
+ description: options.description,
233
+ category: normCategory(options.category),
234
+ ageGroup: normAge(options.targetAge || options.ageGroup),
235
+ };
236
+ const needName = !provided.name;
237
+ const needCategory = !provided.category;
238
+ const needAge = !provided.ageGroup;
239
+ if (nonInteractive && (needName || needCategory || needAge)) {
240
+ const miss = [needName && '--name', needCategory && '--category', needAge && '--target-age']
241
+ .filter(Boolean)
242
+ .join(', ');
243
+ return fail(json, `Missing required option(s): ${miss}`);
244
+ }
150
245
 
151
- const answers = await inquirer.prompt([
152
- {
246
+ const prompts = [];
247
+ if (needName)
248
+ prompts.push({
153
249
  type: 'input',
154
250
  name: 'name',
155
251
  message: 'Game name:',
156
252
  default: 'My Unity Game',
157
- validate: (input) => {
158
- if (!input.trim()) return 'Game name is required.';
159
- return true;
160
- },
161
- },
162
- {
253
+ validate: (input) => (input.trim() ? true : 'Game name is required.'),
254
+ });
255
+ if (provided.description === undefined && !nonInteractive)
256
+ prompts.push({
163
257
  type: 'input',
164
258
  name: 'description',
165
259
  message: 'Game description:',
166
260
  default: 'A Unity game for MyVillageOS',
167
- },
168
- {
261
+ });
262
+ if (needCategory)
263
+ prompts.push({
169
264
  type: 'list',
170
265
  name: 'category',
171
266
  message: 'Game category:',
@@ -176,8 +271,9 @@ async function createUnityGame(options = {}) {
176
271
  { name: 'Puzzle', value: 'puzzle' },
177
272
  { name: 'Other', value: 'other' },
178
273
  ],
179
- },
180
- {
274
+ });
275
+ if (needAge)
276
+ prompts.push({
181
277
  type: 'list',
182
278
  name: 'ageGroup',
183
279
  message: 'Target age group:',
@@ -186,33 +282,58 @@ async function createUnityGame(options = {}) {
186
282
  { name: '6-12', value: '6-12' },
187
283
  { name: '13 and Over', value: '13-and-over' },
188
284
  ],
189
- },
190
- ]);
285
+ });
191
286
 
192
- const slug = answers.name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '');
193
- const targetDir = resolve(process.cwd(), slug);
287
+ const answers = prompts.length ? await inquirer.prompt(prompts) : {};
288
+ const game = {
289
+ name: provided.name || answers.name,
290
+ description: provided.description ?? answers.description ?? 'A Unity game for MyVillageOS',
291
+ category: provided.category || answers.category,
292
+ ageGroup: provided.ageGroup || answers.ageGroup,
293
+ };
294
+
295
+ const slug = game.name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '');
296
+ const targetDir = resolve(options.dir ? resolve(options.dir) : process.cwd(), slug);
194
297
 
195
298
  if (existsSync(targetDir)) {
196
- console.log(chalk.red(`\n \u2717 Directory "${slug}" already exists.`));
197
- return;
299
+ return fail(json, `Directory "${slug}" already exists.`);
198
300
  }
199
301
 
200
- const spinner = villageSpinner('Creating Unity game project...').start();
302
+ const spinner = json ? null : villageSpinner('Creating Unity game project...').start();
201
303
 
202
304
  try {
203
305
  const scaffold = await createUnityGameProject(targetDir, {
204
- name: answers.name,
205
- description: answers.description,
206
- type: answers.category,
207
- ageGroup: answers.ageGroup,
306
+ name: game.name,
307
+ description: game.description,
308
+ type: game.category,
309
+ ageGroup: game.ageGroup,
208
310
  orientation,
209
311
  unityVersion: options.unityVersion,
210
312
  });
211
313
 
212
- spinner.succeed(`Unity game "${answers.name}" created!`);
314
+ if (spinner) spinner.succeed(`Unity game "${game.name}" created!`);
315
+
316
+ if (json) {
317
+ console.log(
318
+ JSON.stringify({
319
+ ok: true,
320
+ slug,
321
+ dir: targetDir,
322
+ name: game.name,
323
+ category: game.category,
324
+ orientation,
325
+ ageGroup: game.ageGroup,
326
+ engine: 'unity',
327
+ unityVersion: scaffold.unityVersion,
328
+ sdkVersion: scaffold.sdkVersion,
329
+ sdkEmbedded: scaffold.sdkEmbedded,
330
+ }),
331
+ );
332
+ return;
333
+ }
213
334
 
214
335
  console.log();
215
- console.log(brand.green(` \u2713 Unity game "${answers.name}" created!`));
336
+ console.log(brand.green(` ✓ Unity game "${game.name}" created!`));
216
337
  console.log(brand.teal(` Unity: ${scaffold.unityVersion}`));
217
338
  console.log(brand.teal(` GameKit SDK: ${scaffold.sdkVersion}${scaffold.sdkEmbedded ? ' (bundled — no git needed)' : ''}`));
218
339
  console.log(brand.teal(` Orientation: ${orientation} (${orientation === 'landscape' ? 'web only' : 'muni mobile + web'})`));
@@ -220,31 +341,19 @@ async function createUnityGame(options = {}) {
220
341
  if (!scaffold.sdkEmbedded) {
221
342
  console.log(chalk.yellow(' ⚠ Could not download the GameKit SDK (offline?). The project falls back to a'));
222
343
  console.log(chalk.yellow(' git-URL dependency, so opening it in Unity will require git to be installed.'));
223
- console.log(chalk.yellow(' Re-run this command with a network connection to bundle the SDK locally instead.'));
224
344
  console.log();
225
345
  }
226
346
  console.log(brand.teal(' Next steps:'));
227
347
  console.log();
228
- console.log(` 1. ${brand.gold('Open this project in Unity Hub')} (Add \u2192 Add project from disk)`);
229
- console.log(` 2. ${brand.teal('Click menu')} ${brand.gold('MyVillage \u2192 Create Starter Mission Scene')}`);
230
- console.log(` 3. ${brand.teal('Press')} ${brand.gold('Play')} ${brand.teal('\u2014 your mission runs end-to-end in the editor')}`);
231
- console.log(` 4. ${brand.teal('Edit')} ${brand.gold(`Assets/Scripts/${answers.name.replace(/[^a-zA-Z0-9]/g, '')}.cs`)} ${brand.teal('and iterate (hit Play again)')}`);
232
- console.log();
233
- console.log(brand.teal(' Ready to ship:'));
234
- if (orientation === 'landscape') {
235
- console.log(` 5. ${brand.teal('Build WebGL via')} ${brand.gold('File \u2192 Build Settings \u2192 WebGL \u2192 Build')} ${brand.teal('to')} ${brand.gold('builds/webgl/')}`);
236
- console.log(` 6. ${brand.gold('cd ' + slug + ' && myvillage deploy --platform webgl')}`);
237
- } else {
238
- console.log(` 5. ${brand.teal('Build AssetBundle:')} ${brand.gold('MyVillage \u2192 Build Mission Bundle')} ${brand.teal('(into builds/ios or builds/android)')}`);
239
- console.log(` 6. ${brand.gold('cd ' + slug + ' && myvillage deploy')} ${brand.teal('\u2014 ships every platform found in builds/')}`);
240
- console.log(` ${brand.teal('Optional: also build WebGL into builds/webgl/ to offer it on the web too.')}`);
241
- }
348
+ console.log(` 1. ${brand.gold('Open this project in Unity Hub')} (Add → Add project from disk)`);
349
+ console.log(` 2. ${brand.teal('Click menu')} ${brand.gold('MyVillage → Create Starter Mission Scene')}`);
350
+ console.log(` 3. ${brand.teal('Press')} ${brand.gold('Play')} ${brand.teal('— your mission runs end-to-end in the editor')}`);
242
351
  console.log();
243
352
  console.log(brand.teal(` Cover art: drop thumbnail.png / banner.png in ${slug}/assets/ before deploy.`));
244
353
  console.log(brand.teal(` See ${slug}/README.md for the full developer guide.`));
245
354
  console.log();
246
355
  } catch (err) {
247
- spinner.fail('Failed to create Unity game project.');
248
- console.error(chalk.red(` ${err.message}`));
356
+ if (spinner) spinner.fail('Failed to create Unity game project.');
357
+ fail(json, err.message);
249
358
  }
250
359
  }
@@ -67,10 +67,23 @@ function saveVillages(dir, isUnity, villageIds) {
67
67
  }
68
68
  }
69
69
 
70
+ // Non-interactive when driven by Studio / scripts (--json, --yes).
71
+ function isNonInteractive(options = {}) {
72
+ return !!(options.json || options.yes || options.nonInteractive);
73
+ }
74
+
70
75
  // Gather the submission's village visibility once per deploy. Returns an array
71
76
  // of village ids (possibly empty), or null if the developer aborted/failed —
72
- // the caller should stop the deploy in that case.
73
- async function gatherVillages(existingIds = []) {
77
+ // the caller should stop the deploy in that case. A `--villages a,b,c` flag (or
78
+ // non-interactive mode) skips the prompt.
79
+ async function gatherVillages(existingIds = [], options = {}) {
80
+ if (options.villages !== undefined) {
81
+ return String(options.villages)
82
+ .split(',')
83
+ .map((s) => s.trim())
84
+ .filter(Boolean);
85
+ }
86
+ if (isNonInteractive(options)) return existingIds; // keep prior selection, no prompt
74
87
  console.log();
75
88
  console.log(brand.teal(' Village visibility — who can see & play this game:'));
76
89
  const villageIds = await selectGameVillages(existingIds);
@@ -80,7 +93,7 @@ async function gatherVillages(existingIds = []) {
80
93
  // If the target game is currently PUBLISHED, a redeploy takes it offline and
81
94
  // sends the new build back through admin review. Confirm with the developer and
82
95
  // move it to DRAFT first. Returns true to proceed with the deploy, false to abort.
83
- async function confirmRedeployIfPublished(gameId) {
96
+ async function confirmRedeployIfPublished(gameId, options = {}) {
84
97
  if (!gameId) return true; // brand-new game — nothing live to disturb
85
98
 
86
99
  let game;
@@ -93,23 +106,26 @@ async function confirmRedeployIfPublished(gameId) {
93
106
 
94
107
  if (!game || game.status !== 'PUBLISHED') return true;
95
108
 
96
- console.log();
97
- console.log(chalk.yellow(` ! "${game.title}" is currently PUBLISHED (live).`));
98
- console.log(brand.teal(' Redeploying takes it offline and sends the new build back through'));
99
- console.log(brand.teal(' admin review before it can go live again.'));
100
- console.log();
109
+ if (!isNonInteractive(options)) {
110
+ console.log();
111
+ console.log(chalk.yellow(` ! "${game.title}" is currently PUBLISHED (live).`));
112
+ console.log(brand.teal(' Redeploying takes it offline and sends the new build back through'));
113
+ console.log(brand.teal(' admin review before it can go live again.'));
114
+ console.log();
101
115
 
102
- const { confirm } = await inquirer.prompt([{
103
- type: 'confirm',
104
- name: 'confirm',
105
- message: 'Take the live game offline and redeploy?',
106
- default: false,
107
- }]);
116
+ const { confirm } = await inquirer.prompt([{
117
+ type: 'confirm',
118
+ name: 'confirm',
119
+ message: 'Take the live game offline and redeploy?',
120
+ default: false,
121
+ }]);
108
122
 
109
- if (!confirm) {
110
- console.log(brand.teal('\n Deploy cancelled — game is still live.\n'));
111
- return false;
123
+ if (!confirm) {
124
+ console.log(brand.teal('\n Deploy cancelled — game is still live.\n'));
125
+ return false;
126
+ }
112
127
  }
128
+ // Non-interactive (--yes/--json): proceed to unpublish without prompting.
113
129
 
114
130
  const spinner = villageSpinner('Taking game offline (returning to draft)...').start();
115
131
  try {
@@ -196,16 +212,22 @@ async function autoSubmitForReview(gameId, deployResult) {
196
212
 
197
213
  // ── Three.js deploy (existing flow) ──
198
214
 
199
- async function deployThreeJsGame(projectDir, pkg) {
215
+ async function deployThreeJsGame(projectDir, pkg, options = {}) {
216
+ const json = !!options.json;
200
217
  // If this game is already live, confirm + unpublish before we rebuild/upload.
201
- if (!(await confirmRedeployIfPublished(pkg.myvillage.gameId))) return;
218
+ if (!(await confirmRedeployIfPublished(pkg.myvillage.gameId, options))) return;
202
219
 
203
220
  // Village visibility is part of the submission — gather it up front (before
204
221
  // the build) so it travels in the deploy metadata.
205
- const villageIds = await gatherVillages(pkg.myvillage.villages || []);
222
+ const villageIds = await gatherVillages(pkg.myvillage.villages || [], options);
206
223
  if (villageIds === null) return; // could not resolve villages — abort
207
224
 
208
- const spinner = villageSpinner('Building game...').start();
225
+ const spinner = json ? null : villageSpinner('Building game...').start();
226
+
227
+ const failOut = (msg) => {
228
+ if (spinner) spinner.fail(msg);
229
+ else if (json) console.log(JSON.stringify({ ok: false, error: msg }));
230
+ };
209
231
 
210
232
  try {
211
233
  // Run production build
@@ -217,16 +239,16 @@ async function deployThreeJsGame(projectDir, pkg) {
217
239
  // Check that build output exists
218
240
  const distDir = join(projectDir, 'dist');
219
241
  if (!existsSync(distDir)) {
220
- spinner.fail('Build completed but no dist/ directory found.');
242
+ failOut('Build completed but no dist/ directory found.');
221
243
  return;
222
244
  }
223
245
 
224
- spinner.text = 'Packaging build artifacts...';
246
+ if (spinner) spinner.text = 'Packaging build artifacts...';
225
247
 
226
248
  const filePaths = collectFilePaths(distDir);
227
249
 
228
250
  if (filePaths.length === 0) {
229
- spinner.fail('No files found in dist/ directory.');
251
+ failOut('No files found in dist/ directory.');
230
252
  return;
231
253
  }
232
254
 
@@ -254,7 +276,7 @@ async function deployThreeJsGame(projectDir, pkg) {
254
276
  formData.append('files', blob, relPath);
255
277
  }
256
278
 
257
- spinner.text = 'Deploying to MyVillageOS...';
279
+ if (spinner) spinner.text = 'Deploying to MyVillageOS...';
258
280
 
259
281
  const gameIdOrNew = pkg.myvillage.gameId || 'new';
260
282
  const result = await deployGame(gameIdOrNew, formData);
@@ -265,7 +287,7 @@ async function deployThreeJsGame(projectDir, pkg) {
265
287
 
266
288
  const finalGameId = result.gameId || pkg.myvillage.gameId;
267
289
 
268
- spinner.succeed('Upload complete!');
290
+ if (spinner) spinner.succeed('Upload complete!');
269
291
 
270
292
  saveVillages(projectDir, false, villageIds);
271
293
 
@@ -274,13 +296,27 @@ async function deployThreeJsGame(projectDir, pkg) {
274
296
  // DRAFT/REJECTED/SUBMITTED (only APPROVED/PUBLISHED lock it), so this works
275
297
  // for non-admin developers too — not just admins.
276
298
  if (finalGameId) {
277
- await submitCoverArt(finalGameId, projectDir, pkg.myvillage);
299
+ await submitCoverArt(finalGameId, projectDir, pkg.myvillage, options);
278
300
  }
279
301
 
280
302
  // Safety net: promote to SUBMITTED if the deploy left the game in DRAFT.
281
303
  // (A normal deploy already submits, so this is usually a no-op.)
282
304
  const finalStatus = await autoSubmitForReview(finalGameId, result);
283
305
 
306
+ if (json) {
307
+ console.log(
308
+ JSON.stringify({
309
+ ok: true,
310
+ gameId: finalGameId || null,
311
+ slug: result.slug || null,
312
+ status: finalStatus,
313
+ title: metadata.title,
314
+ villageIds,
315
+ }),
316
+ );
317
+ return;
318
+ }
319
+
284
320
  console.log();
285
321
  if (finalStatus === 'SUBMITTED') {
286
322
  console.log(brand.green(` \u2713 Game "${metadata.title}" submitted for review`));
@@ -295,18 +331,15 @@ async function deployThreeJsGame(projectDir, pkg) {
295
331
  console.log(brand.teal(' Run "myvillage status" to check the review status.'));
296
332
  console.log();
297
333
  } catch (err) {
298
- if (err.response) {
299
- const message = err.response.data?.error_description
300
- || err.response.data?.error
301
- || err.response.data?.message
302
- || 'Unknown server error';
303
- spinner.fail(`Deployment failed: ${message}`);
304
- } else if (err.stderr) {
305
- spinner.fail('Build failed.');
306
- console.error(chalk.red(` ${err.stderr.toString().trim()}`));
307
- } else {
308
- spinner.fail(`Deployment failed: ${err.message}`);
309
- }
334
+ const message = err.response
335
+ ? err.response.data?.error_description ||
336
+ err.response.data?.error ||
337
+ err.response.data?.message ||
338
+ 'Unknown server error'
339
+ : err.stderr
340
+ ? `Build failed: ${err.stderr.toString().trim()}`
341
+ : err.message;
342
+ failOut(`Deployment failed: ${message}`);
310
343
  }
311
344
  }
312
345
 
@@ -357,11 +390,11 @@ async function deployUnityGame(projectDir, mvConfig, options) {
357
390
  console.log(brand.teal(` Deploying ${platformsToDeploy.length} platform(s): ${platformsToDeploy.join(', ')}`));
358
391
 
359
392
  // If this game is already live, confirm + unpublish before we upload new builds.
360
- if (!(await confirmRedeployIfPublished(mvConfig.gameId))) return;
393
+ if (!(await confirmRedeployIfPublished(mvConfig.gameId, options))) return;
361
394
 
362
395
  // Village visibility is part of the submission — gather once for the whole
363
396
  // multi-platform deploy and thread it into every platform's metadata.
364
- const villageIds = await gatherVillages(mvConfig.villages || []);
397
+ const villageIds = await gatherVillages(mvConfig.villages || [], options);
365
398
  if (villageIds === null) return; // could not resolve villages — abort
366
399
  console.log();
367
400
 
@@ -391,7 +424,7 @@ async function deployUnityGame(projectDir, mvConfig, options) {
391
424
  // DRAFT/REJECTED/SUBMITTED (only APPROVED/PUBLISHED lock it). Submit is kept
392
425
  // out of the per-platform loop so it runs once, after every platform is up.
393
426
  if (finalGameId) {
394
- await submitCoverArt(finalGameId, projectDir, mvConfig);
427
+ await submitCoverArt(finalGameId, projectDir, mvConfig, options);
395
428
  }
396
429
 
397
430
  // Safety net: promote to SUBMITTED if a platform deploy left it in DRAFT.
@@ -618,39 +651,53 @@ function warnOnAspectMismatch(filePath, assetType, orientation) {
618
651
  // Cover art shipped with the submission. Uploads whatever's declared in
619
652
  // myvillage.assets first; only prompts for what's missing. A thumbnail is
620
653
  // required — without one the game card renders blank in muni and on the web.
621
- async function submitCoverArt(gameId, projectDir, config) {
654
+ async function submitCoverArt(gameId, projectDir, config, options = {}) {
655
+ if (options.skipCoverArt) return;
622
656
  const orientation = config?.orientation || 'landscape';
623
657
  const declared = config?.assets || {};
658
+ const nonInteractive = isNonInteractive(options);
624
659
  console.log();
625
660
 
626
- // 1. Thumbnail — declared, else prompt (required).
661
+ // An explicit --thumbnail/--banner path overrides whatever's declared.
662
+ const thumbSource = options.thumbnail
663
+ ? resolve(String(options.thumbnail))
664
+ : declared.thumbnail
665
+ ? resolve(projectDir, declared.thumbnail)
666
+ : null;
667
+ const bannerSource = options.banner
668
+ ? resolve(String(options.banner))
669
+ : declared.banner
670
+ ? resolve(projectDir, declared.banner)
671
+ : null;
672
+
673
+ // 1. Thumbnail — explicit/declared, else prompt (required) when interactive.
627
674
  let thumbUploaded = false;
628
- if (declared.thumbnail) {
629
- const p = resolve(projectDir, declared.thumbnail);
630
- if (existsSync(p)) {
631
- warnOnAspectMismatch(p, 'THUMBNAIL', orientation);
632
- thumbUploaded = await uploadImageAsset(gameId, p, 'THUMBNAIL');
675
+ if (thumbSource) {
676
+ if (existsSync(thumbSource)) {
677
+ warnOnAspectMismatch(thumbSource, 'THUMBNAIL', orientation);
678
+ thumbUploaded = await uploadImageAsset(gameId, thumbSource, 'THUMBNAIL');
633
679
  } else {
634
- console.log(chalk.yellow(` ! Declared thumbnail not found: ${declared.thumbnail}`));
680
+ console.log(chalk.yellow(` ! Thumbnail not found: ${thumbSource}`));
635
681
  }
636
682
  }
637
- if (!thumbUploaded) {
683
+ if (!thumbUploaded && !nonInteractive) {
638
684
  thumbUploaded = await promptForImage(gameId, 'THUMBNAIL', orientation, { required: true });
685
+ } else if (!thumbUploaded && nonInteractive) {
686
+ console.log(chalk.yellow(' ! No thumbnail — add one with "myvillage game upload-thumbnail <file>".'));
639
687
  }
640
688
 
641
- // 2. Banner — declared, else optional prompt.
689
+ // 2. Banner — explicit/declared, else optional prompt when interactive.
642
690
  let bannerHandled = false;
643
- if (declared.banner) {
644
- const p = resolve(projectDir, declared.banner);
645
- if (existsSync(p)) {
646
- warnOnAspectMismatch(p, 'BANNER', orientation);
647
- await uploadImageAsset(gameId, p, 'BANNER');
691
+ if (bannerSource) {
692
+ if (existsSync(bannerSource)) {
693
+ warnOnAspectMismatch(bannerSource, 'BANNER', orientation);
694
+ await uploadImageAsset(gameId, bannerSource, 'BANNER');
648
695
  bannerHandled = true;
649
696
  } else {
650
- console.log(chalk.yellow(` ! Declared banner not found: ${declared.banner}`));
697
+ console.log(chalk.yellow(` ! Banner not found: ${bannerSource}`));
651
698
  }
652
699
  }
653
- if (!bannerHandled) {
700
+ if (!bannerHandled && !nonInteractive) {
654
701
  await promptForImage(gameId, 'BANNER', orientation, { required: false });
655
702
  }
656
703
  }
package/src/index.js CHANGED
@@ -179,6 +179,16 @@ export function run() {
179
179
  .command('create-game')
180
180
  .description('Create a new game project with interactive wizard')
181
181
  .option('--unity-version <version>', 'Override the Unity Editor version for Unity scaffolds (e.g. 6000.0.76f1)')
182
+ .option('--name <name>', 'Game name (skips the prompt)')
183
+ .option('--description <text>', 'Game description')
184
+ .option('--engine <engine>', 'Engine: threejs | unity')
185
+ .option('--orientation <orientation>', 'portrait | landscape')
186
+ .option('--type <type>', 'Three.js type: quiz | exploration | narrative | custom')
187
+ .option('--category <category>', 'Unity category: education | action | adventure | puzzle | other')
188
+ .option('--target-age <age>', 'Target age: under-5 | 6-12 | 13-and-over')
189
+ .option('--dir <path>', 'Parent directory to scaffold into (defaults to cwd)')
190
+ .option('-y, --yes', 'Non-interactive: fail instead of prompting for missing options')
191
+ .option('--json', 'Output the result as JSON (implies non-interactive)')
182
192
  .action(createGameCommand);
183
193
 
184
194
  program
@@ -190,6 +200,12 @@ export function run() {
190
200
  .command('deploy')
191
201
  .description('Build and deploy your game to MyVillageOS')
192
202
  .option('-p, --platform <platform>', 'Target Unity platform (ios, android, webgl). Optional — defaults to deploying every platform found in builds/.')
203
+ .option('--villages <ids>', 'Comma-separated village ids for visibility (skips the prompt)')
204
+ .option('--thumbnail <path>', 'Path to a thumbnail image (skips the prompt)')
205
+ .option('--banner <path>', 'Path to a banner image (skips the prompt)')
206
+ .option('--skip-cover-art', 'Skip cover-art upload entirely')
207
+ .option('-y, --yes', 'Non-interactive: assume yes (e.g. unpublish-to-redeploy) and skip prompts')
208
+ .option('--json', 'Output the result as JSON (implies non-interactive)')
193
209
  .action((options) => deployCommand(options));
194
210
 
195
211
  program