@myvillage/cli 1.83.0 → 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 +1 -1
- package/src/commands/agent-local.js +95 -5
- package/src/commands/agent-mcp.js +59 -59
- package/src/commands/agent.js +8 -2
- package/src/index.js +24 -13
- package/src/utils/agent-recipes.js +83 -0
- package/src/utils/agent-scaffolder.js +6 -6
- package/src/utils/connectors.js +285 -0
- package/src/utils/recipes.js +18 -275
package/package.json
CHANGED
|
@@ -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-
|
|
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
|
-
|
|
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-
|
|
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-
|
|
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
|
-
|
|
12
|
-
|
|
13
|
-
|
|
11
|
+
loadConnector,
|
|
12
|
+
listRegistryConnectors,
|
|
13
|
+
connectorToServerEntry,
|
|
14
14
|
declaredEnvVars,
|
|
15
|
-
} from '../utils/
|
|
15
|
+
} from '../utils/connectors.js';
|
|
16
16
|
import { isAuthenticated } from '../utils/auth.js';
|
|
17
17
|
|
|
18
|
-
// ── add-
|
|
18
|
+
// ── add-connector ───────────────────────────────────────
|
|
19
19
|
//
|
|
20
20
|
// Always interactive. If a source is provided (built-in name, local file,
|
|
21
|
-
// or URL), the
|
|
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
|
|
25
|
+
// Use --yes to skip prompts and accept connector values verbatim (useful for
|
|
26
26
|
// scripts and CI).
|
|
27
27
|
|
|
28
|
-
export async function
|
|
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
|
|
37
|
+
let connector = null;
|
|
38
38
|
if (source) {
|
|
39
39
|
try {
|
|
40
|
-
|
|
41
|
-
console.log(brand.teal(` Loaded
|
|
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 (!
|
|
49
|
-
console.log(chalk.red(' ✗ --yes requires a
|
|
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
|
|
54
|
-
//
|
|
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
|
-
?
|
|
60
|
-
: await walkWizard(
|
|
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
|
|
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] =
|
|
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(
|
|
124
|
-
// If no
|
|
125
|
-
if (!
|
|
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
|
-
|
|
136
|
-
if (kind === 'remote')
|
|
137
|
-
else
|
|
135
|
+
connector = { name: '', description: '', env: {} };
|
|
136
|
+
if (kind === 'remote') connector.url = '';
|
|
137
|
+
else connector.command = 'npx';
|
|
138
138
|
}
|
|
139
139
|
|
|
140
|
-
const isRemote = !!
|
|
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:
|
|
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:
|
|
156
|
+
default: connector.description || '',
|
|
157
157
|
filter: v => v.trim(),
|
|
158
158
|
},
|
|
159
159
|
]);
|
|
160
160
|
|
|
161
161
|
if (isRemote) {
|
|
162
|
-
return await walkRemoteWizard(
|
|
162
|
+
return await walkRemoteWizard(connector, baseAnswers);
|
|
163
163
|
}
|
|
164
|
-
return await walkLocalWizard(
|
|
164
|
+
return await walkLocalWizard(connector, baseAnswers);
|
|
165
165
|
}
|
|
166
166
|
|
|
167
|
-
async function walkLocalWizard(
|
|
168
|
-
const argsDefault = (
|
|
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(
|
|
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
|
|
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] =
|
|
203
|
+
env[name] = connector.env?.[name] || { description: '', required: true };
|
|
204
204
|
}
|
|
205
205
|
|
|
206
206
|
return {
|
|
207
|
-
...
|
|
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(
|
|
216
|
-
const headerEntries = Object.entries(
|
|
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(
|
|
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]:
|
|
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] =
|
|
273
|
+
env[name] = connector.env?.[name] || { description: '', required: true };
|
|
274
274
|
}
|
|
275
275
|
}
|
|
276
276
|
|
|
277
277
|
return {
|
|
278
|
-
...
|
|
278
|
+
...connector,
|
|
279
279
|
...baseAnswers,
|
|
280
280
|
url: answers.url,
|
|
281
|
-
transport:
|
|
281
|
+
transport: connector.transport || 'http',
|
|
282
282
|
headers,
|
|
283
283
|
env,
|
|
284
284
|
};
|
|
285
285
|
}
|
|
286
286
|
|
|
287
|
-
function pickFirstEnvVarName(
|
|
288
|
-
if (!
|
|
289
|
-
return Object.keys(
|
|
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
|
-
// ──
|
|
327
|
+
// ── connectors ──────────────────────────────────────────
|
|
328
328
|
|
|
329
|
-
export async function
|
|
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
|
|
335
|
+
let connectors;
|
|
336
336
|
try {
|
|
337
|
-
|
|
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 (
|
|
344
|
-
console.log(brand.teal(' The
|
|
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
|
|
349
|
-
for (const
|
|
350
|
-
const kind =
|
|
351
|
-
console.log(` ${chalk.bold(
|
|
352
|
-
if (
|
|
353
|
-
console.log(` ${' '.repeat(14)}${chalk.dim('env: ' +
|
|
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
|
|
356
|
+
console.log(brand.teal('\n External connectors: install with `--from <path-or-url>`.\n'));
|
|
357
357
|
}
|
package/src/commands/agent.js
CHANGED
|
@@ -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
|
-
|
|
84
|
+
agentAddConnectorCommand,
|
|
84
85
|
agentListMcpCommand,
|
|
85
|
-
|
|
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
|
-
.
|
|
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-
|
|
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-
|
|
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-
|
|
551
|
-
.
|
|
552
|
-
.
|
|
553
|
-
.option('--
|
|
554
|
-
.
|
|
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
|
|
582
|
-
.action(
|
|
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 "
|
|
16
|
-
// add-
|
|
17
|
-
// Microsoft, Slack) instead connect once and are authorized per-agent
|
|
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/
|
|
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
|
|
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
|
+
}
|
package/src/utils/recipes.js
CHANGED
|
@@ -1,277 +1,20 @@
|
|
|
1
|
-
// ──
|
|
1
|
+
// ── Back-compat shim: recipes → connectors ──────────────
|
|
2
2
|
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
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
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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';
|