@myvillage/cli 1.5.1 → 1.6.1

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.5.1",
3
+ "version": "1.6.1",
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
 
@@ -100,31 +100,7 @@ const recommendationParams = z.object({
100
100
 
101
101
  // ── Helper: Get Anthropic client ────────────────────────
102
102
 
103
- async function getAnthropicProvider() {
104
- const { createAnthropic } = await import('@ai-sdk/anthropic');
105
- const config = getConfig();
106
- let apiKey = config.anthropicApiKey || process.env.ANTHROPIC_API_KEY;
107
-
108
- if (!apiKey) {
109
- console.log(chalk.yellow('\n Anthropic API key required for AI-powered intake.'));
110
- console.log(brand.teal(' Get yours at: https://console.anthropic.com\n'));
111
-
112
- const { key } = await inquirer.prompt([{
113
- type: 'password',
114
- name: 'key',
115
- message: 'Anthropic API key:',
116
- mask: '*',
117
- validate: (input) => input.trim().length > 0 || 'API key is required',
118
- }]);
119
-
120
- apiKey = key.trim();
121
- const { setConfig } = await import('../utils/config.js');
122
- setConfig({ anthropicApiKey: apiKey });
123
- console.log(brand.green(' ✓ API key saved to ~/.myvillage/config.json\n'));
124
- }
125
-
126
- return createAnthropic({ apiKey });
127
- }
103
+ import { getAnthropicProvider } from '../utils/ai.js';
128
104
 
129
105
 
130
106
  // ── bizreqs list ────────────────────────────────────────
@@ -0,0 +1,116 @@
1
+ import chalk from 'chalk';
2
+ import inquirer from 'inquirer';
3
+ import { isAuthenticated } from '../utils/auth.js';
4
+ import { brand, villageSpinner } from '../utils/brand.js';
5
+ import { createPost, structureCheckin } from '../utils/api.js';
6
+ import { resolveVillageContext } from '../utils/village-resolver.js';
7
+ import { formatCheckinSummary } from '../utils/formatters.js';
8
+ import { discoverLogic } from './discover.js';
9
+
10
+ export async function checkinCommand(options) {
11
+ if (!isAuthenticated()) {
12
+ console.log(chalk.red(" \u2717 Authentication required. Run 'myvillage login' first."));
13
+ return;
14
+ }
15
+
16
+ const ctx = await resolveVillageContext();
17
+ if (!ctx) return;
18
+
19
+ console.log();
20
+ console.log(brand.gold(' \u2743 Village Check-in'));
21
+ console.log(chalk.dim(' Answer a few questions about your village. AI will help organize it.\n'));
22
+
23
+ // Conversational prompts
24
+ const answers = await inquirer.prompt([
25
+ {
26
+ type: 'input',
27
+ name: 'work',
28
+ message: 'What did your village work on recently?',
29
+ validate: (v) => v.trim().length > 0 || 'Please share what you worked on.',
30
+ },
31
+ {
32
+ type: 'input',
33
+ name: 'stuck',
34
+ message: 'Any problems you\'re stuck on?',
35
+ default: '',
36
+ },
37
+ {
38
+ type: 'input',
39
+ name: 'share',
40
+ message: 'Anything you want to share with the network?',
41
+ default: '',
42
+ },
43
+ ]);
44
+
45
+ // AI structuring (backend-proxied)
46
+ const spinner = villageSpinner('Organizing your check-in...').start();
47
+
48
+ let extracted;
49
+ try {
50
+ extracted = await structureCheckin({
51
+ work: answers.work,
52
+ stuck: answers.stuck || undefined,
53
+ share: answers.share || undefined,
54
+ });
55
+ spinner.succeed('Check-in organized');
56
+ } catch (err) {
57
+ spinner.fail('AI structuring failed \u2014 posting raw check-in');
58
+ // Fallback: post without AI structuring
59
+ extracted = {
60
+ themes: [],
61
+ needs: [],
62
+ offers: [],
63
+ buildTrack: 'general',
64
+ summary: [answers.work, answers.stuck, answers.share].filter(Boolean).join(' '),
65
+ };
66
+ }
67
+
68
+ // Build tags
69
+ const tags = ['checkin'];
70
+ if (extracted.buildTrack) tags.push(`track:${extracted.buildTrack}`);
71
+ for (const t of extracted.themes) tags.push(`theme:${t}`);
72
+ for (const n of extracted.needs) tags.push(`need:${n}`);
73
+ for (const o of extracted.offers) tags.push(`offer:${o}`);
74
+
75
+ // Create post
76
+ try {
77
+ const post = await createPost({
78
+ communitySlug: ctx.community.slug,
79
+ postType: 'DISCUSSION',
80
+ title: `Village Check-in: ${ctx.village.name}`,
81
+ body: extracted.summary,
82
+ tags,
83
+ });
84
+
85
+ formatCheckinSummary(post, {
86
+ villageName: ctx.village.name,
87
+ buildTrack: extracted.buildTrack,
88
+ themes: extracted.themes,
89
+ needs: extracted.needs,
90
+ offers: extracted.offers,
91
+ });
92
+ } catch (err) {
93
+ const msg = err.response?.data?.error || err.message;
94
+ console.log(chalk.red(` \u2717 Failed to post check-in: ${msg}`));
95
+ return;
96
+ }
97
+
98
+ // Auto-discover prompt
99
+ if (!options.skipDiscover) {
100
+ const { runDiscover } = await inquirer.prompt([{
101
+ type: 'confirm',
102
+ name: 'runDiscover',
103
+ message: 'Look for connections with other villages?',
104
+ default: true,
105
+ }]);
106
+
107
+ if (runDiscover) {
108
+ await discoverLogic(ctx, {
109
+ themes: extracted.themes,
110
+ needs: extracted.needs,
111
+ offers: extracted.offers,
112
+ track: extracted.buildTrack,
113
+ });
114
+ }
115
+ }
116
+ }
@@ -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;
@@ -0,0 +1,220 @@
1
+ import chalk from 'chalk';
2
+ import inquirer from 'inquirer';
3
+ import { isAuthenticated, loadCredentials } from '../utils/auth.js';
4
+ import { brand, villageSpinner } from '../utils/brand.js';
5
+ import {
6
+ listPosts,
7
+ listPostsByFilters,
8
+ createPost,
9
+ getVillagerVillages,
10
+ getVillageCommunities,
11
+ } from '../utils/api.js';
12
+ import { resolveVillageContext } from '../utils/village-resolver.js';
13
+ import { formatConnection } from '../utils/formatters.js';
14
+
15
+ /**
16
+ * Parse prefixed tags into a structured map.
17
+ * e.g., ['checkin', 'track:game', 'theme:ai', 'need:mentors'] →
18
+ * { themes: ['ai'], needs: ['mentors'], offers: [], track: 'game' }
19
+ */
20
+ function parseTags(tags = []) {
21
+ const result = { themes: [], needs: [], offers: [], track: null };
22
+ for (const tag of tags) {
23
+ if (tag.startsWith('theme:')) result.themes.push(tag.replace('theme:', ''));
24
+ else if (tag.startsWith('need:')) result.needs.push(tag.replace('need:', ''));
25
+ else if (tag.startsWith('offer:')) result.offers.push(tag.replace('offer:', ''));
26
+ else if (tag.startsWith('track:')) result.track = tag.replace('track:', '');
27
+ }
28
+ return result;
29
+ }
30
+
31
+ /**
32
+ * Score a candidate post against the user's tags.
33
+ */
34
+ function scoreMatch(myTags, theirTags) {
35
+ let score = 0;
36
+ const matchTypes = [];
37
+
38
+ // Need-to-offer matches (3 points each)
39
+ for (const need of myTags.needs) {
40
+ if (theirTags.offers.includes(need)) {
41
+ score += 3;
42
+ matchTypes.push(`They offer "${need}" (you need it)`);
43
+ }
44
+ }
45
+ for (const offer of myTags.offers) {
46
+ if (theirTags.needs.includes(offer)) {
47
+ score += 3;
48
+ matchTypes.push(`They need "${offer}" (you offer it)`);
49
+ }
50
+ }
51
+
52
+ // Same build track (2 points)
53
+ if (myTags.track && theirTags.track && myTags.track === theirTags.track) {
54
+ score += 2;
55
+ matchTypes.push(`Same track: ${myTags.track}`);
56
+ }
57
+
58
+ // Shared themes (1 point each)
59
+ for (const theme of myTags.themes) {
60
+ if (theirTags.themes.includes(theme)) {
61
+ score += 1;
62
+ matchTypes.push(`Shared theme: ${theme}`);
63
+ }
64
+ }
65
+
66
+ return { score, matchTypes };
67
+ }
68
+
69
+ /**
70
+ * Core discover logic, shared between standalone and post-checkin invocation.
71
+ */
72
+ export async function discoverLogic(ctx, myTags) {
73
+ const spinner = villageSpinner('Searching for connections...').start();
74
+
75
+ try {
76
+ const creds = loadCredentials();
77
+
78
+ // 1. Get all the user's villages' communities (for cross-village search)
79
+ const villagesResult = await getVillagerVillages(ctx.villagerUuid);
80
+ const allVillages = villagesResult?.data || [];
81
+
82
+ const communityIds = [];
83
+ for (const village of allVillages) {
84
+ const commResult = await getVillageCommunities(village.id);
85
+ const comms = commResult?.data || [];
86
+ for (const c of comms) {
87
+ if (!communityIds.includes(c.id)) {
88
+ communityIds.push(c.id);
89
+ }
90
+ }
91
+ }
92
+
93
+ if (communityIds.length === 0) {
94
+ spinner.fail('No village communities found to search.');
95
+ return;
96
+ }
97
+
98
+ // 2. Fetch recent posts (last 14 days)
99
+ const since = new Date(Date.now() - 14 * 24 * 60 * 60 * 1000).toISOString();
100
+ const postsResult = await listPostsByFilters({
101
+ communityIds: communityIds.join(','),
102
+ tags: 'checkin,build-log,knowledge-contribution',
103
+ since,
104
+ pageSize: 50,
105
+ sort: 'new',
106
+ });
107
+
108
+ const posts = postsResult?.data || [];
109
+
110
+ // 3. Filter out current user's own posts
111
+ const otherPosts = posts.filter((p) => p.villagerId !== ctx.villagerUuid);
112
+
113
+ if (otherPosts.length === 0) {
114
+ spinner.info('No connections found right now \u2014 check back after more villages check in.');
115
+ return;
116
+ }
117
+
118
+ // 4. Score and rank matches
119
+ const scored = otherPosts.map((post) => {
120
+ const theirTags = parseTags(post.tags);
121
+ const { score, matchTypes } = scoreMatch(myTags, theirTags);
122
+ const villageName = post.community?.name || 'Unknown Village';
123
+ return { post, score, matchTypes, villageName, theirTags };
124
+ });
125
+
126
+ const topMatches = scored
127
+ .filter((m) => m.score > 0)
128
+ .sort((a, b) => b.score - a.score)
129
+ .slice(0, 3);
130
+
131
+ spinner.stop();
132
+
133
+ if (topMatches.length === 0) {
134
+ console.log(chalk.dim('\n No connections found right now \u2014 check back after more villages check in.\n'));
135
+ return;
136
+ }
137
+
138
+ console.log(brand.gold(`\n Found ${topMatches.length} connection${topMatches.length > 1 ? 's' : ''}:\n`));
139
+
140
+ // 5. Display and optionally share each connection
141
+ for (const match of topMatches) {
142
+ formatConnection({
143
+ villageName: match.villageName,
144
+ topic: match.theirTags.themes[0] || match.theirTags.track || 'general',
145
+ matchType: match.matchTypes.join(', '),
146
+ score: match.score,
147
+ });
148
+
149
+ const { share } = await inquirer.prompt([{
150
+ type: 'confirm',
151
+ name: 'share',
152
+ message: 'Share this connection with the network?',
153
+ default: false,
154
+ }]);
155
+
156
+ if (share) {
157
+ const description = `Connection between ${ctx.village.name} and ${match.villageName}: ${match.matchTypes.join('; ')}`;
158
+ const connectionTags = ['connection'];
159
+ if (match.theirTags.track) connectionTags.push(`track:${match.theirTags.track}`);
160
+ for (const t of match.theirTags.themes) connectionTags.push(`theme:${t}`);
161
+
162
+ try {
163
+ await createPost({
164
+ communitySlug: ctx.community.slug,
165
+ postType: 'DISCUSSION',
166
+ title: `Connection: ${ctx.village.name} \u21C4 ${match.villageName}`,
167
+ body: description,
168
+ tags: connectionTags,
169
+ });
170
+ console.log(brand.green(' \u2713 Connection shared!\n'));
171
+ } catch (err) {
172
+ const msg = err.response?.data?.error || err.message;
173
+ console.log(chalk.red(` \u2717 Failed to share connection: ${msg}\n`));
174
+ }
175
+ }
176
+ }
177
+ } catch (err) {
178
+ spinner.fail(`Discovery failed: ${err.message}`);
179
+ }
180
+ }
181
+
182
+ /**
183
+ * Standalone discover command — fetches user's most recent check-in,
184
+ * parses its tags, then runs discoverLogic.
185
+ */
186
+ export async function discoverCommand(options) {
187
+ if (!isAuthenticated()) {
188
+ console.log(chalk.red(" \u2717 Authentication required. Run 'myvillage login' first."));
189
+ return;
190
+ }
191
+
192
+ const ctx = await resolveVillageContext();
193
+ if (!ctx) return;
194
+
195
+ // Fetch user's most recent check-in
196
+ const spinner = villageSpinner('Loading your latest check-in...').start();
197
+
198
+ try {
199
+ const result = await listPosts({
200
+ communitySlug: ctx.community.slug,
201
+ authorId: ctx.villagerUuid,
202
+ pageSize: 1,
203
+ sort: 'new',
204
+ });
205
+
206
+ const posts = result?.data || [];
207
+ const checkin = posts.find((p) => p.tags?.includes('checkin'));
208
+
209
+ if (!checkin) {
210
+ spinner.info('No recent check-in found. Run `myvillage checkin` first to share what your village is working on.');
211
+ return;
212
+ }
213
+
214
+ spinner.succeed('Check-in loaded');
215
+ const myTags = parseTags(checkin.tags);
216
+ await discoverLogic(ctx, myTags);
217
+ } catch (err) {
218
+ spinner.fail(`Failed to load check-in: ${err.message}`);
219
+ }
220
+ }
@@ -0,0 +1,59 @@
1
+ import chalk from 'chalk';
2
+ import inquirer from 'inquirer';
3
+ import { isAuthenticated } from '../utils/auth.js';
4
+ import { brand } from '../utils/brand.js';
5
+ import { createPost } from '../utils/api.js';
6
+ import { resolveVillageContext } from '../utils/village-resolver.js';
7
+
8
+ const TRACKS = ['game', 'portal', 'agent', 'data', 'robot', 'general'];
9
+
10
+ export async function logCommand(text, options) {
11
+ if (!isAuthenticated()) {
12
+ console.log(chalk.red(" \u2717 Authentication required. Run 'myvillage login' first."));
13
+ return;
14
+ }
15
+
16
+ const ctx = await resolveVillageContext();
17
+ if (!ctx) return;
18
+
19
+ // Get log text
20
+ let logText = text;
21
+ if (!logText) {
22
+ const { input } = await inquirer.prompt([{
23
+ type: 'input',
24
+ name: 'input',
25
+ message: 'What did you work on?',
26
+ validate: (v) => v.trim().length > 0 || 'Please enter something.',
27
+ }]);
28
+ logText = input.trim();
29
+ }
30
+
31
+ // Get track
32
+ let track = options.track;
33
+ if (!track) {
34
+ const { selected } = await inquirer.prompt([{
35
+ type: 'list',
36
+ name: 'selected',
37
+ message: 'Build track:',
38
+ choices: TRACKS,
39
+ }]);
40
+ track = selected;
41
+ }
42
+
43
+ // Build tags and post
44
+ const tags = ['build-log', `track:${track}`];
45
+
46
+ try {
47
+ await createPost({
48
+ communitySlug: ctx.community.slug,
49
+ postType: 'DISCUSSION',
50
+ body: logText,
51
+ tags,
52
+ });
53
+
54
+ console.log(brand.green(` \u2713 Build log posted to ${ctx.village.name} [${track}]`));
55
+ } catch (err) {
56
+ const msg = err.response?.data?.error || err.message;
57
+ console.log(chalk.red(` \u2717 Failed to post build log: ${msg}`));
58
+ }
59
+ }
@@ -268,6 +268,7 @@ export async function loginCommand(options) {
268
268
  user_email: userInfo.email,
269
269
  user_name: userInfo.name,
270
270
  villager_id: userInfo.villager?.villagerId || null,
271
+ villager_uuid: userInfo.villager?.id || null,
271
272
  });
272
273
 
273
274
  spinner.succeed('Authentication complete!');
@@ -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;
@@ -0,0 +1,68 @@
1
+ import chalk from 'chalk';
2
+ import inquirer from 'inquirer';
3
+ import { isAuthenticated } from '../utils/auth.js';
4
+ import { brand } from '../utils/brand.js';
5
+ import { createPost } from '../utils/api.js';
6
+ import { resolveVillageContext } from '../utils/village-resolver.js';
7
+
8
+ export async function storyCommand(options) {
9
+ if (!isAuthenticated()) {
10
+ console.log(chalk.red(" \u2717 Authentication required. Run 'myvillage login' first."));
11
+ return;
12
+ }
13
+
14
+ const ctx = await resolveVillageContext();
15
+ if (!ctx) return;
16
+
17
+ // Guided prompts
18
+ const answers = await inquirer.prompt([
19
+ {
20
+ type: 'editor',
21
+ name: 'story',
22
+ message: 'What story, memory, or piece of wisdom would you like to share?',
23
+ validate: (v) => v.trim().length > 0 || 'Please share something.',
24
+ },
25
+ {
26
+ type: 'input',
27
+ name: 'source',
28
+ message: 'Who is this from? (you, a family elder, a community member)',
29
+ default: 'me',
30
+ },
31
+ {
32
+ type: 'input',
33
+ name: 'connection',
34
+ message: 'Is this connected to something the village is building? (optional)',
35
+ },
36
+ ]);
37
+
38
+ // Build tags
39
+ const tags = ['knowledge-contribution'];
40
+ const sourceName = answers.source.trim();
41
+ if (sourceName && sourceName.toLowerCase() !== 'me') {
42
+ tags.push(`source:${sourceName}`);
43
+ }
44
+ if (answers.connection?.trim()) {
45
+ tags.push(`theme:${answers.connection.trim()}`);
46
+ }
47
+
48
+ // Format body with attribution
49
+ let body = answers.story.trim();
50
+ if (sourceName && sourceName.toLowerCase() !== 'me') {
51
+ body = `"${body}"\n\n\u2014 ${sourceName}`;
52
+ }
53
+
54
+ try {
55
+ await createPost({
56
+ communitySlug: ctx.community.slug,
57
+ postType: 'DISCUSSION',
58
+ title: 'Knowledge Contribution',
59
+ body,
60
+ tags,
61
+ });
62
+
63
+ console.log(brand.green(` \u2713 Story shared with ${ctx.village.name}`));
64
+ } catch (err) {
65
+ const msg = err.response?.data?.error || err.message;
66
+ console.log(chalk.red(` \u2717 Failed to share story: ${msg}`));
67
+ }
68
+ }
@@ -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;
package/src/index.js CHANGED
@@ -53,6 +53,10 @@ import {
53
53
  bizreqsStatusCommand,
54
54
  bizreqsImportCommand,
55
55
  } from './commands/bizreqs.js';
56
+ import { checkinCommand } from './commands/checkin.js';
57
+ import { discoverCommand } from './commands/discover.js';
58
+ import { logCommand } from './commands/log.js';
59
+ import { storyCommand } from './commands/story.js';
56
60
  import {
57
61
  soulprintInitCommand,
58
62
  soulprintIngestCommand,
@@ -321,6 +325,30 @@ export function run() {
321
325
  .description('Remove an MCP server tool from a local agent')
322
326
  .action(agentRemoveToolCommand);
323
327
 
328
+ // ── Village Content Pipeline ───────────────────────────
329
+
330
+ program
331
+ .command('checkin')
332
+ .description('Check in with your village and share updates')
333
+ .option('--skip-discover', 'Skip the auto-discover step')
334
+ .action(checkinCommand);
335
+
336
+ program
337
+ .command('discover')
338
+ .description('Find connections between villages')
339
+ .action(discoverCommand);
340
+
341
+ program
342
+ .command('log [text]')
343
+ .description('Quick build log entry')
344
+ .option('-t, --track <track>', 'Build track (game, portal, agent, data, robot, general)')
345
+ .action(logCommand);
346
+
347
+ program
348
+ .command('story')
349
+ .description('Share a story or wisdom with the network')
350
+ .action(storyCommand);
351
+
324
352
  // ── BizReqs: Business Requirements Pipeline ───────────
325
353
 
326
354
  const bizreqsCmd = program
@@ -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
  },
@@ -0,0 +1,30 @@
1
+ import chalk from 'chalk';
2
+ import inquirer from 'inquirer';
3
+ import { getConfig } from './config.js';
4
+ import { brand } from './brand.js';
5
+
6
+ export async function getAnthropicProvider() {
7
+ const { createAnthropic } = await import('@ai-sdk/anthropic');
8
+ const config = getConfig();
9
+ let apiKey = config.anthropicApiKey || process.env.ANTHROPIC_API_KEY;
10
+
11
+ if (!apiKey) {
12
+ console.log(chalk.yellow('\n Anthropic API key required for AI features.'));
13
+ console.log(brand.teal(' Get yours at: https://console.anthropic.com\n'));
14
+
15
+ const { key } = await inquirer.prompt([{
16
+ type: 'password',
17
+ name: 'key',
18
+ message: 'Anthropic API key:',
19
+ mask: '*',
20
+ validate: (input) => input.trim().length > 0 || 'API key is required',
21
+ }]);
22
+
23
+ apiKey = key.trim();
24
+ const { setConfig } = await import('./config.js');
25
+ setConfig({ anthropicApiKey: apiKey });
26
+ console.log(brand.green(' \u2713 API key saved to ~/.myvillage/config.json\n'));
27
+ }
28
+
29
+ return createAnthropic({ apiKey });
30
+ }
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) {
@@ -379,6 +387,37 @@ export async function registerOAuthClient(name, appType, redirectUris) {
379
387
  return response.data;
380
388
  }
381
389
 
390
+ // ── Platform API Client (/api) ──────────────────────────
391
+
392
+ export function getPlatformClient() {
393
+ const config = getConfig();
394
+ return createClient(config.platformBaseUrl);
395
+ }
396
+
397
+ export async function getVillagerVillages(villagerUuid) {
398
+ const client = getPlatformClient();
399
+ const response = await client.get(`/villagers/${villagerUuid}/villages`);
400
+ return response.data;
401
+ }
402
+
403
+ export async function getVillagerByVillagerId(villagerId) {
404
+ const client = getPlatformClient();
405
+ const response = await client.get(`/villagers/by-villager-id/${villagerId}`);
406
+ return response.data;
407
+ }
408
+
409
+ export async function getVillageCommunities(villageUuid) {
410
+ const client = getNetworkClient();
411
+ const response = await client.get('/communities', { params: { villageId: villageUuid } });
412
+ return response.data;
413
+ }
414
+
415
+ export async function listPostsByFilters(params = {}) {
416
+ const client = getNetworkClient();
417
+ const response = await client.get('/posts', { params });
418
+ return response.data;
419
+ }
420
+
382
421
  // ── BizReqs API Client (/api/bizreqs) ───────────────────
383
422
 
384
423
  export function getBizReqsClient() {
@@ -11,9 +11,11 @@ const DEFAULT_CONFIG = {
11
11
  bizreqsBaseUrl: 'https://portal.myvillageproject.ai/api/bizreqs',
12
12
  oauthBaseUrl: 'https://portal.myvillageproject.ai/api/oauth',
13
13
  soulprintBaseUrl: 'https://soulprint-studio.myvillageproject.ai/api',
14
+ platformBaseUrl: 'https://portal.myvillageproject.ai/api',
14
15
  clientId: 'mvos_aG_c729fuQxvvqYHOnkgTQ',
15
16
  callbackPort: 3737,
16
17
  anthropicApiKey: null,
18
+ openaiApiKey: null,
17
19
  };
18
20
 
19
21
  function ensureConfigDir() {
@@ -39,10 +41,13 @@ export function getConfig() {
39
41
  delete fileConfig.clientId;
40
42
  delete fileConfig.oauthBaseUrl;
41
43
  const config = { ...DEFAULT_CONFIG, ...fileConfig };
42
- // Environment variable override for Anthropic API key
44
+ // Environment variable overrides for API keys
43
45
  if (process.env.ANTHROPIC_API_KEY) {
44
46
  config.anthropicApiKey = process.env.ANTHROPIC_API_KEY;
45
47
  }
48
+ if (process.env.OPENAI_API_KEY) {
49
+ config.openaiApiKey = process.env.OPENAI_API_KEY;
50
+ }
46
51
  return config;
47
52
  } catch {
48
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('');
@@ -743,3 +745,88 @@ export function formatPagination(meta) {
743
745
  console.log(chalk.dim(` ── More results available. Run with --cursor=${meta.nextCursor} to see next page\n`));
744
746
  }
745
747
  }
748
+
749
+ // ── MAN Content Pipeline ────────────────────────────────
750
+
751
+ export function formatCheckinSummary(post, meta) {
752
+ const b = (s) => chalk.hex('#B07C00')(s);
753
+ const width = 56;
754
+ const bdr = '\u2500'.repeat(width);
755
+
756
+ const lines = [''];
757
+ lines.push(` ${b('\u250C' + bdr + '\u2510')}`);
758
+ lines.push(` ${b('\u2502')} ${brand.gold('\u2743 Village Check-in')}${' '.repeat(width - 19)}${b('\u2502')}`);
759
+ lines.push(` ${b('\u2502' + '\u2500'.repeat(width) + '\u2502')}`);
760
+
761
+ if (meta.villageName) {
762
+ const vl = ` Village: ${meta.villageName}`;
763
+ lines.push(` ${b('\u2502')}${brand.cream(vl).padEnd(width + 12)}${b('\u2502')}`);
764
+ }
765
+ if (meta.buildTrack) {
766
+ const tl = ` Track: ${meta.buildTrack}`;
767
+ lines.push(` ${b('\u2502')}${brand.teal(tl).padEnd(width + 12)}${b('\u2502')}`);
768
+ }
769
+ if (meta.themes?.length) {
770
+ const th = ` Themes: ${meta.themes.join(', ')}`;
771
+ lines.push(` ${b('\u2502')}${chalk.white(th).padEnd(width + 12)}${b('\u2502')}`);
772
+ }
773
+ if (meta.needs?.length) {
774
+ const nd = ` Needs: ${meta.needs.join(', ')}`;
775
+ lines.push(` ${b('\u2502')}${chalk.yellow(nd).padEnd(width + 12)}${b('\u2502')}`);
776
+ }
777
+ if (meta.offers?.length) {
778
+ const of_ = ` Offers: ${meta.offers.join(', ')}`;
779
+ lines.push(` ${b('\u2502')}${brand.green(of_).padEnd(width + 12)}${b('\u2502')}`);
780
+ }
781
+ if (post?.data?.id) {
782
+ const id = ` Post ID: ${post.data.id.slice(0, 8)}...`;
783
+ lines.push(` ${b('\u2502')}${chalk.dim(id).padEnd(width + 12)}${b('\u2502')}`);
784
+ }
785
+
786
+ lines.push(` ${b('\u2514' + bdr + '\u2518')}`);
787
+ lines.push('');
788
+
789
+ console.log(lines.join('\n'));
790
+ }
791
+
792
+ export function formatConnection(connection) {
793
+ const b = (s) => chalk.hex('#B07C00')(s);
794
+ const width = 56;
795
+ const bdr = '\u2500'.repeat(width);
796
+
797
+ const lines = [''];
798
+ lines.push(` ${b('\u250C' + bdr + '\u2510')}`);
799
+ lines.push(` ${b('\u2502')} ${brand.gold('\u21C4 Connection Found')}${' '.repeat(width - 20)}${b('\u2502')}`);
800
+ lines.push(` ${b('\u2502' + '\u2500'.repeat(width) + '\u2502')}`);
801
+
802
+ if (connection.villageName) {
803
+ const vl = ` Village: ${connection.villageName}`;
804
+ lines.push(` ${b('\u2502')}${brand.cream(vl).padEnd(width + 12)}${b('\u2502')}`);
805
+ }
806
+ if (connection.topic) {
807
+ const tp = ` Topic: ${connection.topic}`;
808
+ lines.push(` ${b('\u2502')}${chalk.white(tp).padEnd(width + 12)}${b('\u2502')}`);
809
+ }
810
+ if (connection.matchType) {
811
+ const mt = ` Match: ${connection.matchType}`;
812
+ lines.push(` ${b('\u2502')}${brand.teal(mt).padEnd(width + 12)}${b('\u2502')}`);
813
+ }
814
+ if (connection.score !== undefined) {
815
+ const sc = ` Relevance: ${'\u2605'.repeat(Math.min(connection.score, 5))}${'\u2606'.repeat(Math.max(0, 5 - connection.score))}`;
816
+ lines.push(` ${b('\u2502')}${brand.gold(sc).padEnd(width + 12)}${b('\u2502')}`);
817
+ }
818
+
819
+ lines.push(` ${b('\u2514' + bdr + '\u2518')}`);
820
+ lines.push('');
821
+
822
+ console.log(lines.join('\n'));
823
+ }
824
+
825
+ export function formatBuildLog(post) {
826
+ const tags = post.tags || [];
827
+ const track = tags.find((t) => t.startsWith('track:'))?.replace('track:', '') || 'general';
828
+ const body = post.body?.length > 60 ? post.body.slice(0, 57) + '...' : post.body || '';
829
+ const time = relativeTime(post.createdAt);
830
+
831
+ console.log(` ${brand.teal(`[${track}]`)} ${chalk.white(body)} ${chalk.dim(time)}`);
832
+ }
@@ -0,0 +1,16 @@
1
+ import chalk from 'chalk';
2
+
3
+ /**
4
+ * Trigger a MAN post from an automated event (deploy, publish, etc.).
5
+ * Stub implementation — future commands will call this to auto-generate MAN posts.
6
+ *
7
+ * @param {object} params
8
+ * @param {string} params.trigger - What triggered this (e.g., 'deploy', 'publish')
9
+ * @param {string} params.artifactId - ID of the artifact (game ID, model ID, etc.)
10
+ * @param {string} params.summary - Human-readable summary of what happened
11
+ * @param {string} params.villageId - Village UUID
12
+ * @param {string} params.communitySlug - Target community slug
13
+ */
14
+ export async function triggerMANPost({ trigger, artifactId, summary, villageId, communitySlug }) {
15
+ console.log(chalk.dim(` [MAN hook] ${trigger}: ${summary} (not yet wired — future feature)`));
16
+ }
@@ -0,0 +1,104 @@
1
+ import chalk from 'chalk';
2
+ import inquirer from 'inquirer';
3
+ import { loadCredentials } from './auth.js';
4
+ import { brand, villageSpinner } from './brand.js';
5
+ import {
6
+ getVillagerVillages,
7
+ getVillagerByVillagerId,
8
+ getVillageCommunities,
9
+ joinCommunity,
10
+ } from './api.js';
11
+
12
+ /**
13
+ * Resolves a villager's village context: which village they're acting on behalf of
14
+ * and the corresponding MAN community.
15
+ *
16
+ * Returns { villagerUuid, village, community } or null if resolution fails.
17
+ */
18
+ export async function resolveVillageContext() {
19
+ const creds = loadCredentials();
20
+ if (!creds) return null;
21
+
22
+ const spinner = villageSpinner('Resolving village context...').start();
23
+
24
+ try {
25
+ // 1. Get villager UUID
26
+ let villagerUuid = creds.villager_uuid;
27
+
28
+ if (!villagerUuid && creds.villager_id) {
29
+ const result = await getVillagerByVillagerId(creds.villager_id);
30
+ villagerUuid = result?.data?.id || result?.id;
31
+ }
32
+
33
+ if (!villagerUuid) {
34
+ spinner.fail('Could not resolve villager profile.');
35
+ console.log(chalk.dim(' Try logging out and back in: myvillage logout && myvillage login'));
36
+ return null;
37
+ }
38
+
39
+ // 2. Get associated villages
40
+ const villagesResult = await getVillagerVillages(villagerUuid);
41
+ const villages = villagesResult?.data || [];
42
+
43
+ if (villages.length === 0) {
44
+ spinner.fail('You are not associated with any villages.');
45
+ console.log(chalk.dim(' Join a village at portal.myvillageproject.ai to get started.'));
46
+ return null;
47
+ }
48
+
49
+ spinner.stop();
50
+
51
+ // 3. Select village
52
+ let village;
53
+ if (villages.length === 1) {
54
+ village = villages[0];
55
+ console.log(brand.teal(` Village: ${village.name}`));
56
+ } else {
57
+ const { selected } = await inquirer.prompt([{
58
+ type: 'list',
59
+ name: 'selected',
60
+ message: 'Which village are you representing?',
61
+ choices: villages.map((v) => ({
62
+ name: `${v.name} (${v.city}, ${v.state})`,
63
+ value: v,
64
+ })),
65
+ }]);
66
+ village = selected;
67
+ }
68
+
69
+ // 4. Get village's dedicated community
70
+ const commSpinner = villageSpinner('Finding village community...').start();
71
+ const commResult = await getVillageCommunities(village.id);
72
+ const communities = commResult?.data || [];
73
+
74
+ // Find the community linked to this village
75
+ const community = communities.find((c) => c.villageId === village.id) || communities[0];
76
+
77
+ if (!community) {
78
+ commSpinner.fail('No MAN community found for this village.');
79
+ console.log(chalk.dim(' A community needs to be set up for your village first.'));
80
+ return null;
81
+ }
82
+
83
+ // 5. Auto-join community (ignore 409 = already member)
84
+ try {
85
+ await joinCommunity(community.slug);
86
+ } catch (err) {
87
+ if (err.response?.status !== 409 && err.response?.status !== 400) {
88
+ // Only throw if it's not "already a member"
89
+ const msg = err.response?.data?.error || '';
90
+ if (!msg.toLowerCase().includes('already')) {
91
+ throw err;
92
+ }
93
+ }
94
+ }
95
+
96
+ commSpinner.succeed(`Community: ${community.name}`);
97
+
98
+ return { villagerUuid, village, community };
99
+ } catch (err) {
100
+ spinner.stop();
101
+ console.log(chalk.red(` \u2717 Failed to resolve village context: ${err.message}`));
102
+ return null;
103
+ }
104
+ }