@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/package.json +1 -1
- package/src/agent-runtime/context.js +21 -95
- package/src/agent-runtime/loop.js +9 -7
- 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/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';
|