@myvillage/cli 1.82.1 → 1.84.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.82.1",
3
+ "version": "1.84.0",
4
4
  "description": "MyVillageOS CLI for community developers",
5
5
  "type": "module",
6
6
  "bin": {
@@ -112,6 +112,14 @@ export async function agentLoop(agentName, { signal }) {
112
112
  model = anthropic(modelId);
113
113
  }
114
114
  const maxTokens = config.brain?.max_tokens_per_loop || 8000;
115
+ // Max agentic steps (one model turn + its tool executions) per loop
116
+ // iteration. A task like "enumerate N villagers' knowledge, then Slack each
117
+ // lead" needs a step per discovery wave plus the action steps; 10 was too low
118
+ // — the model would exhaust the budget mid-task (e.g. right after listing
119
+ // Slack channels but before sending). Configurable per agent via
120
+ // brain.max_steps_per_loop; default 25. Cost stays bounded by
121
+ // max_tokens_per_loop and the rate limiter regardless of this ceiling.
122
+ const maxSteps = config.brain?.max_steps_per_loop || 25;
115
123
 
116
124
  // Initialize MCP tools
117
125
  let tools;
@@ -293,10 +301,10 @@ Guidelines:
293
301
  // AI SDK v6 removed `maxSteps` — the loop now uses `stopWhen` with
294
302
  // a StopCondition predicate. The default is `stepCountIs(1)`, so
295
303
  // without this the model gets exactly ONE round of tool calls and
296
- // returns immediately. We give it up to 10 steps so a task like
297
- // "find any README under the workspace" can do
298
- // list_allowed_directories → list_directory → search_files → ...
299
- // until it's actually done.
304
+ // returns immediately. We give it up to `maxSteps` steps (default 25,
305
+ // see above) so a multi-step task like "find the low-activity villagers,
306
+ // then message each lead" can do discover → reason → act across enough
307
+ // turns to actually finish, instead of stopping mid-task.
300
308
  //
301
309
  // Anthropic prompt caching: we mark the system block as ephemeral so
302
310
  // everything before it (tool schemas + system prompt) is cached.
@@ -319,7 +327,7 @@ Guidelines:
319
327
  { role: 'user', content: context },
320
328
  ],
321
329
  tools,
322
- stopWhen: stepCountIs(10),
330
+ stopWhen: stepCountIs(maxSteps),
323
331
  maxOutputTokens: maxTokens,
324
332
  });
325
333
 
@@ -46,11 +46,13 @@ import {
46
46
  getLogFilePath,
47
47
  } from '../utils/local-agent.js';
48
48
  import { scaffoldAgent, TOOL_CATALOG, resolveCatalogEntry } from '../utils/agent-scaffolder.js';
49
+ import { loadAgentRecipe, listAgentRecipes } from '../utils/agent-recipes.js';
49
50
  import { formatLocalAgentList, formatLocalAgentStatus } from '../utils/formatters.js';
51
+ import { writeFileSync } from 'fs';
50
52
 
51
53
  // ── Create Local Agent (wizard) ─────────────────────────
52
54
 
