github-search-cli 0.8.2 → 0.9.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/src/history.ts CHANGED
@@ -1,53 +1,30 @@
1
1
  /**
2
2
  * Persistent search history — append-only JSONL with rotation.
3
3
  */
4
- import { existsSync, mkdirSync, readFileSync, appendFileSync, writeFileSync, unlinkSync } from "fs";
5
- import { join } from "path";
6
4
  import type { HistoryEntry } from "./types";
7
- import { stateDir } from "./config";
5
+ import { readJSONL, appendJSONL, writeJSON, writeRaw } from "./storage";
8
6
 
9
- const HISTORY_FILE = join(stateDir(), "history.jsonl");
7
+ const HISTORY_FILE = "history.jsonl";
10
8
  const MAX_ENTRIES = 500;
11
9
 
12
- function ensureDir() {
13
- mkdirSync(stateDir(), { recursive: true });
14
- }
15
-
16
10
  /** Append one entry to the history log. */
17
11
  export function appendHistory(entry: HistoryEntry): void {
18
- ensureDir();
19
- const line = JSON.stringify(entry) + "\n";
20
- try {
21
- appendFileSync(HISTORY_FILE, line, "utf-8");
22
- } catch {
23
- // non-critical
24
- }
12
+ appendJSONL(HISTORY_FILE, entry);
25
13
  }
26
14
 
27
15
  /** Read all history entries, newest first, deduplicating consecutive identical queries. */
