@myvillage/cli 1.83.0 → 1.85.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/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
+ }