@oss-scout/core 0.11.0 → 1.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.
Files changed (68) hide show
  1. package/dist/cli.bundle.cjs +89 -66
  2. package/dist/cli.js +302 -436
  3. package/dist/commands/command-scout.d.ts +21 -0
  4. package/dist/commands/command-scout.js +21 -0
  5. package/dist/commands/config.js +10 -128
  6. package/dist/commands/features.js +15 -28
  7. package/dist/commands/results.d.ts +13 -2
  8. package/dist/commands/results.js +29 -2
  9. package/dist/commands/search.d.ts +4 -0
  10. package/dist/commands/search.js +65 -70
  11. package/dist/commands/setup.d.ts +2 -0
  12. package/dist/commands/setup.js +35 -6
  13. package/dist/commands/skip.d.ts +4 -0
  14. package/dist/commands/skip.js +45 -55
  15. package/dist/commands/sync.d.ts +10 -0
  16. package/dist/commands/sync.js +10 -0
  17. package/dist/commands/vet-list.js +3 -19
  18. package/dist/commands/vet.js +18 -25
  19. package/dist/commands/with-scout.d.ts +32 -0
  20. package/dist/commands/with-scout.js +41 -0
  21. package/dist/core/anti-llm-policy.js +5 -33
  22. package/dist/core/bootstrap.d.ts +2 -2
  23. package/dist/core/bootstrap.js +5 -9
  24. package/dist/core/errors.d.ts +10 -0
  25. package/dist/core/errors.js +20 -5
  26. package/dist/core/feature-discovery.d.ts +13 -1
  27. package/dist/core/feature-discovery.js +104 -81
  28. package/dist/core/gist-state-store.d.ts +13 -12
  29. package/dist/core/gist-state-store.js +128 -53
  30. package/dist/core/http-cache.d.ts +32 -2
  31. package/dist/core/http-cache.js +74 -19
  32. package/dist/core/issue-discovery.d.ts +12 -1
  33. package/dist/core/issue-discovery.js +94 -67
  34. package/dist/core/issue-eligibility.d.ts +11 -4
  35. package/dist/core/issue-eligibility.js +124 -69
  36. package/dist/core/issue-graphql.d.ts +58 -0
  37. package/dist/core/issue-graphql.js +108 -0
  38. package/dist/core/issue-vetting.d.ts +115 -9
  39. package/dist/core/issue-vetting.js +246 -109
  40. package/dist/core/local-state.d.ts +6 -2
  41. package/dist/core/local-state.js +23 -5
  42. package/dist/core/logger.d.ts +12 -4
  43. package/dist/core/logger.js +33 -7
  44. package/dist/core/personalization.d.ts +30 -10
  45. package/dist/core/personalization.js +64 -24
  46. package/dist/core/preference-fields.d.ts +47 -0
  47. package/dist/core/preference-fields.js +180 -0
  48. package/dist/core/probe-repo-file.d.ts +47 -0
  49. package/dist/core/probe-repo-file.js +57 -0
  50. package/dist/core/repo-health.js +40 -32
  51. package/dist/core/roadmap.js +26 -22
  52. package/dist/core/schemas.d.ts +148 -26
  53. package/dist/core/schemas.js +83 -17
  54. package/dist/core/search-budget.d.ts +9 -0
  55. package/dist/core/search-budget.js +36 -3
  56. package/dist/core/search-phases.d.ts +4 -21
  57. package/dist/core/search-phases.js +37 -89
  58. package/dist/core/types.d.ts +151 -38
  59. package/dist/core/utils.js +60 -26
  60. package/dist/formatters/human.d.ts +60 -0
  61. package/dist/formatters/human.js +199 -0
  62. package/dist/formatters/markdown.d.ts +10 -0
  63. package/dist/formatters/markdown.js +31 -0
  64. package/dist/index.d.ts +6 -2
  65. package/dist/index.js +8 -0
  66. package/dist/scout.d.ts +75 -12
  67. package/dist/scout.js +265 -26
  68. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -6,7 +6,8 @@ import { Command } from "commander";
6
6
  import { enableDebug } from "./core/logger.js";
7
7
  import { getCLIVersion } from "./core/utils.js";
8
8
  import { formatJsonSuccess, formatJsonError } from "./formatters/json.js";
9
- import { errorMessage, resolveErrorCode } from "./core/errors.js";
9
+ import { renderSearch, renderFeatures, renderResults, renderVetList, renderVet, RESULTS_EMPTY_MESSAGE, VET_LIST_EMPTY_MESSAGE, } from "./formatters/human.js";
10
+ import { ValidationError, errorMessage, resolveErrorCode, } from "./core/errors.js";
10
11
  import { hasLocalState, loadLocalState, saveLocalState, } from "./core/local-state.js";
11
12
  import { CONCRETE_STRATEGIES, SearchStrategySchema } from "./core/schemas.js";
12
13
  function handleCommandError(err, options) {
@@ -18,6 +19,18 @@ function handleCommandError(err, options) {
18
19
  }
19
20
  process.exit(1);
20
21
  }