28
16
  export function readHistory(): HistoryEntry[] {
29
- try {
30
- const raw = readFileSync(HISTORY_FILE, "utf-8").trim();
31
- if (!raw) return [];
32
- const lines = raw.split("\n").filter(Boolean);
33
- const entries: HistoryEntry[] = lines.map((l) => JSON.parse(l) as HistoryEntry);
34
-
35
- // Sorted oldest-first in file; reverse to newest-first
36
- entries.reverse();
37
-
38
- // Deduplicate consecutive identical queries (keep newest)
39
- const deduped: HistoryEntry[] = [];
40
- let lastQuery: string | undefined;
41
- for (const e of entries) {
42
- if (e.query !== lastQuery) {
43
- deduped.push(e);
44
- lastQuery = e.query;
45
- }
17
+ const entries = readJSONL<HistoryEntry>(HISTORY_FILE);
18
+ entries.reverse();
19
+ const deduped: HistoryEntry[] = [];
20
+ let lastQuery: string | undefined;
21
+ for (const e of entries) {
22
+ if (e.query !== lastQuery) {
23
+ deduped.push(e);
24
+ lastQuery = e.query;
46
25
  }
47
- return deduped;
48
- } catch {
49
- return [];
50
26
  }
27
+ return deduped;
51
28
  }
52
29
 
53
30
  /** Delete a single history entry by index (0 = newest). */
@@ -55,20 +32,13 @@ export function deleteHistoryEntry(index: number): void {
55
32
  const entries = readHistory();
56
33
  if (index < 0 || index >= entries.length) return;
57
34
  const removed = entries[index];
58
- // Rewrite file without the removed entry
59
35
  const remaining = entries.filter((e) => e.timestamp !== removed.timestamp || e.query !== removed.query);
60
- ensureDir();
61
- // Reverse back to oldest-first for file order
62
- writeFileSync(HISTORY_FILE, remaining.reverse().map((e) => JSON.stringify(e)).join("\n") + "\n");
36
+ writeRaw(HISTORY_FILE, remaining.reverse().map((e) => JSON.stringify(e)).join("\n") + "\n");
63
37
  }
64
38
 
65
39
  /** Clear all history. */
66
40
  export function clearHistory(): void {
67
- try {
68
- writeFileSync(HISTORY_FILE, "");
69
- } catch {
70
- // non-critical
71
- }
41
+ writeRaw(HISTORY_FILE, "");
72
42
  }
73
43
 
74
44
  /** Rotate history to max entries (trim oldest). */
@@ -76,16 +46,10 @@ export function rotateHistory(): void {
76
46
  const entries = readHistory();
77
47
  if (entries.length <= MAX_ENTRIES) return;
78
48
  const trimmed = entries.slice(0, MAX_ENTRIES);
79
- ensureDir();
80
- writeFileSync(HISTORY_FILE, trimmed.reverse().map((e) => JSON.stringify(e)).join("\n") + "\n");
49
+ writeRaw(HISTORY_FILE, trimmed.reverse().map((e) => JSON.stringify(e)).join("\n") + "\n");
81
50
  }
82
51
 
83
52
  /** Count entries in history file. */
84
53
  export function historyCount(): number {
85
- try {
86
- const raw = readFileSync(HISTORY_FILE, "utf-8").trim();
87
- return raw ? raw.split("\n").length : 0;
88
- } catch {
89
- return 0;
90
- }
54
+ return readJSONL<HistoryEntry>(HISTORY_FILE).length;
91
55
  }
@@ -1,7 +1,5 @@
1
1
  /** Notification center — ephemeral alerts for rate limits, watch changes, etc. */
2
- import { mkdirSync, readFileSync, writeFileSync } from "fs";
3
- import { join } from "path";
4
- import { stateDir } from "./config";
2
+ import { readJSON, writeJSON } from "./storage";
5
3
 
6
4
  export interface Notification {
7
5
  id: number;
@@ -14,20 +12,15 @@ export interface Notification {
14
12
  let nextId = 1;
15
13
  let notifs: Notification[] = [];
16
14
  const MAX_NOTIFS = 50;
17
- const FILE = join(stateDir(), "notifications.json");
15
+ const FILE = "notifications.json";
18
16
 
19
17
  function load(): void {
20
- try {
21
- notifs = JSON.parse(readFileSync(FILE, "utf-8"));
22
- nextId = (Math.max(...notifs.map((n) => n.id), 0) + 1);
23
- } catch {
24
- notifs = [];
25
- }
18
+ notifs = readJSON<Notification[]>(FILE, []);
19
+ nextId = Math.max(...notifs.map((n) => n.id), 0) + 1;
26
20
  }
27
21
 
28
22
  function save(): void {
29
- mkdirSync(stateDir(), { recursive: true });
30
- writeFileSync(FILE, JSON.stringify(notifs));
23
+ writeJSON(FILE, notifs);
31
24
  }
32
25
 
33
26
  export function addNotification(type: Notification["type"], message: string): Notification {
package/src/output.ts CHANGED
@@ -1,68 +1,137 @@
1
- /** Output layer: render ranked results as JSON or human-readable list. */
2
- import type { Repo, SearchResponse } from "./types";
3
-
4
- /** Format a number compactly (e.g. 1234 -> "1.2k", 999999 -> "1M"). */
5
- export function formatCount(n: number): string {
6
- if (n < 1000) return String(n);
7
- if (n >= 999_500) return "1M"; // ponytail: round 999500+ up before showing 1000k
8
- if (n < 1_000_000) return `${(n / 1000).toFixed(1).replace(/\.0$/, "")}k`;
9
- return `${(n / 1_000_000).toFixed(1).replace(/\.0$/, "")}M`;
1
+ /**
2
+ * Output module — unified formatting, export, and pipe execution.
3
+ *
4
+ * All format functions (JSON, CSV, Markdown, text, urls, names, ssh-urls,
5
+ * clone-commands, ids) register in a single registry. One `format()` function
6
+ * dispatches. `exportToFile` and `pipeExec` are methods on the same module.
7
+ */
8
+ import { writeFileSync } from "fs";
9
+ import type { Repo } from "./types";
10
+ import { openUrl } from "./open-url";
11
+
12
+ export type Format =
13
+ | "json" | "csv" | "markdown" | "text"
14
+ | "urls" | "names" | "ssh-urls" | "clone-commands" | "ids";
15
+
16
+ export type FormatFn = (repos: Repo[]) => string;
17
+
18
+ /** Registry of all format functions. Adding a format = 1 line here. */
19
+ const FORMATS: Map<Format, FormatFn> = new Map([
20
+ // Export formats
21
+ ["json", formatJson],
22
+ ["csv", formatCsv],
23
+ ["markdown", formatMarkdown],
24
+ ["text", formatText],
25
+ // Pipe formats
26
+ ["urls", formatUrls],
27
+ ["names", formatNames],
28
+ ["ssh-urls", formatSshUrls],
29
+ ["clone-commands", formatCloneCommands],
30
+ ["ids", formatIds],
31
+ ]);
32
+
33
+ // ── Export format functions ────────────────────────────────────────────
34
+
35
+ export function formatJson(repos: Repo[]): string {
36
+ return JSON.stringify(repos, null, 2);
10
37
  }
11
38
 
12
- export function formatDate(iso: string): string {
13
- const d = new Date(iso);
14
- if (isNaN(d.getTime())) return "unknown";
15
- return d.toISOString().slice(0, 10);
39
+ function csvEscape(val: unknown): string {
40
+ const str = String(val ?? "");
41
+ if (str.includes('"') || str.includes(",") || str.includes("\n")) {
42
+ return `"${str.replace(/"/g, '""')}"`;
43
+ }
44
+ return str;
16
45
  }
17
46
 
18
- export function renderJson(response: SearchResponse, rawQuery: string): string {
19
- return JSON.stringify(
20
- {
21
- query: rawQuery,
22
- totalCount: response.totalCount,
23
- count: response.repos.length,
24
- rateLimited: response.rateLimited,
25
- results: response.repos,
26
- },
27
- null,
28
- 2,
47
+ export function formatCsv(repos: Repo[]): string {
48
+ const header = "rank,full_name,stars,forks,language,url";
49
+ const rows = repos.map((r, i) =>
50
+ `${i + 1},${csvEscape(r.fullName)},${r.stars},${r.forks},${csvEscape(r.language ?? "")},${csvEscape(r.url)}`,
29
51
  );
52
+ return [header, ...rows].join("\n");
30
53
  }
31
54
 
32
- export function renderHuman(response: SearchResponse, rawQuery: string): string {
33
- const lines: string[] = [];
34
- lines.push(`Query: ${rawQuery}`);
35
- lines.push(`Matches: ${response.repos.length} (total available: ${response.totalCount})`);
36
- if (response.rateLimited) {
37
- lines.push("⚠ Rate limit hit — results may be incomplete. Set GITHUB_TOKEN to raise limits.");
38
- }
39
- lines.push("");
55
+ export function formatMarkdown(repos: Repo[]): string {
56
+ const header = "| # | Repo | Stars | Forks | Language |";
57
+ const sep = "|---|------|-------|-------|----------|";
58
+ const rows = repos.map(
59
+ (r, i) =>
60
+ `| ${i + 1} | [${r.fullName}](${r.url}) | ${r.stars.toLocaleString()} | ${r.forks.toLocaleString()} | ${r.language ?? "—"} |`,
61
+ );
62
+ return [header, sep, ...rows].join("\n");
63
+ }
64
+
65
+ export function formatText(repos: Repo[]): string {
66
+ return repos.map((r, i) => `${i + 1}. ${r.fullName} ★ ${r.stars.toLocaleString()} ${r.url}`).join("\n");
67
+ }
68
+
69
+ // ── Pipe format functions ──────────────────────────────────────────────
70
+
71
+ export function formatUrls(repos: Repo[]): string {
72
+ return repos.map((r) => r.url).join("\n");
73
+ }
74
+
75
+ export function formatNames(repos: Repo[]): string {
76
+ return repos.map((r) => r.fullName).join("\n");
77
+ }
78
+
79
+ export function formatSshUrls(repos: Repo[]): string {
80
+ return repos.map((r) => `git@github.com:${r.fullName}.git`).join("\n");
81
+ }
82
+
83
+ export function formatCloneCommands(repos: Repo[]): string {
84
+ return repos.map((r) => `git clone git@github.com:${r.fullName}.git`).join("\n");
85
+ }
86
+
87
+ export function formatIds(repos: Repo[]): string {
88
+ return repos.map((r) => String(r.id)).join("\n");
89
+ }
90
+
91
+ // ── Public API ─────────────────────────────────────────────────────────
40
92
 
41
- if (response.repos.length === 0) {
42
- lines.push("No repositories found.");
43
- return lines.join("\n");
93
+ /** Format repos using the registered format function. */
94
+ export function format(repos: Repo[], format: Format): string {
95
+ const fn = FORMATS.get(format);
96
+ if (!fn) throw new Error(`Unknown format: ${format}`);
97
+ return fn(repos);
98
+ }
99
+
100
+ /** Export repos to a file. Returns the file path. */
101
+ export function exportToFile(repos: Repo[], fmt: Format): string {
102
+ const ext = fmt === "markdown" ? "md" : fmt;
103
+ const ts = Date.now();
104
+ const path = `./ghfind-export-${ts}.${ext}`;
105
+ writeFileSync(path, format(repos, fmt), "utf-8");
106
+ return path;
107
+ }
108
+
109
+ /** Execute a pipe target (clone, open) on repos. */
110
+ export async function pipeExec(repos: Repo[], target: string): Promise<void> {
111
+ for (const repo of repos) {
112
+ const url = repo.url;
113
+ switch (target) {
114
+ case "open":
115
+ await openUrl(url);
116
+ break;
117
+ case "clone":
118
+ console.log(`git clone git@github.com:${repo.fullName}.git`);
119
+ break;
120
+ }
44
121
  }
122
+ }
123
+
124
+ // ─── Backward-compatible re-exports ─────────────────────────────────────
45
125
 
46
- response.repos.forEach((repo, i) => {
47
- lines.push(renderRepo(i + 1, repo));
48
- lines.push("");
49
- });
126
+ export type ExportFormat = "json" | "csv" | "markdown" | "text";
127
+ export type FormatLine = "urls" | "names" | "ssh-urls" | "clone-commands" | "ids";
50
128
 
51
- return lines.join("\n").trimEnd();
129
+ /** @deprecated Use `format()` instead. */
130
+ export function formatRepos(repos: Repo[], fmt: ExportFormat): string {
131
+ return format(repos, fmt);
52
132
  }
53
133
 
54
- function renderRepo(index: number, repo: Repo): string {
55
- const lang = repo.language ?? "—";
56
- const stars = formatCount(repo.stars);
57
- const updated = formatDate(repo.updatedAt);
58
- const tags: string[] = [];
59
- if (repo.archived) tags.push("archived");
60
- if (repo.isFork) tags.push("fork");
61
- const tagStr = tags.length ? ` [${tags.join(", ")}]` : "";
62
-
63
- const header = `${index}. ${repo.fullName}${tagStr}`;
64
- const meta = ` ★ ${stars} • ${lang} • updated ${updated}`;
65
- const desc = repo.description ? ` ${repo.description}` : " (no description)";
66
- const url = ` ${repo.url}`;
67
- return [header, meta, desc, url].join("\n");
68
- }
134
+ /** @deprecated Use `format()` instead. */
135
+ export function formatLines(repos: Repo[], fmt: FormatLine): string {
136
+ return format(repos, fmt);
137
+ }
@@ -1,25 +1,17 @@
1
1
  /**
2
2
  * Saved searches — named queries you can re-run anytime.
3
3
  */
4
- import { mkdirSync, readFileSync, writeFileSync } from "fs";
5
- import { join } from "path";
6
4
  import type { SavedSearch, SortStrategy } from "./types";
7
- import { stateDir } from "./config";
5
+ import { readJSON, writeJSON } from "./storage";
8
6
 
9
- const SAVED_FILE = join(stateDir(), "saved-searches.json");
7
+ const SAVED_FILE = "saved-searches.json";
10
8
 
11
9
  function loadRaw(): SavedSearch[] {
12
- mkdirSync(stateDir(), { recursive: true });
13
- try {
14
- return JSON.parse(readFileSync(SAVED_FILE, "utf-8")) as SavedSearch[];
15
- } catch {
16
- return [];
17
- }
10
+ return readJSON<SavedSearch[]>(SAVED_FILE, []);
18
11
  }
19
12
 
20
13
  function saveRaw(searches: SavedSearch[]): void {
21
- mkdirSync(stateDir(), { recursive: true });
22
- writeFileSync(SAVED_FILE, JSON.stringify(searches, null, 2));
14
+ writeJSON(SAVED_FILE, searches);
23
15
  }
24
16
 
25
17
  /** Get all saved searches, sorted by last run time (most recent first). */
@@ -0,0 +1,229 @@
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, SearchOptions, SortStrategy, SessionState } from "./types";
8
+ import type { TabName } from "./trending";
9
+ import { tabSince } from "./trending";
10
+ import { parseQuery, applyFlagFilters, rankRepos, createGitHubSearch, SearchModule, TrendingAdapter } from "./search";
11
+ import { restoreSession, saveSession } from "./session";
12
+ import { appendHistory } from "./history";
13
+ import { toggleBookmark, isBookmarked } from "./bookmarks";
14
+ import { saveSearch, getSavedSearches, touchSavedSearch } from "./saved-searches";
15
+ import { fetchDeepDive, buildDeepDiveText } from "./deepdive";
16
+ import { fetchTopics } from "./explore";
17
+ import { loadConfig } from "./config";
18
+
19
+ export type Overlay =
20
+ | "none" | "history" | "bookmarks" | "saved" | "help"
21
+ | "topics" | "export" | "compare" | "notifications"
22
+ | "share" | "leader" | "readme";
23
+
24
+ export interface SearchState {
25
+ // Core search state
26
+ currentRepos: Repo[];
27
+ currentSort: SortStrategy;
28
+ currentLimit: number;
29
+ currentQueryInput: string;
30
+ isLoading: boolean;
31
+ currentMode: "search" | "trending";
32
+ currentPage: number;
33
+ totalCount: number;
34
+
35
+ // Trending
36
+ trendingTab: TabName;
37
+ trendingPeriod: string;
38
+
39
+ // UI overlays
40
+ currentOverlay: Overlay;
41
+ deepDiveActive: boolean;
42
+ compareList: Repo[];
43
+ graphFullscreen: boolean;
44
+ chartCommitData: number[];
45
+ quitArmed: boolean;
46
+ }
47
+
48
+ export class SearchStateManager {
49
+ private config = loadConfig();
50
+ private provider = createGitHubSearch(undefined, this.config.githubTokens);
51
+ private trendingSearch = new SearchModule(new TrendingAdapter());
52
+
53
+ state: SearchState = {
54
+ currentRepos: [],
55
+ currentSort: this.config.defaultSort,
56
+ currentLimit: this.config.defaultLimit,
57
+ currentQueryInput: "",
58
+ isLoading: false,
59
+ currentMode: "search",
60
+ currentPage: 1,
61
+ totalCount: 0,
62
+ trendingTab: "This Week",
63
+ trendingPeriod: "this week",
64
+ currentOverlay: "none",
65
+ deepDiveActive: false,
66
+ compareList: [],
67
+ graphFullscreen: false,
68
+ chartCommitData: [],
69
+ quitArmed: false,
70
+ };
71
+
72
+ constructor() {
73
+ const session = restoreSession();
74
+ if (session) {
75
+ this.state.currentMode = session.mode;
76
+ this.state.currentQueryInput = session.query;
77
+ this.state.currentSort = session.sort;
78
+ this.state.currentLimit = session.limit;
79
+ this.state.trendingTab = (session.trendingTab as TabName) ?? "This Week";
80
+ }
81
+ }
82
+
83
+ // ── Search ──
84
+
85
+ async search(query: string, page: number = 1): Promise<void> {
86
+ this.state.isLoading = true;
87
+ this.state.currentQueryInput = query;
88
+ this.state.currentPage = page;
89
+ try {
90
+ const parsed = applyFlagFilters(parseQuery(query), {});
91
+ const response = await this.provider.search(parsed, {
92
+ limit: this.state.currentLimit,
93
+ sort: this.state.currentSort,
94
+ json: false,
95
+ verbose: false,
96
+ token: this.config.githubToken,
97
+ page,
98
+ });
99
+ this.state.currentRepos = rankRepos(response.repos, this.state.currentSort);
100
+ this.state.totalCount = response.totalCount;
101
+ this.state.currentMode = "search";
102
+ appendHistory({ query, mode: "search", timestamp: Date.now(), resultCount: response.repos.length });
103
+ } catch (err) {
104
+ throw err;
105
+ } finally {
106
+ this.state.isLoading = false;
107
+ }
108
+ }
109
+
110
+ async nextPage(): Promise<void> {
111
+ await this.search(this.state.currentQueryInput, this.state.currentPage + 1);
112
+ }
113
+
114
+ async prevPage(): Promise<void> {
115
+ if (this.state.currentPage > 1) {
116
+ await this.search(this.state.currentQueryInput, this.state.currentPage - 1);
117
+ }
118
+ }
119
+
120
+ // ── Trending ──
121
+
122
+ async loadTrending(tab: TabName): Promise<void> {
123
+ this.state.isLoading = true;
124
+ this.state.trendingTab = tab;
125
+ this.state.currentMode = "trending";
126
+ try {
127
+ const response = await this.trendingSearch.search(
128
+ { keywords: [], qualifiers: [], raw: "trending" },
129
+ { limit: 25, sort: "stars", json: false, verbose: false, trendingSince: tabSince(tab) },
130
+ );
131
+ this.state.currentRepos = response.repos;
132
+ this.state.totalCount = response.totalCount;
133
+ appendHistory({ query: `trending:${tab}`, mode: "trending", tab, timestamp: Date.now(), resultCount: response.repos.length });
134
+ } finally {
135
+ this.state.isLoading = false;
136
+ }
137
+ }
138
+
139
+ // ── Bookmarks ──
140
+
141
+ toggleBookmark(repo: Repo): boolean {
142
+ return toggleBookmark(repo);
143
+ }
144
+
145
+ isBookmarked(fullName: string): boolean {
146
+ return isBookmarked(fullName);
147
+ }
148
+
149
+ // ── Saved searches ──
150
+
151
+ saveCurrentSearch(name: string): void {
152
+ saveSearch(name, this.state.currentQueryInput, this.state.currentMode, this.state.currentSort, this.state.currentLimit);
153
+ }
154
+
155
+ getSavedSearches() {
156
+ return getSavedSearches();
157
+ }
158
+
159
+ touchSavedSearch(name: string): void {
160
+ touchSavedSearch(name);
161
+ }
162
+
163
+ // ── Deep dive ──
164
+
165
+ async deepDive(repo: Repo): Promise<string> {
166
+ this.state.deepDiveActive = true;
167
+ try {
168
+ const data = await fetchDeepDive(repo, this.config.githubToken);
169
+ return buildDeepDiveText(data);
170
+ } finally {
171
+ this.state.deepDiveActive = false;
172
+ }
173
+ }
174
+
175
+ // ── Compare ──
176
+
177
+ addToCompare(repo: Repo): void {
178
+ if (!this.state.compareList.some((r) => r.fullName === repo.fullName)) {
179
+ this.state.compareList.push(repo);
180
+ }
181
+ }
182
+
183
+ removeFromCompare(fullName: string): void {
184
+ this.state.compareList = this.state.compareList.filter((r) => r.fullName !== fullName);
185
+ }
186
+
187
+ clearCompare(): void {
188
+ this.state.compareList = [];
189
+ }
190
+
191
+ // ── Session ──
192
+
193
+ saveSession(): void {
194
+ const s: SessionState = {
195
+ mode: this.state.currentMode,
196
+ query: this.state.currentQueryInput,
197
+ sort: this.state.currentSort,
198
+ limit: this.state.currentLimit,
199
+ trendingTab: this.state.trendingTab,
200
+ };
201
+ saveSession(s);
202
+ }
203
+
204
+ // ── Setters ──
205
+
206
+ setSort(sort: SortStrategy): void {
207
+ this.state.currentSort = sort;
208
+ }
209
+
210
+ setLimit(limit: number): void {
211
+ this.state.currentLimit = limit;
212
+ }
213
+
214
+ setOverlay(overlay: Overlay): void {
215
+ this.state.currentOverlay = overlay;
216
+ }
217
+
218
+ setGraphFullscreen(fullscreen: boolean): void {
219
+ this.state.graphFullscreen = fullscreen;
220
+ }
221
+
222
+ setChartCommitData(data: number[]): void {
223
+ this.state.chartCommitData = data;
224
+ }
225
+
226
+ setQuitArmed(armed: boolean): void {
227
+ this.state.quitArmed = armed;
228
+ }
229
+ }