@myvillage/cli 1.5.0 → 1.6.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.
@@ -0,0 +1,149 @@
1
+ import chalk from 'chalk';
2
+ import inquirer from 'inquirer';
3
+ import { z } from 'zod';
4
+ import { isAuthenticated } from '../utils/auth.js';
5
+ import { brand, villageSpinner } from '../utils/brand.js';
6
+ import { createPost } from '../utils/api.js';
7
+ import { resolveVillageContext } from '../utils/village-resolver.js';
8
+ import { getAnthropicProvider } from '../utils/ai.js';
9
+ import { formatCheckinSummary } from '../utils/formatters.js';
10
+ import { discoverLogic } from './discover.js';
11
+
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
+ export async function checkinCommand(options) {
21
+ if (!isAuthenticated()) {
22
+ console.log(chalk.red(" \u2717 Authentication required. Run 'myvillage login' first."));
23
+ return;
24
+ }
25
+
26
+ const ctx = await resolveVillageContext();
27
+ if (!ctx) return;
28
+
29
+ console.log();
30
+ console.log(brand.gold(' \u2743 Village Check-in'));
31
+ console.log(chalk.dim(' Answer a few questions about your village. AI will help organize it.\n'));
32
+
33
+ // Conversational prompts
34
+ const answers = await inquirer.prompt([
35
+ {
36
+ type: 'input',
37
+ name: 'work',
38
+ message: 'What did your village work on recently?',
39
+ validate: (v) => v.trim().length > 0 || 'Please share what you worked on.',
40
+ },
41
+ {
42
+ type: 'input',
43
+ name: 'stuck',
44
+ message: 'Any problems you\'re stuck on?',
45
+ default: '',
46
+ },
47
+ {
48
+ type: 'input',
49
+ name: 'share',
50
+ message: 'Anything you want to share with the network?',
51
+ default: '',
52
+ },
53
+ ]);
54
+
55
+ // AI structuring
56
+ const spinner = villageSpinner('Organizing your check-in...').start();
57
+
58
+ let extracted;
59
+ 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,
81
+ });
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
+ spinner.succeed('Check-in organized');
89
+ } catch (err) {
90
+ spinner.fail('AI structuring failed — posting raw check-in');
91
+ // Fallback: post without AI structuring
92
+ extracted = {
93
+ themes: [],
94
+ needs: [],
95
+ offers: [],
96
+ buildTrack: 'general',
97
+ summary: [answers.work, answers.stuck, answers.share].filter(Boolean).join(' '),
98
+ };
99
+ }
100
+
101
+ // Build tags
102
+ const tags = ['checkin'];
103
+ if (extracted.buildTrack) tags.push(`track:${extracted.buildTrack}`);
104
+ for (const t of extracted.themes) tags.push(`theme:${t}`);
105
+ for (const n of extracted.needs) tags.push(`need:${n}`);
106
+ for (const o of extracted.offers) tags.push(`offer:${o}`);
107
+
108
+ // Create post
109
+ try {
110
+ const post = await createPost({
111
+ communitySlug: ctx.community.slug,
112
+ postType: 'DISCUSSION',
113
+ title: `Village Check-in: ${ctx.village.name}`,
114
+ body: extracted.summary,
115
+ tags,
116
+ });
117
+
118
+ formatCheckinSummary(post, {
119
+ villageName: ctx.village.name,
120
+ buildTrack: extracted.buildTrack,
121
+ themes: extracted.themes,
122
+ needs: extracted.needs,
123
+ offers: extracted.offers,
124
+ });
125
+ } catch (err) {
126
+ const msg = err.response?.data?.error || err.message;
127
+ console.log(chalk.red(` \u2717 Failed to post check-in: ${msg}`));
128
+ return;
129
+ }
130
+
131
+ // Auto-discover prompt
132
+ if (!options.skipDiscover) {
133
+ const { runDiscover } = await inquirer.prompt([{
134
+ type: 'confirm',
135
+ name: 'runDiscover',
136
+ message: 'Look for connections with other villages?',
137
+ default: true,
138
+ }]);
139
+
140
+ if (runDiscover) {
141
+ await discoverLogic(ctx, {
142
+ themes: extracted.themes,
143
+ needs: extracted.needs,
144
+ offers: extracted.offers,
145
+ track: extracted.buildTrack,
146
+ });
147
+ }
148
+ }
149
+ }
@@ -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
+ }