github-search-cli 9.9.0 → 9.10.1

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/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
- }
@@ -1,278 +0,0 @@
1
- /**
2
- * SearchState — owns all TUI state and domain calls.
3
- *
4
- * The TUI view layer reads from SearchState and dispatches actions.
5
- * This module is testable without the OpenTUI renderer.
6
- */
7
- import type { Repo, SortStrategy, SessionState } from "./types";
8
- import type { TabName } from "./trending";
9
- import { tabSince } from "./trending";
10
- import {
11
- parseQuery,
12
- applyFlagFilters,
13
- rankRepos,
14
- createGitHubSearch,
15
- SearchModule,
16
- TrendingAdapter,
17
- trendingQuery,
18
- } from "./search";
19
- import { restoreSession, saveSession } from "./session";
20
- import { appendHistory } from "./history";
21
- import { toggleBookmark, isBookmarked } from "./bookmarks";
22
- import {
23
- saveSearch,
24
- getSavedSearches,
25
- touchSavedSearch,
26
- } from "./saved-searches";
27
- import { fetchDeepDive, buildDeepDiveText } from "./deepdive";
28
- import { loadConfig } from "./config";
29
-
30
- export type Overlay =
31
- | "none"
32
- | "history"
33
- | "bookmarks"
34
- | "saved"
35
- | "help"
36
- | "topics"
37
- | "export"
38
- | "compare"
39
- | "notifications"
40
- | "share"
41
- | "leader"
42
- | "readme";
43
-
44
- export interface SearchState {
45
- // Core search state
46
- currentRepos: Repo[];
47
- currentSort: SortStrategy;
48
- currentLimit: number;
49
- currentQueryInput: string;
50
- isLoading: boolean;
51
- currentMode: "search" | "trending";
52
- currentPage: number;
53
- totalCount: number;
54
-
55
- // Trending
56
- trendingTab: TabName;
57
- trendingPeriod: string;
58
-
59
- // UI overlays
60
- currentOverlay: Overlay;
61
- deepDiveActive: boolean;
62
- compareList: Repo[];
63
- graphFullscreen: boolean;
64
- chartCommitData: number[];
65
- quitArmed: boolean;
66
- }
67
-
68
- export class SearchStateManager {
69
- private config = loadConfig();
70
- private provider = createGitHubSearch(undefined, this.config.githubTokens);
71
- private trendingSearch = new SearchModule(new TrendingAdapter());
72
-
73
- state: SearchState = {
74
- currentRepos: [],
75
- currentSort: this.config.defaultSort,
76
- currentLimit: this.config.defaultLimit,
77
- currentQueryInput: "",
78
- isLoading: false,
79
- currentMode: "search",
80
- currentPage: 1,
81
- totalCount: 0,
82
- trendingTab: "This Week",
83
- trendingPeriod: "this week",
84
- currentOverlay: "none",
85
- deepDiveActive: false,
86
- compareList: [],
87
- graphFullscreen: false,
88
- chartCommitData: [],
89
- quitArmed: false,
90
- };
91
-
92
- constructor() {
93
- const session = restoreSession();
94
- if (session) {
95
- // Only search/trending are restorable here; packages/bookmarks pages
96
- // are re-entered interactively, so clamp anything else to "search".
97
- this.state.currentMode =
98
- session.mode === "trending" ? "trending" : "search";
99
- this.state.currentQueryInput = session.query;
100
- this.state.currentSort = session.sort;
101
- this.state.currentLimit = session.limit;
102
- this.state.trendingTab = (session.trendingTab as TabName) ?? "This Week";
103
- }
104
- }
105
-
106
- // ── Search ──
107
-
108
- async search(query: string, page: number = 1): Promise<void> {
109
- this.state.isLoading = true;
110
- this.state.currentQueryInput = query;
111
- this.state.currentPage = page;
112
- try {
113
- const parsed = applyFlagFilters(parseQuery(query), {});
114
- const response = await this.provider.search(parsed, {
115
- limit: this.state.currentLimit,
116
- sort: this.state.currentSort,
117
- json: false,
118
- verbose: false,
119
- token: this.config.githubToken,
120
- page,
121
- });
122
- this.state.currentRepos = rankRepos(
123
- response.repos,
124
- this.state.currentSort,
125
- );
126
- this.state.totalCount = response.totalCount;
127
- this.state.currentMode = "search";
128
- appendHistory({
129
- query,
130
- mode: "search",
131
- timestamp: Date.now(),
132
- resultCount: response.repos.length,
133
- });
134
- } finally {
135
- this.state.isLoading = false;
136
- }
137
- }
138
-
139
- async nextPage(): Promise<void> {
140
- await this.search(this.state.currentQueryInput, this.state.currentPage + 1);
141
- }
142
-
143
- async prevPage(): Promise<void> {
144
- if (this.state.currentPage > 1) {
145
- await this.search(
146
- this.state.currentQueryInput,
147
- this.state.currentPage - 1,
148
- );
149
- }
150
- }
151
-
152
- // ── Trending ──
153
-
154
- async loadTrending(tab: TabName): Promise<void> {
155
- this.state.isLoading = true;
156
- this.state.trendingTab = tab;
157
- this.state.currentMode = "trending";
158
- try {
159
- const response = await this.trendingSearch.search(trendingQuery(), {
160
- limit: 25,
161
- sort: "stars",
162
- json: false,
163
- verbose: false,
164
- trendingSince: tabSince(tab),
165
- });
166
- this.state.currentRepos = response.repos;
167
- this.state.totalCount = response.totalCount;
168
- appendHistory({
169
- query: `trending:${tab}`,
170
- mode: "trending",
171
- tab,
172
- timestamp: Date.now(),
173
- resultCount: response.repos.length,
174
- });
175
- } finally {
176
- this.state.isLoading = false;
177
- }
178
- }
179
-
180
- // ── Bookmarks ──
181
-
182
- toggleBookmark(repo: Repo): boolean {
183
- return toggleBookmark(repo);
184
- }
185
-
186
- isBookmarked(fullName: string): boolean {
187
- return isBookmarked(fullName);
188
- }
189
-
190
- // ── Saved searches ──
191
-
192
- saveCurrentSearch(name: string): void {
193
- saveSearch(
194
- name,
195
- this.state.currentQueryInput,
196
- this.state.currentMode,
197
- this.state.currentSort,
198
- this.state.currentLimit,
199
- );
200
- }
201
-
202
- getSavedSearches() {
203
- return getSavedSearches();
204
- }
205
-
206
- touchSavedSearch(name: string): void {
207
- touchSavedSearch(name);
208
- }
209
-
210
- // ── Deep dive ──
211
-
212
- async deepDive(repo: Repo): Promise<string> {
213
- this.state.deepDiveActive = true;
214
- try {
215
- const data = await fetchDeepDive(repo, this.config.githubToken);
216
- return buildDeepDiveText(data);
217
- } finally {
218
- this.state.deepDiveActive = false;
219
- }
220
- }
221
-
222
- // ── Compare ──
223
-
224
- addToCompare(repo: Repo): void {
225
- if (!this.state.compareList.some((r) => r.fullName === repo.fullName)) {
226
- this.state.compareList.push(repo);
227
- }
228
- }
229
-
230
- removeFromCompare(fullName: string): void {
231
- this.state.compareList = this.state.compareList.filter(
232
- (r) => r.fullName !== fullName,
233
- );
234
- }
235
-
236
- clearCompare(): void {
237
- this.state.compareList = [];
238
- }
239
-
240
- // ── Session ──
241
-
242
- saveSession(): void {
243
- const s: SessionState = {
244
- mode: this.state.currentMode,
245
- query: this.state.currentQueryInput,
246
- sort: this.state.currentSort,
247
- limit: this.state.currentLimit,
248
- trendingTab: this.state.trendingTab,
249
- };
250
- saveSession(s);
251
- }
252
-
253
- // ── Setters ──
254
-
255
- setSort(sort: SortStrategy): void {
256
- this.state.currentSort = sort;
257
- }
258
-
259
- setLimit(limit: number): void {
260
- this.state.currentLimit = limit;
261
- }
262
-
263
- setOverlay(overlay: Overlay): void {
264
- this.state.currentOverlay = overlay;
265
- }
266
-
267
- setGraphFullscreen(fullscreen: boolean): void {
268
- this.state.graphFullscreen = fullscreen;
269
- }
270
-
271
- setChartCommitData(data: number[]): void {
272
- this.state.chartCommitData = data;
273
- }
274
-
275
- setQuitArmed(armed: boolean): void {
276
- this.state.quitArmed = armed;
277
- }
278
- }