@myvillage/cli 1.6.0 → 1.6.2

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@myvillage/cli",
3
- "version": "1.6.0",
3
+ "version": "1.6.2",
4
4
  "description": "MyVillageOS CLI for community developers",
5
5
  "type": "module",
6
6
  "bin": {
@@ -27,6 +27,7 @@
27
27
  "license": "MIT",
28
28
  "dependencies": {
29
29
  "@ai-sdk/anthropic": "^1.0.0",
30
+ "@ai-sdk/openai": "^3.0.33",
30
31
  "ai": "^4.0.0",
31
32
  "axios": "^1.6.2",
32
33
  "chalk": "^5.3.0",
@@ -4,6 +4,7 @@
4
4
 
5
5
  import { generateText } from 'ai';
6
6
  import { createAnthropic } from '@ai-sdk/anthropic';
7
+ import { createOpenAI } from '@ai-sdk/openai';
7
8
  import { readFileSync, writeFileSync, appendFileSync, existsSync, mkdirSync } from 'fs';
8
9
  import { join } from 'path';
9
10
  import { homedir } from 'os';
@@ -18,24 +19,47 @@ export async function agentLoop(agentName, { signal }) {
18
19
  const configPath = join(agentDir, 'agent.config.yaml');
19
20
  const config = parseYaml(readFileSync(configPath, 'utf-8'));
20
21
 
21
- // Read API key from global config or environment
22
+ // Determine provider and resolve API key
23
+ const provider = config.brain?.provider || 'anthropic';
22
24
  const globalConfigPath = join(homedir(), '.myvillage', 'config.json');
23
- let apiKey = process.env.ANTHROPIC_API_KEY;
24
- if (!apiKey && existsSync(globalConfigPath)) {
25
- try {
26
- const globalConfig = JSON.parse(readFileSync(globalConfigPath, 'utf-8'));
27
- apiKey = globalConfig.anthropicApiKey;
28
- } catch { /* ignore */ }
25
+ let apiKey;
26
+
27
+ if (provider === 'openai') {
28
+ apiKey = process.env.OPENAI_API_KEY;
29
+ if (!apiKey && existsSync(globalConfigPath)) {
30
+ try {
31
+ const globalConfig = JSON.parse(readFileSync(globalConfigPath, 'utf-8'));
32
+ apiKey = globalConfig.openaiApiKey;
33
+ } catch { /* ignore */ }
34
+ }
35
+ if (!apiKey) {
36
+ logActivity(agentDir, { type: 'error', error: 'No OpenAI API key configured' });
37
+ throw new Error('No OpenAI API key configured. Set OPENAI_API_KEY or run: myvillage agent start ' + agentName);
38
+ }
39
+ } else {
40
+ apiKey = process.env.ANTHROPIC_API_KEY;
41
+ if (!apiKey && existsSync(globalConfigPath)) {
42
+ try {
43
+ const globalConfig = JSON.parse(readFileSync(globalConfigPath, 'utf-8'));
44
+ apiKey = globalConfig.anthropicApiKey;
45
+ } catch { /* ignore */ }
46
+ }
47
+ if (!apiKey) {
48
+ logActivity(agentDir, { type: 'error', error: 'No Anthropic API key configured' });
49
+ throw new Error('No Anthropic API key configured. Set ANTHROPIC_API_KEY or run: myvillage agent start ' + agentName);
50
+ }
29
51
  }
30
52
 
31
- if (!apiKey) {
32
- logActivity(agentDir, { type: 'error', error: 'No Anthropic API key configured' });
33
- throw new Error('No Anthropic API key configured');
53
+ // Create provider and model
54
+ const modelId = config.brain?.model || (provider === 'openai' ? 'gpt-4o' : 'claude-sonnet-4-5-20250929');
55
+ let model;
56
+ if (provider === 'openai') {
57
+ const openai = createOpenAI({ apiKey });
58
+ model = openai(modelId);
59
+ } else {
60
+ const anthropic = createAnthropic({ apiKey });
61
+ model = anthropic(modelId);
34
62
  }
35
-
36
- const anthropic = createAnthropic({ apiKey });
37
- const modelId = config.brain?.model || 'claude-sonnet-4-5-20250929';
38
- const model = anthropic(modelId);
39
63
  const maxTokens = config.brain?.max_tokens_per_loop || 1000;
40
64
 
41
65
  // Initialize MCP tools
@@ -80,6 +80,35 @@ export async function agentCreateLocalCommand() {
80
80
  { name: 'Browser', value: 'browser' },
81
81
  ],
82
82
  },
83
+ {
84
+ type: 'list',
85
+ name: 'provider',
86
+ message: 'AI provider:',
87
+ choices: [
88
+ { name: 'Anthropic (Claude)', value: 'anthropic' },
89
+ { name: 'OpenAI (GPT)', value: 'openai' },
90
+ ],
91
+ default: 'anthropic',
92
+ },
93
+ {
94
+ type: 'list',
95
+ name: 'model',
96
+ message: 'Model:',
97
+ choices: (prev) => {
98
+ if (prev.provider === 'openai') {
99
+ return [
100
+ { name: 'gpt-4o (recommended)', value: 'gpt-4o' },
101
+ { name: 'gpt-4o-mini', value: 'gpt-4o-mini' },
102
+ { name: 'gpt-4.1', value: 'gpt-4.1' },
103
+ { name: 'gpt-4.1-mini', value: 'gpt-4.1-mini' },
104
+ ];
105
+ }
106
+ return [
107
+ { name: 'Claude Sonnet 4.5 (recommended)', value: 'claude-sonnet-4-5-20250929' },
108
+ { name: 'Claude Haiku 3.5', value: 'claude-3-5-haiku-20241022' },
109
+ ];
110
+ },
111
+ },
83
112
  {
84
113
  type: 'list',
85
114
  name: 'checkInInterval',
@@ -118,6 +147,8 @@ export async function agentCreateLocalCommand() {
118
147
  description: answers.description.trim(),
119
148
  tools,
120
149
  checkInInterval: answers.checkInInterval,
150
+ provider: answers.provider,
151
+ model: answers.model,
121
152
  });
122
153
 
123
154
  spinner.succeed('Agent scaffolded!');
@@ -154,25 +185,43 @@ export async function agentStartCommand(name) {
154
185
  return;
155
186
  }
156
187
 
157
- // Check for Anthropic API key
158
- const config = getConfig();
159
- let apiKey = config.anthropicApiKey || process.env.ANTHROPIC_API_KEY;
188
+ // Read agent's configured provider and check for the correct API key
189
+ const agentConfig = readAgentConfig(name);
190
+ const provider = agentConfig?.brain?.provider || 'anthropic';
191
+ const cliConfig = getConfig();
192
+
193
+ let apiKey;
194
+ let configKeyName;
195
+ let providerLabel;
196
+ let consoleUrl;
197
+
198
+ if (provider === 'openai') {
199
+ apiKey = cliConfig.openaiApiKey || process.env.OPENAI_API_KEY;
200
+ configKeyName = 'openaiApiKey';
201
+ providerLabel = 'OpenAI';
202
+ consoleUrl = 'https://platform.openai.com/api-keys';
203
+ } else {
204
+ apiKey = cliConfig.anthropicApiKey || process.env.ANTHROPIC_API_KEY;
205
+ configKeyName = 'anthropicApiKey';
206
+ providerLabel = 'Anthropic';
207
+ consoleUrl = 'https://console.anthropic.com';
208
+ }
160
209
 
161
210
  if (!apiKey) {
162
- console.log(chalk.yellow('\n Anthropic API key required for local agents.'));
163
- console.log(brand.teal(' Get yours at: https://console.anthropic.com\n'));
211
+ console.log(chalk.yellow(`\n ${providerLabel} API key required for this agent.`));
212
+ console.log(brand.teal(` Get yours at: ${consoleUrl}\n`));
164
213
 
165
214
  try {
166
215
  const { key } = await inquirer.prompt([{
167
216
  type: 'password',
168
217
  name: 'key',
169
- message: 'Anthropic API key:',
218
+ message: `${providerLabel} API key:`,
170
219
  mask: '*',
171
220
  validate: (input) => input.trim().length > 0 || 'API key is required',
172
221
  }]);
173
222
 
174
223
  apiKey = key.trim();
175
- setConfig({ anthropicApiKey: apiKey });
224
+ setConfig({ [configKeyName]: apiKey });
176
225
  console.log(brand.green(' \u2713 API key saved.\n'));
177
226
  } catch (err) {
178
227
  if (err.isTtyError) {
@@ -183,7 +232,6 @@ export async function agentStartCommand(name) {
183
232
  }
184
233
 
185
234
  // Register on MAN if first start
186
- const agentConfig = readAgentConfig(name);
187
235
  if (!agentConfig.man?.agent_id) {
188
236
  const regSpinner = villageSpinner('Registering agent on the MAN network...').start();
189
237
  try {
@@ -242,7 +242,7 @@ export async function agentViewCommand(handle) {
242
242
  const agent = await resolveAgentHandle(handle);
243
243
 
244
244
  if (!agent) {
245
- spinner.fail(`Agent @${handle} not found. Run 'myvillage agent' to see your agents.`);
245
+ spinner.fail(`Agent @${handle} not found. Run 'myvillage agent' to see your agents, or 'myvillage agent start ${handle}' if you haven't started it yet.`);
246
246
  return;
247
247
  }
248
248
 
@@ -271,7 +271,7 @@ export async function agentEditCommand(handle) {
271
271
  const agent = await resolveAgentHandle(handle);
272
272
 
273
273
  if (!agent) {
274
- spinner.fail(`Agent @${handle} not found. Run 'myvillage agent' to see your agents.`);
274
+ spinner.fail(`Agent @${handle} not found. Run 'myvillage agent' to see your agents, or 'myvillage agent start ${handle}' if you haven't started it yet.`);
275
275
  return;
276
276
  }
277
277
 
@@ -406,7 +406,7 @@ export async function agentDeleteCommand(handle) {
406
406
  const agent = await resolveAgentHandle(handle);
407
407
 
408
408
  if (!agent) {
409
- console.log(chalk.red(` \u2717 Agent @${handle} not found. Run 'myvillage agent' to see your agents.\n`));
409
+ console.log(chalk.red(` \u2717 Agent @${handle} not found. Run 'myvillage agent' to see your agents, or 'myvillage agent start ${handle}' if you haven't started it yet.\n`));
410
410
  return;
411
411
  }
412
412
 
@@ -446,7 +446,7 @@ export async function agentJoinCommand(handle, slug) {
446
446
  const agent = await resolveAgentHandle(handle);
447
447
 
448
448
  if (!agent) {
449
- spinner.fail(`Agent @${handle} not found. Run 'myvillage agent' to see your agents.`);
449
+ spinner.fail(`Agent @${handle} not found. Run 'myvillage agent' to see your agents, or 'myvillage agent start ${handle}' if you haven't started it yet.`);
450
450
  return;
451
451
  }
452
452
 
@@ -470,7 +470,7 @@ export async function agentLeaveCommand(handle, slug) {
470
470
  const agent = await resolveAgentHandle(handle);
471
471
 
472
472
  if (!agent) {
473
- spinner.fail(`Agent @${handle} not found. Run 'myvillage agent' to see your agents.`);
473
+ spinner.fail(`Agent @${handle} not found. Run 'myvillage agent' to see your agents, or 'myvillage agent start ${handle}' if you haven't started it yet.`);
474
474
  return;
475
475
  }
476
476
 
@@ -492,7 +492,7 @@ export async function agentRunCommand(handle, options = {}) {
492
492
  const agent = await resolveAgentHandle(handle);
493
493
 
494
494
  if (!agent) {
495
- console.log(chalk.red(` \u2717 Agent @${handle} not found. Run 'myvillage agent' to see your agents.\n`));
495
+ console.log(chalk.red(` \u2717 Agent @${handle} not found. Run 'myvillage agent' to see your agents, or 'myvillage agent start ${handle}' if you haven't started it yet.\n`));
496
496
  return;
497
497
  }
498
498
 
@@ -1,22 +1,12 @@
1
1
  import chalk from 'chalk';
2
2
  import inquirer from 'inquirer';
3
- import { z } from 'zod';
4
3
  import { isAuthenticated } from '../utils/auth.js';
5
4
  import { brand, villageSpinner } from '../utils/brand.js';
6
- import { createPost } from '../utils/api.js';
5
+ import { createPost, structureCheckin } from '../utils/api.js';
7
6
  import { resolveVillageContext } from '../utils/village-resolver.js';
8
- import { getAnthropicProvider } from '../utils/ai.js';
9
7
  import { formatCheckinSummary } from '../utils/formatters.js';
10
8
  import { discoverLogic } from './discover.js';
11
9
 
12
- const extractionSchema = z.object({
13
- themes: z.array(z.string()).describe('Key themes from the check-in (1-4 short labels)'),
14
- needs: z.array(z.string()).describe('Things the village needs help with (0-3 short labels)'),
15
- offers: z.array(z.string()).describe('Things the village can offer others (0-3 short labels)'),
16
- buildTrack: z.string().optional().describe('Primary build track if mentioned: game, portal, agent, data, robot, or general'),
17
- summary: z.string().describe('A 2-3 sentence summary combining all responses into a cohesive check-in update'),
18
- });
19
-
20
10
  export async function checkinCommand(options) {
21
11
  if (!isAuthenticated()) {
22
12
  console.log(chalk.red(" \u2717 Authentication required. Run 'myvillage login' first."));
@@ -52,42 +42,19 @@ export async function checkinCommand(options) {
52
42
  },
53
43
  ]);
54
44
 
55
- // AI structuring
45
+ // AI structuring (backend-proxied)
56
46
  const spinner = villageSpinner('Organizing your check-in...').start();
57
47
 
58
48
  let extracted;
59
49
  try {
60
- const anthropic = await getAnthropicProvider();
61
- const { generateText } = await import('ai');
62
-
63
- const userContent = [
64
- `What we worked on: ${answers.work}`,
65
- answers.stuck ? `Problems we're stuck on: ${answers.stuck}` : null,
66
- answers.share ? `What we want to share: ${answers.share}` : null,
67
- ].filter(Boolean).join('\n');
68
-
69
- const result = await generateText({
70
- model: anthropic('claude-haiku-4-5-20251001'),
71
- system: `You are a community network coordinator. Extract structured metadata from a village check-in and write a concise summary. Always call the extract_checkin tool with your analysis.`,
72
- messages: [{ role: 'user', content: userContent }],
73
- tools: {
74
- extract_checkin: {
75
- description: 'Extract structured metadata from a village check-in',
76
- parameters: extractionSchema,
77
- },
78
- },
79
- toolChoice: { type: 'tool', toolName: 'extract_checkin' },
80
- maxTokens: 500,
50
+ extracted = await structureCheckin({
51
+ work: answers.work,
52
+ stuck: answers.stuck || undefined,
53
+ share: answers.share || undefined,
81
54
  });
82
-
83
- const toolCall = result.toolCalls?.[0];
84
- if (!toolCall?.args) {
85
- throw new Error('AI did not return structured data');
86
- }
87
- extracted = toolCall.args;
88
55
  spinner.succeed('Check-in organized');
89
56
  } catch (err) {
90
- spinner.fail('AI structuring failed posting raw check-in');
57
+ spinner.fail('AI structuring failed \u2014 posting raw check-in');
91
58
  // Fallback: post without AI structuring
92
59
  extracted = {
93
60
  themes: [],
@@ -24,7 +24,7 @@ export async function commentCommand(postId, options) {
24
24
  agentsSpinner.stop();
25
25
 
26
26
  if (!agent) {
27
- console.log(chalk.red(` Agent @${options.as} not found. Run 'myvillage agent' to see your agents.`));
27
+ console.log(chalk.red(` \u2717 Agent @${options.as} not found. Run 'myvillage agent' to see your agents, or 'myvillage agent start ${options.as}' if you haven't started it yet.`));
28
28
  return;
29
29
  }
30
30
  agentProfileId = agent.id;
@@ -88,7 +88,7 @@ export async function postCreateCommand(options = {}) {
88
88
  agentsSpinner.stop();
89
89
 
90
90
  if (!agent) {
91
- console.log(chalk.red(` Agent @${options.as} not found. Run 'myvillage agent' to see your agents.`));
91
+ console.log(chalk.red(` \u2717 Agent @${options.as} not found. Run 'myvillage agent' to see your agents, or 'myvillage agent start ${options.as}' if you haven't started it yet.`));
92
92
  return;
93
93
  }
94
94
  agentProfileId = agent.id;
@@ -1,8 +1,8 @@
1
1
  import chalk from 'chalk';
2
2
  import ora from 'ora';
3
3
  import { villageSpinner, brand } from '../utils/brand.js';
4
- import { isAuthenticated, loadCredentials } from '../utils/auth.js';
5
- import { getProfile, getProfilePosts } from '../utils/api.js';
4
+ import { isAuthenticated, loadCredentials, saveCredentials } from '../utils/auth.js';
5
+ import { getProfile, getProfilePosts, getUserInfo, getAccessToken } from '../utils/api.js';
6
6
  import { formatProfile, formatPostList, formatPagination } from '../utils/formatters.js';
7
7
 
8
8
  export async function profileCommand(handle, options) {
@@ -13,18 +13,47 @@ export async function profileCommand(handle, options) {
13
13
 
14
14
  // Resolve handle: default to the logged-in user's villagerId
15
15
  let target = handle;
16
- if (!target) {
16
+ const isSelf = !target;
17
+ if (isSelf) {
17
18
  const creds = loadCredentials();
18
19
  target = creds?.villager_id;
19
20
  if (!target) {
20
- console.log(chalk.red(' No villager ID found. Try logging out and back in, or provide a handle: myvillage profile <handle>'));
21
+ console.log(chalk.red(' \u2717 No villager ID found. Try logging out and back in, or provide a handle: myvillage profile <handle>'));
21
22
  return;
22
23
  }
23
24
  }
24
25
  const spinner = villageSpinner('Loading profile...').start();
25
26
 
26
27
  try {
27
- const result = await getProfile(target);
28
+ let result;
29
+ try {
30
+ result = await getProfile(target);
31
+ } catch (err) {
32
+ // If own profile lookup fails with 404, refresh userinfo and retry
33
+ if (isSelf && err.response?.status === 404) {
34
+ spinner.text = 'Refreshing user info...';
35
+ const accessToken = getAccessToken();
36
+ if (accessToken) {
37
+ const userInfo = await getUserInfo(accessToken);
38
+ const freshId = userInfo.villager?.villagerId;
39
+ if (freshId && freshId !== target) {
40
+ // Update stored credentials with the fresh villagerId
41
+ const creds = loadCredentials();
42
+ saveCredentials({ ...creds, villager_id: freshId });
43
+ target = freshId;
44
+ spinner.text = 'Loading profile...';
45
+ result = await getProfile(target);
46
+ } else {
47
+ throw err;
48
+ }
49
+ } else {
50
+ throw err;
51
+ }
52
+ } else {
53
+ throw err;
54
+ }
55
+ }
56
+
28
57
  spinner.stop();
29
58
 
30
59
  const profile = result.data || result;
@@ -54,5 +83,8 @@ export async function profileCommand(handle, options) {
54
83
  } catch (err) {
55
84
  const message = err.response?.data?.error || err.response?.data?.message || err.message;
56
85
  spinner.fail(`Failed to load profile: ${message}`);
86
+ if (isSelf && err.response?.status === 404) {
87
+ console.log(brand.teal(' Your stored villager ID may be out of sync. Try running \'myvillage logout\' then \'myvillage login\'.'));
88
+ }
57
89
  }
58
90
  }
@@ -20,7 +20,7 @@ export async function voteCommand(options) {
20
20
  const agent = Array.isArray(agents) ? agents.find(a => a.handle === options.as) : null;
21
21
 
22
22
  if (!agent) {
23
- console.log(chalk.red(` \u2717 Agent @${options.as} not found. Run 'myvillage agent' to see your agents.\n`));
23
+ console.log(chalk.red(` \u2717 Agent @${options.as} not found. Run 'myvillage agent' to see your agents, or 'myvillage agent start ${options.as}' if you haven't started it yet.\n`));
24
24
  return;
25
25
  }
26
26
  agentProfileId = agent.id;
@@ -52,7 +52,7 @@ const INTERVAL_MAP = {
52
52
  // ── Scaffolding ─────────────────────────────────────────
53
53
 
54
54
  export function scaffoldAgent(agentDir, options) {
55
- const { name, displayName, description, tools, checkInInterval } = options;
55
+ const { name, displayName, description, tools, checkInInterval, provider, model } = options;
56
56
 
57
57
  // Create directories
58
58
  mkdirSync(join(agentDir, 'routines'), { recursive: true });
@@ -61,7 +61,7 @@ export function scaffoldAgent(agentDir, options) {
61
61
  // Write agent.config.yaml
62
62
  writeFileSync(
63
63
  join(agentDir, 'agent.config.yaml'),
64
- generateAgentConfig({ name, displayName, description, checkInInterval })
64
+ generateAgentConfig({ name, displayName, description, checkInInterval, provider, model })
65
65
  );
66
66
 
67
67
  // Write prompt.md
@@ -82,7 +82,7 @@ export function scaffoldAgent(agentDir, options) {
82
82
 
83
83
  // ── Config Generation ───────────────────────────────────
84
84
 
85
- function generateAgentConfig({ name, displayName, description, checkInInterval }) {
85
+ function generateAgentConfig({ name, displayName, description, checkInInterval, provider, model }) {
86
86
  const intervalMin = INTERVAL_MAP[checkInInterval] || 60;
87
87
 
88
88
  const config = {
@@ -105,8 +105,8 @@ function generateAgentConfig({ name, displayName, description, checkInInterval }
105
105
  },
106
106
 
107
107
  brain: {
108
- provider: 'anthropic',
109
- model: 'claude-sonnet-4-5-20250929',
108
+ provider: provider || 'anthropic',
109
+ model: model || (provider === 'openai' ? 'gpt-4o' : 'claude-sonnet-4-5-20250929'),
110
110
  max_tokens_per_loop: 1000,
111
111
  fallback_provider: null,
112
112
  },
package/src/utils/api.js CHANGED
@@ -367,6 +367,14 @@ export async function deleteAgentMemoryEntry(id, key) {
367
367
  return response.data;
368
368
  }
369
369
 
370
+ // ── Checkin AI Structuring ────────────────────────────────
371
+
372
+ export async function structureCheckin(data) {
373
+ const client = getNetworkClient();
374
+ const response = await client.post('/checkin/structure', data);
375
+ return response.data;
376
+ }
377
+
370
378
  // ── OAuth Client Registration ────────────────────────────
371
379
 
372
380
  export async function registerOAuthClient(name, appType, redirectUris) {
@@ -15,6 +15,7 @@ const DEFAULT_CONFIG = {
15
15
  clientId: 'mvos_aG_c729fuQxvvqYHOnkgTQ',
16
16
  callbackPort: 3737,
17
17
  anthropicApiKey: null,
18
+ openaiApiKey: null,
18
19
  };
19
20
 
20
21
  function ensureConfigDir() {
@@ -40,10 +41,13 @@ export function getConfig() {
40
41
  delete fileConfig.clientId;
41
42
  delete fileConfig.oauthBaseUrl;
42
43
  const config = { ...DEFAULT_CONFIG, ...fileConfig };
43
- // Environment variable override for Anthropic API key
44
+ // Environment variable overrides for API keys
44
45
  if (process.env.ANTHROPIC_API_KEY) {
45
46
  config.anthropicApiKey = process.env.ANTHROPIC_API_KEY;
46
47
  }
48
+ if (process.env.OPENAI_API_KEY) {
49
+ config.openaiApiKey = process.env.OPENAI_API_KEY;
50
+ }
47
51
  return config;
48
52
  } catch {
49
53
  return { ...DEFAULT_CONFIG };
@@ -502,10 +502,12 @@ export function formatLocalAgentStatus(agent) {
502
502
  const toolsStr = Object.keys(agent.config?.tools || {}).join(', ') || 'none';
503
503
  lines.push(` ${chalk.dim('Tools:')} ${toolsStr}`);
504
504
 
505
- // Model
505
+ // Model & provider
506
506
  const model = agent.config?.brain?.model;
507
507
  if (model) {
508
- lines.push(` ${chalk.dim('Model:')} ${model}`);
508
+ const prov = agent.config?.brain?.provider;
509
+ const provLabel = prov === 'openai' ? 'OpenAI' : prov === 'anthropic' ? 'Anthropic' : prov || '';
510
+ lines.push(` ${chalk.dim('Model:')} ${model}${provLabel ? chalk.dim(` (${provLabel})`) : ''}`);
509
511
  }
510
512
 
511
513
  lines.push('');