22
+ /**
23
+ * Run a command body, routing any thrown error through handleCommandError so
24
+ * every command shares one try/catch epilogue instead of repeating it (#154).
25
+ */
26
+ async function runAction(options, body) {
27
+ try {
28
+ await body();
29
+ }
30
+ catch (err) {
31
+ handleCommandError(err, options);
32
+ }
33
+ }
21
34
  const program = new Command();
22
35
  program
23
36
  .name("oss-scout")
@@ -34,157 +47,147 @@ program
34
47
  .command("setup")
35
48
  .description("Interactive first-run configuration")
36
49
  .option("--json", "Output as JSON")
37
- .action(async (options) => {
38
- try {
39
- const { runSetup } = await import("./commands/setup.js");
40
- const prefs = await runSetup();
41
- const state = loadLocalState();
42
- state.preferences = prefs;
43
- saveLocalState(state);
44
- if (options.json) {
45
- console.log(formatJsonSuccess(prefs));
46
- }
47
- }
48
- catch (err) {
49
- handleCommandError(err, options);
50
+ .action(async (options) => runAction(options, async () => {
51
+ const { runSetup } = await import("./commands/setup.js");
52
+ const prefs = await runSetup();
53
+ const state = loadLocalState();
54
+ state.preferences = prefs;
55
+ state.preferencesUpdatedAt = new Date().toISOString(); // #117 merge recency
56
+ saveLocalState(state);
57
+ if (options.json) {
58
+ console.log(formatJsonSuccess(prefs));
50
59
  }
51
- });
60
+ }));
52
61
  program
53
62
  .command("bootstrap")
54
63
  .description("Import starred repos and PR history from GitHub")
55
64
  .option("--json", "Output as JSON")
56
- .action(async (options) => {
57
- try {
58
- const { bootstrapScout } = await import("./core/bootstrap.js");
59
- const { createScout } = await import("./scout.js");
60
- const { requireGitHubToken } = await import("./core/utils.js");
61
- const token = requireGitHubToken();
62
- const state = loadLocalState();
63
- const scout = await createScout({
64
- githubToken: token,
65
- persistence: "provided",
66
- initialState: state,
67
- });
68
- const result = await bootstrapScout(scout, token);
69
- saveLocalState(scout.getState());
70
- if (options.json) {
71
- console.log(formatJsonSuccess(result));
65
+ .action(async (options) => runAction(options, async () => {
66
+ const { bootstrapScout } = await import("./core/bootstrap.js");
67
+ const { createScout } = await import("./scout.js");
68
+ const { requireGitHubToken } = await import("./core/utils.js");
69
+ const token = requireGitHubToken();
70
+ const state = loadLocalState();
71
+ const scout = await createScout({
72
+ githubToken: token,
73
+ persistence: "provided",
74
+ initialState: state,
75
+ });
76
+ const result = await bootstrapScout(scout, token);
77
+ saveLocalState(scout.getState());
78
+ if (options.json) {
79
+ console.log(formatJsonSuccess(result));
80
+ }
81
+ else {
82
+ if (result.skippedDueToRateLimit) {
83
+ console.log("Skipped: GitHub API rate limit too low. Try again later.");
72
84
  }
73
85
  else {
74
- if (result.skippedDueToRateLimit) {
75
- console.log("Skipped: GitHub API rate limit too low. Try again later.");
76
- }
77
- else {
78
- console.log(`Imported ${result.mergedPRCount} merged PRs, ${result.closedPRCount} closed PRs, ${result.starredRepoCount} starred repos`);
79
- console.log(`Scored ${result.reposScoredCount} repositories`);
80
- }
86
+ console.log(`Imported ${result.mergedPRCount} merged PRs, ${result.closedPRCount} closed PRs, ${result.starredRepoCount} starred repos`);
87
+ console.log(`Scored ${result.reposScoredCount} repositories`);
81
88
  }
82
89
  }
83
- catch (err) {
84
- handleCommandError(err, options);
90
+ }));
91
+ program
92
+ .command("sync")
93
+ .description("Reconcile tracked open PRs (mark merged/closed) without a full bootstrap")
94
+ .option("--json", "Output as JSON")
95
+ .action(async (options) => runAction(options, async () => {
96
+ const { runSync } = await import("./commands/sync.js");
97
+ const result = await runSync();
98
+ if (options.json) {
99
+ console.log(formatJsonSuccess(result));
85
100
  }
86
- });
101
+ else {
102
+ console.log(`Synced ${result.checked} open PRs: ${result.merged} merged, ${result.closed} closed, ${result.stillOpen} still open${result.errors > 0 ? `, ${result.errors} unchecked` : ""}.`);
103
+ }
104
+ }));
87
105
  program
88
106
  .command("search [count]")
89
107
  .description("Search for contributable issues using multi-strategy discovery")
90
108
  .option("--json", "Output as JSON")
91
- .option("--strategy <strategies>", `Search strategies (${CONCRETE_STRATEGIES.join(",")},all)`, "all")
109
+ .option("--strategy <strategies>", `Search strategies (${CONCRETE_STRATEGIES.join(",")},all). Defaults to the defaultStrategy preference, or all.`)
92
110
  .option("--prefer-languages <list>", "Comma-separated languages to soft-boost in ranking (#1244). Candidates whose repo language matches sort above equally-recommended non-matches. Does not filter results.")
93
111
  .option("--prefer-repos <list>", "Comma-separated `owner/repo` slugs to soft-boost in ranking (#1244). Stronger weight than language match. Does not filter results.")
112
+ .option("--avoid-repos <list>", "Comma-separated `owner/repo` slugs to soft-penalize in ranking (#168). Milder than excludeRepos: pushes them down but does not filter them out.")
113
+ .option("--boost-issue-types <list>", "Comma-separated issue label types to soft-boost in ranking (#168), case-insensitive (e.g. `bug,good first issue`). Does not filter results.")
94
114
  .option("--diversity-ratio <n>", "Fraction of result slots (0-1) reserved for candidates that matched NEITHER preference list (#1244). Counterweights echo-chamber bias as boosts accumulate. Default 0 (disabled).")
95
- .action(async (count, options) => {
96
- try {
97
- if (!hasLocalState()) {
98
- console.log("💡 Run `oss-scout setup` to configure your preferences for personalized search results.\n");
99
- }
100
- const { runSearch } = await import("./commands/search.js");
101
- const maxResults = count ? parseInt(count, 10) : 10;
102
- if (isNaN(maxResults) || maxResults < 1) {
103
- console.error("Error: count must be a positive integer");
104
- process.exit(1);
105
- }
106
- const state = loadLocalState();
107
- if (state.mergedPRs.length === 0 &&
108
- state.starredRepos.length === 0 &&
109
- state.preferences.githubUsername) {
110
- console.log("Run `oss-scout bootstrap` to import your starred repos and PR history for better results.\n");
111
- }
112
- // Parse --strategy option
113
- const strategyTokens = (options.strategy ?? "all")
115
+ .action(async (count, options) => runAction(options, async () => {
116
+ if (!hasLocalState() && !options.json) {
117
+ // Human hint only: stdout must stay pure JSON under --json (#131)
118
+ console.log("💡 Run `oss-scout setup` to configure your preferences for personalized search results.\n");
119
+ }
120
+ const { runSearch } = await import("./commands/search.js");
121
+ const maxResults = count ? parseInt(count, 10) : 10;
122
+ if (isNaN(maxResults) || maxResults < 1) {
123
+ throw new ValidationError("count must be a positive integer");
124
+ }
125
+ const state = loadLocalState();
126
+ if (state.mergedPRs.length === 0 &&
127
+ state.starredRepos.length === 0 &&
128
+ state.preferences.githubUsername &&
129
+ !options.json) {
130
+ console.log("Run `oss-scout bootstrap` to import your starred repos and PR history for better results.\n");
131
+ }
132
+ // Parse --strategy. Absent means undefined so the stored
133
+ // defaultStrategy preference applies (discovery falls back to "all").
134
+ let strategies;
135
+ if (options.strategy !== undefined) {
136
+ const strategyTokens = options.strategy
114
137
  .split(",")
115
138
  .map((s) => s.trim())
116
139
  .filter(Boolean);
117
- const strategies = [];
140
+ strategies = [];
118
141
  for (const token of strategyTokens) {
119
142
  const parsed = SearchStrategySchema.safeParse(token);
120
143
  if (!parsed.success) {
121
144
  const valid = [...CONCRETE_STRATEGIES, "all"].join(", ");
122
- console.error('Error: unknown strategy "' +
123
- token +
124
- '". Valid strategies: ' +
125
- valid);
126
- process.exit(1);
145
+ throw new ValidationError(`unknown strategy "${token}". Valid strategies: ${valid}`);
127
146
  }
128
147
  strategies.push(parsed.data);
129
148
  }
130
- const splitCsv = (raw) => {
131
- if (!raw)
132
- return undefined;
133
- const parts = raw
134
- .split(",")
135
- .map((s) => s.trim())
136
- .filter(Boolean);
137
- return parts.length > 0 ? parts : undefined;
138
- };
139
- let diversityRatio;
140
- if (options.diversityRatio !== undefined) {
141
- const parsed = Number(options.diversityRatio);
142
- if (!Number.isFinite(parsed) || parsed < 0 || parsed > 1) {
143
- console.error(`Error: --diversity-ratio must be a number in [0, 1] (got "${options.diversityRatio}")`);
144
- process.exit(1);
145
- }
146
- diversityRatio = parsed;
147
- }
148
- const results = await runSearch({
149
- maxResults,
150
- state,
151
- strategies,
152
- preferLanguages: splitCsv(options.preferLanguages),
153
- preferRepos: splitCsv(options.preferRepos),
154
- diversityRatio,
155
- });
156
- if (options.json) {
157
- console.log(formatJsonSuccess(results));
158
- }
159
- else {
160
- // Human-readable output
161
- console.log(`\nFound ${results.candidates.length} issue candidates:\n`);
162
- for (const c of results.candidates) {
163
- const icon = c.recommendation === "approve"
164
- ? "✅"
165
- : c.recommendation === "skip"
166
- ? "❌"
167
- : "⚠️";
168
- const stalledTag = c.linkedPR?.isStalled
169
- ? " (stalled PR, revive opportunity)"
170
- : "";
171
- console.log(` ${icon} ${c.issue.repo}#${c.issue.number} [${c.viabilityScore}/100]${stalledTag}`);
172
- console.log(` ${c.issue.title}`);
173
- console.log(` ${c.issue.url}`);
174
- if (c.repoScore) {
175
- console.log(` Repo: ${c.repoScore.score}/10, ${c.repoScore.mergedPRCount} merged PRs`);
176
- }
177
- console.log();
178
- }
179
- if (results.rateLimitWarning) {
180
- console.error(`\n⚠️ ${results.rateLimitWarning}`);
181
- }
182
- }
183
149
  }
184
- catch (err) {
185
- handleCommandError(err, options);
150
+ const splitCsv = (raw) => {
151
+ if (!raw)
152
+ return undefined;
153
+ const parts = raw
154
+ .split(",")
155
+ .map((s) => s.trim())
156
+ .filter(Boolean);
157
+ return parts.length > 0 ? parts : undefined;
158
+ };
159
+ let diversityRatio;
160
+ if (options.diversityRatio !== undefined) {
161
+ const parsed = Number(options.diversityRatio);
162
+ if (!Number.isFinite(parsed) || parsed < 0 || parsed > 1) {
163
+ throw new ValidationError(`--diversity-ratio must be a number in [0, 1] (got "${options.diversityRatio}")`);
164
+ }
165
+ diversityRatio = parsed;
166
+ }
167
+ const results = await runSearch({
168
+ maxResults,
169
+ state,
170
+ strategies,
171
+ preferLanguages: splitCsv(options.preferLanguages),
172
+ preferRepos: splitCsv(options.preferRepos),
173
+ avoidRepos: splitCsv(options.avoidRepos),
174
+ boostIssueTypes: splitCsv(options.boostIssueTypes),
175
+ diversityRatio,
176
+ });
177
+ if (options.json) {
178
+ console.log(formatJsonSuccess(results));
186
179
  }
187
- });
180
+ else {
181
+ // Human-readable output
182
+ console.log(renderSearch(results));
183
+ // Rate-limit warning stays on stderr (NOT folded into the stdout
184
+ // render), so --json stdout purity and the stdout/stderr split are
185
+ // both preserved.
186
+ if (results.rateLimitWarning) {
187
+ console.error(`\n⚠️ ${results.rateLimitWarning}`);
188
+ }
189
+ }
190
+ }));
188
191
  program
189
192
  .command("features [count]")
190
193
  .description("Surface feature-scoped opportunities in repos where you have 3+ merged PRs")
@@ -192,85 +195,47 @@ program
192
195
  .option("--anchor-threshold <n>", "Override featuresAnchorThreshold (1-50)")
193
196
  .option("--split-ratio <r>", "Override featuresSplitRatio (0-1, e.g. 0.6)")
194
197
  .option("--broad", "Bypass anchor repos; search feature issues across the ecosystem (first-touch mode)")
195
- .action(async (count, options) => {
196
- try {
197
- const { runFeatures } = await import("./commands/features.js");
198
- const maxResults = count ? parseInt(count, 10) : 10;
199
- if (isNaN(maxResults) || maxResults < 1 || maxResults > 50) {
200
- console.error("Error: count must be an integer between 1 and 50");
201
- process.exit(1);
202
- }
203
- let anchorThreshold;
204
- if (options.anchorThreshold !== undefined) {
205
- const parsed = parseInt(options.anchorThreshold, 10);
206
- if (isNaN(parsed) || parsed < 1 || parsed > 50) {
207
- console.error("Error: --anchor-threshold must be an integer between 1 and 50");
208
- process.exit(1);
209
- }
210
- anchorThreshold = parsed;
211
- }
212
- let splitRatio;
213
- if (options.splitRatio !== undefined) {
214
- const parsed = Number.parseFloat(options.splitRatio);
215
- if (isNaN(parsed) || parsed < 0 || parsed > 1) {
216
- console.error("Error: --split-ratio must be a number between 0 and 1");
217
- process.exit(1);
218
- }
219
- splitRatio = parsed;
220
- }
221
- const state = loadLocalState();
222
- const result = await runFeatures({
223
- maxResults,
224
- state,
225
- anchorThreshold,
226
- splitRatio,
227
- broad: options.broad,
228
- });
229
- if (options.json) {
230
- console.log(formatJsonSuccess(result));
231
- }
232
- else {
233
- const total = result.quickWins.length + result.biggerBets.length;
234
- if (result.message) {
235
- console.log(`\n${result.message}\n`);
236
- }
237
- if (total === 0)
238
- return;
239
- const headerScope = options.broad
240
- ? "across the ecosystem"
241
- : "in your anchor repos";
242
- console.log(`\n🎯 Feature opportunities ${headerScope} (${result.quickWins.length} quick wins + ${result.biggerBets.length} bigger bets)\n`);
243
- if (!options.broad) {
244
- console.log(`Anchor repos: ${result.anchorRepos.join(", ")}\n`);
245
- }
246
- if (result.quickWins.length) {
247
- console.log("── Quick wins ─────────────────────────────────────────");
248
- for (const c of result.quickWins) {
249
- const stalledTag = c.linkedPR?.isStalled
250
- ? " (stalled PR, revive opportunity)"
251
- : "";
252
- console.log(` ${c.issue.repo}#${c.issue.number} [${c.viabilityScore}/100] ${c.issue.title}${stalledTag}`);
253
- console.log(` ${c.issue.url}`);
254
- }
255
- console.log("");
256
- }
257
- if (result.biggerBets.length) {
258
- console.log("── Bigger bets ────────────────────────────────────────");
259
- for (const c of result.biggerBets) {
260
- const stalledTag = c.linkedPR?.isStalled
261
- ? " (stalled PR, revive opportunity)"
262
- : "";
263
- console.log(` ${c.issue.repo}#${c.issue.number} [${c.viabilityScore}/100] ${c.issue.title}${stalledTag}`);
264
- console.log(` ${c.issue.url}`);
265
- }
266
- console.log("");
267
- }
268
- }
198
+ .action(async (count, options) => runAction(options, async () => {
199
+ const { runFeatures } = await import("./commands/features.js");
200
+ const maxResults = count ? parseInt(count, 10) : 10;
201
+ if (isNaN(maxResults) || maxResults < 1 || maxResults > 50) {
202
+ throw new ValidationError("count must be an integer between 1 and 50");
203
+ }
204
+ let anchorThreshold;
205
+ if (options.anchorThreshold !== undefined) {
206
+ const parsed = parseInt(options.anchorThreshold, 10);
207
+ if (isNaN(parsed) || parsed < 1 || parsed > 50) {
208
+ throw new ValidationError("--anchor-threshold must be an integer between 1 and 50");
209
+ }
210
+ anchorThreshold = parsed;
211
+ }
212
+ let splitRatio;
213
+ if (options.splitRatio !== undefined) {
214
+ const parsed = Number.parseFloat(options.splitRatio);
215
+ if (isNaN(parsed) || parsed < 0 || parsed > 1) {
216
+ throw new ValidationError("--split-ratio must be a number between 0 and 1");
217
+ }
218
+ splitRatio = parsed;
219
+ }
220
+ const state = loadLocalState();
221
+ const result = await runFeatures({
222
+ maxResults,
223
+ state,
224
+ anchorThreshold,
225
+ splitRatio,
226
+ broad: options.broad,
227
+ });
228
+ if (options.json) {
229
+ console.log(formatJsonSuccess(result));
269
230
  }
270
- catch (err) {
271
- handleCommandError(err, options);
231
+ else {
232
+ // renderFeatures returns "" only when there is no message AND
233
+ // nothing to list; guard so the caller never logs a blank line.
234
+ const out = renderFeatures(result, { broad: options.broad });
235
+ if (out)
236
+ console.log(out);
272
237
  }
273
- });
238
+ }));
274
239
  // ── results command ────────────────────────────────────────────────
275
240
  const resultsCmd = program
276
241
  .command("results")
@@ -279,166 +244,112 @@ resultsCmd
279
244
  .command("show", { isDefault: true })
280
245
  .description("Display saved search results")
281
246
  .option("--json", "Output as JSON")
282
- .action(async (options) => {
283
- try {
284
- const { runResults } = await import("./commands/results.js");
285
- const results = await runResults(options);
286
- if (options.json) {
287
- console.log(formatJsonSuccess(results));
288
- }
289
- else {
290
- if (results.length === 0) {
291
- console.log("\nNo saved results. Run `oss-scout search` to find issues.\n");
292
- return;
293
- }
294
- console.log(`\nSaved results (${results.length}):\n`);
295
- console.log(" Score Repo Issue Recommendation Title");
296
- console.log(" ───── ──────────────────────────────── ────── ────────────── ─────");
297
- for (const r of results) {
298
- const score = String(r.viabilityScore).padStart(3);
299
- const repo = r.repo.padEnd(32).slice(0, 32);
300
- const issue = `#${r.number}`.padEnd(6);
301
- const rec = r.recommendation.padEnd(14);
302
- const title = r.title.length > 50 ? r.title.slice(0, 47) + "..." : r.title;
303
- console.log(` ${score} ${repo} ${issue} ${rec} ${title}`);
304
- }
305
- console.log();
306
- }
247
+ .option("--markdown", "Output as a markdown table (for digests)")
248
+ .option("--new-only", "Only results first seen during/after the last search")
249
+ .option("--since <date>", "Only results first seen at/after this date")
250
+ .action(async (options) => runAction(options, async () => {
251
+ const { runResults } = await import("./commands/results.js");
252
+ const results = await runResults(options);
253
+ if (options.json) {
254
+ console.log(formatJsonSuccess(results));
255
+ return;
307
256
  }
308
- catch (err) {
309
- handleCommandError(err, options);
257
+ if (options.markdown) {
258
+ const { formatResultsMarkdown } = await import("./formatters/markdown.js");
259
+ console.log(formatResultsMarkdown(results));
260
+ return;
310
261
  }
311
- });
262
+ if (results.length === 0) {
263
+ console.log(RESULTS_EMPTY_MESSAGE);
264
+ return;
265
+ }
266
+ console.log(renderResults(results));
267
+ }));
312
268
  resultsCmd
313
269
  .command("clear")
314
270
  .description("Clear all saved results")
315
271
  .option("--json", "Output as JSON")
316
- .action(async (options) => {
317
- try {
318
- const { runResultsClear } = await import("./commands/results.js");
319
- await runResultsClear();
320
- if (options.json) {
321
- console.log(formatJsonSuccess({ cleared: true }));
322
- }
323
- else {
324
- console.log("Saved results cleared.");
325
- }
272
+ .action(async (options) => runAction(options, async () => {
273
+ const { runResultsClear } = await import("./commands/results.js");
274
+ await runResultsClear();
275
+ if (options.json) {
276
+ console.log(formatJsonSuccess({ cleared: true }));
326
277
  }
327
- catch (err) {
328
- handleCommandError(err, options);
278
+ else {
279
+ console.log("Saved results cleared.");
329
280
  }
330
- });
281
+ }));
331
282
  // ── config command ──────────────────────────────────────────────────
332
283
  const configCmd = program
333
284
  .command("config")
334
285
  .description("View and update preferences")
335
286
  .option("--json", "Output as JSON")
336
- .action(async (options) => {
337
- try {
338
- const { runConfigShow, getConfigData } = await import("./commands/config.js");
339
- if (options.json) {
340
- console.log(formatJsonSuccess(getConfigData()));
341
- }
342
- else {
343
- runConfigShow();
344
- }
287
+ .action(async (options) => runAction(options, async () => {
288
+ const { runConfigShow, getConfigData } = await import("./commands/config.js");
289
+ if (options.json) {
290
+ console.log(formatJsonSuccess(getConfigData()));
345
291
  }
346
- catch (err) {
347
- handleCommandError(err, options);
292
+ else {
293
+ runConfigShow();
348
294
  }
349
- });
295
+ }));
350
296
  configCmd
351
297
  .command("set <key> <value>")
352
- .description("Update a single preference (e.g. config set minStars 100)")
298
+ .description('Update a single preference (e.g. config set minStars 100). For dash-prefixed values like the array-remove form, escape with --: config set excludeRepos -- "-spam/repo"')
353
299
  .option("--json", "Output as JSON")
354
- .action(async (key, value, options) => {
355
- try {
356
- const { runConfigSet } = await import("./commands/config.js");
357
- const updated = runConfigSet(key, value);
358
- if (options.json) {
359
- console.log(formatJsonSuccess(updated));
360
- }
361
- else {
362
- console.log(`✅ Updated "${key}" successfully.`);
363
- }
300
+ .action(async (key, value, options) => runAction(options, async () => {
301
+ const { runConfigSet } = await import("./commands/config.js");
302
+ const updated = runConfigSet(key, value);
303
+ if (options.json) {
304
+ console.log(formatJsonSuccess(updated));
364
305
  }
365
- catch (err) {
366
- handleCommandError(err, options);
306
+ else {
307
+ console.log(`✅ Updated "${key}" successfully.`);
367
308
  }
368
- });
309
+ }));
369
310
  configCmd
370
311
  .command("reset")
371
312
  .description("Reset all preferences to defaults")
372
313
  .option("--json", "Output as JSON")
373
- .action(async (options) => {
374
- try {
375
- const { runConfigReset } = await import("./commands/config.js");
376
- const defaults = runConfigReset();
377
- if (options.json) {
378
- console.log(formatJsonSuccess(defaults));
379
- }
380
- else {
381
- console.log("✅ Preferences reset to defaults.");
382
- }
314
+ .action(async (options) => runAction(options, async () => {
315
+ const { runConfigReset } = await import("./commands/config.js");
316
+ const defaults = runConfigReset();
317
+ if (options.json) {
318
+ console.log(formatJsonSuccess(defaults));
383
319
  }
384
- catch (err) {
385
- handleCommandError(err, options);
320
+ else {
321
+ console.log("✅ Preferences reset to defaults.");
386
322
  }
387
- });
323
+ }));
388
324
  program
389
325
  .command("vet-list")
390
326
  .description("Re-vet all saved search results and classify their current status")
391
327
  .option("--prune", "Remove unavailable issues from saved results")
392
328
  .option("--concurrency <n>", "Max concurrent API requests (default: 5)", parseInt)
393
329
  .option("--json", "Output as JSON")
394
- .action(async (options) => {
395
- try {
396
- if (options.concurrency !== undefined &&
397
- (isNaN(options.concurrency) || options.concurrency < 1)) {
398
- console.error("Error: --concurrency must be a positive integer");
399
- process.exit(1);
400
- }
401
- const { runVetList } = await import("./commands/vet-list.js");
402
- const state = loadLocalState();
403
- const result = await runVetList({
404
- state,
405
- prune: options.prune,
406
- concurrency: options.concurrency,
407
- });
408
- if (options.json) {
409
- console.log(formatJsonSuccess(result));
410
- }
411
- else {
412
- if (result.results.length === 0) {
413
- console.log("\nNo saved results to vet. Run `oss-scout search` first.\n");
414
- return;
415
- }
416
- console.log(`\nVet-list results (${result.summary.total}):\n`);
417
- for (const r of result.results) {
418
- const icon = r.status === "still_available"
419
- ? "✅"
420
- : r.status === "claimed"
421
- ? "🔒"
422
- : r.status === "has_pr"
423
- ? "🔀"
424
- : r.status === "closed"
425
- ? "🚫"
426
- : "❌";
427
- const score = r.viabilityScore != null ? ` [${r.viabilityScore}/100]` : "";
428
- console.log(` ${icon} ${r.repo}#${r.number} — ${r.status}${score}`);
429
- console.log(` ${r.title}`);
430
- }
431
- console.log(`\nSummary: ${result.summary.stillAvailable} available, ${result.summary.claimed} claimed, ${result.summary.hasPR} has PR, ${result.summary.closed} closed, ${result.summary.errors} errors`);
432
- if (result.prunedCount != null) {
433
- console.log(`Pruned ${result.prunedCount} unavailable issues from saved results.`);
434
- }
435
- console.log();
436
- }
330
+ .action(async (options) => runAction(options, async () => {
331
+ if (options.concurrency !== undefined &&
332
+ (isNaN(options.concurrency) || options.concurrency < 1)) {
333
+ throw new ValidationError("--concurrency must be a positive integer");
334
+ }
335
+ const { runVetList } = await import("./commands/vet-list.js");
336
+ const state = loadLocalState();
337
+ const result = await runVetList({
338
+ state,
339
+ prune: options.prune,
340
+ concurrency: options.concurrency,
341
+ });
342
+ if (options.json) {
343
+ console.log(formatJsonSuccess(result));
437
344
  }
438
- catch (err) {
439
- handleCommandError(err, options);
345
+ else {
346
+ if (result.results.length === 0) {
347
+ console.log(VET_LIST_EMPTY_MESSAGE);
348
+ return;
349
+ }
350
+ console.log(renderVetList(result));
440
351
  }
441
- });
352
+ }));
442
353
  // ── skip command ───────────────────────────────────────────────────
443
354
  const skipCmd = program
444
355
  .command("skip")
@@ -447,143 +358,98 @@ skipCmd
447
358
  .command("add <issue-url>")
448
359
  .description("Skip an issue by URL")
449
360
  .option("--json", "Output as JSON")
450
- .action(async (issueUrl, options) => {
451
- try {
452
- const { runSkip } = await import("./commands/skip.js");
453
- const state = loadLocalState();
454
- const result = await runSkip({ issueUrl, state });
455
- if (options.json) {
456
- console.log(formatJsonSuccess(result));
361
+ .action(async (issueUrl, options) => runAction(options, async () => {
362
+ const { runSkip } = await import("./commands/skip.js");
363
+ const state = loadLocalState();
364
+ const result = await runSkip({ issueUrl, state });
365
+ if (options.json) {
366
+ console.log(formatJsonSuccess(result));
367
+ }
368
+ else {
369
+ if (result.alreadySkipped) {
370
+ console.log("Issue already in skip list.");
457
371
  }
458
372
  else {
459
- if (result.alreadySkipped) {
460
- console.log("Issue already in skip list.");
461
- }
462
- else {
463
- console.log(`Skipped: ${issueUrl}`);
464
- }
373
+ console.log(`Skipped: ${issueUrl}`);
465
374
  }
466
375
  }
467
- catch (err) {
468
- handleCommandError(err, options);
469
- }
470
- });
376
+ }));
471
377
  skipCmd
472
378
  .command("list")
473
379
  .description("Show all skipped issues")
474
380
  .option("--json", "Output as JSON")
475
- .action(async (options) => {
476
- try {
477
- const { runSkipList } = await import("./commands/skip.js");
478
- const results = runSkipList();
479
- if (options.json) {
480
- console.log(formatJsonSuccess(results));
481
- }
482
- else {
483
- if (results.length === 0) {
484
- console.log("\nNo skipped issues.\n");
485
- return;
486
- }
487
- console.log(`\nSkipped issues (${results.length}):\n`);
488
- console.log(" Repo Issue Skipped Title");
489
- console.log(" ──────────────────────────────── ────── ────────── ─────");
490
- for (const s of results) {
491
- const repo = (s.repo || "unknown").padEnd(32).slice(0, 32);
492
- const issue = s.number ? `#${s.number}`.padEnd(6) : "—".padEnd(6);
493
- const skippedDate = s.skippedAt.split("T")[0] ?? "";
494
- const title = s.title.length > 50
495
- ? s.title.slice(0, 47) + "..."
496
- : s.title || s.url;
497
- console.log(` ${repo} ${issue} ${skippedDate} ${title}`);
498
- }
499
- console.log();
500
- }
501
- }
502
- catch (err) {
503
- handleCommandError(err, options);
381
+ .action(async (options) => runAction(options, async () => {
382
+ const { runSkipList } = await import("./commands/skip.js");
383
+ const results = runSkipList();
384
+ if (options.json) {
385
+ console.log(formatJsonSuccess(results));
504
386
  }
505
- });
387
+ else {
388
+ if (results.length === 0) {
389
+ console.log("\nNo skipped issues.\n");
390
+ return;
391
+ }
392
+ console.log(`\nSkipped issues (${results.length}):\n`);
393
+ console.log(" Repo Issue Skipped Title");
394
+ console.log(" ──────────────────────────────── ────── ────────── ─────");
395
+ for (const s of results) {
396
+ const repo = (s.repo || "unknown").padEnd(32).slice(0, 32);
397
+ const issue = s.number ? `#${s.number}`.padEnd(6) : "—".padEnd(6);
398
+ const skippedDate = s.skippedAt.split("T")[0] ?? "";
399
+ const title = s.title.length > 50
400
+ ? s.title.slice(0, 47) + "..."
401
+ : s.title || s.url;
402
+ console.log(` ${repo} ${issue} ${skippedDate} ${title}`);
403
+ }
404
+ console.log();
405
+ }
406
+ }));
506
407
  skipCmd
507
408
  .command("remove <issue-url>")
508
409
  .description("Remove an issue from the skip list (unskip)")
509
410
  .option("--json", "Output as JSON")
510
- .action(async (issueUrl, options) => {
511
- try {
512
- const { runSkipRemove } = await import("./commands/skip.js");
513
- const result = await runSkipRemove({ issueUrl });
514
- if (options.json) {
515
- console.log(formatJsonSuccess(result));
411
+ .action(async (issueUrl, options) => runAction(options, async () => {
412
+ const { runSkipRemove } = await import("./commands/skip.js");
413
+ const result = await runSkipRemove({ issueUrl });
414
+ if (options.json) {
415
+ console.log(formatJsonSuccess(result));
416
+ }
417
+ else {
418
+ if (result.removed) {
419
+ console.log(`Removed from skip list: ${issueUrl}`);
516
420
  }
517
421
  else {
518
- if (result.removed) {
519
- console.log(`Removed from skip list: ${issueUrl}`);
520
- }
521
- else {
522
- console.log("Issue was not in the skip list.");
523
- }
422
+ console.log("Issue was not in the skip list.");
524
423
  }
525
424
  }
526
- catch (err) {
527
- handleCommandError(err, options);
528
- }
529
- });
425
+ }));
530
426
  skipCmd
531
427
  .command("clear")
532
428
  .description("Clear all skipped issues")
533
429
  .option("--json", "Output as JSON")
534
- .action(async (options) => {
535
- try {
536
- const { runSkipClear } = await import("./commands/skip.js");
537
- await runSkipClear();
538
- if (options.json) {
539
- console.log(formatJsonSuccess({ cleared: true }));
540
- }
541
- else {
542
- console.log("Skip list cleared.");
543
- }
430
+ .action(async (options) => runAction(options, async () => {
431
+ const { runSkipClear } = await import("./commands/skip.js");
432
+ await runSkipClear();
433
+ if (options.json) {
434
+ console.log(formatJsonSuccess({ cleared: true }));
544
435
  }
545
- catch (err) {
546
- handleCommandError(err, options);
436
+ else {
437
+ console.log("Skip list cleared.");
547
438
  }
548
- });
439
+ }));
549
440
  program
550
441
  .command("vet <issue-url>")
551
442
  .description("Vet a specific GitHub issue for claimability and project health")
552
443
  .option("--json", "Output as JSON")
553
- .action(async (issueUrl, options) => {
554
- try {
555
- const { runVet } = await import("./commands/vet.js");
556
- const state = loadLocalState();
557
- const result = await runVet({ issueUrl, state });
558
- if (options.json) {
559
- console.log(formatJsonSuccess(result));
560
- }
561
- else {
562
- const icon = result.recommendation === "approve"
563
- ? "✅"
564
- : result.recommendation === "skip"
565
- ? "❌"
566
- : "⚠️";
567
- console.log(`\n${icon} ${result.issue.repo}#${result.issue.number}: ${result.recommendation.toUpperCase()}`);
568
- console.log(` ${result.issue.title}`);
569
- console.log(` ${result.issue.url}\n`);
570
- if (result.reasonsToApprove.length > 0) {
571
- console.log("Reasons to approve:");
572
- for (const r of result.reasonsToApprove)
573
- console.log(` + ${r}`);
574
- }
575
- if (result.reasonsToSkip.length > 0) {
576
- console.log("Reasons to skip:");
577
- for (const r of result.reasonsToSkip)
578
- console.log(` - ${r}`);
579
- }
580
- console.log(`\nProject health: ${result.projectHealth.isActive ? "Active" : "Inactive"}`);
581
- console.log(` Last commit: ${result.projectHealth.daysSinceLastCommit} days ago`);
582
- console.log(` CI status: ${result.projectHealth.ciStatus}`);
583
- }
444
+ .action(async (issueUrl, options) => runAction(options, async () => {
445
+ const { runVet } = await import("./commands/vet.js");
446
+ const state = loadLocalState();
447
+ const result = await runVet({ issueUrl, state });
448
+ if (options.json) {
449
+ console.log(formatJsonSuccess(result));
584
450
  }
585
- catch (err) {
586
- handleCommandError(err, options);
451
+ else {
452
+ console.log(renderVet(result));
587
453
  }
588
- });
454
+ }));
589
455
  program.parse();