stars-profile 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/cli.js ADDED
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env node
2
+ import { run } from '../src/cli.js';
3
+ run();
package/package.json ADDED
@@ -0,0 +1,34 @@
1
+ {
2
+ "name": "stars-profile",
3
+ "version": "1.0.0",
4
+ "description": "Manage GitHub Stars profile contributions using Copilot CLI deep research",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": "Fatih Kadir Akın",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "https://github.com/nicedoc/stars-profile.git"
11
+ },
12
+ "keywords": [
13
+ "github",
14
+ "stars",
15
+ "contributions",
16
+ "copilot",
17
+ "cli"
18
+ ],
19
+ "bin": {
20
+ "stars-profile": "./bin/cli.js"
21
+ },
22
+ "files": [
23
+ "bin",
24
+ "src"
25
+ ],
26
+ "engines": {
27
+ "node": ">=18"
28
+ },
29
+ "dependencies": {
30
+ "@inquirer/prompts": "^7.0.0",
31
+ "chalk": "^5.3.0",
32
+ "ora": "^8.0.0"
33
+ }
34
+ }
package/src/api.js ADDED
@@ -0,0 +1,92 @@
1
+ const API_URL = 'https://api-stars.github.com/';
2
+
3
+ export const CONTRIBUTION_TYPES = [
4
+ 'SPEAKING',
5
+ 'BLOGPOST',
6
+ 'ARTICLE_PUBLICATION',
7
+ 'EVENT_ORGANIZATION',
8
+ 'HACKATHON',
9
+ 'OPEN_SOURCE_PROJECT',
10
+ 'VIDEO_PODCAST',
11
+ 'FORUM',
12
+ 'OTHER',
13
+ ];
14
+
15
+ async function gql(token, query) {
16
+ const res = await fetch(API_URL, {
17
+ method: 'POST',
18
+ headers: {
19
+ 'Authorization': `Bearer ${token}`,
20
+ 'Content-Type': 'application/json',
21
+ },
22
+ body: JSON.stringify({ query }),
23
+ });
24
+
25
+ const json = await res.json();
26
+
27
+ if (json.errors) {
28
+ const msg = json.errors.map(e => e.message).join('; ');
29
+ throw new Error(`GraphQL error: ${msg}`);
30
+ }
31
+
32
+ return json.data;
33
+ }
34
+
35
+ export async function fetchExistingContributions(token) {
36
+ const data = await gql(token, '{ contributions { id title url description type date } }');
37
+ return data.contributions;
38
+ }
39
+
40
+ export async function createContributions(token, items) {
41
+ const entries = items.map(item => {
42
+ const type = item.type ? `type:${item.type}` : '';
43
+ const escapedTitle = item.title.replace(/"/g, '\\"');
44
+ const escapedDesc = item.description.replace(/"/g, '\\"');
45
+ const parts = [
46
+ `title:"${escapedTitle}"`,
47
+ `description:"${escapedDesc}"`,
48
+ `date:"${item.date}"`,
49
+ ];
50
+ if (item.url) parts.push(`url:"${item.url}"`);
51
+ if (type) parts.push(type);
52
+ return `{ ${parts.join(' ')} }`;
53
+ });
54
+
55
+ const mutation = `mutation {
56
+ createContributions(data: [${entries.join(', ')}]) {
57
+ id
58
+ }
59
+ }`;
60
+
61
+ const data = await gql(token, mutation);
62
+ return data.createContributions;
63
+ }
64
+
65
+ export function buildDryCurlCommand(token, items) {
66
+ const entries = items.map(item => {
67
+ const url = item.url ? `url:"${item.url}"` : '';
68
+ const type = item.type ? `type:${item.type}` : '';
69
+ const escapedTitle = item.title.replace(/"/g, '\\"');
70
+ const escapedDesc = item.description.replace(/"/g, '\\"');
71
+ return ` {
72
+ title:"${escapedTitle}"
73
+ ${url}
74
+ description:"${escapedDesc}"
75
+ ${type}
76
+ date: "${item.date}"
77
+ }`;
78
+ });
79
+
80
+ return `curl --location --request POST '${API_URL}' \\
81
+ --header 'Authorization: Bearer ${token}' \\
82
+ --header 'Content-Type: application/json' \\
83
+ --data-raw '{"query":"
84
+ mutation {
85
+ createContributions(data:
86
+ [${entries.join('\n')}])
87
+ {
88
+ id
89
+ }
90
+ }"
91
+ ,"variables":{}}'`;
92
+ }
package/src/cli.js ADDED
@@ -0,0 +1,161 @@
1
+ import chalk from 'chalk';
2
+ import ora from 'ora';
3
+ import { input } from '@inquirer/prompts';
4
+ import { fetchExistingContributions, createContributions, buildDryCurlCommand } from './api.js';
5
+ import { researchActivities } from './research.js';
6
+ import { showExistingSummary, displayNewActivities, selectActivities, confirmSubmission } from './display.js';
7
+ import { getSavedQuery, saveQuery, getSavedToken, saveToken } from './config.js';
8
+
9
+ function parseArgs(argv) {
10
+ const args = argv.slice(2);
11
+ const opts = { query: null, token: null, dryRun: false, help: false };
12
+
13
+ for (let i = 0; i < args.length; i++) {
14
+ const arg = args[i];
15
+ if (arg === '--help' || arg === '-h') {
16
+ opts.help = true;
17
+ } else if (arg === '--dry-run') {
18
+ opts.dryRun = true;
19
+ } else if ((arg === '--query' || arg === '-q') && args[i + 1]) {
20
+ opts.query = args[++i];
21
+ } else if ((arg === '--token' || arg === '-t') && args[i + 1]) {
22
+ opts.token = args[++i];
23
+ } else if (!arg.startsWith('-') && !opts.query) {
24
+ opts.query = arg;
25
+ }
26
+ }
27
+
28
+ return opts;
29
+ }
30
+
31
+ function showHelp() {
32
+ console.log(`
33
+ ${chalk.bold('stars-profile')} — Manage GitHub Stars contributions with Copilot CLI deep research
34
+
35
+ ${chalk.bold('USAGE')}
36
+ npx stars-profile [options] [query]
37
+
38
+ ${chalk.bold('OPTIONS')}
39
+ -q, --query <text> Search query (e.g. "fatih kadir akın speeches")
40
+ -t, --token <token> Stars API token (default: $GITHUB_STARS_TOKEN)
41
+ --dry-run Print the curl command instead of submitting
42
+ -h, --help Show this help message
43
+
44
+ ${chalk.bold('EXAMPLES')}
45
+ npx stars-profile -q "john doe conference talks"
46
+ npx stars-profile --dry-run -q "jane smith open source"
47
+ GITHUB_STARS_TOKEN=abc npx stars-profile
48
+
49
+ ${chalk.bold('HOW IT WORKS')}
50
+ 1. Fetches your existing contributions from the Stars API
51
+ 2. Analyzes the language, tone, and style of your entries
52
+ 3. Uses GitHub Copilot CLI to deep-research new activities
53
+ 4. Presents results for review and selection
54
+ 5. Batch-creates selected contributions via the Stars API
55
+ `);
56
+ }
57
+
58
+ export async function run() {
59
+ const opts = parseArgs(process.argv);
60
+
61
+ if (opts.help) {
62
+ showHelp();
63
+ process.exit(0);
64
+ }
65
+
66
+ let token = opts.token || process.env.GITHUB_STARS_TOKEN || getSavedToken();
67
+ if (!token) {
68
+ token = await input({ message: 'Stars API token (Bearer token):' });
69
+ if (!token.trim()) {
70
+ console.error(chalk.red('Error: No Stars API token provided.'));
71
+ process.exit(1);
72
+ }
73
+ saveToken(token.trim());
74
+ console.log(chalk.dim('Token saved to ~/.config/stars-profile/config.json'));
75
+ }
76
+ token = token.trim();
77
+
78
+ // Step 1: Fetch existing contributions
79
+ const fetchSpinner = ora('Fetching existing contributions...').start();
80
+ let existing;
81
+ try {
82
+ existing = await fetchExistingContributions(token);
83
+ fetchSpinner.succeed(`Fetched ${existing.length} existing contributions`);
84
+ } catch (err) {
85
+ fetchSpinner.fail('Failed to fetch contributions');
86
+ console.error(chalk.red(err.message));
87
+ process.exit(1);
88
+ }
89
+
90
+ showExistingSummary(existing);
91
+
92
+ // Step 2: Get search query
93
+ let query = opts.query || getSavedQuery();
94
+ if (query) {
95
+ console.log(chalk.dim(`Using search query: "${query}"`));
96
+ } else {
97
+ query = await input({
98
+ message: 'Search query (e.g. "your name" + activities):',
99
+ });
100
+ if (!query.trim()) {
101
+ console.error(chalk.red('No search query provided.'));
102
+ process.exit(1);
103
+ }
104
+ }
105
+ query = query.trim();
106
+ saveQuery(query);
107
+
108
+ // Step 3: Research with Copilot CLI (streams output to terminal)
109
+ let newActivities;
110
+ try {
111
+ newActivities = await researchActivities(query, existing);
112
+ console.log(chalk.green(`\n✔ Found ${newActivities.length} new activities\n`));
113
+ } catch (err) {
114
+ console.error(chalk.red('\n✖ Research failed'));
115
+ console.error(chalk.red(err.message));
116
+ process.exit(1);
117
+ }
118
+
119
+ if (newActivities.length === 0) {
120
+ console.log(chalk.yellow('\nNo new activities found. Try a different search query.'));
121
+ process.exit(0);
122
+ }
123
+
124
+ // Step 4: Display and select
125
+ displayNewActivities(newActivities);
126
+ const selected = await selectActivities(newActivities);
127
+
128
+ if (selected.length === 0) {
129
+ console.log(chalk.yellow('No activities selected.'));
130
+ process.exit(0);
131
+ }
132
+
133
+ // Step 5: Confirm and submit
134
+ const proceed = await confirmSubmission(selected);
135
+ if (!proceed) {
136
+ console.log(chalk.yellow('Cancelled.'));
137
+ process.exit(0);
138
+ }
139
+
140
+ // Step 6: Create or dry-run
141
+ if (opts.dryRun) {
142
+ console.log(chalk.bold('\n--- Dry run: curl command ---\n'));
143
+ console.log(buildDryCurlCommand(token, selected));
144
+ console.log();
145
+ } else {
146
+ const createSpinner = ora('Creating contributions...').start();
147
+ try {
148
+ const created = await createContributions(token, selected);
149
+ createSpinner.succeed(`Created ${created.length} contribution(s)`);
150
+ console.log();
151
+ for (const c of created) {
152
+ console.log(` ${chalk.green('✓')} ${c.id}`);
153
+ }
154
+ console.log();
155
+ } catch (err) {
156
+ createSpinner.fail('Failed to create contributions');
157
+ console.error(chalk.red(err.message));
158
+ process.exit(1);
159
+ }
160
+ }
161
+ }
package/src/config.js ADDED
@@ -0,0 +1,39 @@
1
+ import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { homedir } from 'node:os';
4
+
5
+ const CONFIG_DIR = join(homedir(), '.config', 'stars-profile');
6
+ const CONFIG_FILE = join(CONFIG_DIR, 'config.json');
7
+
8
+ function readConfig() {
9
+ try {
10
+ return JSON.parse(readFileSync(CONFIG_FILE, 'utf-8'));
11
+ } catch {
12
+ return {};
13
+ }
14
+ }
15
+
16
+ function writeConfig(config) {
17
+ mkdirSync(CONFIG_DIR, { recursive: true });
18
+ writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2) + '\n');
19
+ }
20
+
21
+ export function getSavedQuery() {
22
+ return readConfig().query || null;
23
+ }
24
+
25
+ export function saveQuery(query) {
26
+ const config = readConfig();
27
+ config.query = query;
28
+ writeConfig(config);
29
+ }
30
+
31
+ export function getSavedToken() {
32
+ return readConfig().token || null;
33
+ }
34
+
35
+ export function saveToken(token) {
36
+ const config = readConfig();
37
+ config.token = token;
38
+ writeConfig(config);
39
+ }
package/src/display.js ADDED
@@ -0,0 +1,93 @@
1
+ import chalk from 'chalk';
2
+ import { checkbox, confirm } from '@inquirer/prompts';
3
+
4
+ const TYPE_COLORS = {
5
+ SPEAKING: 'cyan',
6
+ BLOGPOST: 'green',
7
+ ARTICLE_PUBLICATION: 'greenBright',
8
+ EVENT_ORGANIZATION: 'yellow',
9
+ HACKATHON: 'magenta',
10
+ OPEN_SOURCE_PROJECT: 'blue',
11
+ VIDEO_PODCAST: 'red',
12
+ FORUM: 'gray',
13
+ OTHER: 'white',
14
+ };
15
+
16
+ function colorType(type) {
17
+ const color = TYPE_COLORS[type] || 'white';
18
+ return chalk[color](type.padEnd(22));
19
+ }
20
+
21
+ function truncate(str, len) {
22
+ if (!str) return '';
23
+ return str.length > len ? str.slice(0, len - 1) + '…' : str;
24
+ }
25
+
26
+ export function showExistingSummary(contributions) {
27
+ console.log();
28
+ console.log(chalk.bold.underline(`Existing contributions: ${contributions.length}`));
29
+ console.log();
30
+
31
+ if (contributions.length === 0) {
32
+ console.log(chalk.dim(' No existing contributions found. New entries will use a generic format.'));
33
+ console.log();
34
+ return;
35
+ }
36
+
37
+ // Count by type
38
+ const counts = {};
39
+ for (const c of contributions) {
40
+ const t = c.type || 'OTHER';
41
+ counts[t] = (counts[t] || 0) + 1;
42
+ }
43
+
44
+ const sorted = Object.entries(counts).sort((a, b) => b[1] - a[1]);
45
+ for (const [type, count] of sorted) {
46
+ console.log(` ${colorType(type)} ${chalk.bold(count)}`);
47
+ }
48
+ console.log();
49
+ }
50
+
51
+ export function displayNewActivities(activities) {
52
+ console.log(chalk.bold.underline(`Found ${activities.length} new activities:`));
53
+ console.log();
54
+
55
+ for (let i = 0; i < activities.length; i++) {
56
+ const a = activities[i];
57
+ const idx = chalk.dim(`${(i + 1).toString().padStart(2)}.`);
58
+ console.log(` ${idx} ${colorType(a.type)} ${chalk.bold(truncate(a.title, 60))}`);
59
+ console.log(` ${chalk.dim(a.date?.slice(0, 10) || 'no date')} ${chalk.dim(truncate(a.url || 'no url', 70))}`);
60
+ console.log(` ${chalk.dim(truncate(a.description, 80))}`);
61
+ console.log();
62
+ }
63
+ }
64
+
65
+ export async function selectActivities(activities) {
66
+ const choices = activities.map((a, i) => ({
67
+ name: `[${a.type}] ${truncate(a.title, 50)} (${a.date?.slice(0, 10) || '?'})`,
68
+ value: i,
69
+ checked: true,
70
+ }));
71
+
72
+ const selected = await checkbox({
73
+ message: 'Select activities to create as contributions:',
74
+ choices,
75
+ pageSize: 20,
76
+ });
77
+
78
+ return selected.map(i => activities[i]);
79
+ }
80
+
81
+ export async function confirmSubmission(items) {
82
+ console.log();
83
+ console.log(chalk.bold(`About to create ${items.length} contribution(s):`));
84
+ for (const item of items) {
85
+ console.log(` ${colorType(item.type)} ${item.title}`);
86
+ }
87
+ console.log();
88
+
89
+ return confirm({
90
+ message: 'Proceed with creating these contributions?',
91
+ default: true,
92
+ });
93
+ }
@@ -0,0 +1,265 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { readFileSync, writeFileSync } from 'node:fs';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import { CONTRIBUTION_TYPES } from './api.js';
6
+
7
+ function sampleExamples(contributions) {
8
+ const byType = {};
9
+ for (const c of contributions) {
10
+ const t = c.type || 'OTHER';
11
+ if (!byType[t]) byType[t] = [];
12
+ byType[t].push(c);
13
+ }
14
+
15
+ const samples = [];
16
+ for (const [type, items] of Object.entries(byType)) {
17
+ samples.push(...items.slice(0, 2));
18
+ }
19
+ return samples.slice(0, 15);
20
+ }
21
+
22
+ function detectLanguage(contributions) {
23
+ if (contributions.length === 0) return null;
24
+
25
+ const text = contributions
26
+ .map(c => `${c.title} ${c.description}`)
27
+ .join(' ')
28
+ .toLowerCase();
29
+
30
+ const langSignals = {
31
+ Turkish: ['hakkında', 'konuştum', 'etkinlik', 'üniversitesi', 'çağında', 'hala', 'gerekli', 'topluluk', 'ile', 'için', 'olarak', 'bir'],
32
+ Portuguese: ['sobre', 'como', 'para', 'uma', 'comunidade', 'desenvolvimento'],
33
+ Spanish: ['sobre', 'cómo', 'para', 'una', 'comunidad', 'desarrollo', 'habló'],
34
+ Japanese: ['について', 'です', 'した', 'ます'],
35
+ Korean: ['에서', '대해', '입니다', '했습니다'],
36
+ };
37
+
38
+ const scores = {};
39
+ for (const [lang, words] of Object.entries(langSignals)) {
40
+ scores[lang] = words.filter(w => text.includes(w)).length;
41
+ }
42
+
43
+ const best = Object.entries(scores).sort((a, b) => b[1] - a[1])[0];
44
+ if (best && best[1] >= 3) {
45
+ return `primarily English with some ${best[0]} titles/phrases`;
46
+ }
47
+
48
+ return 'English';
49
+ }
50
+
51
+ // Phase 1: Research prompt — gather data freely, no JSON constraint
52
+ function buildResearchPrompt(query) {
53
+ return `Do deep research on "${query}" and find their public activities from the LAST 6 MONTHS ONLY (October 2025 – April 2026). Do NOT include older activities.
54
+
55
+ Specifically search these platforms:
56
+ - X (Twitter): Search x.com/search for recent posts and mentions about this person's talks, projects, and activities
57
+ - LinkedIn: Search linkedin.com for their recent posts, articles, and event announcements
58
+ - YouTube: Search for their recent talks, interviews, and podcast appearances
59
+ - GitHub: Search for their recently active repositories and open source contributions
60
+ - Google: General web search for recent conference appearances, blog posts, and news mentions
61
+ - Dev.to, Medium, personal blogs: Search for articles they've recently written
62
+
63
+ IMPORTANT: Only include activities from the last 6 months. Skip anything older.
64
+
65
+ For each activity found, note:
66
+ - What it is (talk, blog post, open source project, video, podcast, event, hackathon, etc.)
67
+ - The title
68
+ - The full URL/link (must be a complete URL starting with https://)
69
+ - A brief description
70
+ - The approximate date
71
+
72
+ List everything you find. Be thorough — check multiple pages and sources, but only recent items.`;
73
+ }
74
+
75
+ // Phase 2: Convert prompt — take raw research and convert to structured JSON
76
+ function buildConvertPrompt(researchDataPath, outputPath, existingContributions) {
77
+ const samples = sampleExamples(existingContributions);
78
+ const existingUrls = existingContributions
79
+ .map(c => c.url)
80
+ .filter(Boolean);
81
+
82
+ const language = detectLanguage(existingContributions);
83
+
84
+ let styleSection;
85
+ if (samples.length > 0) {
86
+ const examplesJson = JSON.stringify(
87
+ samples.map(({ title, url, description, type, date }) => ({ title, url, description, type, date })),
88
+ null,
89
+ 2
90
+ );
91
+
92
+ styleSection = `Here are examples of how this person's existing contributions are formatted — match the SAME language, tone, and description style exactly:
93
+
94
+ ${examplesJson}
95
+
96
+ The detected writing style is ${language}. Write all new entries in the same language and voice as these examples.`;
97
+ } else {
98
+ styleSection = `This person has no existing contributions yet. Use a professional, first-person style for descriptions (e.g. "I spoke about X at Y", "A workshop about X for Y").`;
99
+ }
100
+
101
+ const excludeSection = existingUrls.length > 0
102
+ ? `\nExclude any of these URLs (already recorded):\n${existingUrls.join('\n')}\n`
103
+ : '';
104
+
105
+ return `Read the research data from the file at ${researchDataPath}.
106
+
107
+ Convert ALL the activities found into a valid JSON array and write it to ${outputPath}.
108
+
109
+ ${styleSection}
110
+ ${excludeSection}
111
+ Each object in the JSON array must have exactly these fields:
112
+ - "title": string
113
+ - "url": string or null
114
+ - "description": string (matching the style above)
115
+ - "type": one of: ${CONTRIBUTION_TYPES.join(', ')}
116
+ - "date": ISO 8601 string (e.g. "2025-03-22T00:00:00.000Z")
117
+
118
+ Write ONLY valid JSON to the output file. No explanations, no markdown — just the raw JSON array.
119
+ Make sure the file is written successfully.`;
120
+ }
121
+
122
+ function isValidUrl(str) {
123
+ if (!str || typeof str !== 'string') return false;
124
+ try {
125
+ const u = new URL(str);
126
+ return u.protocol === 'https:' || u.protocol === 'http:';
127
+ } catch {
128
+ return false;
129
+ }
130
+ }
131
+
132
+ function validateContribution(item) {
133
+ if (!item || typeof item !== 'object') return null;
134
+ if (!item.title || !item.description || !item.date) return null;
135
+ if (item.type && !CONTRIBUTION_TYPES.includes(item.type)) {
136
+ item.type = 'OTHER';
137
+ }
138
+
139
+ let url = item.url ? String(item.url).trim() : null;
140
+ // Fix URLs missing protocol
141
+ if (url && !url.startsWith('http')) {
142
+ url = 'https://' + url;
143
+ }
144
+ // Drop invalid URLs entirely — the API crashes on bad URLs
145
+ if (url && !isValidUrl(url)) {
146
+ url = null;
147
+ }
148
+
149
+ return {
150
+ title: String(item.title),
151
+ url,
152
+ description: String(item.description),
153
+ type: item.type || 'OTHER',
154
+ date: String(item.date),
155
+ };
156
+ }
157
+
158
+ function spawnCopilotStreaming(prompt, timeout = 180_000) {
159
+ return new Promise((resolve, reject) => {
160
+ let stdout = '';
161
+ let stderr = '';
162
+
163
+ const proc = spawn('copilot', ['-p', prompt, '--allow-all-tools'], {
164
+ stdio: ['ignore', 'pipe', 'pipe'],
165
+ timeout,
166
+ });
167
+
168
+ proc.stdout.on('data', chunk => {
169
+ const text = chunk.toString();
170
+ stdout += text;
171
+ process.stderr.write(text); // stream to terminal in real-time
172
+ });
173
+
174
+ proc.stderr.on('data', chunk => {
175
+ stderr += chunk.toString();
176
+ });
177
+
178
+ proc.on('error', (err) => {
179
+ if (err.code === 'ENOENT') {
180
+ reject(new Error(
181
+ 'GitHub Copilot CLI not found. Install it:\n npm install -g @github/copilot\nThen authenticate:\n copilot /login'
182
+ ));
183
+ } else {
184
+ reject(err);
185
+ }
186
+ });
187
+
188
+ proc.on('close', (code) => {
189
+ if (code !== 0 && !stdout.trim()) {
190
+ reject(new Error(`Copilot CLI exited with code ${code}: ${stderr.trim()}`));
191
+ } else {
192
+ resolve(stdout);
193
+ }
194
+ });
195
+ });
196
+ }
197
+
198
+ export async function researchActivities(query, existingContributions) {
199
+ const timestamp = Date.now();
200
+ const researchDataPath = join(tmpdir(), `stars-profile-research-${timestamp}.txt`);
201
+ const outputPath = join(tmpdir(), `stars-profile-results-${timestamp}.json`);
202
+
203
+ // Phase 1: Deep research — streams output to terminal
204
+ console.log('\n--- Phase 1: Deep Research ---\n');
205
+ const researchOutput = await spawnCopilotStreaming(
206
+ buildResearchPrompt(query)
207
+ );
208
+ console.log('\n');
209
+
210
+ // Save research data to temp file for phase 2
211
+ writeFileSync(researchDataPath, researchOutput);
212
+ console.log(`Research data saved to: ${researchDataPath}`);
213
+
214
+ // Phase 2: Convert to structured JSON
215
+ console.log('\n--- Phase 2: Converting to structured data ---\n');
216
+ await spawnCopilotStreaming(
217
+ buildConvertPrompt(researchDataPath, outputPath, existingContributions)
218
+ );
219
+ console.log('\n');
220
+
221
+ // Read the JSON output file
222
+ let jsonContent;
223
+ try {
224
+ jsonContent = readFileSync(outputPath, 'utf-8');
225
+ } catch {
226
+ throw new Error(
227
+ `Copilot did not write the JSON output file at ${outputPath}.\nResearch data saved at: ${researchDataPath}`
228
+ );
229
+ }
230
+
231
+ // Parse JSON
232
+ let parsed;
233
+ try {
234
+ parsed = JSON.parse(jsonContent.trim());
235
+ } catch {
236
+ const match = jsonContent.match(/\[[\s\S]*\]/);
237
+ if (match) {
238
+ try {
239
+ parsed = JSON.parse(match[0]);
240
+ } catch {
241
+ throw new Error(
242
+ `Could not parse JSON from output file.\nFile: ${outputPath}\nResearch data: ${researchDataPath}`
243
+ );
244
+ }
245
+ } else {
246
+ throw new Error(
247
+ `Invalid JSON in output file.\nFile: ${outputPath}\nResearch data: ${researchDataPath}`
248
+ );
249
+ }
250
+ }
251
+
252
+ if (!Array.isArray(parsed)) {
253
+ throw new Error('Output JSON is not an array.');
254
+ }
255
+
256
+ // Validate and dedup
257
+ const existingUrls = new Set(
258
+ existingContributions.map(c => c.url).filter(Boolean)
259
+ );
260
+
261
+ return parsed
262
+ .map(validateContribution)
263
+ .filter(Boolean)
264
+ .filter(item => !item.url || !existingUrls.has(item.url));
265
+ }