@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.
@@ -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';