@townco/agent 0.1.55 → 0.1.56

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.
@@ -0,0 +1,412 @@
1
+ import { mkdir } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import Kernel from "@onkernel/sdk";
4
+ import { tool } from "langchain";
5
+ import { z } from "zod";
6
+ let _kernelClient = null;
7
+ let _sessionId = null;
8
+ let _liveViewUrl = null;
9
+ function getKernelClient() {
10
+ if (_kernelClient) {
11
+ return _kernelClient;
12
+ }
13
+ const apiKey = process.env.KERNEL_API_KEY;
14
+ if (!apiKey) {
15
+ throw new Error("KERNEL_API_KEY environment variable is required to use the browser tool. " +
16
+ "Please set it to your Kernel API key from https://onkernel.com");
17
+ }
18
+ _kernelClient = new Kernel({ apiKey });
19
+ return _kernelClient;
20
+ }
21
+ async function ensureBrowserSession() {
22
+ if (_sessionId && _liveViewUrl) {
23
+ return { sessionId: _sessionId, liveViewUrl: _liveViewUrl };
24
+ }
25
+ const kernel = getKernelClient();
26
+ let kernelBrowser;
27
+ try {
28
+ kernelBrowser = await kernel.browsers.create();
29
+ }
30
+ catch (error) {
31
+ const msg = error instanceof Error ? error.message : String(error);
32
+ throw new Error(`Failed to create Kernel browser. Check KERNEL_API_KEY is valid. Error: ${msg}`);
33
+ }
34
+ _sessionId = kernelBrowser.session_id;
35
+ // biome-ignore lint/suspicious/noExplicitAny: browser_live_view_url not yet typed in @onkernel/sdk
36
+ _liveViewUrl = kernelBrowser.browser_live_view_url;
37
+ if (!_sessionId) {
38
+ throw new Error("Kernel browser created but no session_id returned.");
39
+ }
40
+ if (!_liveViewUrl) {
41
+ throw new Error("Kernel browser created but no browser_live_view_url returned.");
42
+ }
43
+ return { sessionId: _sessionId, liveViewUrl: _liveViewUrl };
44
+ }
45
+ // biome-ignore lint/suspicious/noExplicitAny: Kernel SDK playwright.execute not yet typed
46
+ async function executePlaywright(code) {
47
+ const kernel = getKernelClient();
48
+ const { sessionId } = await ensureBrowserSession();
49
+ // biome-ignore lint/suspicious/noExplicitAny: playwright.execute not yet typed in @onkernel/sdk
50
+ const response = await kernel.browsers.playwright.execute(sessionId, {
51
+ code,
52
+ timeout_sec: 60,
53
+ });
54
+ return response;
55
+ }
56
+ export function makeBrowserTools() {
57
+ // Browser Navigate tool
58
+ const browserNavigate = tool(async ({ url, waitUntil = "domcontentloaded", }) => {
59
+ try {
60
+ const { liveViewUrl } = await ensureBrowserSession();
61
+ const code = `
62
+ await page.goto('${url.replace(/'/g, "\\'")}', {
63
+ waitUntil: '${waitUntil}',
64
+ timeout: 30000
65
+ });
66
+ return { url: page.url(), title: await page.title() };
67
+ `;
68
+ const response = await executePlaywright(code);
69
+ return {
70
+ success: true,
71
+ url: response.result?.url,
72
+ title: response.result?.title,
73
+ liveViewUrl,
74
+ };
75
+ }
76
+ catch (error) {
77
+ const errorMessage = error instanceof Error ? error.message : "Unknown error occurred";
78
+ return {
79
+ success: false,
80
+ error: `Navigation failed: ${errorMessage}`,
81
+ ...(_liveViewUrl ? { liveViewUrl: _liveViewUrl } : {}),
82
+ };
83
+ }
84
+ }, {
85
+ name: "BrowserNavigate",
86
+ description: "Navigate a cloud browser to a specified URL using Kernel's browser-as-a-service.\n" +
87
+ "- Opens web pages in a cloud-hosted browser\n" +
88
+ "- Returns the final URL (after redirects), page title, and a liveViewUrl\n" +
89
+ "- The liveViewUrl allows viewing the browser session in real-time\n" +
90
+ "- Browser session persists across tool calls\n" +
91
+ "\n" +
92
+ "Usage notes:\n" +
93
+ " - First call creates a new browser session\n" +
94
+ " - Subsequent calls reuse the same browser\n" +
95
+ " - Share the liveViewUrl with users so they can watch the browser\n" +
96
+ " - Use BrowserClose when done to release resources\n",
97
+ schema: z.object({
98
+ url: z.string().url().describe("The URL to navigate to"),
99
+ waitUntil: z
100
+ .enum(["load", "domcontentloaded", "networkidle"])
101
+ .optional()
102
+ .default("domcontentloaded")
103
+ .describe("When to consider navigation complete"),
104
+ }),
105
+ });
106
+ browserNavigate.prettyName = "Browser Navigate";
107
+ browserNavigate.icon = "Globe";
108
+ // Browser Screenshot tool
109
+ const browserScreenshot = tool(async ({ fullPage = false, selector, }) => {
110
+ try {
111
+ const { liveViewUrl } = await ensureBrowserSession();
112
+ let code;
113
+ if (selector) {
114
+ code = `
115
+ const element = await page.$('${selector.replace(/'/g, "\\'")}');
116
+ if (!element) {
117
+ return { error: 'Element not found: ${selector.replace(/'/g, "\\'")}' };
118
+ }
119
+ const screenshot = await element.screenshot({ type: 'png' });
120
+ return { screenshot: screenshot.toString('base64') };
121
+ `;
122
+ }
123
+ else {
124
+ code = `
125
+ const screenshot = await page.screenshot({ type: 'png', fullPage: ${fullPage} });
126
+ return { screenshot: screenshot.toString('base64') };
127
+ `;
128
+ }
129
+ const response = await executePlaywright(code);
130
+ if (response.result?.error) {
131
+ return {
132
+ success: false,
133
+ error: response.result.error,
134
+ liveViewUrl,
135
+ };
136
+ }
137
+ // Save screenshot to local file instead of returning base64
138
+ const screenshotsDir = join(process.cwd(), "screenshots");
139
+ await mkdir(screenshotsDir, { recursive: true });
140
+ const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
141
+ const filename = `screenshot-${timestamp}.png`;
142
+ const filepath = join(screenshotsDir, filename);
143
+ const imageBuffer = Buffer.from(response.result?.screenshot, "base64");
144
+ await Bun.write(filepath, imageBuffer);
145
+ return {
146
+ success: true,
147
+ screenshotPath: filepath,
148
+ liveViewUrl,
149
+ };
150
+ }
151
+ catch (error) {
152
+ const errorMessage = error instanceof Error ? error.message : "Unknown error occurred";
153
+ return {
154
+ success: false,
155
+ error: `Screenshot failed: ${errorMessage}`,
156
+ ...(_liveViewUrl ? { liveViewUrl: _liveViewUrl } : {}),
157
+ };
158
+ }
159
+ }, {
160
+ name: "BrowserScreenshot",
161
+ description: "Take a screenshot of the current browser page or a specific element.\n" +
162
+ "- Captures the current viewport or full page\n" +
163
+ "- Can target specific elements using CSS selectors\n" +
164
+ "- Saves the screenshot to a local file and returns the file path\n" +
165
+ "\n" +
166
+ "Usage notes:\n" +
167
+ " - Use fullPage=true to capture the entire scrollable page\n" +
168
+ " - Use selector to capture a specific element\n" +
169
+ " - Requires an active browser session (call BrowserNavigate first)\n" +
170
+ " - Screenshots are saved in the 'screenshots' folder in the current working directory\n",
171
+ schema: z.object({
172
+ fullPage: z
173
+ .boolean()
174
+ .optional()
175
+ .default(false)
176
+ .describe("Whether to capture the full scrollable page"),
177
+ selector: z
178
+ .string()
179
+ .optional()
180
+ .describe("CSS selector of element to screenshot (captures whole page if not provided)"),
181
+ }),
182
+ });
183
+ browserScreenshot.prettyName = "Browser Screenshot";
184
+ browserScreenshot.icon = "Camera";
185
+ // Browser Extract Content tool
186
+ const browserExtract = tool(async ({ selector, extractType = "text", }) => {
187
+ try {
188
+ const { liveViewUrl } = await ensureBrowserSession();
189
+ let code;
190
+ if (selector) {
191
+ if (extractType === "html") {
192
+ code = `
193
+ const element = await page.$('${selector.replace(/'/g, "\\'")}');
194
+ if (!element) {
195
+ return { error: 'Element not found: ${selector.replace(/'/g, "\\'")}' };
196
+ }
197
+ const content = await element.innerHTML();
198
+ return { content, url: page.url(), title: await page.title() };
199
+ `;
200
+ }
201
+ else {
202
+ code = `
203
+ const element = await page.$('${selector.replace(/'/g, "\\'")}');
204
+ if (!element) {
205
+ return { error: 'Element not found: ${selector.replace(/'/g, "\\'")}' };
206
+ }
207
+ const content = await element.innerText();
208
+ return { content, url: page.url(), title: await page.title() };
209
+ `;
210
+ }
211
+ }
212
+ else {
213
+ if (extractType === "html") {
214
+ code = `
215
+ const content = await page.content();
216
+ return { content, url: page.url(), title: await page.title() };
217
+ `;
218
+ }
219
+ else {
220
+ code = `
221
+ const content = await page.innerText('body');
222
+ return { content, url: page.url(), title: await page.title() };
223
+ `;
224
+ }
225
+ }
226
+ const response = await executePlaywright(code);
227
+ if (response.result?.error) {
228
+ return {
229
+ success: false,
230
+ error: response.result.error,
231
+ liveViewUrl,
232
+ };
233
+ }
234
+ return {
235
+ success: true,
236
+ content: response.result?.content,
237
+ url: response.result?.url,
238
+ title: response.result?.title,
239
+ liveViewUrl,
240
+ };
241
+ }
242
+ catch (error) {
243
+ const errorMessage = error instanceof Error ? error.message : "Unknown error occurred";
244
+ return {
245
+ success: false,
246
+ error: `Content extraction failed: ${errorMessage}`,
247
+ ...(_liveViewUrl ? { liveViewUrl: _liveViewUrl } : {}),
248
+ };
249
+ }
250
+ }, {
251
+ name: "BrowserExtract",
252
+ description: "Extract text or HTML content from the current page or a specific element.\n" +
253
+ "- Gets readable text content from web pages\n" +
254
+ "- Can extract HTML for structured data\n" +
255
+ "- Supports CSS selectors for targeting specific elements\n" +
256
+ "\n" +
257
+ "Usage notes:\n" +
258
+ " - Default extracts text from the entire page body\n" +
259
+ " - Use selector to target specific page sections\n" +
260
+ " - Use extractType='html' when you need the raw HTML structure\n",
261
+ schema: z.object({
262
+ selector: z
263
+ .string()
264
+ .optional()
265
+ .describe("CSS selector of element to extract from (extracts from body if not provided)"),
266
+ extractType: z
267
+ .enum(["text", "html"])
268
+ .optional()
269
+ .default("text")
270
+ .describe("Type of content to extract"),
271
+ }),
272
+ });
273
+ browserExtract.prettyName = "Browser Extract";
274
+ browserExtract.icon = "FileText";
275
+ // Browser Click tool
276
+ const browserClick = tool(async ({ selector, button = "left" }) => {
277
+ try {
278
+ const { liveViewUrl } = await ensureBrowserSession();
279
+ const code = `
280
+ await page.click('${selector.replace(/'/g, "\\'")}', {
281
+ button: '${button}',
282
+ timeout: 10000,
283
+ });
284
+ await page.waitForTimeout(500);
285
+ return { success: true };
286
+ `;
287
+ await executePlaywright(code);
288
+ return {
289
+ success: true,
290
+ message: `Clicked element: ${selector}`,
291
+ liveViewUrl,
292
+ };
293
+ }
294
+ catch (error) {
295
+ const errorMessage = error instanceof Error ? error.message : "Unknown error occurred";
296
+ return {
297
+ success: false,
298
+ error: `Click failed: ${errorMessage}`,
299
+ ...(_liveViewUrl ? { liveViewUrl: _liveViewUrl } : {}),
300
+ };
301
+ }
302
+ }, {
303
+ name: "BrowserClick",
304
+ description: "Click on an element in the current browser page.\n" +
305
+ "- Supports left, right, and middle mouse buttons\n" +
306
+ "- Waits for the element to be visible before clicking\n" +
307
+ "- Useful for interacting with buttons, links, and other clickable elements\n" +
308
+ "\n" +
309
+ "Usage notes:\n" +
310
+ " - Provide a CSS selector to identify the target element\n" +
311
+ " - The tool waits briefly after clicking for page updates\n",
312
+ schema: z.object({
313
+ selector: z.string().describe("CSS selector of the element to click"),
314
+ button: z
315
+ .enum(["left", "right", "middle"])
316
+ .optional()
317
+ .default("left")
318
+ .describe("Which mouse button to use"),
319
+ }),
320
+ });
321
+ browserClick.prettyName = "Browser Click";
322
+ browserClick.icon = "MousePointer";
323
+ // Browser Type tool
324
+ const browserType = tool(async ({ selector, text, pressEnter = false, }) => {
325
+ try {
326
+ const { liveViewUrl } = await ensureBrowserSession();
327
+ const code = `
328
+ await page.fill('${selector.replace(/'/g, "\\'")}', '${text.replace(/'/g, "\\'")}', { timeout: 10000 });
329
+ ${pressEnter ? `await page.press('${selector.replace(/'/g, "\\'")}', 'Enter'); await page.waitForTimeout(500);` : ""}
330
+ return { success: true };
331
+ `;
332
+ await executePlaywright(code);
333
+ return {
334
+ success: true,
335
+ message: `Typed text into element: ${selector}${pressEnter ? " (pressed Enter)" : ""}`,
336
+ liveViewUrl,
337
+ };
338
+ }
339
+ catch (error) {
340
+ const errorMessage = error instanceof Error ? error.message : "Unknown error occurred";
341
+ return {
342
+ success: false,
343
+ error: `Type failed: ${errorMessage}`,
344
+ ...(_liveViewUrl ? { liveViewUrl: _liveViewUrl } : {}),
345
+ };
346
+ }
347
+ }, {
348
+ name: "BrowserType",
349
+ description: "Type text into an input field in the current browser page.\n" +
350
+ "- Fills text into input fields, textareas, and contenteditable elements\n" +
351
+ "- Can optionally press Enter after typing (useful for search forms)\n" +
352
+ "- Clears existing content before typing\n" +
353
+ "\n" +
354
+ "Usage notes:\n" +
355
+ " - Provide a CSS selector to identify the input element\n" +
356
+ " - Set pressEnter=true to submit forms after typing\n",
357
+ schema: z.object({
358
+ selector: z.string().describe("CSS selector of the input element"),
359
+ text: z.string().describe("The text to type into the element"),
360
+ pressEnter: z
361
+ .boolean()
362
+ .optional()
363
+ .default(false)
364
+ .describe("Whether to press Enter after typing"),
365
+ }),
366
+ });
367
+ browserType.prettyName = "Browser Type";
368
+ browserType.icon = "Type";
369
+ // Browser Close tool
370
+ const browserClose = tool(async () => {
371
+ try {
372
+ if (_sessionId) {
373
+ const kernel = getKernelClient();
374
+ await kernel.browsers.deleteByID(_sessionId);
375
+ }
376
+ _sessionId = null;
377
+ _liveViewUrl = null;
378
+ return {
379
+ success: true,
380
+ message: "Browser session closed successfully",
381
+ };
382
+ }
383
+ catch (error) {
384
+ const errorMessage = error instanceof Error ? error.message : "Unknown error occurred";
385
+ return {
386
+ success: false,
387
+ error: `Close failed: ${errorMessage}`,
388
+ };
389
+ }
390
+ }, {
391
+ name: "BrowserClose",
392
+ description: "Close the current browser session and release cloud resources.\n" +
393
+ "- Terminates the active browser connection\n" +
394
+ "- Frees up cloud browser resources\n" +
395
+ "- Call this when done with browser automation\n" +
396
+ "\n" +
397
+ "Usage notes:\n" +
398
+ " - A new browser session will be created on the next BrowserNavigate call\n" +
399
+ " - Calling this when no browser is active has no effect\n",
400
+ schema: z.object({}),
401
+ });
402
+ browserClose.prettyName = "Browser Close";
403
+ browserClose.icon = "X";
404
+ return [
405
+ browserNavigate,
406
+ browserScreenshot,
407
+ browserExtract,
408
+ browserClick,
409
+ browserType,
410
+ browserClose,
411
+ ];
412
+ }
@@ -1,6 +1,6 @@
1
1
  import { z } from "zod";
2
2
  /** Built-in tool types. */
3
- export declare const zBuiltInToolType: z.ZodUnion<readonly [z.ZodLiteral<"todo_write">, z.ZodLiteral<"get_weather">, z.ZodLiteral<"web_search">, z.ZodLiteral<"filesystem">, z.ZodLiteral<"generate_image">]>;
3
+ export declare const zBuiltInToolType: z.ZodUnion<readonly [z.ZodLiteral<"todo_write">, z.ZodLiteral<"get_weather">, z.ZodLiteral<"web_search">, z.ZodLiteral<"filesystem">, z.ZodLiteral<"generate_image">, z.ZodLiteral<"browser">]>;
4
4
  /** Subagent configuration schema for Task tools. */
5
5
  export declare const zSubagentConfig: z.ZodObject<{
6
6
  agentName: z.ZodString;
@@ -23,7 +23,7 @@ declare const zDirectTool: z.ZodObject<{
23
23
  }, z.core.$strip>>>;
24
24
  }, z.core.$strip>;
25
25
  /** Tool type - can be a built-in tool string or custom tool object. */
26
- export declare const zToolType: z.ZodUnion<readonly [z.ZodUnion<readonly [z.ZodLiteral<"todo_write">, z.ZodLiteral<"get_weather">, z.ZodLiteral<"web_search">, z.ZodLiteral<"filesystem">, z.ZodLiteral<"generate_image">]>, z.ZodObject<{
26
+ export declare const zToolType: z.ZodUnion<readonly [z.ZodUnion<readonly [z.ZodLiteral<"todo_write">, z.ZodLiteral<"get_weather">, z.ZodLiteral<"web_search">, z.ZodLiteral<"filesystem">, z.ZodLiteral<"generate_image">, z.ZodLiteral<"browser">]>, z.ZodObject<{
27
27
  type: z.ZodLiteral<"custom">;
28
28
  modulePath: z.ZodString;
29
29
  }, z.core.$strip>, z.ZodObject<{
@@ -6,6 +6,7 @@ export const zBuiltInToolType = z.union([
6
6
  z.literal("web_search"),
7
7
  z.literal("filesystem"),
8
8
  z.literal("generate_image"),
9
+ z.literal("browser"),
9
10
  ]);
10
11
  /** Custom tool schema (loaded from module path). */
11
12
  const zCustomTool = z.object({
@@ -1,7 +1,7 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import { mkdir, stat, writeFile } from "node:fs/promises";
3
3
  import { join } from "node:path";
4
- import { generateBinTs, generateIndexTs, getTemplateVars } from "../templates";
4
+ import { generateBinTs, generateEnvExample, generateIndexTs, generateReadme, getTemplateVars, } from "../templates";
5
5
  import { copyGuiApp } from "./copy-gui";
6
6
  import { copyTuiApp } from "./copy-tui";
7
7
  /**
@@ -34,7 +34,13 @@ export async function scaffoldAgent(options) {
34
34
  const files = [
35
35
  { path: "index.ts", content: await generateIndexTs(vars) },
36
36
  { path: "bin.ts", content: generateBinTs(), executable: true },
37
+ { path: "README.md", content: generateReadme(vars) },
37
38
  ];
39
+ // Add .env.example if needed
40
+ const envExample = generateEnvExample(vars);
41
+ if (envExample) {
42
+ files.push({ path: ".env.example", content: envExample });
43
+ }
38
44
  // Write all files
39
45
  for (const file of files) {
40
46
  const filePath = join(agentPath, file.path);
@@ -9,6 +9,8 @@ The following built-in tools are available:
9
9
  - `todo_write`: Task management and planning tool
10
10
  - `web_search`: Exa-powered web search (requires EXA_API_KEY)
11
11
  - `filesystem`: Read, write, and search files in the project directory
12
+ - `generate_image`: Image generation using Google Gemini (requires GEMINI_API_KEY or GOOGLE_API_KEY)
13
+ - `browser`: Cloud browser automation using Kernel (requires KERNEL_API_KEY)
12
14
 
13
15
  To use built-in tools, simply add them to the `tools` array in your agent definition:
14
16
  ```
@@ -23,6 +23,11 @@ declare class AgentTelemetry {
23
23
  private serviceName;
24
24
  private baseAttributes;
25
25
  configure(config: TelemetryConfig): void;
26
+ /**
27
+ * Update base attributes that will be added to all future spans
28
+ * @param attributes - Attributes to merge with existing base attributes
29
+ */
30
+ setBaseAttributes(attributes: Record<string, string | number>): void;
26
31
  /**
27
32
  * Start a new span
28
33
  * @param name - Span name
@@ -30,6 +30,16 @@ class AgentTelemetry {
30
30
  }
31
31
  }
32
32
  }
33
+ /**
34
+ * Update base attributes that will be added to all future spans
35
+ * @param attributes - Attributes to merge with existing base attributes
36
+ */
37
+ setBaseAttributes(attributes) {
38
+ this.baseAttributes = {
39
+ ...this.baseAttributes,
40
+ ...attributes,
41
+ };
42
+ }
33
43
  /**
34
44
  * Start a new span
35
45
  * @param name - Span name
@@ -20,6 +20,8 @@ export interface TemplateVars {
20
20
  }>;
21
21
  systemPrompt: string | null;
22
22
  hasWebSearch: boolean;
23
+ hasGenerateImage: boolean;
24
+ hasBrowser: boolean;
23
25
  hooks?: Array<{
24
26
  type: "context_size";
25
27
  setting?: {
@@ -7,6 +7,8 @@ export function getTemplateVars(name, definition) {
7
7
  tools,
8
8
  systemPrompt: definition.systemPrompt,
9
9
  hasWebSearch: tools.some((tool) => typeof tool === "string" && tool === "web_search"),
10
+ hasGenerateImage: tools.some((tool) => typeof tool === "string" && tool === "generate_image"),
11
+ hasBrowser: tools.some((tool) => typeof tool === "string" && tool === "browser"),
10
12
  hooks: definition.hooks,
11
13
  };
12
14
  if (definition.displayName) {
@@ -148,9 +150,15 @@ export function generateReadme(vars) {
148
150
  })
149
151
  .join(", ")
150
152
  : "None";
151
- const envVars = vars.hasWebSearch
152
- ? "\n- `EXA_API_KEY`: Required for web_search tool"
153
- : "";
153
+ const envVars = [
154
+ vars.hasWebSearch ? "- `EXA_API_KEY`: Required for web_search tool" : "",
155
+ vars.hasGenerateImage
156
+ ? "- `GEMINI_API_KEY` (or `GOOGLE_API_KEY`): Required for generate_image tool"
157
+ : "",
158
+ vars.hasBrowser ? "- `KERNEL_API_KEY`: Required for browser tool" : "",
159
+ ]
160
+ .filter(Boolean)
161
+ .join("\n");
154
162
  return `# ${vars.name}
155
163
 
156
164
  Agent created with \`town create\`.
@@ -211,10 +219,24 @@ bun run build
211
219
  `;
212
220
  }
213
221
  export function generateEnvExample(vars) {
214
- if (!vars.hasWebSearch) {
222
+ const envs = [];
223
+ if (vars.hasWebSearch) {
224
+ envs.push("# Required for web_search tool");
225
+ envs.push("EXA_API_KEY=your_exa_api_key_here");
226
+ envs.push("");
227
+ }
228
+ if (vars.hasGenerateImage) {
229
+ envs.push("# Required for generate_image tool");
230
+ envs.push("GEMINI_API_KEY=your_gemini_api_key_here");
231
+ envs.push("");
232
+ }
233
+ if (vars.hasBrowser) {
234
+ envs.push("# Required for browser tool");
235
+ envs.push("KERNEL_API_KEY=your_kernel_api_key_here");
236
+ envs.push("");
237
+ }
238
+ if (envs.length === 0) {
215
239
  return null;
216
240
  }
217
- return `# Required for web_search tool
218
- EXA_API_KEY=your_exa_api_key_here
219
- `;
241
+ return envs.join("\n");
220
242
  }