53
- export async function agentCreateLocalCommand() {
55
+ export async function agentCreateLocalCommand(options = {}) {
54
56
  if (!isAuthenticated()) {
55
57
  console.log(chalk.red(' \u2717 Authentication required. Run \'myvillage login\' first.'));
56
58
  return;
@@ -118,7 +120,7 @@ export async function agentCreateLocalCommand() {
118
120
  {
119
121
  type: 'checkbox',
120
122
  name: 'tools',
121
- message: 'Which built-in tools should your agent have? (you can add more later with `agent add-mcp`)',
123
+ message: 'Which built-in tools should your agent have? (you can add more later with `agent add-connector`)',
122
124
  choices: [
123
125
  { name: 'MyVillageOS MCP (feed, posts, communities, wallet)', value: 'myvillage', checked: true, disabled: 'always enabled' },
124
126
  { name: 'Local Files (read/write a sandboxed workspace)', value: 'filesystem', checked: true },
@@ -223,11 +225,28 @@ export async function agentCreateLocalCommand() {
223
225
  writeAgentConfig(answers.name, cfg);
224
226
  }
225
227
 
226
- spinner.succeed('Agent scaffolded!');
228
+ // If --recipe was passed, fetch the Agent Recipe and materialize its
229
+ // prompt/tools/schedule over the freshly-scaffolded files. Best-effort:
230
+ // a fetch failure warns and leaves the blank agent in place — we never
231
+ // roll back the created agent.
232
+ let recipeApplied = null;
233
+ if (options.recipe) {
234
+ spinner.text = `Applying recipe "${options.recipe}"...`;
235
+ try {
236
+ recipeApplied = await applyAgentRecipe(answers.name, options.recipe);
237
+ } catch (err) {
238
+ recipeApplied = null;
239
+ // Downgrade spinner to a warning so the succeed() below still fires.
240
+ spinner.warn(`Recipe "${options.recipe}" not applied: ${err.message}`);
241
+ console.log(brand.teal(' Created a blank agent instead — customize it with `myvillage agent edit`.\n'));
242
+ }
243
+ }
244
+
245
+ spinner.succeed(recipeApplied ? `Agent scaffolded from recipe "${recipeApplied.name || options.recipe}"!` : 'Agent scaffolded!');
227
246
 
228
247
  console.log(brand.green(` \u2713 Agent created at ~/.myvillage/agents/${answers.name}/\n`));
229
248
  console.log(brand.teal(' Next steps:'));
230
- console.log(` ${brand.gold(`myvillage agent add-mcp ${answers.name}`)} Add a tool (GitHub, Twilio, your own MCP, etc.)`);
249
+ console.log(` ${brand.gold(`myvillage agent add-connector ${answers.name}`)} Add a tool (GitHub, Twilio, your own MCP, etc.)`);
231
250
  console.log(` ${brand.gold(`myvillage agent grant ${answers.name} <provider>`)} Authorize Google, Microsoft, or Slack`);
232
251
  console.log(` ${brand.gold(`myvillage agent start ${answers.name}`)} Launch the agent`);
233
252
  console.log(` ${brand.gold(`myvillage agent edit ${answers.name}`)} Customize the prompt`);
@@ -241,6 +260,77 @@ export async function agentCreateLocalCommand() {
241
260
  }
242
261
  }
243
262
 
263
+ // ── Agent Recipes (starter templates) ───────────────────
264
+ //
265
+ // Fetch a server-side Agent Recipe and materialize it over the three local
266
+ // files an agent already has: prompt.md (overwritten with recipe.promptMd),
267
+ // tools.yaml (myvillage.allowed_tools set to recipe.myvillageTools), and
268
+ // agent.config.yaml (schedule.check_in_interval / active_hours from
269
+ // recipe.scheduleDefaults, if present). Returns the fetched recipe on success.
270
+ // Throws on a fetch/404/network failure so the caller can warn and continue.
271
+
272
+ async function applyAgentRecipe(agentName, slug) {
273
+ const recipe = await loadAgentRecipe(slug);
274
+
275
+ // 1. prompt.md — overwrite with the recipe's prompt.
276
+ if (typeof recipe.promptMd === 'string') {
277
+ writeFileSync(join(getAgentDir(agentName), 'prompt.md'), recipe.promptMd);
278
+ }
279
+
280
+ // 2. tools.yaml — set the myvillage server's allowed_tools list.
281
+ if (Array.isArray(recipe.myvillageTools)) {
282
+ const toolsConfig = readToolsYaml(agentName);
283
+ toolsConfig.servers = toolsConfig.servers || {};
284
+ toolsConfig.servers.myvillage = toolsConfig.servers.myvillage
285
+ || resolveCatalogEntry('myvillage', agentName);
286
+ toolsConfig.servers.myvillage.allowed_tools = [...recipe.myvillageTools];
287
+ writeToolsYaml(agentName, toolsConfig);
288
+ }
289
+
290
+ // 3. agent.config.yaml — apply schedule defaults if the recipe carries any.
291
+ const sched = recipe.scheduleDefaults;
292
+ if (sched && typeof sched === 'object') {
293
+ const cfg = readAgentConfig(agentName);
294
+ cfg.schedule = cfg.schedule || {};
295
+ if (sched.check_in_interval != null) {
296
+ cfg.schedule.check_in_interval = sched.check_in_interval;
297
+ }
298
+ if (sched.active_hours && typeof sched.active_hours === 'object') {
299
+ cfg.schedule.active_hours = { ...cfg.schedule.active_hours, ...sched.active_hours };
300
+ }
301
+ writeAgentConfig(agentName, cfg);
302
+ }
303
+
304
+ return recipe;
305
+ }
306
+
307
+ export async function agentRecipesListCommand() {
308
+ if (!isAuthenticated()) {
309
+ console.log(chalk.red(" ✗ Authentication required. Run 'myvillage login' first.\n"));
310
+ return;
311
+ }
312
+
313
+ let recipes;
314
+ try {
315
+ recipes = await listAgentRecipes();
316
+ } catch (err) {
317
+ console.log(chalk.red(` ✗ ${err.message}\n`));
318
+ return;
319
+ }
320
+
321
+ if (!Array.isArray(recipes) || recipes.length === 0) {
322
+ console.log(brand.teal(' No agent recipes available yet.\n'));
323
+ return;
324
+ }
325
+
326
+ console.log(brand.teal('\n Agent recipes (create with `myvillage agent create --recipe <slug>`):\n'));
327
+ for (const r of recipes) {
328
+ console.log(` ${chalk.bold(r.slug)} ${chalk.dim(r.name || '')}`);
329
+ if (r.description) console.log(` ${chalk.dim(r.description)}`);
330
+ }
331
+ console.log('');
332
+ }
333
+
244
334
  // ── Start Agent ─────────────────────────────────────────
245
335
 
246
336
  export async function agentStartCommand(name) {
@@ -762,7 +852,7 @@ export async function agentAddToolCommand(name, tool) {
762
852
  if (!validTools.includes(tool)) {
763
853
  console.log(chalk.red(` \u2717 Unknown built-in tool "${tool}".`));
764
854
  console.log(brand.teal(` Built-in tools: ${validTools.join(', ')}`));
765
- console.log(brand.teal(` For GitHub, Twilio, custom MCPs, etc. use: ${chalk.bold(`myvillage agent add-mcp ${name}`)}`));
855
+ console.log(brand.teal(` For GitHub, Twilio, custom MCPs, etc. use: ${chalk.bold(`myvillage agent add-connector ${name}`)}`));
766
856
  console.log(brand.teal(` For Google, Microsoft, or Slack use: ${chalk.bold(`myvillage agent grant ${name} <provider>`)}\n`));
767
857
  return;
768
858
  }
@@ -8,24 +8,24 @@ import {
8
8
  isDaemonRunning,
9
9
  } from '../utils/local-agent.js';
10
10
  import {
11
- loadRecipe,
12
- listRegistryRecipes,
13
- recipeToServerEntry,
11
+ loadConnector,
12
+ listRegistryConnectors,
13
+ connectorToServerEntry,
14
14
  declaredEnvVars,
15
- } from '../utils/recipes.js';
15
+ } from '../utils/connectors.js';
16
16
  import { isAuthenticated } from '../utils/auth.js';
17
17
 
18
- // ── add-mcp ─────────────────────────────────────────────
18
+ // ── add-connector ───────────────────────────────────────
19
19
  //
20
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
21
+ // or URL), the connector's fields pre-fill the prompts but the developer can
22
22
  // edit every value before it's written. If no source, the wizard collects
23
23
  // the fields from scratch.
24
24
  //
25
- // Use --yes to skip prompts and accept recipe values verbatim (useful for
25
+ // Use --yes to skip prompts and accept connector values verbatim (useful for
26
26
  // scripts and CI).
27
27
 
28
- export async function agentAddMcpCommand(agentName, sourceArg, options = {}) {
28
+ export async function agentAddConnectorCommand(agentName, sourceArg, options = {}) {
29
29
  if (!agentExists(agentName)) {
30
30
  console.log(chalk.red(` ✗ Agent "${agentName}" not found. Run 'myvillage agent' to see your agents.\n`));
31
31
  return;
@@ -34,33 +34,33 @@ export async function agentAddMcpCommand(agentName, sourceArg, options = {}) {
34
34
  const source = sourceArg || options.from || null;
35
35
  const nonInteractive = options.yes === true;
36
36
 
37
- let recipe = null;
37
+ let connector = null;
38
38
  if (source) {
39
39
  try {
40
- recipe = await loadRecipe(source);
41
- console.log(brand.teal(` Loaded recipe: "${recipe.name}"${recipe.description ? ` — ${chalk.dim(recipe.description)}` : ''}\n`));
40
+ connector = await loadConnector(source);
41
+ console.log(brand.teal(` Loaded connector: "${connector.name}"${connector.description ? ` — ${chalk.dim(connector.description)}` : ''}\n`));
42
42
  } catch (err) {
43
43
  console.log(chalk.red(` ✗ ${err.message}\n`));
44
44
  return;
45
45
  }
46
46
  }
47
47
 
48
- if (!recipe && nonInteractive) {
49
- console.log(chalk.red(' ✗ --yes requires a recipe source (positional arg or --from). No source = interactive wizard.\n'));
48
+ if (!connector && nonInteractive) {
49
+ console.log(chalk.red(' ✗ --yes requires a connector source (positional arg or --from). No source = interactive wizard.\n'));
50
50
  return;
51
51
  }
52
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
53
+ // Walk through prompts. With a connector, each prompt is pre-filled with the
54
+ // connector's value. Without one, the developer fills in each field from
55
55
  // scratch starting with the local-vs-remote choice.
56
56
  let finalized;
57
57
  try {
58
58
  finalized = nonInteractive
59
- ? recipe
60
- : await walkWizard(recipe);
59
+ ? connector
60
+ : await walkWizard(connector);
61
61
  } catch (err) {
62
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'));
63
+ console.log(chalk.red(' ✗ Prompts cannot be rendered in this environment. Use --yes with a connector source for scripted setup.\n'));
64
64
  return;
65
65
  }
66
66
  console.log(chalk.red(` ✗ ${err.message}\n`));
@@ -92,7 +92,7 @@ export async function agentAddMcpCommand(agentName, sourceArg, options = {}) {
92
92
  }
93
93
  }
94
94
 
95
- toolsConfig.servers[finalized.name] = recipeToServerEntry(finalized);
95
+ toolsConfig.servers[finalized.name] = connectorToServerEntry(finalized);
96
96
  writeToolsYaml(agentName, toolsConfig);
97
97
 
98
98
  console.log(brand.green(` ✓ Added "${finalized.name}" to agent "${agentName}".`));
@@ -120,9 +120,9 @@ export async function agentAddMcpCommand(agentName, sourceArg, options = {}) {
120
120
 
121
121
  // ── Wizard ──────────────────────────────────────────────
122
122
 
123
- async function walkWizard(recipe) {
124
- // If no recipe, ask the developer what kind of MCP they're adding
125
- if (!recipe) {
123
+ async function walkWizard(connector) {
124
+ // If no connector, ask the developer what kind of MCP they're adding
125
+ if (!connector) {
126
126
  const { kind } = await inquirer.prompt([{
127
127
  type: 'list',
128
128
  name: 'kind',
@@ -132,12 +132,12 @@ async function walkWizard(recipe) {
132
132
  { name: 'Remote URL (e.g. https://mcp.example.com)', value: 'remote' },
133
133
  ],
134
134
  }]);
135
- recipe = { name: '', description: '', env: {} };
136
- if (kind === 'remote') recipe.url = '';
137
- else recipe.command = 'npx';
135
+ connector = { name: '', description: '', env: {} };
136
+ if (kind === 'remote') connector.url = '';
137
+ else connector.command = 'npx';
138
138
  }
139
139
 
140
- const isRemote = !!recipe.url;
140
+ const isRemote = !!connector.url;
141
141
 
142
142
  // Common fields
143
143
  const baseAnswers = await inquirer.prompt([
@@ -145,7 +145,7 @@ async function walkWizard(recipe) {
145
145
  type: 'input',
146
146
  name: 'name',
147
147
  message: 'Server name (key in tools.yaml):',
148
- default: recipe.name || undefined,
148
+ default: connector.name || undefined,
149
149
  validate: v => /^[a-z0-9_-]+$/i.test((v || '').trim()) || 'lowercase letters, digits, dashes, underscores only',
150
150
  filter: v => v.trim(),
151
151
  },
@@ -153,19 +153,19 @@ async function walkWizard(recipe) {
153
153
  type: 'input',
154
154
  name: 'description',
155
155
  message: 'Description (optional, shown in tools.yaml):',
156
- default: recipe.description || '',
156
+ default: connector.description || '',
157
157
  filter: v => v.trim(),
158
158
  },
159
159
  ]);
160
160
 
161
161
  if (isRemote) {
162
- return await walkRemoteWizard(recipe, baseAnswers);
162
+ return await walkRemoteWizard(connector, baseAnswers);
163
163
  }
164
- return await walkLocalWizard(recipe, baseAnswers);
164
+ return await walkLocalWizard(connector, baseAnswers);
165
165
  }
166
166
 
167
- async function walkLocalWizard(recipe, baseAnswers) {
168
- const argsDefault = (recipe.args || []).join(' ');
167
+ async function walkLocalWizard(connector, baseAnswers) {
168
+ const argsDefault = (connector.args || []).join(' ');
169
169
  const answers = await inquirer.prompt([
170
170
  {
171
171
  type: 'input',
@@ -186,7 +186,7 @@ async function walkLocalWizard(recipe, baseAnswers) {
186
186
  type: 'input',
187
187
  name: 'envNamesRaw',
188
188
  message: 'Env vars this MCP needs (comma-separated, leave blank for none):',
189
- default: Object.keys(recipe.env || {}).join(', '),
189
+ default: Object.keys(connector.env || {}).join(', '),
190
190
  filter: v => v.trim(),
191
191
  },
192
192
  ]);
@@ -196,15 +196,15 @@ async function walkLocalWizard(recipe, baseAnswers) {
196
196
  ? answers.envNamesRaw.split(',').map(s => s.trim()).filter(Boolean)
197
197
  : [];
198
198
 
199
- // Build env schema — preserve recipe-provided descriptions for known vars,
199
+ // Build env schema — preserve connector-provided descriptions for known vars,
200
200
  // mark new ones the developer added as required-by-default with no description.
201
201
  const env = {};
202
202
  for (const name of envNames) {
203
- env[name] = recipe.env?.[name] || { description: '', required: true };
203
+ env[name] = connector.env?.[name] || { description: '', required: true };
204
204
  }
205
205
 
206
206
  return {
207
- ...recipe,
207
+ ...connector,
208
208
  ...baseAnswers,
209
209
  command: answers.command,
210
210
  args,
@@ -212,8 +212,8 @@ async function walkLocalWizard(recipe, baseAnswers) {
212
212
  };
213
213
  }
214
214
 
215
- async function walkRemoteWizard(recipe, baseAnswers) {
216
- const headerEntries = Object.entries(recipe.headers || {});
215
+ async function walkRemoteWizard(connector, baseAnswers) {
216
+ const headerEntries = Object.entries(connector.headers || {});
217
217
  const defaultAuth = headerEntries.some(([k]) => /^authorization$/i.test(k)) ? 'bearer'
218
218
  : headerEntries.length > 0 ? 'custom' : 'none';
219
219
 
@@ -247,12 +247,12 @@ async function walkRemoteWizard(recipe, baseAnswers) {
247
247
  type: 'input',
248
248
  name: 'envVarName',
249
249
  message: 'Env var name that will hold the token:',
250
- default: pickFirstEnvVarName(recipe) || 'MY_MCP_TOKEN',
250
+ default: pickFirstEnvVarName(connector) || 'MY_MCP_TOKEN',
251
251
  validate: v => /^[A-Z_][A-Z0-9_]*$/i.test(v) || 'must be a valid env-var name (letters, digits, underscores)',
252
252
  filter: v => v.trim(),
253
253
  }]);
254
254
  headers = { Authorization: `Bearer \${${envVarName}}` };
255
- env = { [envVarName]: recipe.env?.[envVarName] || { description: 'Bearer token', required: true } };
255
+ env = { [envVarName]: connector.env?.[envVarName] || { description: 'Bearer token', required: true } };
256
256
  } else if (answers.authKind === 'custom') {
257
257
  const { headersRaw } = await inquirer.prompt([{
258
258
  type: 'input',
@@ -270,23 +270,23 @@ async function walkRemoteWizard(recipe, baseAnswers) {
270
270
  for (const m of matches) envNamesFromHeaders.add(m[1]);
271
271
  }
272
272
  for (const name of envNamesFromHeaders) {
273
- env[name] = recipe.env?.[name] || { description: '', required: true };
273
+ env[name] = connector.env?.[name] || { description: '', required: true };
274
274
  }
275
275
  }
276
276
 
277
277
  return {
278
- ...recipe,
278
+ ...connector,
279
279
  ...baseAnswers,
280
280
  url: answers.url,
281
- transport: recipe.transport || 'http',
281
+ transport: connector.transport || 'http',
282
282
  headers,
283
283
  env,
284
284
  };
285
285
  }
286
286
 
287
- function pickFirstEnvVarName(recipe) {
288
- if (!recipe.env || typeof recipe.env !== 'object') return null;
289
- return Object.keys(recipe.env)[0] || null;
287
+ function pickFirstEnvVarName(connector) {
288
+ if (!connector.env || typeof connector.env !== 'object') return null;
289
+ return Object.keys(connector.env)[0] || null;
290
290
  }
291
291
 
292
292
  function parseHeaderList(raw) {
@@ -324,34 +324,34 @@ export async function agentListMcpCommand(agentName) {
324
324
  console.log('');
325
325
  }
326
326
 
327
- // ── recipes ─────────────────────────────────────────────
327
+ // ── connectors ──────────────────────────────────────────
328
328
 
329
- export async function recipesListCommand() {
329
+ export async function connectorsListCommand() {
330
330
  if (!isAuthenticated()) {
331
331
  console.log(chalk.red(" ✗ Authentication required. Run 'myvillage login' first.\n"));
332
332
  return;
333
333
  }
334
334
 
335
- let recipes;
335
+ let connectors;
336
336
  try {
337
- recipes = await listRegistryRecipes();
337
+ connectors = await listRegistryConnectors();
338
338
  } catch (err) {
339
339
  console.log(chalk.red(` ✗ ${err.message}\n`));
340
340
  return;
341
341
  }
342
342
 
343
- if (recipes.length === 0) {
344
- console.log(brand.teal(' The recipe registry is empty.\n'));
343
+ if (connectors.length === 0) {
344
+ console.log(brand.teal(' The connector registry is empty.\n'));
345
345
  return;
346
346
  }
347
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(', '))}`);
348
+ console.log(brand.teal('\n MyVillage curated MCP connectors (install with `agent add-connector <agent> <name>`):\n'));
349
+ for (const c of connectors) {
350
+ const kind = c.kind === 'remote' ? 'remote' : 'local';
351
+ console.log(` ${chalk.bold(c.name.padEnd(14))}${chalk.dim(`(${kind})`)} ${chalk.dim(c.description || '')}`);
352
+ if (c.envVars && c.envVars.length > 0) {
353
+ console.log(` ${' '.repeat(14)}${chalk.dim('env: ' + c.envVars.join(', '))}`);
354
354
  }
355
355
  }
356
- console.log(brand.teal('\n External recipes: install with `--from <path-or-url>`.\n'));
356
+ console.log(brand.teal('\n External connectors: install with `--from <path-or-url>`.\n'));
357
357
  }
@@ -66,13 +66,19 @@ export async function agentCommand(handle) {
66
66
  }
67
67
  }
68
68
 
69
- export async function agentCreateCommand() {
69
+ export async function agentCreateCommand(options = {}) {
70
70
  if (!isAuthenticated()) {
71
71
  console.log(chalk.red(' \u2717 Authentication required. Run \'myvillage login\' first.'));
72
72
  return;
73
73
  }
74
74
 
75
75
  try {
76
+ // A --recipe implies a local agent (recipes materialize prompt.md / tools.yaml
77
+ // / agent.config.yaml, which only local agents have) \u2014 skip the gate question.
78
+ if (options.recipe) {
79
+ return agentCreateLocalCommand(options);
80
+ }
81
+
76
82
  // Gate question: local or external?
77
83
  const { agentType } = await inquirer.prompt([{
78
84
  type: 'list',
@@ -85,7 +91,7 @@ export async function agentCreateCommand() {
85
91
  }]);
86
92
 
87
93
  if (agentType === 'local') {
88
- return agentCreateLocalCommand();
94
+ return agentCreateLocalCommand(options);
89
95
  }
90
96
 
91
97
  // Fetch the caller's existing agents to offer as parent choices for the
package/src/index.js CHANGED
@@ -70,6 +70,7 @@ import {
70
70
  agentMemoryCommand,
71
71
  agentRecallCommand,
72
72
  agentRememberCommand,
73
+ agentRecipesListCommand,
73
74
  } from './commands/agent-local.js';
74
75
  import {
75
76
  agentRegisterClientCommand,
@@ -80,9 +81,9 @@ import {
80
81
  agentRotateClientKeyCommand,
81
82
  } from './commands/agent-client.js';
82
83
  import {
83
- agentAddMcpCommand,
84
+ agentAddConnectorCommand,
84
85
  agentListMcpCommand,
85
- recipesListCommand,
86
+ connectorsListCommand,
86
87
  } from './commands/agent-mcp.js';
87
88
  import {
88
89
  agentGrantCommand,
@@ -444,7 +445,8 @@ export function run() {
444
445
  agentCmd
445
446
  .command('create')
446
447
  .description('Create a new agent (interactive)')
447
- .action(agentCreateCommand);
448
+ .option('--recipe <slug>', 'Start from an Agent Recipe template (prompt + preselected MyVillageOS tools). Implies a local agent. List them with `myvillage agent recipes`.')
449
+ .action((options) => agentCreateCommand(options));
448
450
 
449
451
  agentCmd
450
452
  .command('view <handle>')
@@ -536,7 +538,7 @@ export function run() {
536
538
 
537
539
  agentCmd
538
540
  .command('add-tool <name> <tool>')
539
- .description('Add a built-in tool (filesystem, browser) — for custom MCPs use `add-mcp`, for Google/Microsoft/Slack use `grant`')
541
+ .description('Add a built-in tool (filesystem, browser) — for custom MCPs use `add-connector`, for Google/Microsoft/Slack use `grant`')
540
542
  .action(agentAddToolCommand);
541
543
 
542
544
  agentCmd
@@ -544,14 +546,16 @@ export function run() {
544
546
  .description('Remove an MCP server tool from a local agent')
545
547
  .action(agentRemoveToolCommand);
546
548
 
547
- // Generic add-mcp — interactive wizard, optionally pre-filled by a recipe
548
- // (built-in name, local YAML file, or URL).
549
+ // Generic add-connector — interactive wizard, optionally pre-filled by a
550
+ // connector (built-in name, local YAML file, or URL). `add-mcp` stays as a
551
+ // hidden deprecated alias so already-documented invocations keep working.
549
552
  agentCmd
550
- .command('add-mcp <name> [recipe]')
551
- .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).')
552
- .option('--from <source>', 'Recipe source — same as the positional [recipe] arg')
553
- .option('--yes', 'Skip prompts and accept recipe values verbatim (requires a recipe source)')
554
- .action((name, recipe, options) => agentAddMcpCommand(name, recipe, options));
553
+ .command('add-connector <name> [connector]')
554
+ .alias('add-mcp')
555
+ .description('Interactively add any MCP server (connector) to an agent. Optional connector pre-fills the wizard (built-in name, ./path.yaml, or https://url/connector.yaml).')
556
+ .option('--from <source>', 'Connector source — same as the positional [connector] arg')
557
+ .option('--yes', 'Skip prompts and accept connector values verbatim (requires a connector source)')
558
+ .action((name, connector, options) => agentAddConnectorCommand(name, connector, options));
555
559
 
556
560
  agentCmd
557
561
  .command('list-mcp <name>')
@@ -577,9 +581,16 @@ export function run() {
577
581
  .action(agentGrantsListCommand);
578
582
 
579
583
  program
584
+ .command('connectors')
585
+ .alias('recipes')
586
+ .description('List the curated MCP connectors in the MyVillage registry')
587
+ .action(connectorsListCommand);
588
+
589
+ // List available Agent Recipes (starter templates for `agent create --recipe`).
590
+ agentCmd
580
591
  .command('recipes')
581
- .description('List the MCP recipes bundled with the CLI')
582
- .action(recipesListCommand);
592
+ .description('List available Agent Recipes (starter templates: prompt + preselected tools)')
593
+ .action(agentRecipesListCommand);
583
594
 
584
595
  // Task queue commands — assign and inspect work for a developer's agent
585
596
  agentCmd
@@ -0,0 +1,83 @@
1
+ // ── Agent Recipes ───────────────────────────────────────
2
+ //
3
+ // An "Agent Recipe" is a get-started template for creating an agent: a
4
+ // `prompt.md` plus a pre-selected set of MyVillageOS tools (and optional
5
+ // schedule defaults). Recipes are DB-backed and served by the ed-platform;
6
+ // the CLI materializes them into the three local agent files at create time.
7
+ //
8
+ // Distinct from a "Connector" (see connectors.js), which installs an external
9
+ // MCP server. Recipes carry no credentials.
10
+ //
11
+ // API (auth: bearer token, any session):
12
+ // GET ${apiBaseUrl}/agent-recipes -> { recipes: AgentRecipeSummary[] }
13
+ // GET ${apiBaseUrl}/agent-recipes/{slug} -> { recipe: AgentRecipe }
14
+ //
15
+ // AgentRecipe {
16
+ // slug, name, description, promptMd,
17
+ // myvillageTools: string[],
18
+ // scheduleDefaults?: { check_in_interval?, active_hours? }
19
+ // }
20
+
21
+ import { getConfig } from './config.js';
22
+ import { getAccessToken } from './auth.js';
23
+
24
+ async function authedFetch(url, options = {}) {
25
+ const token = getAccessToken();
26
+ const headers = { ...(options.headers || {}) };
27
+ if (token) headers['Authorization'] = `Bearer ${token}`;
28
+ return fetch(url, { ...options, headers });
29
+ }
30
+
31
+ async function recipeError(response, action) {
32
+ let detail = '';
33
+ try {
34
+ const body = await response.json();
35
+ if (body?.error) detail = body.error;
36
+ if (body?.hint) detail += detail ? ` — ${body.hint}` : body.hint;
37
+ } catch {
38
+ // non-JSON body, ignore
39
+ }
40
+ if (response.status === 401) {
41
+ return new Error(`Could not ${action}: authentication required. Run 'myvillage login' first.`);
42
+ }
43
+ if (response.status === 404) {
44
+ return new Error(`Could not ${action}: not found.`);
45
+ }
46
+ return new Error(`Could not ${action}: HTTP ${response.status}${detail ? ` — ${detail}` : ''}`);
47
+ }
48
+
49
+ /**
50
+ * Fetch a single Agent Recipe by slug. Returns the recipe object
51
+ * ({ slug, name, description, promptMd, myvillageTools, scheduleDefaults? }).
52
+ * Throws with a friendly message on 404 / auth / network failure.
53
+ */
54
+ export async function loadAgentRecipe(slug) {
55
+ if (!slug) throw new Error('No agent recipe slug provided');
56
+ const url = `${getConfig().apiBaseUrl}/agent-recipes/${encodeURIComponent(slug)}`;
57
+ let res;
58
+ try {
59
+ res = await authedFetch(url);
60
+ } catch (err) {
61
+ throw new Error(`Could not fetch recipe "${slug}": ${err.message}`);
62
+ }
63
+ if (!res.ok) {
64
+ throw await recipeError(res, `fetch recipe "${slug}"`);
65
+ }
66
+ const body = await res.json();
67
+ // Accept either { recipe: {...} } (spec shape) or a bare recipe object.
68
+ return body.recipe || body;
69
+ }
70
+
71
+ /**
72
+ * List available Agent Recipes. Returns an array of summaries
73
+ * ({ slug, name, description, ... }). Requires `myvillage login`.
74
+ */
75
+ export async function listAgentRecipes() {
76
+ const url = `${getConfig().apiBaseUrl}/agent-recipes`;
77
+ const res = await authedFetch(url);
78
+ if (!res.ok) {
79
+ throw await recipeError(res, 'list agent recipes');
80
+ }
81
+ const body = await res.json();
82
+ return body.recipes || body.data || [];
83
+ }
@@ -12,10 +12,10 @@ import { stringify as stringifyYaml } from 'yaml';
12
12
  // themselves).
13
13
  //
14
14
  // Everything else — Twilio, custom remote MCPs, anything new the community
15
- // ships — lives as a "recipe" the developer installs via `myvillage agent
16
- // add-mcp` after creation (see src/utils/recipes.js). OAuth providers (Google,
17
- // Microsoft, Slack) instead connect once and are authorized per-agent with
18
- // `myvillage agent grant`.
15
+ // ships — lives as a "connector" the developer installs via `myvillage agent
16
+ // add-connector` after creation (see src/utils/connectors.js). OAuth providers
17
+ // (Google, Microsoft, Slack) instead connect once and are authorized per-agent
18
+ // with `myvillage agent grant`.
19
19
 
20
20
  const TOOL_CATALOG = {
21
21
 
@@ -100,7 +100,7 @@ export function scaffoldAgent(agentDir, options) {
100
100
  generateToolsYaml(tools, name),
101
101
  );
102
102
 
103
- // A starter .env file for the developer to populate. Tools/recipes that
103
+ // A starter .env file for the developer to populate. Tools/connectors that
104
104
  // need credentials (Twilio SID/auth, custom headers for remote MCPs) read env
105
105
  // vars from this file at agent start. OAuth providers (Google, Microsoft,
106
106
  // Slack) are NOT env vars — they connect once and are authorized per-agent
@@ -123,7 +123,7 @@ function starterEnvFile() {
123
123
  '# environment of every MCP subprocess it spawns. Use it for tokens, API',
124
124
  '# keys, and any other secret your tools (or remote MCPs) need.',
125
125
  '#',
126
- '# Examples (the curated recipes — install with `myvillage agent add-mcp`):',
126
+ '# Examples (the curated connectors — install with `myvillage agent add-connector`):',
127
127
  '# GITHUB_PERSONAL_ACCESS_TOKEN=ghp_xxxxxxxxxxxx',
128
128
  '# TWILIO_ACCOUNT_SID=ACxxxxxxxxxxxx',
129
129
  '# TWILIO_API_KEY=SKxxxxxxxxxxxx',
@@ -0,0 +1,285 @@
1
+ // ── MCP Connectors ──────────────────────────────────────
2
+ //
3
+ // A "connector" is a small declarative YAML file describing how to plug an
4
+ // MCP server into an agent. Connectors are NOT bundled with the CLI — they
5
+ // live in the MyVillage connector registry (auth-gated) and any other URL a
6
+ // developer points at via `--from`.
7
+ //
8
+ // (Formerly called "recipes." The name moved to "connector" so "recipe" could
9
+ // be reused for agent starter templates — see agent-recipes.js. The old
10
+ // `recipes.js` module re-exports these symbols under their previous names for
11
+ // back-compat with the published package's consumers, e.g. myvillage-studio.)
12
+ //
13
+ // `loadConnector(source)` accepts:
14
+ // - a bare name like "github" → fetches from the MyVillage registry,
15
+ // attaches the developer's bearer token, requires `myvillage login`
16
+ // - a local file path (./connector.yaml or absolute) → reads from disk
17
+ // - a remote URL (http(s)://...) → fetches; bearer token attached only
18
+ // when the host is a MyVillage host (never leaked to third parties)
19
+ //
20
+ // Connector format — same two shapes the daemon's `tools.yaml` already
21
+ // supports:
22
+ //
23
+ // Local subprocess MCP:
24
+ // name: string
25
+ // description: string (optional)
26
+ // command: string # e.g. "npx"
27
+ // args: [string, string, ...] # passed to command; ${VAR} ok
28
+ // env: # optional schema for the wizard
29
+ // NAME:
30
+ // description: string
31
+ // required: bool
32
+ // setup_help: string (optional URL)
33
+ //
34
+ // Remote URL MCP:
35
+ // name: string
36
+ // description: string (optional)
37
+ // url: string
38
+ // transport: "http" | "sse" (optional, default "http")
39
+ // headers: # optional; values may use ${ENV}
40
+ // Authorization: "Bearer ${MY_TOKEN}"
41
+ // env: # optional schema for the wizard
42
+ // MY_TOKEN:
43
+ // description: string
44
+ // required: bool
45
+ // setup_help: string (optional URL)
46
+
47
+ import { readFileSync, existsSync } from 'fs';
48
+ import { join, isAbsolute } from 'path';
49
+ import { parse as parseYaml } from 'yaml';
50
+ import { getConfig } from './config.js';
51
+ import { getAccessToken } from './auth.js';
52
+
53
+ // ── Deprecated registry connectors ──────────────────────
54
+ //
55
+ // Curated connectors retired in favor of one-click OAuth provider connect
56
+ // (`myvillage agent grant <agent> <provider>`). We hide them from the registry
57
+ // listing and block a direct fetch so there's a single, obvious path — even if
58
+ // the backend registry still serves the old entry. Keyed by registry connector
59
+ // name → the provider slug that replaces it. Only affects bare-name registry
60
+ // loads; a developer's own `--from ./slack.yaml` is untouched.
61
+ const DEPRECATED_REGISTRY_CONNECTORS = {
62
+ slack: 'slack', // bot-token Slack MCP → "Sign in with Slack" (acts as you)
63
+ };
64
+
65
+ function deprecationMessage(name, providerSlug) {
66
+ const label = name.charAt(0).toUpperCase() + name.slice(1);
67
+ return (
68
+ `The "${name}" bot-token tool has been replaced by one-click ${label} sign-in. ` +
69
+ `Connect ${label} in MyVillage Studio (or at myvillageproject.ai), then run ` +
70
+ `\`myvillage agent grant <agent> ${providerSlug}\` — no token to copy.`
71
+ );
72
+ }
73
+
74
+ // ── Public API ──────────────────────────────────────────
75
+
76
+ /**
77
+ * Resolve any connector source (bare name | local path | URL) to a parsed,
78
+ * validated connector object. Throws with a friendly message if the source
79
+ * can't be read, the request fails, or the YAML is invalid.
80
+ */
81
+ export async function loadConnector(source) {
82
+ if (!source) throw new Error('No connector source provided');
83
+
84
+ // URL — fetch (auth attached if MyVillage host)
85
+ if (/^https?:\/\//i.test(source)) {
86
+ return loadConnectorFromUrl(source);
87
+ }
88
+
89
+ // Looks like a path — read from disk
90
+ if (source.includes('/') || source.includes('\\') || /\.(yaml|yml)$/i.test(source)) {
91
+ return loadConnectorFromFile(source);
92
+ }
93
+
94
+ // Bare name — pull from the MyVillage curated registry
95
+ return loadConnectorFromRegistry(source);
96
+ }
97
+
98
+ /**
99
+ * List curated connectors available in the MyVillage registry. Requires
100
+ * `myvillage login`. Returns an array of summaries:
101
+ * { name, description, kind, envVars, setup_help? }
102
+ */
103
+ export async function listRegistryConnectors() {
104
+ const url = `${getConfig().apiBaseUrl}/connectors`;
105
+ const res = await authedFetch(url);
106
+ if (!res.ok) {
107
+ throw await registryError(res, 'list connectors');
108
+ }
109
+ const body = await res.json();
110
+ // Back-compat: the endpoint returns `{ connectors: [...] }`; older/deprecated
111
+ // deploys may still return `{ recipes: [...] }`. Accept either.
112
+ const items = body.connectors || body.recipes || [];
113
+ // Hide retired connectors (e.g. the bot-token Slack MCP) so they never show up
114
+ // as an option — the OAuth provider path supersedes them.
115
+ return items.filter(
116
+ (r) => !Object.hasOwn(DEPRECATED_REGISTRY_CONNECTORS, String(r?.name || '').toLowerCase()),
117
+ );
118
+ }
119
+
120
+ // ── Loaders ─────────────────────────────────────────────
121
+
122
+ async function loadConnectorFromRegistry(name) {
123
+ const slug = String(name || '').toLowerCase();
124
+ if (Object.hasOwn(DEPRECATED_REGISTRY_CONNECTORS, slug)) {
125
+ throw new Error(deprecationMessage(slug, DEPRECATED_REGISTRY_CONNECTORS[slug]));
126
+ }
127
+ const url = `${getConfig().apiBaseUrl}/connectors/${encodeURIComponent(name)}`;
128
+ const res = await authedFetch(url);
129
+ if (!res.ok) {
130
+ throw await registryError(res, `fetch connector "${name}"`);
131
+ }
132
+ const text = await res.text();
133
+ return validateConnector(parseYaml(text), `registry:${name}`);
134
+ }
135
+
136
+ function loadConnectorFromFile(path) {
137
+ const abs = isAbsolute(path) ? path : join(process.cwd(), path);
138
+ if (!existsSync(abs)) {
139
+ throw new Error(`Connector file not found: ${abs}`);
140
+ }
141
+ let parsed;
142
+ try {
143
+ parsed = parseYaml(readFileSync(abs, 'utf-8'));
144
+ } catch (err) {
145
+ throw new Error(`Failed to parse connector at ${abs}: ${err.message}`);
146
+ }
147
+ return validateConnector(parsed, abs);
148
+ }
149
+
150
+ async function loadConnectorFromUrl(url) {
151
+ // Attach the bearer token only if this URL is on a MyVillage host —
152
+ // sending it to a third-party URL would leak the developer's
153
+ // platform credentials.
154
+ const fetcher = isMyVillageHost(url) ? authedFetch : fetch;
155
+ let response;
156
+ try {
157
+ response = await fetcher(url, { redirect: 'follow' });
158
+ } catch (err) {
159
+ throw new Error(`Failed to fetch connector from ${url}: ${err.message}`);
160
+ }
161
+ if (!response.ok) {
162
+ if (isMyVillageHost(url) && (response.status === 401 || response.status === 403)) {
163
+ throw new Error(`Connector at ${url} requires authentication. Run 'myvillage login' first.`);
164
+ }
165
+ throw new Error(`Failed to fetch connector from ${url}: HTTP ${response.status}`);
166
+ }
167
+ const text = await response.text();
168
+ let parsed;
169
+ try {
170
+ parsed = parseYaml(text);
171
+ } catch (err) {
172
+ throw new Error(`Failed to parse connector from ${url}: ${err.message}`);
173
+ }
174
+ return validateConnector(parsed, url);
175
+ }
176
+
177
+ // ── Auth ────────────────────────────────────────────────
178
+
179
+ function isMyVillageHost(urlString) {
180
+ try {
181
+ const host = new URL(urlString).hostname;
182
+ return host === 'myvillageproject.ai' || host.endsWith('.myvillageproject.ai');
183
+ } catch {
184
+ return false;
185
+ }
186
+ }
187
+
188
+ async function authedFetch(url, options = {}) {
189
+ const token = getAccessToken();
190
+ const headers = { ...(options.headers || {}) };
191
+ if (token) headers['Authorization'] = `Bearer ${token}`;
192
+ return fetch(url, { ...options, headers });
193
+ }
194
+
195
+ async function registryError(response, action) {
196
+ let detail = '';
197
+ try {
198
+ const body = await response.json();
199
+ if (body?.error) detail = body.error;
200
+ if (body?.hint) detail += detail ? ` — ${body.hint}` : body.hint;
201
+ } catch {
202
+ // non-JSON body, ignore
203
+ }
204
+ if (response.status === 401) {
205
+ return new Error(`Could not ${action}: authentication required. Run 'myvillage login' first.`);
206
+ }
207
+ if (response.status === 404) {
208
+ return new Error(`Could not ${action}: not found in the MyVillage connector registry.`);
209
+ }
210
+ return new Error(`Could not ${action}: HTTP ${response.status}${detail ? ` — ${detail}` : ''}`);
211
+ }
212
+
213
+ // ── Validation ──────────────────────────────────────────
214
+
215
+ function validateConnector(connector, sourceLabel) {
216
+ if (!connector || typeof connector !== 'object') {
217
+ throw new Error(`Connector at ${sourceLabel} is empty or not a YAML object`);
218
+ }
219
+ if (!connector.name || typeof connector.name !== 'string') {
220
+ throw new Error(`Connector at ${sourceLabel} is missing a "name" field`);
221
+ }
222
+
223
+ const hasCommand = typeof connector.command === 'string' && connector.command.length > 0;
224
+ const hasUrl = typeof connector.url === 'string' && connector.url.length > 0;
225
+ if (hasCommand && hasUrl) {
226
+ throw new Error(`Connector "${connector.name}" has both "command" and "url" — pick one`);
227
+ }
228
+ if (!hasCommand && !hasUrl) {
229
+ throw new Error(`Connector "${connector.name}" needs either a "command" (for local subprocess) or "url" (for remote MCP)`);
230
+ }
231
+
232
+ if (hasCommand && connector.args && !Array.isArray(connector.args)) {
233
+ throw new Error(`Connector "${connector.name}": "args" must be an array of strings`);
234
+ }
235
+ if (hasUrl && connector.transport && !['http', 'sse'].includes(connector.transport)) {
236
+ throw new Error(`Connector "${connector.name}": "transport" must be "http" or "sse"`);
237
+ }
238
+
239
+ if (connector.env != null) {
240
+ if (typeof connector.env !== 'object' || Array.isArray(connector.env)) {
241
+ throw new Error(`Connector "${connector.name}": "env" must be a mapping of env-var-name to descriptor`);
242
+ }
243
+ }
244
+
245
+ return connector;
246
+ }
247
+
248
+ // ── Connector → tools.yaml entry ────────────────────────
249
+
250
+ /**
251
+ * Translate a finalized connector (with developer edits already applied)
252
+ * into the server entry that goes under `servers:` in tools.yaml.
253
+ */
254
+ export function connectorToServerEntry(connector) {
255
+ if (connector.url) {
256
+ const entry = { url: connector.url };
257
+ if (connector.transport) entry.transport = connector.transport;
258
+ if (connector.headers && Object.keys(connector.headers).length > 0) {
259
+ entry.headers = { ...connector.headers };
260
+ }
261
+ if (connector.description) entry.description = connector.description;
262
+ return entry;
263
+ }
264
+
265
+ const entry = { command: connector.command };
266
+ if (connector.args && connector.args.length > 0) entry.args = [...connector.args];
267
+ if (connector.env_vars && Object.keys(connector.env_vars).length > 0) {
268
+ entry.env = { ...connector.env_vars };
269
+ }
270
+ if (connector.description) entry.description = connector.description;
271
+ return entry;
272
+ }
273
+
274
+ /**
275
+ * List of env-var names a connector declares as needed. The wizard prints
276
+ * these so the developer knows what to set in their .env / shell.
277
+ */
278
+ export function declaredEnvVars(connector) {
279
+ if (!connector.env || typeof connector.env !== 'object') return [];
280
+ return Object.entries(connector.env).map(([name, schema]) => ({
281
+ name,
282
+ description: schema?.description || '',
283
+ required: schema?.required !== false,
284
+ }));
285
+ }
@@ -1,277 +1,20 @@
1
- // ── MCP Recipes ─────────────────────────────────────────
1
+ // ── Back-compat shim: recipes → connectors ──────────────
2
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`.
3
+ // "Recipes" were renamed to "Connectors" (see connectors.js and the
4
+ // Agent Recipes & Connectors spec). The published `@myvillage/cli` package is
5
+ // imported by sibling repos (notably myvillage-studio) under the OLD names,
6
+ // so this module stays as a thin re-export that maps the new symbol names
7
+ // back to the old ones. Do NOT add logic here — edit connectors.js instead.
7
8
  //
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
- // ── Deprecated registry recipes ─────────────────────────
49
- //
50
- // Curated recipes retired in favor of one-click OAuth provider connect
51
- // (`myvillage agent grant <agent> <provider>`). We hide them from the registry
52
- // listing and block a direct fetch so there's a single, obvious path — even if
53
- // the backend registry still serves the old entry. Keyed by registry recipe
54
- // name → the provider slug that replaces it. Only affects bare-name registry
55
- // loads; a developer's own `--from ./slack.yaml` is untouched.
56
- const DEPRECATED_REGISTRY_RECIPES = {
57
- slack: 'slack', // bot-token Slack MCP → "Sign in with Slack" (acts as you)
58
- };
59
-
60
- function deprecationMessage(name, providerSlug) {
61
- const label = name.charAt(0).toUpperCase() + name.slice(1);
62
- return (
63
- `The "${name}" bot-token tool has been replaced by one-click ${label} sign-in. ` +
64
- `Connect ${label} in MyVillage Studio (or at myvillageproject.ai), then run ` +
65
- `\`myvillage agent grant <agent> ${providerSlug}\` — no token to copy.`
66
- );
67
- }
68
-
69
- // ── Public API ──────────────────────────────────────────
70
-
71
- /**
72
- * Resolve any recipe source (bare name | local path | URL) to a parsed,
73
- * validated recipe object. Throws with a friendly message if the source
74
- * can't be read, the request fails, or the YAML is invalid.
75
- */
76
- export async function loadRecipe(source) {
77
- if (!source) throw new Error('No recipe source provided');
78
-
79
- // URL — fetch (auth attached if MyVillage host)
80
- if (/^https?:\/\//i.test(source)) {
81
- return loadRecipeFromUrl(source);
82
- }
83
-
84
- // Looks like a path — read from disk
85
- if (source.includes('/') || source.includes('\\') || /\.(yaml|yml)$/i.test(source)) {
86
- return loadRecipeFromFile(source);
87
- }
88
-
89
- // Bare name — pull from the MyVillage curated registry
90
- return loadRecipeFromRegistry(source);
91
- }
92
-
93
- /**
94
- * List curated recipes available in the MyVillage registry. Requires
95
- * `myvillage login`. Returns an array of summaries:
96
- * { name, description, kind, envVars, setup_help? }
97
- */
98
- export async function listRegistryRecipes() {
99
- const url = `${getConfig().apiBaseUrl}/recipes`;
100
- const res = await authedFetch(url);
101
- if (!res.ok) {
102
- throw await registryError(res, 'list recipes');
103
- }
104
- const body = await res.json();
105
- // Hide retired recipes (e.g. the bot-token Slack MCP) so they never show up
106
- // as an option — the OAuth provider path supersedes them.
107
- return (body.recipes || []).filter(
108
- (r) => !Object.hasOwn(DEPRECATED_REGISTRY_RECIPES, String(r?.name || '').toLowerCase()),
109
- );
110
- }
111
-
112
- // ── Loaders ─────────────────────────────────────────────
113
-
114
- async function loadRecipeFromRegistry(name) {
115
- const slug = String(name || '').toLowerCase();
116
- if (Object.hasOwn(DEPRECATED_REGISTRY_RECIPES, slug)) {
117
- throw new Error(deprecationMessage(slug, DEPRECATED_REGISTRY_RECIPES[slug]));
118
- }
119
- const url = `${getConfig().apiBaseUrl}/recipes/${encodeURIComponent(name)}`;
120
- const res = await authedFetch(url);
121
- if (!res.ok) {
122
- throw await registryError(res, `fetch recipe "${name}"`);
123
- }
124
- const text = await res.text();
125
- return validateRecipe(parseYaml(text), `registry:${name}`);
126
- }
127
-
128
- function loadRecipeFromFile(path) {
129
- const abs = isAbsolute(path) ? path : join(process.cwd(), path);
130
- if (!existsSync(abs)) {
131
- throw new Error(`Recipe file not found: ${abs}`);
132
- }
133
- let parsed;
134
- try {
135
- parsed = parseYaml(readFileSync(abs, 'utf-8'));
136
- } catch (err) {
137
- throw new Error(`Failed to parse recipe at ${abs}: ${err.message}`);
138
- }
139
- return validateRecipe(parsed, abs);
140
- }
141
-
142
- async function loadRecipeFromUrl(url) {
143
- // Attach the bearer token only if this URL is on a MyVillage host —
144
- // sending it to a third-party URL would leak the developer's
145
- // platform credentials.
146
- const fetcher = isMyVillageHost(url) ? authedFetch : fetch;
147
- let response;
148
- try {
149
- response = await fetcher(url, { redirect: 'follow' });
150
- } catch (err) {
151
- throw new Error(`Failed to fetch recipe from ${url}: ${err.message}`);
152
- }
153
- if (!response.ok) {
154
- if (isMyVillageHost(url) && (response.status === 401 || response.status === 403)) {
155
- throw new Error(`Recipe at ${url} requires authentication. Run 'myvillage login' first.`);
156
- }
157
- throw new Error(`Failed to fetch recipe from ${url}: HTTP ${response.status}`);
158
- }
159
- const text = await response.text();
160
- let parsed;
161
- try {
162
- parsed = parseYaml(text);
163
- } catch (err) {
164
- throw new Error(`Failed to parse recipe from ${url}: ${err.message}`);
165
- }
166
- return validateRecipe(parsed, url);
167
- }
168
-
169
- // ── Auth ────────────────────────────────────────────────
170
-
171
- function isMyVillageHost(urlString) {
172
- try {
173
- const host = new URL(urlString).hostname;
174
- return host === 'myvillageproject.ai' || host.endsWith('.myvillageproject.ai');
175
- } catch {
176
- return false;
177
- }
178
- }
179
-
180
- async function authedFetch(url, options = {}) {
181
- const token = getAccessToken();
182
- const headers = { ...(options.headers || {}) };
183
- if (token) headers['Authorization'] = `Bearer ${token}`;
184
- return fetch(url, { ...options, headers });
185
- }
186
-
187
- async function registryError(response, action) {
188
- let detail = '';
189
- try {
190
- const body = await response.json();
191
- if (body?.error) detail = body.error;
192
- if (body?.hint) detail += detail ? ` — ${body.hint}` : body.hint;
193
- } catch {
194
- // non-JSON body, ignore
195
- }
196
- if (response.status === 401) {
197
- return new Error(`Could not ${action}: authentication required. Run 'myvillage login' first.`);
198
- }
199
- if (response.status === 404) {
200
- return new Error(`Could not ${action}: not found in the MyVillage recipe registry.`);
201
- }
202
- return new Error(`Could not ${action}: HTTP ${response.status}${detail ? ` — ${detail}` : ''}`);
203
- }
204
-
205
- // ── Validation ──────────────────────────────────────────
206
-
207
- function validateRecipe(recipe, sourceLabel) {
208
- if (!recipe || typeof recipe !== 'object') {
209
- throw new Error(`Recipe at ${sourceLabel} is empty or not a YAML object`);
210
- }
211
- if (!recipe.name || typeof recipe.name !== 'string') {
212
- throw new Error(`Recipe at ${sourceLabel} is missing a "name" field`);
213
- }
214
-
215
- const hasCommand = typeof recipe.command === 'string' && recipe.command.length > 0;
216
- const hasUrl = typeof recipe.url === 'string' && recipe.url.length > 0;
217
- if (hasCommand && hasUrl) {
218
- throw new Error(`Recipe "${recipe.name}" has both "command" and "url" — pick one`);
219
- }
220
- if (!hasCommand && !hasUrl) {
221
- throw new Error(`Recipe "${recipe.name}" needs either a "command" (for local subprocess) or "url" (for remote MCP)`);
222
- }
223
-
224
- if (hasCommand && recipe.args && !Array.isArray(recipe.args)) {
225
- throw new Error(`Recipe "${recipe.name}": "args" must be an array of strings`);
226
- }
227
- if (hasUrl && recipe.transport && !['http', 'sse'].includes(recipe.transport)) {
228
- throw new Error(`Recipe "${recipe.name}": "transport" must be "http" or "sse"`);
229
- }
230
-
231
- if (recipe.env != null) {
232
- if (typeof recipe.env !== 'object' || Array.isArray(recipe.env)) {
233
- throw new Error(`Recipe "${recipe.name}": "env" must be a mapping of env-var-name to descriptor`);
234
- }
235
- }
236
-
237
- return recipe;
238
- }
239
-
240
- // ── Recipe → tools.yaml entry ───────────────────────────
241
-
242
- /**
243
- * Translate a finalized recipe (with developer edits already applied)
244
- * into the server entry that goes under `servers:` in tools.yaml.
245
- */
246
- export function recipeToServerEntry(recipe) {
247
- if (recipe.url) {
248
- const entry = { url: recipe.url };
249
- if (recipe.transport) entry.transport = recipe.transport;
250
- if (recipe.headers && Object.keys(recipe.headers).length > 0) {
251
- entry.headers = { ...recipe.headers };
252
- }
253
- if (recipe.description) entry.description = recipe.description;
254
- return entry;
255
- }
256
-
257
- const entry = { command: recipe.command };
258
- if (recipe.args && recipe.args.length > 0) entry.args = [...recipe.args];
259
- if (recipe.env_vars && Object.keys(recipe.env_vars).length > 0) {
260
- entry.env = { ...recipe.env_vars };
261
- }
262
- if (recipe.description) entry.description = recipe.description;
263
- return entry;
264
- }
265
-
266
- /**
267
- * List of env-var names a recipe declares as needed. The wizard prints
268
- * these so the developer knows what to set in their .env / shell.
269
- */
270
- export function declaredEnvVars(recipe) {
271
- if (!recipe.env || typeof recipe.env !== 'object') return [];
272
- return Object.entries(recipe.env).map(([name, schema]) => ({
273
- name,
274
- description: schema?.description || '',
275
- required: schema?.required !== false,
276
- }));
277
- }
9
+ // Old name → New name (in connectors.js)
10
+ // loadRecipe → loadConnector
11
+ // recipeToServerEntry → connectorToServerEntry
12
+ // listRegistryRecipes → listRegistryConnectors
13
+ // declaredEnvVars → declaredEnvVars (unchanged)
14
+
15
+ export {
16
+ loadConnector as loadRecipe,
17
+ connectorToServerEntry as recipeToServerEntry,
18
+ listRegistryConnectors as listRegistryRecipes,
19
+ declaredEnvVars,
20
+ } from './connectors.js';