@myvillage/cli 1.46.1 → 1.48.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@myvillage/cli",
3
- "version": "1.46.1",
3
+ "version": "1.48.3",
4
4
  "description": "MyVillageOS CLI for community developers",
5
5
  "type": "module",
6
6
  "bin": {
@@ -17,7 +17,7 @@ process.on('beforeExit', (code) => {
17
17
  process.stderr.write(`[daemon-entry] BEFORE EXIT code=${code} — event loop emptied (likely a hung await inside agent setup)\n`);
18
18
  });
19
19
 
20
- import { writeFileSync, unlinkSync, existsSync, mkdirSync, appendFileSync } from 'fs';
20
+ import { writeFileSync, unlinkSync, existsSync, mkdirSync, appendFileSync, readFileSync } from 'fs';
21
21
  import { join } from 'path';
22
22
  import { homedir } from 'os';
23
23
  import { agentLoop } from './loop.js';
@@ -33,6 +33,47 @@ const agentDir = join(homedir(), '.myvillage', 'agents', agentName);
33
33
  const pidFile = join(agentDir, 'daemon.pid');
34
34
  const logsDir = join(agentDir, 'logs');
35
35
 
36
+ // Load per-agent .env file BEFORE anything reads tools.yaml / spawns MCPs.
37
+ // MCP server env blocks reference these via ${ENV_VAR} substitution; the
38
+ // mcp-client passes process.env into each subprocess, so loading them here
39
+ // makes them visible to every MCP this agent spawns. Existing process.env
40
+ // values (shell exports, parent process) take precedence — .env only fills
41
+ // in what's missing, matching the conventional dotenv behavior.
42
+ loadEnvFile(join(agentDir, '.env'));
43
+
44
+ function loadEnvFile(path) {
45
+ if (!existsSync(path)) return;
46
+ let text;
47
+ try {
48
+ text = readFileSync(path, 'utf-8');
49
+ } catch (err) {
50
+ process.stderr.write(`[daemon-entry] Could not read ${path}: ${err.message}\n`);
51
+ return;
52
+ }
53
+ // Minimal dotenv parser: KEY=value lines, # comments, blank lines.
54
+ // Values may be unquoted, single-quoted, or double-quoted. Backslash
55
+ // escapes are honored only inside double quotes.
56
+ for (const rawLine of text.split('\n')) {
57
+ const line = rawLine.trim();
58
+ if (!line || line.startsWith('#')) continue;
59
+ const eq = line.indexOf('=');
60
+ if (eq <= 0) continue;
61
+ const key = line.slice(0, eq).trim();
62
+ if (!/^[A-Z_][A-Z0-9_]*$/i.test(key)) continue;
63
+ let value = line.slice(eq + 1).trim();
64
+ if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
65
+ const quote = value[0];
66
+ value = value.slice(1, -1);
67
+ if (quote === '"') {
68
+ value = value.replace(/\\n/g, '\n').replace(/\\r/g, '\r').replace(/\\"/g, '"').replace(/\\\\/g, '\\');
69
+ }
70
+ }
71
+ if (!(key in process.env)) {
72
+ process.env[key] = value;
73
+ }
74
+ }
75
+ }
76
+
36
77
  // Ensure logs dir exists
37
78
  if (!existsSync(logsDir)) {
38
79
  mkdirSync(logsDir, { recursive: true });
@@ -110,9 +110,17 @@ export async function getMCPTools(agentDir, agentConfig) {
110
110
  // `/mcp-stdio` subpath and pass the instance.
111
111
  const { Experimental_StdioMCPTransport } =
112
112
  await import('@ai-sdk/mcp/mcp-stdio');
113
+ // Expand `${VAR}` in args too — some MCPs (e.g. @twilio-alpha/mcp)
114
+ // expect credentials as a positional CLI argument rather than env
115
+ // vars. Same substitution rules as `env` and `headers`.
116
+ const resolvedArgs = (server.args || []).map(arg =>
117
+ typeof arg === 'string'
118
+ ? arg.replace(/\$\{(\w+)\}/g, (_, name) => process.env[name] || '')
119
+ : arg,
120
+ );
113
121
  const stdioTransport = new Experimental_StdioMCPTransport({
114
122
  command: server.command,
115
- args: server.args || [],
123
+ args: resolvedArgs,
116
124
  env: { ...process.env, ...resolveEnvVars(server.env || {}) },
117
125
  });
118
126
  client = await withTimeout(
@@ -116,13 +116,10 @@ export async function agentCreateLocalCommand() {
116
116
  {
117
117
  type: 'checkbox',
118
118
  name: 'tools',
119
- message: 'Which tools should your agent have?',
119
+ message: 'Which built-in tools should your agent have? (you can add more later with `agent add-mcp`)',
120
120
  choices: [
121
121
  { name: 'MyVillageOS MCP (feed, posts, communities, wallet)', value: 'myvillage', checked: true, disabled: 'always enabled' },
122
122
  { name: 'Local Files (read/write a sandboxed workspace)', value: 'filesystem', checked: true },
123
- { name: 'Gmail', value: 'gmail' },
124
- { name: 'Calendar', value: 'calendar' },
125
- { name: 'GitHub', value: 'github' },
126
123
  { name: 'Browser (Puppeteer — downloads Chromium on first run)', value: 'browser' },
127
124
  ],
128
125
  },
@@ -211,9 +208,10 @@ export async function agentCreateLocalCommand() {
211
208
 
212
209
  console.log(brand.green(` \u2713 Agent created at ~/.myvillage/agents/${answers.name}/\n`));
213
210
  console.log(brand.teal(' Next steps:'));
214
- console.log(` ${brand.gold(`myvillage agent start ${answers.name}`)} Launch the agent`);
215
- console.log(` ${brand.gold(`myvillage agent edit ${answers.name}`)} Customize the prompt`);
216
- console.log(` ${brand.gold(`myvillage agent logs ${answers.name}`)} View activity\n`);
211
+ console.log(` ${brand.gold(`myvillage agent add-mcp ${answers.name}`)} Add a tool (Gmail, Slack, your own MCP, etc.)`);
212
+ console.log(` ${brand.gold(`myvillage agent start ${answers.name}`)} Launch the agent`);
213
+ console.log(` ${brand.gold(`myvillage agent edit ${answers.name}`)} Customize the prompt`);
214
+ console.log(` ${brand.gold(`myvillage agent logs ${answers.name}`)} View activity\n`);
217
215
  } catch (err) {
218
216
  if (err.isTtyError) {
219
217
  console.log(chalk.red(' \u2717 Prompts cannot be rendered in this environment.\n'));
@@ -736,8 +734,9 @@ export async function agentAddToolCommand(name, tool) {
736
734
 
737
735
  const validTools = Object.keys(TOOL_CATALOG).filter(t => t !== 'myvillage');
738
736
  if (!validTools.includes(tool)) {
739
- console.log(chalk.red(` \u2717 Unknown tool "${tool}".`));
740
- console.log(brand.teal(` Available tools: ${validTools.join(', ')}\n`));
737
+ console.log(chalk.red(` \u2717 Unknown built-in tool "${tool}".`));
738
+ console.log(brand.teal(` Built-in tools: ${validTools.join(', ')}`));
739
+ console.log(brand.teal(` For Gmail, Slack, Twilio, custom MCPs, etc. use: ${chalk.bold(`myvillage agent add-mcp ${name}`)}\n`));
741
740
  return;
742
741
  }
743
742
 
@@ -758,9 +757,6 @@ export async function agentAddToolCommand(name, tool) {
758
757
  const workspace = join(homedir(), '.myvillage', 'agents', name, 'workspace');
759
758
  console.log(brand.teal(` Workspace: ${workspace}`));
760
759
  }
761
- if (tool === 'github') {
762
- console.log(brand.teal(' Note: Set GITHUB_TOKEN in your environment for GitHub access.'));
763
- }
764
760
 
765
761
  if (isDaemonRunning(name)) {
766
762
  console.log(chalk.yellow(' Restart the agent to apply: myvillage agent stop ' + name + ' && myvillage agent start ' + name));
@@ -0,0 +1,357 @@
1
+ import chalk from 'chalk';
2
+ import inquirer from 'inquirer';
3
+ import { brand } from '../utils/brand.js';
4
+ import {
5
+ agentExists,
6
+ readToolsYaml,
7
+ writeToolsYaml,
8
+ isDaemonRunning,
9
+ } from '../utils/local-agent.js';
10
+ import {
11
+ loadRecipe,
12
+ listRegistryRecipes,
13
+ recipeToServerEntry,
14
+ declaredEnvVars,
15
+ } from '../utils/recipes.js';
16
+ import { isAuthenticated } from '../utils/auth.js';
17
+
18
+ // ── add-mcp ─────────────────────────────────────────────
19
+ //
20
+ // Always interactive. If a source is provided (built-in name, local file,
21
+ // or URL), the recipe's fields pre-fill the prompts but the developer can
22
+ // edit every value before it's written. If no source, the wizard collects
23
+ // the fields from scratch.
24
+ //
25
+ // Use --yes to skip prompts and accept recipe values verbatim (useful for
26
+ // scripts and CI).
27
+
28
+ export async function agentAddMcpCommand(agentName, sourceArg, options = {}) {
29
+ if (!agentExists(agentName)) {
30
+ console.log(chalk.red(` ✗ Agent "${agentName}" not found. Run 'myvillage agent' to see your agents.\n`));
31
+ return;
32
+ }
33
+
34
+ const source = sourceArg || options.from || null;
35
+ const nonInteractive = options.yes === true;
36
+
37
+ let recipe = null;
38
+ if (source) {
39
+ try {
40
+ recipe = await loadRecipe(source);
41
+ console.log(brand.teal(` Loaded recipe: "${recipe.name}"${recipe.description ? ` — ${chalk.dim(recipe.description)}` : ''}\n`));
42
+ } catch (err) {
43
+ console.log(chalk.red(` ✗ ${err.message}\n`));
44
+ return;
45
+ }
46
+ }
47
+
48
+ if (!recipe && nonInteractive) {
49
+ console.log(chalk.red(' ✗ --yes requires a recipe source (positional arg or --from). No source = interactive wizard.\n'));
50
+ return;
51
+ }
52
+
53
+ // Walk through prompts. With a recipe, each prompt is pre-filled with the
54
+ // recipe's value. Without one, the developer fills in each field from
55
+ // scratch starting with the local-vs-remote choice.
56
+ let finalized;
57
+ try {
58
+ finalized = nonInteractive
59
+ ? recipe
60
+ : await walkWizard(recipe);
61
+ } catch (err) {
62
+ if (err.isTtyError) {
63
+ console.log(chalk.red(' ✗ Prompts cannot be rendered in this environment. Use --yes with a recipe source for scripted setup.\n'));
64
+ return;
65
+ }
66
+ console.log(chalk.red(` ✗ ${err.message}\n`));
67
+ return;
68
+ }
69
+
70
+ if (!finalized) {
71
+ console.log(brand.teal(' Cancelled.\n'));
72
+ return;
73
+ }
74
+
75
+ // Check for name collisions in tools.yaml
76
+ const toolsConfig = readToolsYaml(agentName);
77
+ toolsConfig.servers = toolsConfig.servers || {};
78
+ if (toolsConfig.servers[finalized.name]) {
79
+ if (!nonInteractive) {
80
+ const { overwrite } = await inquirer.prompt([{
81
+ type: 'confirm',
82
+ name: 'overwrite',
83
+ message: `Server "${finalized.name}" already exists in this agent's tools.yaml. Overwrite?`,
84
+ default: false,
85
+ }]);
86
+ if (!overwrite) {
87
+ console.log(brand.teal(' Cancelled.\n'));
88
+ return;
89
+ }
90
+ } else {
91
+ console.log(chalk.yellow(` Overwriting existing server "${finalized.name}".`));
92
+ }
93
+ }
94
+
95
+ toolsConfig.servers[finalized.name] = recipeToServerEntry(finalized);
96
+ writeToolsYaml(agentName, toolsConfig);
97
+
98
+ console.log(brand.green(` ✓ Added "${finalized.name}" to agent "${agentName}".`));
99
+
100
+ const envVars = declaredEnvVars(finalized);
101
+ if (envVars.length > 0) {
102
+ console.log(brand.teal('\n Env vars this MCP expects:'));
103
+ for (const v of envVars) {
104
+ const reqLabel = v.required ? chalk.bold('required') : 'optional';
105
+ const desc = v.description ? ` — ${v.description}` : '';
106
+ console.log(` ${chalk.cyan(v.name)} (${reqLabel})${desc}`);
107
+ }
108
+ console.log(brand.teal(`\n Set them in ~/.myvillage/agents/${agentName}/.env (recommended) or your shell.`));
109
+ }
110
+
111
+ if (finalized.setup_help) {
112
+ console.log(brand.teal(` Setup help: ${finalized.setup_help}`));
113
+ }
114
+
115
+ if (isDaemonRunning(agentName)) {
116
+ console.log(chalk.yellow(`\n Restart the agent to apply: myvillage agent stop ${agentName} && myvillage agent start ${agentName}`));
117
+ }
118
+ console.log('');
119
+ }
120
+
121
+ // ── Wizard ──────────────────────────────────────────────
122
+
123
+ async function walkWizard(recipe) {
124
+ // If no recipe, ask the developer what kind of MCP they're adding
125
+ if (!recipe) {
126
+ const { kind } = await inquirer.prompt([{
127
+ type: 'list',
128
+ name: 'kind',
129
+ message: 'Is this MCP a local subprocess (npm package, binary) or a remote URL?',
130
+ choices: [
131
+ { name: 'Local subprocess (most npm-distributed MCPs like @some/mcp-thing)', value: 'local' },
132
+ { name: 'Remote URL (e.g. https://mcp.example.com)', value: 'remote' },
133
+ ],
134
+ }]);
135
+ recipe = { name: '', description: '', env: {} };
136
+ if (kind === 'remote') recipe.url = '';
137
+ else recipe.command = 'npx';
138
+ }
139
+
140
+ const isRemote = !!recipe.url;
141
+
142
+ // Common fields
143
+ const baseAnswers = await inquirer.prompt([
144
+ {
145
+ type: 'input',
146
+ name: 'name',
147
+ message: 'Server name (key in tools.yaml):',
148
+ default: recipe.name || undefined,
149
+ validate: v => /^[a-z0-9_-]+$/i.test((v || '').trim()) || 'lowercase letters, digits, dashes, underscores only',
150
+ filter: v => v.trim(),
151
+ },
152
+ {
153
+ type: 'input',
154
+ name: 'description',
155
+ message: 'Description (optional, shown in tools.yaml):',
156
+ default: recipe.description || '',
157
+ filter: v => v.trim(),
158
+ },
159
+ ]);
160
+
161
+ if (isRemote) {
162
+ return await walkRemoteWizard(recipe, baseAnswers);
163
+ }
164
+ return await walkLocalWizard(recipe, baseAnswers);
165
+ }
166
+
167
+ async function walkLocalWizard(recipe, baseAnswers) {
168
+ const argsDefault = (recipe.args || []).join(' ');
169
+ const answers = await inquirer.prompt([
170
+ {
171
+ type: 'input',
172
+ name: 'command',
173
+ message: 'Command:',
174
+ default: recipe.command || 'npx',
175
+ validate: v => v.trim().length > 0 || 'command is required',
176
+ filter: v => v.trim(),
177
+ },
178
+ {
179
+ type: 'input',
180
+ name: 'argsRaw',
181
+ message: 'Args (space-separated):',
182
+ default: argsDefault,
183
+ filter: v => v.trim(),
184
+ },
185
+ {
186
+ type: 'input',
187
+ name: 'envNamesRaw',
188
+ message: 'Env vars this MCP needs (comma-separated, leave blank for none):',
189
+ default: Object.keys(recipe.env || {}).join(', '),
190
+ filter: v => v.trim(),
191
+ },
192
+ ]);
193
+
194
+ const args = answers.argsRaw ? answers.argsRaw.split(/\s+/) : [];
195
+ const envNames = answers.envNamesRaw
196
+ ? answers.envNamesRaw.split(',').map(s => s.trim()).filter(Boolean)
197
+ : [];
198
+
199
+ // Build env schema — preserve recipe-provided descriptions for known vars,
200
+ // mark new ones the developer added as required-by-default with no description.
201
+ const env = {};
202
+ for (const name of envNames) {
203
+ env[name] = recipe.env?.[name] || { description: '', required: true };
204
+ }
205
+
206
+ return {
207
+ ...recipe,
208
+ ...baseAnswers,
209
+ command: answers.command,
210
+ args,
211
+ env,
212
+ };
213
+ }
214
+
215
+ async function walkRemoteWizard(recipe, baseAnswers) {
216
+ const headerEntries = Object.entries(recipe.headers || {});
217
+ const defaultAuth = headerEntries.some(([k]) => /^authorization$/i.test(k)) ? 'bearer'
218
+ : headerEntries.length > 0 ? 'custom' : 'none';
219
+
220
+ const answers = await inquirer.prompt([
221
+ {
222
+ type: 'input',
223
+ name: 'url',
224
+ message: 'URL:',
225
+ default: recipe.url || '',
226
+ validate: v => /^https?:\/\//i.test((v || '').trim()) || 'must start with http:// or https://',
227
+ filter: v => v.trim(),
228
+ },
229
+ {
230
+ type: 'list',
231
+ name: 'authKind',
232
+ message: 'Authentication:',
233
+ choices: [
234
+ { name: 'None', value: 'none' },
235
+ { name: 'Bearer token (Authorization: Bearer ${VAR})', value: 'bearer' },
236
+ { name: 'Custom header(s)', value: 'custom' },
237
+ ],
238
+ default: defaultAuth,
239
+ },
240
+ ]);
241
+
242
+ let headers = {};
243
+ let env = {};
244
+
245
+ if (answers.authKind === 'bearer') {
246
+ const { envVarName } = await inquirer.prompt([{
247
+ type: 'input',
248
+ name: 'envVarName',
249
+ message: 'Env var name that will hold the token:',
250
+ default: pickFirstEnvVarName(recipe) || 'MY_MCP_TOKEN',
251
+ validate: v => /^[A-Z_][A-Z0-9_]*$/i.test(v) || 'must be a valid env-var name (letters, digits, underscores)',
252
+ filter: v => v.trim(),
253
+ }]);
254
+ headers = { Authorization: `Bearer \${${envVarName}}` };
255
+ env = { [envVarName]: recipe.env?.[envVarName] || { description: 'Bearer token', required: true } };
256
+ } else if (answers.authKind === 'custom') {
257
+ const { headersRaw } = await inquirer.prompt([{
258
+ type: 'input',
259
+ name: 'headersRaw',
260
+ message: 'Headers (comma-separated "Header-Name=value-or-\\${ENV}"):',
261
+ default: headerEntries.map(([k, v]) => `${k}=${v}`).join(', '),
262
+ filter: v => v.trim(),
263
+ }]);
264
+ headers = parseHeaderList(headersRaw);
265
+
266
+ // Any ${VAR} reference in the values implies that env var is needed
267
+ const envNamesFromHeaders = new Set();
268
+ for (const v of Object.values(headers)) {
269
+ const matches = String(v).matchAll(/\$\{([A-Z_][A-Z0-9_]*)\}/gi);
270
+ for (const m of matches) envNamesFromHeaders.add(m[1]);
271
+ }
272
+ for (const name of envNamesFromHeaders) {
273
+ env[name] = recipe.env?.[name] || { description: '', required: true };
274
+ }
275
+ }
276
+
277
+ return {
278
+ ...recipe,
279
+ ...baseAnswers,
280
+ url: answers.url,
281
+ transport: recipe.transport || 'http',
282
+ headers,
283
+ env,
284
+ };
285
+ }
286
+
287
+ function pickFirstEnvVarName(recipe) {
288
+ if (!recipe.env || typeof recipe.env !== 'object') return null;
289
+ return Object.keys(recipe.env)[0] || null;
290
+ }
291
+
292
+ function parseHeaderList(raw) {
293
+ const headers = {};
294
+ if (!raw) return headers;
295
+ for (const piece of raw.split(',')) {
296
+ const [k, ...rest] = piece.split('=');
297
+ const name = (k || '').trim();
298
+ const value = rest.join('=').trim();
299
+ if (name && value) headers[name] = value;
300
+ }
301
+ return headers;
302
+ }
303
+
304
+ // ── list-mcp ────────────────────────────────────────────
305
+
306
+ export async function agentListMcpCommand(agentName) {
307
+ if (!agentExists(agentName)) {
308
+ console.log(chalk.red(` ✗ Agent "${agentName}" not found.\n`));
309
+ return;
310
+ }
311
+ const toolsConfig = readToolsYaml(agentName);
312
+ const servers = Object.entries(toolsConfig.servers || {});
313
+ if (servers.length === 0) {
314
+ console.log(brand.teal(` No MCP servers configured for "${agentName}".\n`));
315
+ return;
316
+ }
317
+ console.log(brand.teal(`\n MCP servers for "${agentName}":\n`));
318
+ for (const [name, server] of servers) {
319
+ const kind = server.url ? `remote (${server.url})` : `local (${server.command} ${(server.args || []).join(' ')})`;
320
+ console.log(` ${chalk.bold(name)}`);
321
+ console.log(` ${chalk.dim(kind)}`);
322
+ if (server.description) console.log(` ${chalk.dim(server.description)}`);
323
+ }
324
+ console.log('');
325
+ }
326
+
327
+ // ── recipes ─────────────────────────────────────────────
328
+
329
+ export async function recipesListCommand() {
330
+ if (!isAuthenticated()) {
331
+ console.log(chalk.red(" ✗ Authentication required. Run 'myvillage login' first.\n"));
332
+ return;
333
+ }
334
+
335
+ let recipes;
336
+ try {
337
+ recipes = await listRegistryRecipes();
338
+ } catch (err) {
339
+ console.log(chalk.red(` ✗ ${err.message}\n`));
340
+ return;
341
+ }
342
+
343
+ if (recipes.length === 0) {
344
+ console.log(brand.teal(' The recipe registry is empty.\n'));
345
+ return;
346
+ }
347
+
348
+ console.log(brand.teal('\n MyVillage curated MCP recipes (install with `agent add-mcp <agent> <name>`):\n'));
349
+ for (const r of recipes) {
350
+ const kind = r.kind === 'remote' ? 'remote' : 'local';
351
+ console.log(` ${chalk.bold(r.name.padEnd(14))}${chalk.dim(`(${kind})`)} ${chalk.dim(r.description || '')}`);
352
+ if (r.envVars && r.envVars.length > 0) {
353
+ console.log(` ${' '.repeat(14)}${chalk.dim('env: ' + r.envVars.join(', '))}`);
354
+ }
355
+ }
356
+ console.log(brand.teal('\n External recipes: install with `--from <path-or-url>`.\n'));
357
+ }
package/src/index.js CHANGED
@@ -66,11 +66,6 @@ import {
66
66
  agentRecallCommand,
67
67
  agentRememberCommand,
68
68
  } from './commands/agent-local.js';
69
- import {
70
- agentGrantCommand,
71
- agentRevokeCommand,
72
- agentGrantsCommand,
73
- } from './commands/agent-grant.js';
74
69
  import {
75
70
  agentRegisterClientCommand,
76
71
  agentListClientsCommand,
@@ -79,6 +74,11 @@ import {
79
74
  agentDeactivateClientCommand,
80
75
  agentRotateClientKeyCommand,
81
76
  } from './commands/agent-client.js';
77
+ import {
78
+ agentAddMcpCommand,
79
+ agentListMcpCommand,
80
+ recipesListCommand,
81
+ } from './commands/agent-mcp.js';
82
82
  import {
83
83
  gameUpdateCommand,
84
84
  gameUploadThumbnailCommand,
@@ -467,7 +467,7 @@ export function run() {
467
467
 
468
468
  agentCmd
469
469
  .command('add-tool <name> <tool>')
470
- .description('Add an MCP server tool to a local agent')
470
+ .description('Add a built-in tool (filesystem, browser) for everything else (Gmail, GitHub, Slack, custom MCPs) use `add-mcp`')
471
471
  .action(agentAddToolCommand);
472
472
 
473
473
  agentCmd
@@ -475,6 +475,25 @@ export function run() {
475
475
  .description('Remove an MCP server tool from a local agent')
476
476
  .action(agentRemoveToolCommand);
477
477
 
478
+ // Generic add-mcp — interactive wizard, optionally pre-filled by a recipe
479
+ // (built-in name, local YAML file, or URL).
480
+ agentCmd
481
+ .command('add-mcp <name> [recipe]')
482
+ .description('Interactively add any MCP server to an agent. Optional recipe pre-fills the wizard (built-in name, ./path.yaml, or https://url/recipe.yaml).')
483
+ .option('--from <source>', 'Recipe source — same as the positional [recipe] arg')
484
+ .option('--yes', 'Skip prompts and accept recipe values verbatim (requires a recipe source)')
485
+ .action((name, recipe, options) => agentAddMcpCommand(name, recipe, options));
486
+
487
+ agentCmd
488
+ .command('list-mcp <name>')
489
+ .description('List the MCP servers currently configured for an agent')
490
+ .action(agentListMcpCommand);
491
+
492
+ program
493
+ .command('recipes')
494
+ .description('List the MCP recipes bundled with the CLI')
495
+ .action(recipesListCommand);
496
+
478
497
  // Task queue commands — assign and inspect work for a developer's agent
479
498
  agentCmd
480
499
  .command('task-list <name>')
@@ -523,21 +542,6 @@ export function run() {
523
542
  .option('--sharing <option>', 'PRIVATE | VILLAGE_ONLY | PUBLIC', 'PRIVATE')
524
543
  .action(agentRememberCommand);
525
544
 
526
- // Per-agent OAuth credential grants
527
- agentCmd
528
- .command('grants <name>')
529
- .description('List active OAuth credential grants for a local agent')
530
- .action(agentGrantsCommand);
531
-
532
- agentCmd
533
- .command('grant <name> <provider>')
534
- .description('Grant the agent access to a connected OAuth provider (google|microsoft|zoom)')
535
- .action(agentGrantCommand);
536
-
537
- agentCmd
538
- .command('revoke <name> <provider>')
539
- .description('Revoke an OAuth provider grant from the agent')
540
- .action(agentRevokeCommand);
541
545
 
542
546
  // Client agent registration commands
543
547
  agentCmd
@@ -5,44 +5,39 @@ import { stringify as stringifyYaml } from 'yaml';
5
5
 
6
6
  // ── MCP Tool Catalog ────────────────────────────────────
7
7
  //
8
- // Static-shape entries. The `filesystem` entry's allow-listed root is
9
- // resolved per-agent at scaffold/add-tool time see
10
- // resolveCatalogEntry() so each agent gets its own sandboxed workspace.
8
+ // Minimal entries the scaffold wizard surfaces by default. These are the
9
+ // MCPs that need no third-party credential at all (myvillage uses the
10
+ // developer's existing platform OAuth session; filesystem/browser use
11
+ // local-machine resources only; github uses an env var the developer sets
12
+ // themselves).
13
+ //
14
+ // Everything else — Gmail, Slack, Twilio, custom remote MCPs, anything new
15
+ // the community ships — lives as a "recipe" under src/recipes/ that the
16
+ // developer installs via `myvillage agent add-mcp` after creation. See
17
+ // src/utils/recipes.js.
11
18
 
12
19
  const TOOL_CATALOG = {
20
+
13
21
  myvillage: {
14
22
  url: 'https://mcp.myvillageproject.ai',
15
23
  description: 'MyVillageOS platform access (feed, posts, communities, wallet, knowledge)',
16
24
  always_enabled: true,
17
25
  },
26
+
18
27
  filesystem: {
19
28
  command: 'npx',
20
29
  // Last arg is a placeholder; replaced with `<agentDir>/workspace`
21
- // by resolveCatalogEntry().
30
+ // by resolveCatalogEntry() at scaffold time.
22
31
  args: ['-y', '@modelcontextprotocol/server-filesystem', '__AGENT_WORKSPACE__'],
23
32
  description: 'Read/write files under the agent\'s sandboxed workspace',
24
33
  },
25
- gmail: {
26
- command: 'npx',
27
- args: ['-y', '@anthropic/mcp-gmail'],
28
- description: 'Read and search Gmail',
29
- },
30
- calendar: {
31
- command: 'npx',
32
- args: ['-y', '@anthropic/mcp-google-calendar'],
33
- description: 'Read events, check availability, create reminders',
34
- },
35
- github: {
36
- command: 'npx',
37
- args: ['-y', '@modelcontextprotocol/server-github'],
38
- env: { GITHUB_TOKEN: '${GITHUB_TOKEN}' },
39
- description: 'GitHub repository access',
40
- },
34
+
41
35
  browser: {
42
36
  command: 'npx',
43
37
  args: ['-y', '@modelcontextprotocol/server-puppeteer'],
44
38
  description: 'Navigate web pages, extract content (downloads Chromium on first run)',
45
39
  },
40
+
46
41
  };
47
42
 
48
43
  export { TOOL_CATALOG };
@@ -50,12 +45,9 @@ export { TOOL_CATALOG };
50
45
  // ── Catalog Resolution ──────────────────────────────────
51
46
 
52
47
  /**
53
- * Returns a deep-copied catalog entry with per-agent placeholders
54
- * resolved (e.g. the filesystem workspace path). Also creates any
55
- * directories the entry depends on (the workspace dir for filesystem).
56
- *
57
- * Use this anywhere you'd otherwise do `{ ...TOOL_CATALOG[toolId] }`
58
- * to write into a tools.yaml.
48
+ * Returns a deep-copied catalog entry with per-agent placeholders resolved
49
+ * (e.g. the filesystem workspace path). Also creates any directories the
50
+ * entry depends on (the workspace dir for filesystem).
59
51
  */
60
52
  export function resolveCatalogEntry(toolId, agentName) {
61
53
  const entry = TOOL_CATALOG[toolId];
@@ -89,32 +81,55 @@ const INTERVAL_MAP = {
89
81
  export function scaffoldAgent(agentDir, options) {
90
82
  const { name, displayName, description, tools, checkInInterval, provider, model } = options;
91
83
 
92
- // Create directories
93
84
  mkdirSync(join(agentDir, 'routines'), { recursive: true });
94
85
  mkdirSync(join(agentDir, 'logs'), { recursive: true });
95
86
 
96
- // Write agent.config.yaml
97
87
  writeFileSync(
98
88
  join(agentDir, 'agent.config.yaml'),
99
- generateAgentConfig({ name, displayName, description, checkInInterval, provider, model })
89
+ generateAgentConfig({ name, displayName, description, checkInInterval, provider, model }),
100
90
  );
101
91
 
102
- // Write prompt.md
103
92
  writeFileSync(
104
93
  join(agentDir, 'prompt.md'),
105
- generatePromptMd()
94
+ generatePromptMd(),
106
95
  );
107
96
 
108
- // Write tools.yaml
109
97
  writeFileSync(
110
98
  join(agentDir, 'tools.yaml'),
111
- generateToolsYaml(tools, name)
99
+ generateToolsYaml(tools, name),
100
+ );
101
+
102
+ // A starter .env file for the developer to populate. Tools/recipes that
103
+ // need credentials (Gmail token, Slack bot token, Twilio SID/auth, custom
104
+ // headers for remote MCPs) read env vars from this file at agent start.
105
+ // Gitignore-able by convention — it's plaintext on the developer's machine
106
+ // and isn't transmitted anywhere.
107
+ writeFileSync(
108
+ join(agentDir, '.env'),
109
+ starterEnvFile(),
110
+ { mode: 0o600 },
112
111
  );
113
112
 
114
- // Write logs/.gitkeep
115
113
  writeFileSync(join(agentDir, 'logs', '.gitkeep'), '');
116
114
  }
117
115
 
116
+ function starterEnvFile() {
117
+ return [
118
+ '# Agent-scoped environment variables.',
119
+ '# The daemon loads this file at startup and merges its values into the',
120
+ '# environment of every MCP subprocess it spawns. Use it for tokens, API',
121
+ '# keys, and any other secret your tools (or remote MCPs) need.',
122
+ '#',
123
+ '# Examples:',
124
+ '# GITHUB_TOKEN=ghp_xxxxxxxxxxxx',
125
+ '# GMAIL_TOKEN=xxxxxxxxxxxx',
126
+ '# SLACK_BOT_TOKEN=xoxb-xxxxxxxxxxxx',
127
+ '#',
128
+ '# This file is plaintext — keep it out of version control.',
129
+ '',
130
+ ].join('\n');
131
+ }
132
+
118
133
  // ── Config Generation ───────────────────────────────────
119
134
 
120
135
  function generateAgentConfig({ name, displayName, description, checkInInterval, provider, model }) {
@@ -159,10 +174,6 @@ function generateAgentConfig({ name, displayName, description, checkInInterval,
159
174
  // ── Prompt Generation ───────────────────────────────────
160
175
 
161
176
  function generatePromptMd() {
162
- // Identity (display name + description) lives in agent.config.yaml and is
163
- // prepended automatically by the runtime loop on every iteration. This file
164
- // is the persona body only — Voice, behavior rules, examples. To change the
165
- // agent's name or one-line description, edit agent.config.yaml and restart.
166
177
  return `<!-- Starter prompt — replace the specifics below with what makes this agent
167
178
  yours. Keep the section structure (Voice / What you do / What you don't
168
179
  do / Examples) — it follows the recommended shape in the docs:
@@ -0,0 +1,248 @@
1
+ // ── MCP Recipes ─────────────────────────────────────────
2
+ //
3
+ // A "recipe" is a small declarative YAML file describing how to plug an
4
+ // MCP server into an agent. Recipes are NOT bundled with the CLI — they
5
+ // live in the MyVillage recipe registry (auth-gated) and any other URL a
6
+ // developer points at via `--from`.
7
+ //
8
+ // `loadRecipe(source)` accepts:
9
+ // - a bare name like "gmail" → fetches from the MyVillage registry,
10
+ // attaches the developer's bearer token, requires `myvillage login`
11
+ // - a local file path (./recipe.yaml or absolute) → reads from disk
12
+ // - a remote URL (http(s)://...) → fetches; bearer token attached only
13
+ // when the host is a MyVillage host (never leaked to third parties)
14
+ //
15
+ // Recipe format — same two shapes the daemon's `tools.yaml` already
16
+ // supports:
17
+ //
18
+ // Local subprocess MCP:
19
+ // name: string
20
+ // description: string (optional)
21
+ // command: string # e.g. "npx"
22
+ // args: [string, string, ...] # passed to command; ${VAR} ok
23
+ // env: # optional schema for the wizard
24
+ // NAME:
25
+ // description: string
26
+ // required: bool
27
+ // setup_help: string (optional URL)
28
+ //
29
+ // Remote URL MCP:
30
+ // name: string
31
+ // description: string (optional)
32
+ // url: string
33
+ // transport: "http" | "sse" (optional, default "http")
34
+ // headers: # optional; values may use ${ENV}
35
+ // Authorization: "Bearer ${MY_TOKEN}"
36
+ // env: # optional schema for the wizard
37
+ // MY_TOKEN:
38
+ // description: string
39
+ // required: bool
40
+ // setup_help: string (optional URL)
41
+
42
+ import { readFileSync, existsSync } from 'fs';
43
+ import { join, isAbsolute } from 'path';
44
+ import { parse as parseYaml } from 'yaml';
45
+ import { getConfig } from './config.js';
46
+ import { getAccessToken } from './auth.js';
47
+
48
+ // ── Public API ──────────────────────────────────────────
49
+
50
+ /**
51
+ * Resolve any recipe source (bare name | local path | URL) to a parsed,
52
+ * validated recipe object. Throws with a friendly message if the source
53
+ * can't be read, the request fails, or the YAML is invalid.
54
+ */
55
+ export async function loadRecipe(source) {
56
+ if (!source) throw new Error('No recipe source provided');
57
+
58
+ // URL — fetch (auth attached if MyVillage host)
59
+ if (/^https?:\/\//i.test(source)) {
60
+ return loadRecipeFromUrl(source);
61
+ }
62
+
63
+ // Looks like a path — read from disk
64
+ if (source.includes('/') || source.includes('\\') || /\.(yaml|yml)$/i.test(source)) {
65
+ return loadRecipeFromFile(source);
66
+ }
67
+
68
+ // Bare name — pull from the MyVillage curated registry
69
+ return loadRecipeFromRegistry(source);
70
+ }
71
+
72
+ /**
73
+ * List curated recipes available in the MyVillage registry. Requires
74
+ * `myvillage login`. Returns an array of summaries:
75
+ * { name, description, kind, envVars, setup_help? }
76
+ */
77
+ export async function listRegistryRecipes() {
78
+ const url = `${getConfig().apiBaseUrl}/recipes`;
79
+ const res = await authedFetch(url);
80
+ if (!res.ok) {
81
+ throw await registryError(res, 'list recipes');
82
+ }
83
+ const body = await res.json();
84
+ return body.recipes || [];
85
+ }
86
+
87
+ // ── Loaders ─────────────────────────────────────────────
88
+
89
+ async function loadRecipeFromRegistry(name) {
90
+ const url = `${getConfig().apiBaseUrl}/recipes/${encodeURIComponent(name)}`;
91
+ const res = await authedFetch(url);
92
+ if (!res.ok) {
93
+ throw await registryError(res, `fetch recipe "${name}"`);
94
+ }
95
+ const text = await res.text();
96
+ return validateRecipe(parseYaml(text), `registry:${name}`);
97
+ }
98
+
99
+ function loadRecipeFromFile(path) {
100
+ const abs = isAbsolute(path) ? path : join(process.cwd(), path);
101
+ if (!existsSync(abs)) {
102
+ throw new Error(`Recipe file not found: ${abs}`);
103
+ }
104
+ let parsed;
105
+ try {
106
+ parsed = parseYaml(readFileSync(abs, 'utf-8'));
107
+ } catch (err) {
108
+ throw new Error(`Failed to parse recipe at ${abs}: ${err.message}`);
109
+ }
110
+ return validateRecipe(parsed, abs);
111
+ }
112
+
113
+ async function loadRecipeFromUrl(url) {
114
+ // Attach the bearer token only if this URL is on a MyVillage host —
115
+ // sending it to a third-party URL would leak the developer's
116
+ // platform credentials.
117
+ const fetcher = isMyVillageHost(url) ? authedFetch : fetch;
118
+ let response;
119
+ try {
120
+ response = await fetcher(url, { redirect: 'follow' });
121
+ } catch (err) {
122
+ throw new Error(`Failed to fetch recipe from ${url}: ${err.message}`);
123
+ }
124
+ if (!response.ok) {
125
+ if (isMyVillageHost(url) && (response.status === 401 || response.status === 403)) {
126
+ throw new Error(`Recipe at ${url} requires authentication. Run 'myvillage login' first.`);
127
+ }
128
+ throw new Error(`Failed to fetch recipe from ${url}: HTTP ${response.status}`);
129
+ }
130
+ const text = await response.text();
131
+ let parsed;
132
+ try {
133
+ parsed = parseYaml(text);
134
+ } catch (err) {
135
+ throw new Error(`Failed to parse recipe from ${url}: ${err.message}`);
136
+ }
137
+ return validateRecipe(parsed, url);
138
+ }
139
+
140
+ // ── Auth ────────────────────────────────────────────────
141
+
142
+ function isMyVillageHost(urlString) {
143
+ try {
144
+ const host = new URL(urlString).hostname;
145
+ return host === 'myvillageproject.ai' || host.endsWith('.myvillageproject.ai');
146
+ } catch {
147
+ return false;
148
+ }
149
+ }
150
+
151
+ async function authedFetch(url, options = {}) {
152
+ const token = getAccessToken();
153
+ const headers = { ...(options.headers || {}) };
154
+ if (token) headers['Authorization'] = `Bearer ${token}`;
155
+ return fetch(url, { ...options, headers });
156
+ }
157
+
158
+ async function registryError(response, action) {
159
+ let detail = '';
160
+ try {
161
+ const body = await response.json();
162
+ if (body?.error) detail = body.error;
163
+ if (body?.hint) detail += detail ? ` — ${body.hint}` : body.hint;
164
+ } catch {
165
+ // non-JSON body, ignore
166
+ }
167
+ if (response.status === 401) {
168
+ return new Error(`Could not ${action}: authentication required. Run 'myvillage login' first.`);
169
+ }
170
+ if (response.status === 404) {
171
+ return new Error(`Could not ${action}: not found in the MyVillage recipe registry.`);
172
+ }
173
+ return new Error(`Could not ${action}: HTTP ${response.status}${detail ? ` — ${detail}` : ''}`);
174
+ }
175
+
176
+ // ── Validation ──────────────────────────────────────────
177
+
178
+ function validateRecipe(recipe, sourceLabel) {
179
+ if (!recipe || typeof recipe !== 'object') {
180
+ throw new Error(`Recipe at ${sourceLabel} is empty or not a YAML object`);
181
+ }
182
+ if (!recipe.name || typeof recipe.name !== 'string') {
183
+ throw new Error(`Recipe at ${sourceLabel} is missing a "name" field`);
184
+ }
185
+
186
+ const hasCommand = typeof recipe.command === 'string' && recipe.command.length > 0;
187
+ const hasUrl = typeof recipe.url === 'string' && recipe.url.length > 0;
188
+ if (hasCommand && hasUrl) {
189
+ throw new Error(`Recipe "${recipe.name}" has both "command" and "url" — pick one`);
190
+ }
191
+ if (!hasCommand && !hasUrl) {
192
+ throw new Error(`Recipe "${recipe.name}" needs either a "command" (for local subprocess) or "url" (for remote MCP)`);
193
+ }
194
+
195
+ if (hasCommand && recipe.args && !Array.isArray(recipe.args)) {
196
+ throw new Error(`Recipe "${recipe.name}": "args" must be an array of strings`);
197
+ }
198
+ if (hasUrl && recipe.transport && !['http', 'sse'].includes(recipe.transport)) {
199
+ throw new Error(`Recipe "${recipe.name}": "transport" must be "http" or "sse"`);
200
+ }
201
+
202
+ if (recipe.env != null) {
203
+ if (typeof recipe.env !== 'object' || Array.isArray(recipe.env)) {
204
+ throw new Error(`Recipe "${recipe.name}": "env" must be a mapping of env-var-name to descriptor`);
205
+ }
206
+ }
207
+
208
+ return recipe;
209
+ }
210
+
211
+ // ── Recipe → tools.yaml entry ───────────────────────────
212
+
213
+ /**
214
+ * Translate a finalized recipe (with developer edits already applied)
215
+ * into the server entry that goes under `servers:` in tools.yaml.
216
+ */
217
+ export function recipeToServerEntry(recipe) {
218
+ if (recipe.url) {
219
+ const entry = { url: recipe.url };
220
+ if (recipe.transport) entry.transport = recipe.transport;
221
+ if (recipe.headers && Object.keys(recipe.headers).length > 0) {
222
+ entry.headers = { ...recipe.headers };
223
+ }
224
+ if (recipe.description) entry.description = recipe.description;
225
+ return entry;
226
+ }
227
+
228
+ const entry = { command: recipe.command };
229
+ if (recipe.args && recipe.args.length > 0) entry.args = [...recipe.args];
230
+ if (recipe.env_vars && Object.keys(recipe.env_vars).length > 0) {
231
+ entry.env = { ...recipe.env_vars };
232
+ }
233
+ if (recipe.description) entry.description = recipe.description;
234
+ return entry;
235
+ }
236
+
237
+ /**
238
+ * List of env-var names a recipe declares as needed. The wizard prints
239
+ * these so the developer knows what to set in their .env / shell.
240
+ */
241
+ export function declaredEnvVars(recipe) {
242
+ if (!recipe.env || typeof recipe.env !== 'object') return [];
243
+ return Object.entries(recipe.env).map(([name, schema]) => ({
244
+ name,
245
+ description: schema?.description || '',
246
+ required: schema?.required !== false,
247
+ }));
248
+ }
@@ -1,131 +0,0 @@
1
- import chalk from 'chalk';
2
- import { isAuthenticated } from '../utils/auth.js';
3
- import { readAgentConfig } from '../utils/local-agent.js';
4
- import { getPlatformClient } from '../utils/api.js';
5
-
6
- const VALID_PROVIDERS = ['google', 'microsoft', 'zoom'];
7
-
8
- function ensureAuthed() {
9
- if (!isAuthenticated()) {
10
- console.log(chalk.red(" ✗ Authentication required. Run 'myvillage login' first."));
11
- return false;
12
- }
13
- return true;
14
- }
15
-
16
- function resolveAgentId(name) {
17
- const config = readAgentConfig(name);
18
- if (!config) {
19
- console.log(chalk.red(` ✗ Local agent "${name}" not found`));
20
- return null;
21
- }
22
- const agentId = config?.man?.agent_id;
23
- if (!agentId) {
24
- console.log(
25
- chalk.red(
26
- ` ✗ Agent "${name}" has not been registered on the platform yet. ` +
27
- `Run 'myvillage agent start ${name}' once to register it.`,
28
- ),
29
- );
30
- return null;
31
- }
32
- return agentId;
33
- }
34
-
35
- function normalizeProvider(provider) {
36
- const lc = (provider || '').toLowerCase();
37
- if (!VALID_PROVIDERS.includes(lc)) {
38
- console.log(
39
- chalk.red(` ✗ Invalid provider "${provider}". Must be one of: ${VALID_PROVIDERS.join(', ')}`),
40
- );
41
- return null;
42
- }
43
- return lc.toUpperCase();
44
- }
45
-
46
- function printApiError(err, fallback) {
47
- const status = err?.response?.status;
48
- const data = err?.response?.data;
49
- const code = data?.code ? ` (${data.code})` : '';
50
- const message = data?.error || err?.message || fallback;
51
- console.log(chalk.red(` ✗ ${message}${code}${status ? ` [HTTP ${status}]` : ''}`));
52
- }
53
-
54
- // ── List grants ────────────────────────────────────────────
55
-
56
- export async function agentGrantsCommand(name) {
57
- if (!ensureAuthed()) return;
58
- const agentId = resolveAgentId(name);
59
- if (!agentId) return;
60
-
61
- const client = getPlatformClient();
62
- try {
63
- const response = await client.get(`/agents/${agentId}/credential-grants`);
64
- const grants = response.data?.data ?? [];
65
- if (grants.length === 0) {
66
- console.log(chalk.dim(` No active credential grants for agent "${name}".`));
67
- console.log(chalk.dim(` Grant one with: myvillage agent grant ${name} <google|microsoft|zoom>`));
68
- return;
69
- }
70
- console.log(chalk.bold(`Active credential grants for "${name}" (${agentId}):\n`));
71
- for (const g of grants) {
72
- const granted = new Date(g.grantedAt).toISOString().slice(0, 10);
73
- console.log(` - ${chalk.cyan(g.provider)} granted ${granted}`);
74
- }
75
- } catch (err) {
76
- printApiError(err, 'Failed to list grants');
77
- }
78
- }
79
-
80
- // ── Grant a provider ───────────────────────────────────────
81
-
82
- export async function agentGrantCommand(name, provider) {
83
- if (!ensureAuthed()) return;
84
- const agentId = resolveAgentId(name);
85
- if (!agentId) return;
86
- const upperProvider = normalizeProvider(provider);
87
- if (!upperProvider) return;
88
-
89
- const client = getPlatformClient();
90
- try {
91
- await client.post(`/agents/${agentId}/credential-grants`, { provider: upperProvider });
92
- console.log(
93
- chalk.green(` ✓ Granted ${upperProvider} to agent "${name}".`) +
94
- chalk.dim(`\n The agent can now use ${upperProvider}-backed tools (e.g. gmail_send).`),
95
- );
96
- } catch (err) {
97
- if (err?.response?.status === 404 && err?.response?.data?.code === 'CREDENTIAL_NOT_CONNECTED') {
98
- console.log(
99
- chalk.yellow(
100
- ` ⚠ You haven't connected ${upperProvider} yet. ` +
101
- `Connect it from the mobile app or web portal, then re-run this command.`,
102
- ),
103
- );
104
- return;
105
- }
106
- printApiError(err, 'Failed to grant provider');
107
- }
108
- }
109
-
110
- // ── Revoke a provider ──────────────────────────────────────
111
-
112
- export async function agentRevokeCommand(name, provider) {
113
- if (!ensureAuthed()) return;
114
- const agentId = resolveAgentId(name);
115
- if (!agentId) return;
116
- const upperProvider = normalizeProvider(provider);
117
- if (!upperProvider) return;
118
-
119
- const client = getPlatformClient();
120
- try {
121
- await client.delete(`/agents/${agentId}/credential-grants/${upperProvider}`);
122
- console.log(
123
- chalk.green(` ✓ Revoked ${upperProvider} from agent "${name}".`) +
124
- chalk.dim(
125
- `\n Tools using ${upperProvider} will fail within ~60 seconds (cache TTL).`,
126
- ),
127
- );
128
- } catch (err) {
129
- printApiError(err, 'Failed to revoke provider');
130
- }
131
- }