github-star-lists 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,191 @@
1
+ # GitHub Star List Organizer
2
+
3
+ Organize GitHub starred repositories into GitHub Star Lists. The CLI creates missing lists, classifies starred repositories with configurable rules, writes a dry-run plan first, and can sync list descriptions and visibility.
4
+
5
+ ## Install
6
+
7
+ Run without installing:
8
+
9
+ ```bash
10
+ npx github-star-lists
11
+ ```
12
+
13
+ Or install globally:
14
+
15
+ ```bash
16
+ npm install -g github-star-lists
17
+ github-star-lists
18
+ ```
19
+
20
+ ## Authentication
21
+
22
+ Use GitHub CLI:
23
+
24
+ ```bash
25
+ gh auth login
26
+ gh auth refresh -h github.com -s user -s repo
27
+ ```
28
+
29
+ Or provide a token:
30
+
31
+ ```bash
32
+ GITHUB_TOKEN=ghp_xxx npx github-star-lists
33
+ ```
34
+
35
+ The token needs the classic `user` scope. Add `repo` if your starred repositories include private repositories.
36
+
37
+ ## Quick Start
38
+
39
+ Create a local config:
40
+
41
+ ```bash
42
+ npx github-star-lists init
43
+ ```
44
+
45
+ Use the guided flow:
46
+
47
+ ```bash
48
+ npx github-star-lists wizard
49
+ ```
50
+
51
+ The wizard asks you to choose:
52
+
53
+ - preview or apply
54
+ - public, private, or config-based list visibility
55
+ - whether to use existing lists only
56
+ - whether to sync descriptions
57
+ - whether to sync existing list visibility
58
+ - whether to scan all stars or only the newest N
59
+
60
+ Preview directly. This does not write to GitHub:
61
+
62
+ ```bash
63
+ npx github-star-lists
64
+ ```
65
+
66
+ Apply changes:
67
+
68
+ ```bash
69
+ npx github-star-lists --apply
70
+ ```
71
+
72
+ Most users should start with `wizard`. The flags below are useful for automation or repeatable workflows.
73
+
74
+ ## Visibility
75
+
76
+ New lists are public by default. Before applying, the CLI prints lists as `[x] public` or `[x] private` and asks for confirmation.
77
+
78
+ ```bash
79
+ npx github-star-lists --apply
80
+ npx github-star-lists --all-private --apply
81
+ npx github-star-lists --all-public --apply
82
+ ```
83
+
84
+ Sync existing configured lists to the chosen visibility:
85
+
86
+ ```bash
87
+ npx github-star-lists --all-public --sync-list-visibility --apply
88
+ npx github-star-lists --all-private --sync-list-visibility --apply
89
+ ```
90
+
91
+ Skip confirmation in automation:
92
+
93
+ ```bash
94
+ npx github-star-lists --apply --yes
95
+ ```
96
+
97
+ ## Common Commands
98
+
99
+ Scan only the newest 50 stars:
100
+
101
+ ```bash
102
+ npx github-star-lists --limit=50
103
+ ```
104
+
105
+ Use existing lists only:
106
+
107
+ ```bash
108
+ npx github-star-lists --existing-only
109
+ npx github-star-lists --existing-only --apply
110
+ ```
111
+
112
+ Fill empty list descriptions from config:
113
+
114
+ ```bash
115
+ npx github-star-lists --sync-list-descriptions
116
+ npx github-star-lists --sync-list-descriptions --apply
117
+ ```
118
+
119
+ Only sync list metadata without changing repository assignments:
120
+
121
+ ```bash
122
+ npx github-star-lists --sync-list-descriptions --only-list-metadata --apply
123
+ npx github-star-lists --sync-list-visibility --all-public --only-list-metadata --apply
124
+ ```
125
+
126
+ Overwrite existing descriptions:
127
+
128
+ ```bash
129
+ npx github-star-lists --sync-list-descriptions --overwrite-list-descriptions --apply
130
+ ```
131
+
132
+ Hide progress output:
133
+
134
+ ```bash
135
+ npx github-star-lists --quiet
136
+ ```
137
+
138
+ ## Configuration
139
+
140
+ By default the CLI uses the first existing file from:
141
+
142
+ 1. `star-lists.config.json`
143
+ 2. `config/star-lists.json`
144
+ 3. the packaged default config
145
+
146
+ Use a specific config:
147
+
148
+ ```bash
149
+ npx github-star-lists --config ./my-star-lists.json
150
+ ```
151
+
152
+ Each list can define:
153
+
154
+ ```json
155
+ {
156
+ "name": "LLM",
157
+ "description": "Large language models, RAG, inference, evals, and LLM application tooling.",
158
+ "isPrivate": false,
159
+ "keywords": ["llm", "rag", "embedding", "inference"],
160
+ "topics": ["llm", "rag", "embeddings"]
161
+ }
162
+ ```
163
+
164
+ `--all-private` and `--all-public` override per-list `isPrivate` values.
165
+
166
+ ## How Classification Works
167
+
168
+ The CLI first checks repository name, description, homepage, primary language, and topics. Repositories that do not meet the score threshold are checked again using their README. README matches are shown in the plan as `readme:<keyword>`.
169
+
170
+ Existing list assignments are preserved by default. The CLI only adds newly matched lists unless you change `preserveExistingAssignments` in config.
171
+
172
+ ## Output
173
+
174
+ Dry-run and apply commands write:
175
+
176
+ ```text
177
+ out/plan.md
178
+ out/plan.json
179
+ ```
180
+
181
+ `--existing-only` writes `out/plan-existing-only.md` and `out/plan-existing-only.json`.
182
+
183
+ `--only-list-metadata` writes `out/plan-list-metadata.md` and `out/plan-list-metadata.json`.
184
+
185
+ ## Local Development
186
+
187
+ ```bash
188
+ npm run check
189
+ npm run plan
190
+ npm run apply
191
+ ```
@@ -0,0 +1,118 @@
1
+ {
2
+ "settings": {
3
+ "defaultPrivate": false,
4
+ "minScore": 2,
5
+ "maxListsPerRepo": 3,
6
+ "preserveExistingAssignments": true
7
+ },
8
+ "lists": [
9
+ {
10
+ "name": "Agentic Coding",
11
+ "description": "AI coding agents, code generation, MCP, and developer automation.",
12
+ "keywords": [
13
+ "agent", "agentic", "ai coding", "autonomous", "codegen", "coding assistant",
14
+ "copilot", "cursor", "claude", "codex", "mcp", "multi-agent", "swe", "devin",
15
+ "software engineering agent", "llm app"
16
+ ],
17
+ "topics": ["agents", "ai-agent", "mcp", "code-generation", "coding-agent", "developer-tools"]
18
+ },
19
+ {
20
+ "name": "LLM",
21
+ "description": "Large language models, RAG, inference, evals, and LLM application tooling.",
22
+ "keywords": [
23
+ "llm", "large language model", "language model", "rag", "retrieval",
24
+ "transformer", "prompt", "embedding", "inference", "ollama", "llama",
25
+ "vllm", "langchain", "llamaindex", "eval", "chatbot"
26
+ ],
27
+ "topics": ["llm", "rag", "transformers", "langchain", "llama", "openai", "chatbot", "embeddings"]
28
+ },
29
+ {
30
+ "name": "Vision",
31
+ "description": "Computer vision, image generation, video, OCR, and multimodal vision.",
32
+ "keywords": [
33
+ "vision", "computer vision", "image", "video", "ocr", "segmentation",
34
+ "detection", "object detection", "diffusion", "stable diffusion", "multimodal",
35
+ "vae", "gan", "opencv", "yolo"
36
+ ],
37
+ "topics": ["computer-vision", "opencv", "image-processing", "diffusion", "ocr", "yolo", "multimodal"]
38
+ },
39
+ {
40
+ "name": "Quantization",
41
+ "description": "Model compression, quantization, GGUF, and efficient inference.",
42
+ "keywords": [
43
+ "quantization", "quantize", "quantized", "int4", "int8", "fp8", "gguf",
44
+ "ggml", "awq", "gptq", "bitsandbytes", "compression", "efficient inference"
45
+ ],
46
+ "topics": ["quantization", "gguf", "ggml", "model-compression", "inference"]
47
+ },
48
+ {
49
+ "name": "ROS",
50
+ "description": "ROS, robotics middleware, robot tooling, and autonomy systems.",
51
+ "keywords": ["ros", "ros2", "robot", "robotics", "navigation2", "nav2", "gazebo", "rviz", "urdf"],
52
+ "topics": ["ros", "ros2", "robotics", "gazebo", "rviz", "autonomous-robots"]
53
+ },
54
+ {
55
+ "name": "SLAM",
56
+ "description": "SLAM, localization, mapping, LiDAR, and visual odometry.",
57
+ "keywords": [
58
+ "slam", "localization", "mapping", "lidar", "visual odometry", "odometry",
59
+ "bundle adjustment", "point cloud", "pointcloud", "3d reconstruction"
60
+ ],
61
+ "topics": ["slam", "lidar", "mapping", "localization", "point-cloud", "visual-odometry"]
62
+ },
63
+ {
64
+ "name": "Utilities",
65
+ "description": "Practical CLI, desktop, productivity, and small workflow tools.",
66
+ "keywords": [
67
+ "cli", "terminal", "tui", "utility", "tool", "productivity", "workflow",
68
+ "automation", "desktop", "app", "editor", "viewer", "formatter", "converter"
69
+ ],
70
+ "topics": ["cli", "terminal", "tui", "productivity", "developer-tools", "automation"]
71
+ },
72
+ {
73
+ "name": "Frontend",
74
+ "description": "Frontend frameworks, UI components, design systems, and web apps.",
75
+ "keywords": [
76
+ "react", "next.js", "nextjs", "vue", "svelte", "frontend", "ui", "component",
77
+ "design system", "tailwind", "css", "web app", "vite"
78
+ ],
79
+ "topics": ["react", "nextjs", "vue", "svelte", "tailwindcss", "frontend", "ui"]
80
+ },
81
+ {
82
+ "name": "Backend",
83
+ "description": "APIs, servers, databases, infrastructure services, and backend frameworks.",
84
+ "keywords": [
85
+ "api", "backend", "server", "database", "postgres", "sqlite", "redis",
86
+ "queue", "microservice", "fastapi", "django", "rails", "spring", "nestjs"
87
+ ],
88
+ "topics": ["api", "backend", "database", "postgresql", "sqlite", "redis", "server"]
89
+ },
90
+ {
91
+ "name": "DevOps",
92
+ "description": "Deployment, containers, Kubernetes, CI, observability, and operations.",
93
+ "keywords": [
94
+ "docker", "kubernetes", "k8s", "terraform", "ansible", "ci", "cd",
95
+ "deployment", "observability", "monitoring", "prometheus", "grafana"
96
+ ],
97
+ "topics": ["docker", "kubernetes", "terraform", "devops", "ci-cd", "monitoring"]
98
+ },
99
+ {
100
+ "name": "Data",
101
+ "description": "Data engineering, analytics, notebooks, ETL, and visualization.",
102
+ "keywords": [
103
+ "data", "analytics", "etl", "pipeline", "notebook", "pandas", "duckdb",
104
+ "spark", "visualization", "dashboard", "chart"
105
+ ],
106
+ "topics": ["data-engineering", "analytics", "etl", "pandas", "duckdb", "visualization"]
107
+ },
108
+ {
109
+ "name": "Security",
110
+ "description": "Security tooling, secrets, auth, vulnerability research, and privacy.",
111
+ "keywords": [
112
+ "security", "auth", "oauth", "passkey", "secret", "vulnerability",
113
+ "scanner", "privacy", "encryption", "cryptography", "sso"
114
+ ],
115
+ "topics": ["security", "privacy", "oauth", "cryptography", "vulnerability-scanner"]
116
+ }
117
+ ]
118
+ }
package/package.json ADDED
@@ -0,0 +1,37 @@
1
+ {
2
+ "name": "github-star-lists",
3
+ "version": "0.1.0",
4
+ "description": "Organize GitHub starred repositories into Star Lists with dry-run plans, README fallback classification, and list description sync.",
5
+ "type": "module",
6
+ "bin": {
7
+ "github-star-lists": "scripts/organize-stars.mjs",
8
+ "gh-star-lists": "scripts/organize-stars.mjs"
9
+ },
10
+ "files": [
11
+ "scripts/",
12
+ "config/",
13
+ "README.md",
14
+ "LICENSE"
15
+ ],
16
+ "scripts": {
17
+ "check": "node --check scripts/organize-stars.mjs",
18
+ "plan": "node scripts/organize-stars.mjs",
19
+ "apply": "node scripts/organize-stars.mjs --apply"
20
+ },
21
+ "keywords": [
22
+ "github",
23
+ "stars",
24
+ "starred",
25
+ "lists",
26
+ "cli",
27
+ "graphql"
28
+ ],
29
+ "author": "",
30
+ "license": "MIT",
31
+ "engines": {
32
+ "node": ">=20"
33
+ },
34
+ "publishConfig": {
35
+ "access": "public"
36
+ }
37
+ }
@@ -0,0 +1,940 @@
1
+ #!/usr/bin/env node
2
+ import { execFileSync, spawnSync } from "node:child_process";
3
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
4
+ import { createInterface } from "node:readline/promises";
5
+ import { stdin as input, stdout as output } from "node:process";
6
+ import { dirname, resolve } from "node:path";
7
+ import { fileURLToPath } from "node:url";
8
+
9
+ const PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
10
+ const DEFAULT_CONFIG_PATH = resolve(PACKAGE_ROOT, "config/star-lists.json");
11
+
12
+ const command = process.argv[2]?.startsWith("-") ? null : process.argv[2];
13
+ const args = new Set(process.argv.slice(2));
14
+ const configPath = resolveOption("--config") ?? findDefaultConfigPath();
15
+ const outDir = resolve(process.cwd(), resolveOption("--out-dir") ?? "out");
16
+ const apply = args.has("--apply");
17
+ const verbose = args.has("--verbose");
18
+ const quiet = args.has("--quiet");
19
+ const existingOnly = args.has("--existing-only");
20
+ const syncListDescriptions = args.has("--sync-list-descriptions");
21
+ const syncListVisibility = args.has("--sync-list-visibility");
22
+ const overwriteListDescriptions = args.has("--overwrite-list-descriptions");
23
+ const onlyListDescriptions = args.has("--only-list-descriptions");
24
+ const onlyListMetadata = args.has("--only-list-metadata") || onlyListDescriptions;
25
+ const allPrivate = args.has("--all-private");
26
+ const allPublic = args.has("--all-public");
27
+ const yes = args.has("--yes") || args.has("-y");
28
+ const limitArg = process.argv.find((arg) => arg.startsWith("--limit="));
29
+ const limit = limitArg ? Number(limitArg.slice("--limit=".length)) : Infinity;
30
+
31
+ if (args.has("--help") || args.has("-h")) {
32
+ printHelp();
33
+ process.exit(0);
34
+ }
35
+
36
+ if (command === "init") {
37
+ initConfig();
38
+ process.exit(0);
39
+ }
40
+
41
+ if (command === "wizard") {
42
+ const status = await runWizard();
43
+ process.exit(status);
44
+ }
45
+
46
+ if (command && !["run", "wizard"].includes(command)) {
47
+ fail(`Unknown command: ${command}\nRun with --help for usage.`);
48
+ }
49
+
50
+ if (Number.isNaN(limit) || limit <= 0) {
51
+ fail("--limit must be a positive number");
52
+ }
53
+
54
+ if (allPrivate && allPublic) {
55
+ fail("Choose only one of --all-private or --all-public.");
56
+ }
57
+
58
+ const config = JSON.parse(readFileSync(configPath, "utf8"));
59
+ const settings = {
60
+ defaultPrivate: false,
61
+ minScore: 2,
62
+ maxListsPerRepo: 3,
63
+ preserveExistingAssignments: true,
64
+ readmeFallback: true,
65
+ readmeMaxChars: 20000,
66
+ readmeIgnoredKeywords: [
67
+ "api", "app", "cd", "ci", "css", "data", "image", "prompt", "server",
68
+ "tool", "ui", "video", "workflow"
69
+ ],
70
+ ...config.settings
71
+ };
72
+
73
+ const token = getToken();
74
+
75
+ function resolveOption(name) {
76
+ const equalsArg = process.argv.find((arg) => arg.startsWith(`${name}=`));
77
+ if (equalsArg) return equalsArg.slice(name.length + 1);
78
+
79
+ const index = process.argv.indexOf(name);
80
+ if (index !== -1) return process.argv[index + 1];
81
+ return null;
82
+ }
83
+
84
+ function findDefaultConfigPath() {
85
+ const candidates = [
86
+ resolve(process.cwd(), "star-lists.config.json"),
87
+ resolve(process.cwd(), "config/star-lists.json"),
88
+ DEFAULT_CONFIG_PATH
89
+ ];
90
+ return candidates.find((candidate) => existsSync(candidate)) ?? DEFAULT_CONFIG_PATH;
91
+ }
92
+
93
+ function initConfig() {
94
+ const target = resolve(process.cwd(), resolveOption("--config") ?? "star-lists.config.json");
95
+ if (existsSync(target) && !args.has("--force")) {
96
+ fail(`${target} already exists. Re-run init with --force to overwrite it.`);
97
+ }
98
+ writeFileSync(target, readFileSync(DEFAULT_CONFIG_PATH, "utf8"));
99
+ console.log(`Wrote ${target}`);
100
+ }
101
+
102
+ async function runWizard() {
103
+ if (!process.stdin.isTTY) {
104
+ fail("Wizard requires an interactive terminal. Use flags directly in non-interactive environments.");
105
+ }
106
+
107
+ const rl = createInterface({ input, output });
108
+ try {
109
+ console.log("GitHub Star Lists setup");
110
+ console.log("");
111
+
112
+ const mode = await promptChoice(rl, "What do you want to do?", [
113
+ ["preview", "Preview a plan only"],
114
+ ["apply", "Apply star list changes"],
115
+ ["metadata", "Only sync list descriptions/visibility"]
116
+ ], "preview");
117
+
118
+ const visibility = await promptChoice(rl, "New list visibility?", [
119
+ ["public", "Public"],
120
+ ["private", "Private"],
121
+ ["config", "Use config per list"]
122
+ ], "public");
123
+
124
+ const generatedArgs = [];
125
+ appendPassthroughOption(generatedArgs, "--config");
126
+ appendPassthroughOption(generatedArgs, "--out-dir");
127
+
128
+ if (mode === "apply" || mode === "metadata") generatedArgs.push("--apply");
129
+ if (mode === "metadata") generatedArgs.push("--only-list-metadata");
130
+
131
+ if (visibility === "public") generatedArgs.push("--all-public");
132
+ if (visibility === "private") generatedArgs.push("--all-private");
133
+
134
+ const useExistingOnly = mode !== "metadata" &&
135
+ await promptYesNo(rl, "Use existing lists only?", false);
136
+ if (useExistingOnly) generatedArgs.push("--existing-only");
137
+
138
+ const syncDescriptions = await promptYesNo(rl, "Fill empty list descriptions from config?", true);
139
+ if (syncDescriptions) generatedArgs.push("--sync-list-descriptions");
140
+
141
+ const syncVisibility = await promptYesNo(rl, "Also sync existing list visibility?", false);
142
+ if (syncVisibility) generatedArgs.push("--sync-list-visibility");
143
+
144
+ if (mode !== "metadata") {
145
+ const limitValue = await promptText(rl, "Limit to newest N stars? Leave blank for all", "");
146
+ if (limitValue.trim()) generatedArgs.push(`--limit=${limitValue.trim()}`);
147
+ }
148
+
149
+ if (args.has("--verbose")) generatedArgs.push("--verbose");
150
+ if (args.has("--quiet")) generatedArgs.push("--quiet");
151
+
152
+ console.log("");
153
+ console.log("Checklist:");
154
+ console.log(` [x] Mode: ${mode === "preview" ? "preview only" : mode === "apply" ? "apply changes" : "metadata only"}`);
155
+ console.log(` [x] New list visibility: ${visibility}`);
156
+ console.log(` [${useExistingOnly ? "x" : " "}] Existing lists only`);
157
+ console.log(` [${syncDescriptions ? "x" : " "}] Sync descriptions`);
158
+ console.log(` [${syncVisibility ? "x" : " "}] Sync existing visibility`);
159
+ console.log("");
160
+ console.log(`Command: github-star-lists ${generatedArgs.join(" ") || "(preview)"}`);
161
+ console.log("");
162
+
163
+ const shouldRun = await promptYesNo(rl, "Run this now?", true);
164
+ if (!shouldRun) return 0;
165
+
166
+ const scriptPath = fileURLToPath(import.meta.url);
167
+ const child = spawnSync(process.execPath, [scriptPath, ...generatedArgs], {
168
+ cwd: process.cwd(),
169
+ env: process.env,
170
+ stdio: "inherit"
171
+ });
172
+ return child.status ?? 1;
173
+ } finally {
174
+ rl.close();
175
+ }
176
+ }
177
+
178
+ function appendPassthroughOption(targetArgs, name) {
179
+ const value = resolveOption(name);
180
+ if (!value) return;
181
+ targetArgs.push(name, value);
182
+ }
183
+
184
+ async function promptChoice(rl, question, choices, defaultValue) {
185
+ const defaultIndex = choices.findIndex(([value]) => value === defaultValue);
186
+ const fallbackIndex = defaultIndex === -1 ? 0 : defaultIndex;
187
+
188
+ console.log(question);
189
+ choices.forEach(([, label], index) => {
190
+ const checked = index === fallbackIndex ? "x" : " ";
191
+ console.log(` ${index + 1}. [${checked}] ${label}`);
192
+ });
193
+
194
+ const answer = await rl.question(`Choose 1-${choices.length} [${fallbackIndex + 1}]: `);
195
+ const selected = Number(answer.trim() || String(fallbackIndex + 1));
196
+ if (!Number.isInteger(selected) || selected < 1 || selected > choices.length) {
197
+ console.log("");
198
+ console.log(`Using default: ${choices[fallbackIndex][1]}`);
199
+ console.log("");
200
+ return choices[fallbackIndex][0];
201
+ }
202
+ console.log("");
203
+ return choices[selected - 1][0];
204
+ }
205
+
206
+ async function promptYesNo(rl, question, defaultYes) {
207
+ const answer = await rl.question(`${question} [${defaultYes ? "Y/n" : "y/N"}]: `);
208
+ const normalized = answer.trim().toLowerCase();
209
+ if (!normalized) return defaultYes;
210
+ return ["y", "yes"].includes(normalized);
211
+ }
212
+
213
+ async function promptText(rl, question, defaultValue) {
214
+ const suffix = defaultValue ? ` [${defaultValue}]` : "";
215
+ const answer = await rl.question(`${question}${suffix}: `);
216
+ return answer || defaultValue;
217
+ }
218
+
219
+ function getToken() {
220
+ const envToken = process.env.GITHUB_TOKEN || process.env.GH_TOKEN;
221
+ if (envToken) return envToken;
222
+
223
+ try {
224
+ return execFileSync("gh", ["auth", "token"], { encoding: "utf8" }).trim();
225
+ } catch {
226
+ fail("No GitHub token found. Set GITHUB_TOKEN/GH_TOKEN or run `gh auth login`.");
227
+ }
228
+ }
229
+
230
+ function resolveListPrivacy(listConfig) {
231
+ if (allPrivate) return true;
232
+ if (allPublic) return false;
233
+ return listConfig.isPrivate ?? settings.defaultPrivate;
234
+ }
235
+
236
+ async function confirmListVisibility({ listsToCreate, listVisibilityUpdates }) {
237
+ if (yes) return;
238
+ console.log("");
239
+ if (listsToCreate.length) {
240
+ console.log("Lists to create:");
241
+ for (const list of listsToCreate) {
242
+ const mark = list.isPrivate ? "[x] private" : "[x] public";
243
+ console.log(` ${mark} ${list.name}`);
244
+ }
245
+ }
246
+ if (listVisibilityUpdates.length) {
247
+ if (listsToCreate.length) console.log("");
248
+ console.log("Existing list visibility changes:");
249
+ for (const update of listVisibilityUpdates) {
250
+ const mark = update.isPrivate ? "[x] private" : "[x] public";
251
+ console.log(` ${mark} ${update.name} (${update.from} -> ${update.to})`);
252
+ }
253
+ }
254
+ console.log("");
255
+ console.log("Default visibility is public. Re-run with --all-private for private, or --all-public for public.");
256
+
257
+ if (!process.stdin.isTTY) {
258
+ fail("Confirmation required before changing list visibility. Re-run with --yes to skip this prompt.");
259
+ }
260
+
261
+ const rl = createInterface({ input, output });
262
+ const answer = await rl.question("Continue with the visibility shown above? Type yes to continue: ");
263
+ rl.close();
264
+
265
+ if (answer.trim().toLowerCase() !== "yes") {
266
+ fail("Aborted.");
267
+ }
268
+ }
269
+
270
+ function printHelp() {
271
+ console.log(`GitHub Star List Organizer
272
+
273
+ Usage:
274
+ github-star-lists init [--config star-lists.config.json] [--force]
275
+ github-star-lists wizard
276
+ github-star-lists [run] [options]
277
+
278
+ Options:
279
+ --apply Apply changes to GitHub. Without this, only writes a plan.
280
+ --config <path> Classification config path.
281
+ --out-dir <path> Plan output directory. Default: out
282
+ --limit <number> Scan only the newest N starred repositories.
283
+ --existing-only Do not create missing lists; only use existing lists.
284
+ --all-public Create missing lists as public. This is the default.
285
+ --all-private Create missing lists as private.
286
+ --sync-list-descriptions Fill empty list descriptions from config.
287
+ --sync-list-visibility Update existing configured lists to the chosen visibility.
288
+ --only-list-metadata Only update list descriptions/visibility; do not classify stars.
289
+ --only-list-descriptions Alias for --only-list-metadata.
290
+ --overwrite-list-descriptions Replace existing descriptions when syncing.
291
+ --yes, -y Skip apply confirmation prompts.
292
+ --verbose Print each updated repository/list.
293
+ --quiet Hide progress output.
294
+ --help Show this help.
295
+
296
+ Authentication:
297
+ Use gh auth login, or set GITHUB_TOKEN/GH_TOKEN. The token needs the classic "user" scope.
298
+ `);
299
+ }
300
+
301
+ async function main() {
302
+ const progress = createProgress({ quiet });
303
+ progress.start("Loading viewer lists");
304
+ const viewer = await graphql(`
305
+ query {
306
+ viewer {
307
+ login
308
+ id
309
+ lists(first: 100) {
310
+ nodes {
311
+ id
312
+ name
313
+ description
314
+ isPrivate
315
+ items(first: 100) {
316
+ nodes {
317
+ __typename
318
+ ... on Repository { id nameWithOwner }
319
+ }
320
+ pageInfo { hasNextPage endCursor }
321
+ }
322
+ }
323
+ }
324
+ }
325
+ }
326
+ `);
327
+ progress.done("Loaded viewer lists");
328
+
329
+ const listByName = new Map(viewer.viewer.lists.nodes.map((list) => [list.name, list]));
330
+ const listsToCreate = [];
331
+ const listDescriptionUpdates = [];
332
+ const listVisibilityUpdates = [];
333
+
334
+ for (const listConfig of config.lists) {
335
+ const isPrivate = resolveListPrivacy(listConfig);
336
+ const existingList = listByName.get(listConfig.name);
337
+ if (existingList) {
338
+ const wantedDescription = listConfig.description ?? "";
339
+ const currentDescription = existingList.description ?? "";
340
+ const shouldUpdateDescription = syncListDescriptions &&
341
+ wantedDescription &&
342
+ currentDescription !== wantedDescription &&
343
+ (overwriteListDescriptions || currentDescription.trim() === "");
344
+
345
+ if (shouldUpdateDescription) {
346
+ listDescriptionUpdates.push({
347
+ id: existingList.id,
348
+ name: existingList.name,
349
+ from: currentDescription,
350
+ to: wantedDescription
351
+ });
352
+ }
353
+ if (syncListVisibility && existingList.isPrivate !== isPrivate) {
354
+ listVisibilityUpdates.push({
355
+ id: existingList.id,
356
+ name: existingList.name,
357
+ from: existingList.isPrivate ? "private" : "public",
358
+ to: isPrivate ? "private" : "public",
359
+ isPrivate
360
+ });
361
+ }
362
+ continue;
363
+ }
364
+ if (onlyListMetadata) continue;
365
+ if (existingOnly) continue;
366
+ listsToCreate.push({
367
+ name: listConfig.name,
368
+ description: listConfig.description ?? "",
369
+ isPrivate
370
+ });
371
+ if (!apply) {
372
+ listByName.set(listConfig.name, {
373
+ id: `DRY_RUN:${listConfig.name}`,
374
+ name: listConfig.name,
375
+ description: listConfig.description ?? "",
376
+ isPrivate
377
+ });
378
+ continue;
379
+ }
380
+ }
381
+
382
+ if (apply && listsToCreate.length) {
383
+ await confirmListVisibility({ listsToCreate, listVisibilityUpdates: [] });
384
+ for (const list of listsToCreate) {
385
+ const created = await graphql(
386
+ `
387
+ mutation($input: CreateUserListInput!) {
388
+ createUserList(input: $input) {
389
+ list { id name description isPrivate }
390
+ }
391
+ }
392
+ `,
393
+ {
394
+ input: {
395
+ name: list.name,
396
+ description: list.description,
397
+ isPrivate: list.isPrivate
398
+ }
399
+ }
400
+ );
401
+ listByName.set(list.name, created.createUserList.list);
402
+ }
403
+ }
404
+
405
+ if (onlyListMetadata) {
406
+ mkdirSync(outDir, { recursive: true });
407
+ const jsonPath = resolve(outDir, "plan-list-metadata.json");
408
+ const mdPath = resolve(outDir, "plan-list-metadata.md");
409
+ writeFileSync(jsonPath, JSON.stringify({
410
+ apply,
411
+ onlyListMetadata,
412
+ listDescriptionUpdates,
413
+ listVisibilityUpdates,
414
+ totalStars: 0,
415
+ changes: [],
416
+ skipped: [],
417
+ unchanged: []
418
+ }, null, 2));
419
+ writeFileSync(mdPath, renderMarkdown({
420
+ apply,
421
+ existingOnly,
422
+ listsToCreate: [],
423
+ listDescriptionUpdates,
424
+ listVisibilityUpdates,
425
+ stars: [],
426
+ changes: [],
427
+ skipped: [],
428
+ unchanged: []
429
+ }));
430
+
431
+ if (apply) {
432
+ if (listVisibilityUpdates.length) {
433
+ await confirmListVisibility({ listsToCreate: [], listVisibilityUpdates });
434
+ }
435
+ await applyListDescriptionUpdates(listDescriptionUpdates, progress);
436
+ await applyListVisibilityUpdates(listVisibilityUpdates, progress);
437
+ }
438
+
439
+ console.log(`${apply ? "Applied" : "Planned"} ${listDescriptionUpdates.length} list description updates and ${listVisibilityUpdates.length} visibility updates.`);
440
+ console.log(`Wrote ${mdPath}`);
441
+ console.log(`Wrote ${jsonPath}`);
442
+ if (!apply) console.log("Run with --apply to update GitHub Star List descriptions.");
443
+ return;
444
+ }
445
+
446
+ const existingAssignments = await fetchExistingAssignments(viewer.viewer.lists.nodes, progress);
447
+ const stars = await fetchStars(limit, progress);
448
+ const changes = [];
449
+ const skipped = [];
450
+ const unchanged = [];
451
+ let readmeChecks = 0;
452
+ let readmeHits = 0;
453
+ let processed = 0;
454
+
455
+ progress.start(`Classifying stars 0/${stars.length}`);
456
+ for (const repo of stars) {
457
+ processed += 1;
458
+ const allMatches = classify(repo);
459
+ let matches = thresholdMatches(allMatches);
460
+ let readme = "";
461
+ let finalMatches = allMatches;
462
+
463
+ if (matches.length === 0 && settings.readmeFallback) {
464
+ readmeChecks += 1;
465
+ progress.tick(`Classifying stars ${processed}/${stars.length} | README checks ${readmeChecks}`);
466
+ readme = await fetchReadme(repo.nameWithOwner);
467
+ if (readme) readmeHits += 1;
468
+ if (readme) {
469
+ finalMatches = classify(repo, readme);
470
+ matches = thresholdMatches(finalMatches);
471
+ }
472
+ }
473
+
474
+ if (matches.length === 0) {
475
+ skipped.push({
476
+ repo: repo.nameWithOwner,
477
+ description: repo.description ?? "",
478
+ language: repo.primaryLanguage?.name ?? "",
479
+ topics: topicNames(repo),
480
+ readmeChecked: settings.readmeFallback,
481
+ readmeFound: Boolean(readme),
482
+ bestMatches: finalMatches.slice(0, 3)
483
+ });
484
+ if (processed % 25 === 0 || processed === stars.length) {
485
+ progress.tick(`Classifying stars ${processed}/${stars.length} | README checks ${readmeChecks}, found ${readmeHits}`);
486
+ }
487
+ continue;
488
+ }
489
+
490
+ const oldIds = existingAssignments.get(repo.id) ?? [];
491
+ const oldSet = new Set(oldIds);
492
+ const targetIds = new Set(settings.preserveExistingAssignments ? oldIds : []);
493
+ const targetNames = [];
494
+
495
+ for (const match of matches) {
496
+ const list = listByName.get(match.name);
497
+ if (!list) continue;
498
+ targetIds.add(list.id);
499
+ targetNames.push({ name: match.name, score: match.score, reasons: match.reasons });
500
+ }
501
+
502
+ const hasNewList = [...targetIds].some((id) => !oldSet.has(id));
503
+ if (!hasNewList) {
504
+ unchanged.push({
505
+ repo: repo.nameWithOwner,
506
+ matchedLists: targetNames
507
+ });
508
+ if (processed % 25 === 0 || processed === stars.length) {
509
+ progress.tick(`Classifying stars ${processed}/${stars.length} | README checks ${readmeChecks}, found ${readmeHits}`);
510
+ }
511
+ continue;
512
+ }
513
+
514
+ changes.push({
515
+ repo: repo.nameWithOwner,
516
+ id: repo.id,
517
+ oldListIds: [...oldSet],
518
+ targetListIds: [...targetIds],
519
+ targetLists: targetNames
520
+ });
521
+
522
+ if (processed % 25 === 0 || processed === stars.length) {
523
+ progress.tick(`Classifying stars ${processed}/${stars.length} | README checks ${readmeChecks}, found ${readmeHits}`);
524
+ }
525
+ }
526
+ progress.done(`Classified ${stars.length} stars | README checks ${readmeChecks}, found ${readmeHits}`);
527
+
528
+ mkdirSync(outDir, { recursive: true });
529
+ const basename = existingOnly ? "plan-existing-only" : "plan";
530
+ const jsonPath = resolve(outDir, `${basename}.json`);
531
+ const mdPath = resolve(outDir, `${basename}.md`);
532
+ writeFileSync(jsonPath, JSON.stringify({ apply, existingOnly, listsToCreate, listDescriptionUpdates, listVisibilityUpdates, totalStars: stars.length, changes, skipped, unchanged }, null, 2));
533
+ writeFileSync(mdPath, renderMarkdown({ apply, existingOnly, listsToCreate, listDescriptionUpdates, listVisibilityUpdates, stars, changes, skipped, unchanged }));
534
+
535
+ if (apply) {
536
+ if (listVisibilityUpdates.length) {
537
+ await confirmListVisibility({ listsToCreate: [], listVisibilityUpdates });
538
+ }
539
+ if (listDescriptionUpdates.length) {
540
+ await applyListDescriptionUpdates(listDescriptionUpdates, progress);
541
+ }
542
+ if (listVisibilityUpdates.length) {
543
+ await applyListVisibilityUpdates(listVisibilityUpdates, progress);
544
+ }
545
+
546
+ progress.start(`Applying updates 0/${changes.length}`);
547
+ for (let index = 0; index < changes.length; index += 1) {
548
+ const change = changes[index];
549
+ await graphql(
550
+ `
551
+ mutation($input: UpdateUserListsForItemInput!) {
552
+ updateUserListsForItem(input: $input) {
553
+ item { __typename ... on Repository { nameWithOwner } }
554
+ }
555
+ }
556
+ `,
557
+ { input: { itemId: change.id, listIds: change.targetListIds } }
558
+ );
559
+ if (verbose) console.log(`updated ${change.repo}`);
560
+ progress.tick(`Applying updates ${index + 1}/${changes.length}`);
561
+ }
562
+ progress.done(`Applied updates ${changes.length}/${changes.length}`);
563
+ }
564
+
565
+ console.log(`${apply ? "Applied" : "Planned"} ${changes.length} repository list updates.`);
566
+ console.log(`Wrote ${mdPath}`);
567
+ console.log(`Wrote ${jsonPath}`);
568
+ if (!apply) console.log("Run with --apply to update GitHub Star Lists.");
569
+ }
570
+
571
+ async function applyListDescriptionUpdates(updates, progress) {
572
+ if (!updates.length) return;
573
+ progress.start(`Updating list descriptions 0/${updates.length}`);
574
+ for (let index = 0; index < updates.length; index += 1) {
575
+ const update = updates[index];
576
+ await graphql(
577
+ `
578
+ mutation($input: UpdateUserListInput!) {
579
+ updateUserList(input: $input) {
580
+ list { id name description }
581
+ }
582
+ }
583
+ `,
584
+ { input: { listId: update.id, description: update.to } }
585
+ );
586
+ if (verbose) console.log(`updated list description ${update.name}`);
587
+ progress.tick(`Updating list descriptions ${index + 1}/${updates.length}`);
588
+ }
589
+ progress.done(`Updated list descriptions ${updates.length}/${updates.length}`);
590
+ }
591
+
592
+ async function applyListVisibilityUpdates(updates, progress) {
593
+ if (!updates.length) return;
594
+ progress.start(`Updating list visibility 0/${updates.length}`);
595
+ for (let index = 0; index < updates.length; index += 1) {
596
+ const update = updates[index];
597
+ await graphql(
598
+ `
599
+ mutation($input: UpdateUserListInput!) {
600
+ updateUserList(input: $input) {
601
+ list { id name isPrivate }
602
+ }
603
+ }
604
+ `,
605
+ { input: { listId: update.id, isPrivate: update.isPrivate } }
606
+ );
607
+ if (verbose) console.log(`updated list visibility ${update.name}: ${update.to}`);
608
+ progress.tick(`Updating list visibility ${index + 1}/${updates.length}`);
609
+ }
610
+ progress.done(`Updated list visibility ${updates.length}/${updates.length}`);
611
+ }
612
+
613
+ async function fetchExistingAssignments(lists, progress) {
614
+ const assignments = new Map();
615
+ progress.start(`Scanning existing list assignments 0/${lists.length}`);
616
+
617
+ for (let index = 0; index < lists.length; index += 1) {
618
+ const list = lists[index];
619
+ let page = list.items;
620
+ addItems(list.id, page.nodes, assignments);
621
+
622
+ while (page.pageInfo.hasNextPage) {
623
+ const data = await graphql(
624
+ `
625
+ query($listId: ID!, $cursor: String) {
626
+ node(id: $listId) {
627
+ ... on UserList {
628
+ items(first: 100, after: $cursor) {
629
+ nodes {
630
+ __typename
631
+ ... on Repository { id nameWithOwner }
632
+ }
633
+ pageInfo { hasNextPage endCursor }
634
+ }
635
+ }
636
+ }
637
+ }
638
+ `,
639
+ { listId: list.id, cursor: page.pageInfo.endCursor }
640
+ );
641
+ page = data.node.items;
642
+ addItems(list.id, page.nodes, assignments);
643
+ }
644
+ progress.tick(`Scanning existing list assignments ${index + 1}/${lists.length}`);
645
+ }
646
+ progress.done(`Scanned existing list assignments ${lists.length}/${lists.length}`);
647
+
648
+ return assignments;
649
+ }
650
+
651
+ function addItems(listId, nodes, assignments) {
652
+ for (const item of nodes ?? []) {
653
+ if (item.__typename !== "Repository") continue;
654
+ const ids = assignments.get(item.id) ?? [];
655
+ ids.push(listId);
656
+ assignments.set(item.id, ids);
657
+ }
658
+ }
659
+
660
+ async function fetchStars(max, progress) {
661
+ const stars = [];
662
+ let cursor = null;
663
+ progress.start("Fetching starred repositories");
664
+
665
+ do {
666
+ const first = Math.min(100, max - stars.length);
667
+ if (first <= 0) break;
668
+ const data = await graphql(
669
+ `
670
+ query($first: Int!, $cursor: String) {
671
+ viewer {
672
+ starredRepositories(first: $first, after: $cursor, orderBy: {field: STARRED_AT, direction: DESC}) {
673
+ nodes {
674
+ id
675
+ name
676
+ nameWithOwner
677
+ description
678
+ homepageUrl
679
+ primaryLanguage { name }
680
+ repositoryTopics(first: 30) {
681
+ nodes { topic { name } }
682
+ }
683
+ }
684
+ pageInfo { hasNextPage endCursor }
685
+ }
686
+ }
687
+ }
688
+ `,
689
+ { first, cursor }
690
+ );
691
+ stars.push(...data.viewer.starredRepositories.nodes);
692
+ const total = Number.isFinite(max) ? max : "all";
693
+ progress.tick(`Fetching starred repositories ${stars.length}/${total}`);
694
+ cursor = data.viewer.starredRepositories.pageInfo.endCursor;
695
+ if (!data.viewer.starredRepositories.pageInfo.hasNextPage) break;
696
+ } while (stars.length < max);
697
+
698
+ progress.done(`Fetched ${stars.length} starred repositories`);
699
+ return stars;
700
+ }
701
+
702
+ async function fetchReadme(nameWithOwner) {
703
+ const response = await fetch(`https://api.github.com/repos/${nameWithOwner}/readme`, {
704
+ headers: {
705
+ authorization: `bearer ${token}`,
706
+ accept: "application/vnd.github.raw",
707
+ "user-agent": "git-star-organizer"
708
+ }
709
+ });
710
+
711
+ if (response.status === 404) return "";
712
+ if (!response.ok) return "";
713
+
714
+ const text = await response.text();
715
+ return text.slice(0, settings.readmeMaxChars);
716
+ }
717
+
718
+ function thresholdMatches(matches) {
719
+ return matches
720
+ .filter((match) => match.score >= settings.minScore)
721
+ .slice(0, settings.maxListsPerRepo);
722
+ }
723
+
724
+ function classify(repo, extraText = "") {
725
+ const metadataHaystack = [
726
+ repo.nameWithOwner,
727
+ repo.description ?? "",
728
+ repo.homepageUrl ?? "",
729
+ repo.primaryLanguage?.name ?? "",
730
+ ...topicNames(repo)
731
+ ].join(" ").toLowerCase();
732
+ const readmeHaystack = extraText.toLowerCase();
733
+ const topics = new Set(topicNames(repo));
734
+
735
+ return config.lists
736
+ .map((list) => {
737
+ let score = 0;
738
+ const reasons = [];
739
+ const seenReasons = new Set();
740
+
741
+ for (const topic of list.topics ?? []) {
742
+ if (topics.has(topic.toLowerCase())) {
743
+ score += 3;
744
+ addReason(reasons, seenReasons, `topic:${topic}`);
745
+ }
746
+ }
747
+
748
+ for (const keyword of list.keywords ?? []) {
749
+ const needle = keyword.toLowerCase();
750
+ if (matchesKeyword(metadataHaystack, needle)) {
751
+ score += needle.includes(" ") ? 2 : 1;
752
+ addReason(reasons, seenReasons, `keyword:${keyword}`);
753
+ }
754
+
755
+ if (
756
+ readmeHaystack &&
757
+ !settings.readmeIgnoredKeywords.includes(needle) &&
758
+ matchesKeyword(readmeHaystack, needle)
759
+ ) {
760
+ score += needle.includes(" ") ? 2 : 1;
761
+ addReason(reasons, seenReasons, `readme:${keyword}`);
762
+ }
763
+ }
764
+
765
+ return { name: list.name, score, reasons };
766
+ })
767
+ .filter((match) => match.score > 0)
768
+ .sort((a, b) => b.score - a.score || a.name.localeCompare(b.name));
769
+ }
770
+
771
+ function topicNames(repo) {
772
+ return (repo.repositoryTopics?.nodes ?? []).map((node) => node.topic.name.toLowerCase());
773
+ }
774
+
775
+ function addReason(reasons, seenReasons, reason) {
776
+ if (seenReasons.has(reason)) return;
777
+ seenReasons.add(reason);
778
+ reasons.push(reason);
779
+ }
780
+
781
+ function matchesKeyword(haystack, keyword) {
782
+ const escaped = keyword.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
783
+ const startsWithWord = /^[a-z0-9]/i.test(keyword);
784
+ const endsWithWord = /[a-z0-9]$/i.test(keyword);
785
+ const prefix = startsWithWord ? "(^|[^a-z0-9])" : "";
786
+ const suffix = endsWithWord ? "($|[^a-z0-9])" : "";
787
+ return new RegExp(`${prefix}${escaped}${suffix}`, "i").test(haystack);
788
+ }
789
+
790
+ function renderMarkdown({ apply, existingOnly, listsToCreate, listDescriptionUpdates, listVisibilityUpdates = [], stars, changes, skipped, unchanged }) {
791
+ const lines = [
792
+ `# GitHub Star List ${apply ? "Apply" : "Plan"}`,
793
+ "",
794
+ `- Stars scanned: ${stars.length}`,
795
+ `- Repositories to update: ${changes.length}`,
796
+ `- Unmatched repositories: ${skipped.length}`,
797
+ `- Already covered repositories: ${unchanged.length}`,
798
+ `- Existing lists only: ${existingOnly ? "yes" : "no"}`,
799
+ `- Lists to create: ${listsToCreate.length ? listsToCreate.map(formatListCreate).join(", ") : "none"}`,
800
+ `- List descriptions to update: ${listDescriptionUpdates.length}`,
801
+ `- List visibility changes: ${listVisibilityUpdates.length}`,
802
+ ""
803
+ ];
804
+
805
+ if (listDescriptionUpdates.length) {
806
+ lines.push("## List Description Updates");
807
+ lines.push("");
808
+ for (const update of listDescriptionUpdates) {
809
+ lines.push(`### ${update.name}`);
810
+ if (update.from) lines.push(`- from: ${update.from}`);
811
+ lines.push(`- to: ${update.to}`);
812
+ lines.push("");
813
+ }
814
+ }
815
+
816
+ if (listVisibilityUpdates.length) {
817
+ lines.push("## List Visibility Changes");
818
+ lines.push("");
819
+ for (const update of listVisibilityUpdates) {
820
+ lines.push(`### ${update.name}`);
821
+ lines.push(`- from: ${update.from}`);
822
+ lines.push(`- to: ${update.to}`);
823
+ lines.push("");
824
+ }
825
+ }
826
+
827
+ if (changes.length) {
828
+ lines.push("## Updates");
829
+ lines.push("");
830
+
831
+ for (const change of changes) {
832
+ lines.push(`### ${change.repo}`);
833
+ for (const target of change.targetLists) {
834
+ lines.push(`- ${target.name} (score ${target.score}): ${target.reasons.join(", ")}`);
835
+ }
836
+ lines.push("");
837
+ }
838
+ }
839
+
840
+ if (skipped.length) {
841
+ lines.push("## Unmatched");
842
+ lines.push("");
843
+ for (const item of skipped) {
844
+ const metadata = [
845
+ item.language ? `language: ${item.language}` : null,
846
+ item.topics.length ? `topics: ${item.topics.join(", ")}` : null,
847
+ item.description ? `description: ${item.description}` : null
848
+ ].filter(Boolean);
849
+ lines.push(`### ${item.repo}`);
850
+ if (metadata.length) lines.push(`- ${metadata.join(" | ")}`);
851
+ if (item.bestMatches.length) {
852
+ for (const match of item.bestMatches) {
853
+ lines.push(`- below threshold: ${match.name} (score ${match.score}): ${match.reasons.join(", ")}`);
854
+ }
855
+ } else {
856
+ lines.push("- no configured keyword/topic matched");
857
+ }
858
+ lines.push("");
859
+ }
860
+ }
861
+
862
+ if (unchanged.length) {
863
+ lines.push("## Already Covered");
864
+ lines.push("");
865
+ for (const item of unchanged) {
866
+ lines.push(`### ${item.repo}`);
867
+ for (const match of item.matchedLists) {
868
+ lines.push(`- ${match.name} (score ${match.score}): ${match.reasons.join(", ")}`);
869
+ }
870
+ lines.push("");
871
+ }
872
+ }
873
+
874
+ return `${lines.join("\n")}\n`;
875
+ }
876
+
877
+ function formatListCreate(list) {
878
+ if (typeof list === "string") return list;
879
+ return `${list.name} (${list.isPrivate ? "private" : "public"})`;
880
+ }
881
+
882
+ async function graphql(query, variables = {}) {
883
+ const response = await fetch("https://api.github.com/graphql", {
884
+ method: "POST",
885
+ headers: {
886
+ authorization: `bearer ${token}`,
887
+ "content-type": "application/json",
888
+ "user-agent": "git-star-organizer"
889
+ },
890
+ body: JSON.stringify({ query, variables })
891
+ });
892
+
893
+ const body = await response.json();
894
+ if (!response.ok || body.errors) {
895
+ fail(JSON.stringify(body, null, 2));
896
+ }
897
+ return body.data;
898
+ }
899
+
900
+ function fail(message) {
901
+ console.error(message);
902
+ process.exit(1);
903
+ }
904
+
905
+ function createProgress({ quiet }) {
906
+ const enabled = !quiet && process.stderr.isTTY;
907
+ let lastLength = 0;
908
+
909
+ function write(message, final = false) {
910
+ if (!enabled) return;
911
+ const text = final ? `✓ ${message}` : renderSpinner(message);
912
+ const padding = " ".repeat(Math.max(0, lastLength - text.length));
913
+ process.stderr.write(`\r${text}${padding}`);
914
+ lastLength = text.length;
915
+ if (final) {
916
+ process.stderr.write("\n");
917
+ lastLength = 0;
918
+ }
919
+ }
920
+
921
+ return {
922
+ start(message) {
923
+ write(message);
924
+ },
925
+ tick(message) {
926
+ write(message);
927
+ },
928
+ done(message) {
929
+ write(message, true);
930
+ }
931
+ };
932
+ }
933
+
934
+ function renderSpinner(message) {
935
+ const frames = ["-", "\\", "|", "/"];
936
+ const frame = frames[Math.floor(Date.now() / 120) % frames.length];
937
+ return `${frame} ${message}`;
938
+ }
939
+
940
+ main().catch((error) => fail(error.stack || error.message));