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 +3 -0
- package/package.json +34 -0
- package/src/api.js +92 -0
- package/src/cli.js +161 -0
- package/src/config.js +39 -0
- package/src/display.js +93 -0
- package/src/research.js +265 -0
package/bin/cli.js
ADDED
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
|
+
}
|
package/src/research.js
ADDED
|
@@ -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
|
+
}
|