github-search-cli 9.10.0 → 9.10.2
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/CHANGELOG.md +57 -0
- package/README.md +18 -34
- package/dist/cli.js +124 -17
- package/package.json +1 -1
- package/release-notes/9.10.1.md +59 -0
- package/release-notes/9.10.2.md +26 -0
- package/src/cli.ts +77 -7
- package/src/history.ts +4 -5
- package/src/mcp-server.ts +4 -2
- package/src/output.ts +11 -1
- package/src/package-browser.ts +2 -0
- package/src/status.ts +40 -0
- package/src/trending.ts +3 -1
- package/src/tui.ts +65 -9
- package/src/watch.ts +18 -6
- package/src/export.ts +0 -73
- package/src/pipe.ts +0 -60
- package/src/search-state.ts +0 -278
package/src/cli.ts
CHANGED
|
@@ -25,6 +25,8 @@ import { reportError } from "./error-report";
|
|
|
25
25
|
import {
|
|
26
26
|
format as formatOutput,
|
|
27
27
|
pipeExec,
|
|
28
|
+
FORMAT_VALUES,
|
|
29
|
+
PIPE_TARGETS,
|
|
28
30
|
type Format,
|
|
29
31
|
type ExportFormat,
|
|
30
32
|
} from "./output";
|
|
@@ -104,6 +106,8 @@ interface CLIFlags {
|
|
|
104
106
|
token?: string;
|
|
105
107
|
trending: boolean;
|
|
106
108
|
since: string;
|
|
109
|
+
/** True when --since was passed (watch falls back to the query-string convention otherwise). */
|
|
110
|
+
sinceExplicit: boolean;
|
|
107
111
|
pipe?: string;
|
|
108
112
|
format?: string;
|
|
109
113
|
watch: boolean;
|
|
@@ -156,6 +160,9 @@ const SORT_VALUES = [
|
|
|
156
160
|
"name",
|
|
157
161
|
];
|
|
158
162
|
|
|
163
|
+
/** Valid `--since` values — the periods github.com/trending accepts. */
|
|
164
|
+
const SINCE_VALUES: readonly string[] = ["daily", "weekly", "monthly"];
|
|
165
|
+
|
|
159
166
|
function parseArgs(args: string[]): CLIFlags {
|
|
160
167
|
const flags: CLIFlags = {
|
|
161
168
|
query: "",
|
|
@@ -168,6 +175,7 @@ function parseArgs(args: string[]): CLIFlags {
|
|
|
168
175
|
token: process.env.GITHUB_TOKEN,
|
|
169
176
|
trending: false,
|
|
170
177
|
since: "daily",
|
|
178
|
+
sinceExplicit: false,
|
|
171
179
|
watch: false,
|
|
172
180
|
releases: false,
|
|
173
181
|
interval: 300,
|
|
@@ -276,15 +284,51 @@ function parseArgs(args: string[]): CLIFlags {
|
|
|
276
284
|
case "--token":
|
|
277
285
|
flags.token = args[++i];
|
|
278
286
|
break;
|
|
279
|
-
case "--
|
|
280
|
-
|
|
287
|
+
case "--interval": {
|
|
288
|
+
const parsed = parseInt(args[++i], 10);
|
|
289
|
+
if (Number.isNaN(parsed) || parsed < 1) {
|
|
290
|
+
console.error(
|
|
291
|
+
`Invalid interval: ${args[i] ?? "(missing)"} (must be a whole number of seconds, e.g. 300).`,
|
|
292
|
+
);
|
|
293
|
+
process.exit(1);
|
|
294
|
+
}
|
|
295
|
+
flags.interval = parsed;
|
|
281
296
|
break;
|
|
282
|
-
|
|
283
|
-
|
|
297
|
+
}
|
|
298
|
+
case "--since": {
|
|
299
|
+
const since = args[++i];
|
|
300
|
+
if (!since || !SINCE_VALUES.includes(since)) {
|
|
301
|
+
console.error(
|
|
302
|
+
`Invalid since: ${since ?? "(missing)"} (use daily|weekly|monthly).`,
|
|
303
|
+
);
|
|
304
|
+
process.exit(1);
|
|
305
|
+
}
|
|
306
|
+
flags.since = since;
|
|
307
|
+
flags.sinceExplicit = true;
|
|
284
308
|
break;
|
|
285
|
-
|
|
286
|
-
|
|
309
|
+
}
|
|
310
|
+
case "--pipe": {
|
|
311
|
+
const target = args[++i];
|
|
312
|
+
if (!target || !PIPE_TARGETS.includes(target)) {
|
|
313
|
+
console.error(
|
|
314
|
+
`Invalid pipe target: ${target ?? "(missing)"} (use open|clone).`,
|
|
315
|
+
);
|
|
316
|
+
process.exit(1);
|
|
317
|
+
}
|
|
318
|
+
flags.pipe = target;
|
|
319
|
+
break;
|
|
320
|
+
}
|
|
321
|
+
case "--format": {
|
|
322
|
+
const fmt = args[++i];
|
|
323
|
+
if (!fmt || !FORMAT_VALUES.includes(fmt)) {
|
|
324
|
+
console.error(
|
|
325
|
+
`Invalid format: ${fmt ?? "(missing)"} (use ${FORMAT_VALUES.join("|")}).`,
|
|
326
|
+
);
|
|
327
|
+
process.exit(1);
|
|
328
|
+
}
|
|
329
|
+
flags.format = fmt;
|
|
287
330
|
break;
|
|
331
|
+
}
|
|
288
332
|
case "--registry":
|
|
289
333
|
flags.registry = args[++i] ?? "npm";
|
|
290
334
|
break;
|
|
@@ -636,7 +680,13 @@ Options:
|
|
|
636
680
|
return;
|
|
637
681
|
}
|
|
638
682
|
|
|
639
|
-
await launchBrowser(
|
|
683
|
+
await launchBrowser(
|
|
684
|
+
flags.theme,
|
|
685
|
+
// `ghfind --trending` with no query/format promises the trending view
|
|
686
|
+
// (TRACKER.md), but the bare flag used to fall through to the landing
|
|
687
|
+
// menu. An explicit flag is explicit intent — skip the menu.
|
|
688
|
+
flags.trending ? "trending" : undefined,
|
|
689
|
+
);
|
|
640
690
|
}
|
|
641
691
|
|
|
642
692
|
interface SearchContext {
|
|
@@ -1197,6 +1247,7 @@ async function runNonInteractive(flags: CLIFlags, outputFormat?: ExportFormat) {
|
|
|
1197
1247
|
token: flags.token,
|
|
1198
1248
|
intervalMs: flags.interval * 1000,
|
|
1199
1249
|
trending: flags.trending,
|
|
1250
|
+
since: flags.sinceExplicit ? flags.since : undefined,
|
|
1200
1251
|
},
|
|
1201
1252
|
(repos, delta) => {
|
|
1202
1253
|
tick++;
|
|
@@ -1298,6 +1349,25 @@ async function runNonInteractive(flags: CLIFlags, outputFormat?: ExportFormat) {
|
|
|
1298
1349
|
? "format"
|
|
1299
1350
|
: "search";
|
|
1300
1351
|
|
|
1352
|
+
// The search/format/pipe/watch handlers all need a query. Without this
|
|
1353
|
+
// gate an empty query was sent to GitHub as `q=" "`, which returns 422 and
|
|
1354
|
+
// surfaced to the user as a misleading "Network error — check your
|
|
1355
|
+
// connection" retry loop.
|
|
1356
|
+
const needsQuery =
|
|
1357
|
+
key === "search" ||
|
|
1358
|
+
key === "format" ||
|
|
1359
|
+
key === "pipe" ||
|
|
1360
|
+
(key === "watch" && !flags.trending);
|
|
1361
|
+
if (needsQuery && flags.query.trim() === "") {
|
|
1362
|
+
console.error("⚠ No search query given.");
|
|
1363
|
+
console.error(
|
|
1364
|
+
' Usage: ghfind "<query>" [--count|--format <fmt>|--pipe <tgt>|--watch]',
|
|
1365
|
+
);
|
|
1366
|
+
console.error(' Example: ghfind "language:Rust stars:>500" --count');
|
|
1367
|
+
console.error(" Run `ghfind --help` for all options.");
|
|
1368
|
+
process.exit(1);
|
|
1369
|
+
}
|
|
1370
|
+
|
|
1301
1371
|
const ctx = buildSearchContext(flags);
|
|
1302
1372
|
await handlers[key](ctx);
|
|
1303
1373
|
}
|
package/src/history.ts
CHANGED
|
@@ -29,6 +29,10 @@ export function readHistory(): HistoryEntry[] {
|
|
|
29
29
|
|
|
30
30
|
/** Delete a single history entry by index (0 = newest). */
|
|
31
31
|
export function deleteHistoryEntry(index: number): void {
|
|
32
|
+
// The index comes from the overlay, which lists readHistory()'s deduplicated
|
|
33
|
+
// newest-first view — so delete against that same view. Raw lines can't be
|
|
34
|
+
// addressed by this index: consecutive duplicate queries are collapsed in
|
|
35
|
+
// the view, which shifts every raw-line position after them.
|
|
32
36
|
const entries = readHistory();
|
|
33
37
|
if (index < 0 || index >= entries.length) return;
|
|
34
38
|
entries.splice(index, 1);
|
|
@@ -59,8 +63,3 @@ export function rotateHistory(): void {
|
|
|
59
63
|
.join("\n")}\n`,
|
|
60
64
|
);
|
|
61
65
|
}
|
|
62
|
-
|
|
63
|
-
/** Count entries in history file. */
|
|
64
|
-
export function historyCount(): number {
|
|
65
|
-
return readJSONL<HistoryEntry>(HISTORY_FILE).length;
|
|
66
|
-
}
|
package/src/mcp-server.ts
CHANGED
|
@@ -38,7 +38,7 @@ import {
|
|
|
38
38
|
type PackageSortMode,
|
|
39
39
|
} from "./package";
|
|
40
40
|
import { fetchOrgProfile, formatOrgText } from "./org";
|
|
41
|
-
import { fetchUserProfile,
|
|
41
|
+
import { fetchUserProfile, formatUserText } from "./user";
|
|
42
42
|
import {
|
|
43
43
|
fetchDeepDive,
|
|
44
44
|
buildDeepDiveText,
|
|
@@ -511,7 +511,9 @@ const TOOLS: ToolDefinition[] = [
|
|
|
511
511
|
token: resolveToken(state, args.token),
|
|
512
512
|
limit,
|
|
513
513
|
});
|
|
514
|
-
|
|
514
|
+
// Human-readable text like ghfind_org_profile (the structured data
|
|
515
|
+
// still rides along in `data` for callers that want the raw numbers).
|
|
516
|
+
return { text: formatUserText(profile), data: { ...profile } };
|
|
515
517
|
},
|
|
516
518
|
},
|
|
517
519
|
{
|
package/src/output.ts
CHANGED
|
@@ -22,8 +22,11 @@ export type Format =
|
|
|
22
22
|
|
|
23
23
|
export type FormatFn = (repos: Repo[]) => string;
|
|
24
24
|
|
|
25
|
+
/** Accepted `--pipe` targets — the cases `pipeExec` actually handles. */
|
|
26
|
+
export const PIPE_TARGETS: readonly string[] = ["open", "clone"];
|
|
27
|
+
|
|
25
28
|
/** Registry of all format functions. Adding a format = 1 line here. */
|
|
26
|
-
const FORMATS: Map<Format, FormatFn> = new Map([
|
|
29
|
+
const FORMATS: Map<Format, FormatFn> = new Map<Format, FormatFn>([
|
|
27
30
|
// Export formats
|
|
28
31
|
["json", formatJson],
|
|
29
32
|
["csv", formatCsv],
|
|
@@ -37,6 +40,13 @@ const FORMATS: Map<Format, FormatFn> = new Map([
|
|
|
37
40
|
["ids", formatIds],
|
|
38
41
|
]);
|
|
39
42
|
|
|
43
|
+
/**
|
|
44
|
+
* Every accepted `--format` value, derived from the registry above so the CLI
|
|
45
|
+
* can validate up front instead of throwing `Unknown format` mid-run (a plain
|
|
46
|
+
* typo used to route into the crash-report flow).
|
|
47
|
+
*/
|
|
48
|
+
export const FORMAT_VALUES: readonly string[] = [...FORMATS.keys()];
|
|
49
|
+
|
|
40
50
|
// ── Export format functions ────────────────────────────────────────────
|
|
41
51
|
|
|
42
52
|
export function formatJson(repos: Repo[]): string {
|
package/src/package-browser.ts
CHANGED
|
@@ -265,6 +265,8 @@ export async function launchPackageBrowser(): Promise<void> {
|
|
|
265
265
|
renderer.keyInput.on("keypress", (key: import("@opentui/core").KeyEvent) => {
|
|
266
266
|
const name = key.name || "";
|
|
267
267
|
if (name === "q" || name === "escape") {
|
|
268
|
+
// Release the terminal before the hard exit, same as the main TUI.
|
|
269
|
+
renderer.destroy();
|
|
268
270
|
process.exit(0);
|
|
269
271
|
return;
|
|
270
272
|
}
|
package/src/status.ts
CHANGED
|
@@ -12,12 +12,16 @@ export class StatusManager {
|
|
|
12
12
|
private type: StatusType = "idle";
|
|
13
13
|
private message = "";
|
|
14
14
|
private onUpdate: (text: string) => void;
|
|
15
|
+
private disposed = false;
|
|
15
16
|
|
|
16
17
|
constructor(onUpdate: (text: string) => void) {
|
|
17
18
|
this.onUpdate = onUpdate;
|
|
18
19
|
}
|
|
19
20
|
|
|
20
21
|
set(type: StatusType, message: string): void {
|
|
22
|
+
// Late async continuations (a search resolving after quit) can call
|
|
23
|
+
// set() long after the UI is gone; a disposed manager is inert.
|
|
24
|
+
if (this.disposed) return;
|
|
21
25
|
this.stopSpinner();
|
|
22
26
|
this.type = type;
|
|
23
27
|
this.message = message;
|
|
@@ -32,6 +36,7 @@ export class StatusManager {
|
|
|
32
36
|
}
|
|
33
37
|
|
|
34
38
|
private startSpinner(frames: string[]): void {
|
|
39
|
+
if (this.disposed) return;
|
|
35
40
|
this.spinnerIndex = 0;
|
|
36
41
|
this.emit(frames[0]);
|
|
37
42
|
this.spinnerInterval = setInterval(() => {
|
|
@@ -48,6 +53,8 @@ export class StatusManager {
|
|
|
48
53
|
}
|
|
49
54
|
|
|
50
55
|
private emit(spinner?: string): void {
|
|
56
|
+
// A tick can already be queued when dispose() clears the interval.
|
|
57
|
+
if (this.disposed) return;
|
|
51
58
|
const icon =
|
|
52
59
|
this.type === "searching"
|
|
53
60
|
? "🔍"
|
|
@@ -63,6 +70,39 @@ export class StatusManager {
|
|
|
63
70
|
}
|
|
64
71
|
|
|
65
72
|
dispose(): void {
|
|
73
|
+
this.disposed = true;
|
|
66
74
|
this.stopSpinner();
|
|
67
75
|
}
|
|
68
76
|
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* The status-bar renderable, narrowed to what the manager needs. TextRenderable
|
|
80
|
+
* satisfies this structurally (isDestroyed is a public getter on Renderable).
|
|
81
|
+
*/
|
|
82
|
+
export interface StatusBarTarget {
|
|
83
|
+
readonly isDestroyed: boolean;
|
|
84
|
+
// Accessor pair, not `content: string`: TextRenderable's getter widens to
|
|
85
|
+
// StyledText while its setter accepts a plain string. `unknown` on the read
|
|
86
|
+
// side keeps the renderable assignable; we only ever write strings.
|
|
87
|
+
get content(): unknown;
|
|
88
|
+
set content(value: string);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Build a StatusManager wired to a status bar, plus a hook to request a frame.
|
|
93
|
+
*
|
|
94
|
+
* Writes are dropped once the renderable is destroyed. The renderer tears its
|
|
95
|
+
* tree down on Ctrl+C / SIGTERM / terminal close but leaves the event loop
|
|
96
|
+
* running, so an unguarded spinner tick would assign to a destroyed TextBuffer
|
|
97
|
+
* and throw out of a timer callback, taking the whole process with it.
|
|
98
|
+
*/
|
|
99
|
+
export function createStatusManager(
|
|
100
|
+
bar: StatusBarTarget,
|
|
101
|
+
onFrame: () => void,
|
|
102
|
+
): StatusManager {
|
|
103
|
+
return new StatusManager((text) => {
|
|
104
|
+
if (bar.isDestroyed) return;
|
|
105
|
+
bar.content = ` ${text}`;
|
|
106
|
+
onFrame();
|
|
107
|
+
});
|
|
108
|
+
}
|
package/src/trending.ts
CHANGED
|
@@ -398,7 +398,7 @@ export async function launchTrending(): Promise<void> {
|
|
|
398
398
|
paddingX: 1,
|
|
399
399
|
});
|
|
400
400
|
const footerText = new TextRenderable(renderer, {
|
|
401
|
-
content: " ↑↓ navigate ↵ open /
|
|
401
|
+
content: " ↑↓ navigate ↵ open / top 1-3 tab q quit r refresh",
|
|
402
402
|
fg: C.muted,
|
|
403
403
|
height: 1,
|
|
404
404
|
});
|
|
@@ -430,6 +430,8 @@ export async function launchTrending(): Promise<void> {
|
|
|
430
430
|
renderer.keyInput.on("keypress", (key) => {
|
|
431
431
|
const name = key.name || "";
|
|
432
432
|
if (name === "q") {
|
|
433
|
+
// Release the terminal before the hard exit, same as the main TUI.
|
|
434
|
+
renderer.destroy();
|
|
433
435
|
process.exit(0);
|
|
434
436
|
return;
|
|
435
437
|
}
|
package/src/tui.ts
CHANGED
|
@@ -108,7 +108,7 @@ import {
|
|
|
108
108
|
PACKAGE_SORT_MODES,
|
|
109
109
|
type PackageSortMode,
|
|
110
110
|
} from "./package";
|
|
111
|
-
import { StatusManager } from "./status";
|
|
111
|
+
import { type StatusManager, createStatusManager } from "./status";
|
|
112
112
|
import {
|
|
113
113
|
getNotifications,
|
|
114
114
|
dismissNotification,
|
|
@@ -199,6 +199,15 @@ function createTuiLogger(): Logger {
|
|
|
199
199
|
|
|
200
200
|
const logger: Logger = createTuiLogger();
|
|
201
201
|
|
|
202
|
+
// The live StatusManager for the current renderer. Kept at module scope so the
|
|
203
|
+
// hoisted cleanup() can dispose the spinner interval on the quit path; a stale
|
|
204
|
+
// interval would otherwise outlive the destroyed renderable it writes to.
|
|
205
|
+
let activeStatusMgr: StatusManager | null = null;
|
|
206
|
+
// The live renderer, so the hoisted cleanup() can release the terminal
|
|
207
|
+
// (raw mode, alternate screen) before process.exit() — same reasoning as
|
|
208
|
+
// activeStatusMgr above.
|
|
209
|
+
let activeRenderer: CliRenderer | null = null;
|
|
210
|
+
|
|
202
211
|
// ─── Sort cycle ───────────────────────────────────────────────────────
|
|
203
212
|
const SORT_MODES: { key: SortStrategy; label: string }[] = [
|
|
204
213
|
{ key: "best-match", label: "best-match" },
|
|
@@ -208,7 +217,10 @@ const SORT_MODES: { key: SortStrategy; label: string }[] = [
|
|
|
208
217
|
];
|
|
209
218
|
|
|
210
219
|
// ─── Main ─────────────────────────────────────────────────────────────
|
|
211
|
-
export async function launchBrowser(
|
|
220
|
+
export async function launchBrowser(
|
|
221
|
+
theme_override?: string,
|
|
222
|
+
startMode?: "trending",
|
|
223
|
+
): Promise<void> {
|
|
212
224
|
// Ask the terminal for its real background color (OSC 11) BEFORE the
|
|
213
225
|
// renderer takes over stdin — after that, reading the reply gets messy.
|
|
214
226
|
// Used below so the app backdrop matches the user's terminal theme.
|
|
@@ -247,6 +259,7 @@ export async function launchBrowser(theme_override?: string): Promise<void> {
|
|
|
247
259
|
}
|
|
248
260
|
|
|
249
261
|
const root = renderer.root;
|
|
262
|
+
activeRenderer = renderer;
|
|
250
263
|
root.flexDirection = "column";
|
|
251
264
|
|
|
252
265
|
// ── Config ──
|
|
@@ -692,6 +705,10 @@ export async function launchBrowser(theme_override?: string): Promise<void> {
|
|
|
692
705
|
leaderBox.visible = false;
|
|
693
706
|
leaderDim.visible = false;
|
|
694
707
|
readmeBox.visible = false;
|
|
708
|
+
// The org panel is a flexGrow sibling of `body` just like every other
|
|
709
|
+
// overlay — omitting it here meant it stayed visible after Esc, permanently
|
|
710
|
+
// stealing half the content height and every click in the lower half.
|
|
711
|
+
orgBox.visible = false;
|
|
695
712
|
updateBox.visible = false;
|
|
696
713
|
updateDim.visible = false;
|
|
697
714
|
if (type === "none") {
|
|
@@ -1929,6 +1946,7 @@ export async function launchBrowser(theme_override?: string): Promise<void> {
|
|
|
1929
1946
|
},
|
|
1930
1947
|
});
|
|
1931
1948
|
const orgText = new TextRenderable(renderer, {
|
|
1949
|
+
...NON_INTERACTIVE_TEXT,
|
|
1932
1950
|
content: "",
|
|
1933
1951
|
fg: colors.text,
|
|
1934
1952
|
bg: colors.bg,
|
|
@@ -1936,6 +1954,7 @@ export async function launchBrowser(theme_override?: string): Promise<void> {
|
|
|
1936
1954
|
orgScroll.add(orgText);
|
|
1937
1955
|
orgBox.add(orgScroll);
|
|
1938
1956
|
const orgFooter = new TextRenderable(renderer, {
|
|
1957
|
+
...NON_INTERACTIVE_TEXT,
|
|
1939
1958
|
content: " ↑↓/jk scroll Esc/q close",
|
|
1940
1959
|
fg: colors.muted,
|
|
1941
1960
|
bg: colors.bg,
|
|
@@ -2130,10 +2149,16 @@ export async function launchBrowser(theme_override?: string): Promise<void> {
|
|
|
2130
2149
|
renderer.requestRender();
|
|
2131
2150
|
};
|
|
2132
2151
|
|
|
2133
|
-
|
|
2134
|
-
|
|
2135
|
-
|
|
2136
|
-
|
|
2152
|
+
// Held at module scope so cleanup() (a hoisted function declaration, called
|
|
2153
|
+
// from key handlers defined above the manager's creation) can still reach it.
|
|
2154
|
+
activeStatusMgr = createStatusManager(statusBar, () =>
|
|
2155
|
+
renderer.requestRender(),
|
|
2156
|
+
);
|
|
2157
|
+
const statusMgr = activeStatusMgr;
|
|
2158
|
+
// The renderer tears down its renderables on Ctrl+C / SIGTERM / terminal
|
|
2159
|
+
// close, but the 120ms spinner interval is ours. Without this, the next tick
|
|
2160
|
+
// assigns to a destroyed TextBuffer and throws out of a timer callback.
|
|
2161
|
+
renderer.on("destroy", () => statusMgr.dispose());
|
|
2137
2162
|
setTimeout(() => {
|
|
2138
2163
|
if (currentOverlay === "none") {
|
|
2139
2164
|
setStatus(nextTip());
|
|
@@ -3433,6 +3458,20 @@ ${pack.description ?? ""}`;
|
|
|
3433
3458
|
showOverlay("help");
|
|
3434
3459
|
return;
|
|
3435
3460
|
}
|
|
3461
|
+
// Ctrl+E opens the export chooser (CSV / Markdown / JSON / plain text)
|
|
3462
|
+
// straight from the results — the shortcut the startup tip advertises.
|
|
3463
|
+
// Claim the chord first: while the query input is focused its default
|
|
3464
|
+
// emacs binding would otherwise also treat Ctrl+E as "move to line end".
|
|
3465
|
+
if (key.ctrl && key.name === "e") {
|
|
3466
|
+
key.stopPropagation();
|
|
3467
|
+
if (currentRepos.length > 0) {
|
|
3468
|
+
showOverlay("export");
|
|
3469
|
+
} else {
|
|
3470
|
+
setStatus("No results to export — run a search first");
|
|
3471
|
+
}
|
|
3472
|
+
renderer.requestRender();
|
|
3473
|
+
return;
|
|
3474
|
+
}
|
|
3436
3475
|
|
|
3437
3476
|
// Tab auto-completes qualifiers in search input
|
|
3438
3477
|
if (key.name === "tab") {
|
|
@@ -3518,12 +3557,18 @@ ${pack.description ?? ""}`;
|
|
|
3518
3557
|
// ── Update check (before start) ──────────────────────────────────────
|
|
3519
3558
|
const _currentVersion = getVersion();
|
|
3520
3559
|
// ── Start ──────────────────────────────────────────────────────────
|
|
3521
|
-
//
|
|
3560
|
+
// Default is the landing menu. Saved sessions restore preferences
|
|
3522
3561
|
// (sort/limit/trending tab) and pre-fill the query once a mode is picked,
|
|
3523
3562
|
// but never auto-run a search — a stale or corrupted session (e.g. an MCP
|
|
3524
3563
|
// handshake blob saved as a query) used to hijack startup and skip the
|
|
3525
|
-
// menu on every launch.
|
|
3526
|
-
|
|
3564
|
+
// menu on every launch. An explicit `ghfind --trending` is not a stale
|
|
3565
|
+
// session though: it is the mode the user asked for, so it skips the menu.
|
|
3566
|
+
if (startMode === "trending") {
|
|
3567
|
+
showLanding(false);
|
|
3568
|
+
loadTrending();
|
|
3569
|
+
} else {
|
|
3570
|
+
showLanding(true);
|
|
3571
|
+
}
|
|
3527
3572
|
checkForUpdateAndShow();
|
|
3528
3573
|
checkPostUpgradePanel();
|
|
3529
3574
|
renderer.start();
|
|
@@ -3549,11 +3594,22 @@ function formatToolbar(
|
|
|
3549
3594
|
}
|
|
3550
3595
|
|
|
3551
3596
|
function cleanup(): void {
|
|
3597
|
+
// Stop the spinner before exiting: process.exit() would kill the process
|
|
3598
|
+
// anyway, but a tick already queued between here and the exit can still fire.
|
|
3599
|
+
activeStatusMgr?.dispose();
|
|
3600
|
+
activeStatusMgr = null;
|
|
3552
3601
|
try {
|
|
3553
3602
|
rotateHistory();
|
|
3554
3603
|
} catch {
|
|
3555
3604
|
/* non-critical */
|
|
3556
3605
|
}
|
|
3606
|
+
// Release the terminal (raw mode, alternate screen) before the hard exit.
|
|
3607
|
+
try {
|
|
3608
|
+
activeRenderer?.destroy();
|
|
3609
|
+
} catch {
|
|
3610
|
+
/* non-critical */
|
|
3611
|
+
}
|
|
3612
|
+
activeRenderer = null;
|
|
3557
3613
|
process.exit(0);
|
|
3558
3614
|
}
|
|
3559
3615
|
|
package/src/watch.ts
CHANGED
|
@@ -19,6 +19,23 @@ export interface WatchOptions {
|
|
|
19
19
|
token?: string;
|
|
20
20
|
intervalMs: number;
|
|
21
21
|
trending?: boolean;
|
|
22
|
+
/** Trending period (`--since`); falls back to the query-string convention. */
|
|
23
|
+
since?: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Resolve the trending period for a watch tick. `--since` wins when it names a
|
|
28
|
+
* real period; otherwise fall back to the legacy `ghfind --watch weekly`
|
|
29
|
+
* convention where the query string itself carried the period.
|
|
30
|
+
*/
|
|
31
|
+
export function resolveTrendingSince(
|
|
32
|
+
opts: Pick<WatchOptions, "query" | "since">,
|
|
33
|
+
): "daily" | "weekly" | "monthly" {
|
|
34
|
+
if (opts.since === "weekly" || opts.since === "monthly") return opts.since;
|
|
35
|
+
if (opts.since === "daily") return "daily";
|
|
36
|
+
if (opts.query === "weekly") return "weekly";
|
|
37
|
+
if (opts.query === "monthly") return "monthly";
|
|
38
|
+
return "daily";
|
|
22
39
|
}
|
|
23
40
|
|
|
24
41
|
const MAX_FAILS = 5;
|
|
@@ -44,12 +61,7 @@ export async function runWatch(
|
|
|
44
61
|
try {
|
|
45
62
|
let repos: Repo[];
|
|
46
63
|
if (opts.trending) {
|
|
47
|
-
const since =
|
|
48
|
-
opts.query === "weekly"
|
|
49
|
-
? "weekly"
|
|
50
|
-
: opts.query === "monthly"
|
|
51
|
-
? "monthly"
|
|
52
|
-
: "daily";
|
|
64
|
+
const since = resolveTrendingSince(opts);
|
|
53
65
|
const trendingSearch = createTrendingSearch();
|
|
54
66
|
const response = await trendingSearch.search(trendingQuery(), {
|
|
55
67
|
limit: opts.limit,
|
package/src/export.ts
DELETED
|
@@ -1,73 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Export — format repos as JSON, CSV, Markdown, or plain text.
|
|
3
|
-
*/
|
|
4
|
-
import { writeFileSync } from "node:fs";
|
|
5
|
-
import type { Repo } from "./types";
|
|
6
|
-
|
|
7
|
-
export type ExportFormat = "json" | "csv" | "markdown" | "text";
|
|
8
|
-
|
|
9
|
-
export function formatJson(repos: Repo[]): string {
|
|
10
|
-
return JSON.stringify(repos, null, 2);
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
function csvEscape(val: unknown): string {
|
|
14
|
-
const str = String(val ?? "");
|
|
15
|
-
if (str.includes('"') || str.includes(",") || str.includes("\n")) {
|
|
16
|
-
return `"${str.replace(/"/g, '""')}"`;
|
|
17
|
-
}
|
|
18
|
-
return str;
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
/** Format repos as CSV string. */
|
|
22
|
-
export function formatCsv(repos: Repo[]): string {
|
|
23
|
-
const header = "rank,full_name,stars,forks,language,url";
|
|
24
|
-
const rows = repos.map(
|
|
25
|
-
(r, i) =>
|
|
26
|
-
`${i + 1},${csvEscape(r.fullName)},${r.stars},${r.forks},${csvEscape(r.language ?? "")},${csvEscape(r.url)}`,
|
|
27
|
-
);
|
|
28
|
-
return [header, ...rows].join("\n");
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
/** Format repos as Markdown table. */
|
|
32
|
-
export function formatMarkdown(repos: Repo[]): string {
|
|
33
|
-
const header = "| # | Repo | Stars | Forks | Language |";
|
|
34
|
-
const sep = "|---|------|-------|-------|----------|";
|
|
35
|
-
const rows = repos.map(
|
|
36
|
-
(r, i) =>
|
|
37
|
-
`| ${i + 1} | [${r.fullName}](${r.url}) | ${r.stars.toLocaleString()} | ${r.forks.toLocaleString()} | ${r.language ?? "—"} |`,
|
|
38
|
-
);
|
|
39
|
-
return [header, sep, ...rows].join("\n");
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
/** Format repos as plain text, one per line. */
|
|
43
|
-
export function formatText(repos: Repo[]): string {
|
|
44
|
-
return repos
|
|
45
|
-
.map(
|
|
46
|
-
(r, i) =>
|
|
47
|
-
`${i + 1}. ${r.fullName} ★ ${r.stars.toLocaleString()} ${r.url}`,
|
|
48
|
-
)
|
|
49
|
-
.join("\n");
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
/** Format repos in the given format. */
|
|
53
|
-
export function formatRepos(repos: Repo[], format: ExportFormat): string {
|
|
54
|
-
switch (format) {
|
|
55
|
-
case "json":
|
|
56
|
-
return formatJson(repos);
|
|
57
|
-
case "csv":
|
|
58
|
-
return formatCsv(repos);
|
|
59
|
-
case "markdown":
|
|
60
|
-
return formatMarkdown(repos);
|
|
61
|
-
case "text":
|
|
62
|
-
return formatText(repos);
|
|
63
|
-
}
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
/** Export repos to a file. Returns the file path. */
|
|
67
|
-
export function exportToFile(repos: Repo[], format: ExportFormat): string {
|
|
68
|
-
const ext = format === "markdown" ? "md" : format;
|
|
69
|
-
const ts = Date.now();
|
|
70
|
-
const path = `./ghfind-export-${ts}.${ext}`;
|
|
71
|
-
writeFileSync(path, formatRepos(repos, format), "utf-8");
|
|
72
|
-
return path;
|
|
73
|
-
}
|
package/src/pipe.ts
DELETED
|
@@ -1,60 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Pipe-friendly format modifiers — output repo data as lines for shell piping.
|
|
3
|
-
*/
|
|
4
|
-
import type { Repo } from "./types";
|
|
5
|
-
import { openUrl } from "./open-url";
|
|
6
|
-
|
|
7
|
-
export type FormatLine =
|
|
8
|
-
"urls" | "names" | "ssh-urls" | "clone-commands" | "ids";
|
|
9
|
-
|
|
10
|
-
export function formatUrls(repos: Repo[]): string {
|
|
11
|
-
return repos.map((r) => r.url).join("\n");
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
export function formatNames(repos: Repo[]): string {
|
|
15
|
-
return repos.map((r) => r.fullName).join("\n");
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
export function formatSshUrls(repos: Repo[]): string {
|
|
19
|
-
return repos.map((r) => `git@github.com:${r.fullName}.git`).join("\n");
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
export function formatCloneCommands(repos: Repo[]): string {
|
|
23
|
-
return repos
|
|
24
|
-
.map((r) => `git clone git@github.com:${r.fullName}.git`)
|
|
25
|
-
.join("\n");
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
export function formatIds(repos: Repo[]): string {
|
|
29
|
-
return repos.map((r) => String(r.id)).join("\n");
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
export function formatLines(repos: Repo[], format: FormatLine): string {
|
|
33
|
-
switch (format) {
|
|
34
|
-
case "urls":
|
|
35
|
-
return formatUrls(repos);
|
|
36
|
-
case "names":
|
|
37
|
-
return formatNames(repos);
|
|
38
|
-
case "ssh-urls":
|
|
39
|
-
return formatSshUrls(repos);
|
|
40
|
-
case "clone-commands":
|
|
41
|
-
return formatCloneCommands(repos);
|
|
42
|
-
case "ids":
|
|
43
|
-
return formatIds(repos);
|
|
44
|
-
}
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
/** Execute a pipe target (clone, open) on repos. */
|
|
48
|
-
export async function pipeExec(repos: Repo[], target: string): Promise<void> {
|
|
49
|
-
for (const repo of repos) {
|
|
50
|
-
const url = repo.url;
|
|
51
|
-
switch (target) {
|
|
52
|
-
case "open":
|
|
53
|
-
await openUrl(url);
|
|
54
|
-
break;
|
|
55
|
-
case "clone":
|
|
56
|
-
console.log(`git clone git@github.com:${repo.fullName}.git`);
|
|
57
|
-
break;
|
|
58
|
-
}
|
|
59
|
-
}
|
|
60
|
-
}
|