github-search-cli 0.6.1 → 0.8.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/bookmarks.ts CHANGED
@@ -1,69 +1,69 @@
1
- /**
2
- * Bookmarks — saved repos with tags, persisted as JSON.
3
- */
4
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
5
- import { join } from "path";
6
- import type { Bookmark, Repo } from "./types.ts";
7
- import { stateDir } from "./config.ts";
8
-
9
- const BOOKMARK_FILE = join(stateDir(), "bookmarks.json");
10
-
11
- function ensureDir() {
12
- mkdirSync(stateDir(), { recursive: true });
13
- }
14
-
15
- function loadRaw(): Bookmark[] {
16
- ensureDir();
17
- try {
18
- const raw = readFileSync(BOOKMARK_FILE, "utf-8");
19
- return JSON.parse(raw) as Bookmark[];
20
- } catch {
21
- return [];
22
- }
23
- }
24
-
25
- function saveRaw(bookmarks: Bookmark[]): void {
26
- ensureDir();
27
- writeFileSync(BOOKMARK_FILE, JSON.stringify(bookmarks, null, 2));
28
- }
29
-
30
- /** Get all bookmarks, newest first. */
31
- export function getBookmarks(): Bookmark[] {
32
- return loadRaw().sort((a, b) => b.savedAt - a.savedAt);
33
- }
34
-
35
- /** Check if a repo is bookmarked by fullName. */
36
- export function isBookmarked(fullName: string): boolean {
37
- return loadRaw().some((b) => b.repo.fullName === fullName);
38
- }
39
-
40
- /** Toggle bookmark: add if not present, remove if present. Returns new state. */
41
- export function toggleBookmark(repo: Repo, tags: string[] = []): boolean {
42
- const bookmarks = loadRaw();
43
- const idx = bookmarks.findIndex((b) => b.repo.fullName === repo.fullName);
44
- if (idx >= 0) {
45
- bookmarks.splice(idx, 1);
46
- saveRaw(bookmarks);
47
- return false; // removed
48
- }
49
- bookmarks.push({ repo, savedAt: Date.now(), tags });
50
- saveRaw(bookmarks);
51
- return true; // added
52
- }
53
-
54
- /** Remove a bookmark by fullName. */
55
- export function removeBookmark(fullName: string): void {
56
- const bookmarks = loadRaw().filter((b) => b.repo.fullName !== fullName);
57
- saveRaw(bookmarks);
58
- }
59
-
60
- /** Search bookmarks by text (matches name, description, tags). */
61
- export function searchBookmarks(query: string): Bookmark[] {
62
- const q = query.toLowerCase();
63
- return getBookmarks().filter(
64
- (b) =>
65
- b.repo.fullName.toLowerCase().includes(q) ||
66
- (b.repo.description ?? "").toLowerCase().includes(q) ||
67
- b.tags.some((t) => t.toLowerCase().includes(q)),
68
- );
69
- }
1
+ /**
2
+ * Bookmarks — saved repos with tags, persisted as JSON.
3
+ */
4
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
5
+ import { join } from "path";
6
+ import type { Bookmark, Repo } from "./types";
7
+ import { stateDir } from "./config";
8
+
9
+ const BOOKMARK_FILE = join(stateDir(), "bookmarks.json");
10
+
11
+ function ensureDir() {
12
+ mkdirSync(stateDir(), { recursive: true });
13
+ }
14
+
15
+ function loadRaw(): Bookmark[] {
16
+ ensureDir();
17
+ try {
18
+ const raw = readFileSync(BOOKMARK_FILE, "utf-8");
19
+ return JSON.parse(raw) as Bookmark[];
20
+ } catch {
21
+ return [];
22
+ }
23
+ }
24
+
25
+ function saveRaw(bookmarks: Bookmark[]): void {
26
+ ensureDir();
27
+ writeFileSync(BOOKMARK_FILE, JSON.stringify(bookmarks, null, 2));
28
+ }
29
+
30
+ /** Get all bookmarks, newest first. */
31
+ export function getBookmarks(): Bookmark[] {
32
+ return loadRaw().sort((a, b) => b.savedAt - a.savedAt);
33
+ }
34
+
35
+ /** Check if a repo is bookmarked by fullName. */
36
+ export function isBookmarked(fullName: string): boolean {
37
+ return loadRaw().some((b) => b.repo.fullName === fullName);
38
+ }
39
+
40
+ /** Toggle bookmark: add if not present, remove if present. Returns new state. */
41
+ export function toggleBookmark(repo: Repo, tags: string[] = []): boolean {
42
+ const bookmarks = loadRaw();
43
+ const idx = bookmarks.findIndex((b) => b.repo.fullName === repo.fullName);
44
+ if (idx >= 0) {
45
+ bookmarks.splice(idx, 1);
46
+ saveRaw(bookmarks);
47
+ return false; // removed
48
+ }
49
+ bookmarks.push({ repo, savedAt: Date.now(), tags });
50
+ saveRaw(bookmarks);
51
+ return true; // added
52
+ }
53
+
54
+ /** Remove a bookmark by fullName. */
55
+ export function removeBookmark(fullName: string): void {
56
+ const bookmarks = loadRaw().filter((b) => b.repo.fullName !== fullName);
57
+ saveRaw(bookmarks);
58
+ }
59
+
60
+ /** Search bookmarks by text (matches name, description, tags). */
61
+ export function searchBookmarks(query: string): Bookmark[] {
62
+ const q = query.toLowerCase();
63
+ return getBookmarks().filter(
64
+ (b) =>
65
+ b.repo.fullName.toLowerCase().includes(q) ||
66
+ (b.repo.description ?? "").toLowerCase().includes(q) ||
67
+ b.tags.some((t) => t.toLowerCase().includes(q)),
68
+ );
69
+ }
package/src/cli.ts CHANGED
@@ -1,242 +1,239 @@
1
- #!/usr/bin/env bun
2
- /**
3
- * CLI entry point — routes between TUI browser and non-interactive modes.
4
- *
5
- * Usage:
6
- * search-cli Launch interactive TUI
7
- * search-cli "query" --json Search + JSON output
8
- * search-cli "query" --csv Search + CSV output
9
- * search-cli "query" --markdown Search + Markdown table
10
- * search-cli "query" --count Just the result count
11
- * search-cli --trending --json Trending repos as JSON
12
- * search-cli init Setup wizard
13
- * search-cli --watch "query" Periodic watch mode
14
- * search-cli --completion bash Print shell completion
15
- * search-cli --version Print version
16
- * search-cli --help Print help
17
- */
18
- import { parseQuery, applyFlagFilters } from "./query.ts";
19
- import { GitHubSearchProvider } from "./provider.ts";
20
- import { rankRepos } from "./ranking.ts";
21
- import { formatRepos, type ExportFormat } from "./export.ts";
22
- import { formatLines, pipeExec, type FormatLine } from "./pipe.ts";
23
- import { runWatch } from "./watch.ts";
24
- import { runInitWizard } from "./init.ts";
25
- import { readFileSync } from "fs";
26
- import { launchBrowser } from "./tui.ts";
27
- import type { SearchOptions } from "./types.ts";
28
-
29
- interface CLIFlags {
30
- query: string;
31
- json: boolean;
32
- csv: boolean;
33
- markdown: boolean;
34
- count: boolean;
35
- limit: number;
36
- sort: SearchOptions["sort"];
37
- token?: string;
38
- trending: boolean;
39
- since: string;
40
- pipe?: string;
41
- format?: string;
42
- watch: boolean;
43
- interval: number;
44
- completion?: string;
45
- init: boolean;
46
- version: boolean;
47
- help: boolean;
48
- }
49
-
50
- function parseArgs(args: string[]): CLIFlags {
51
- const flags: CLIFlags = {
52
- query: "",
53
- json: false,
54
- csv: false,
55
- markdown: false,
56
- count: false,
57
- limit: 50,
58
- sort: "best-match",
59
- token: process.env.GITHUB_TOKEN,
60
- trending: false,
61
- since: "daily",
62
- watch: false,
63
- interval: 300,
64
- init: false,
65
- version: false,
66
- help: false,
67
- };
68
-
69
- let queryParts: string[] = [];
70
- for (let i = 0; i < args.length; i++) {
71
- const arg = args[i];
72
- switch (arg) {
73
- case "--json": flags.json = true; break;
74
- case "--csv": flags.csv = true; break;
75
- case "--markdown": flags.markdown = true; break;
76
- case "--count": flags.count = true; break;
77
- case "--trending": flags.trending = true; break;
78
- case "--watch": flags.watch = true; break;
79
- case "--version": case "-v": flags.version = true; break;
80
- case "--help": case "-h": flags.help = true; break;
81
- case "init": flags.init = true; break;
82
- case "--limit": flags.limit = parseInt(args[++i]) || 50; break;
83
- case "--sort": flags.sort = args[++i] as SearchOptions["sort"]; break;
84
- case "--token": flags.token = args[++i]; break;
85
- case "--since": flags.since = args[++i]; break;
86
- case "--pipe": flags.pipe = args[++i]; break;
87
- case "--format": flags.format = args[++i]; break;
88
- case "--interval": flags.interval = parseInt(args[++i]) || 300; break;
89
- case "--completion": flags.completion = args[++i]; break;
90
- default:
91
- if (!arg.startsWith("-")) queryParts.push(arg);
92
- }
93
- }
94
- flags.query = queryParts.join(" ");
95
- return flags;
96
- }
97
-
98
- async function main() {
99
- const flags = parseArgs(process.argv.slice(2));
100
-
101
- // Version
102
- if (flags.version) {
103
- const pkg = JSON.parse(readFileSync("package.json", "utf-8"));
104
- console.log(`search-cli v${pkg.version}`);
105
- return;
106
- }
107
-
108
- // Help
109
- if (flags.help) {
110
- console.log(`
111
- search-cli — Interactive GitHub repository browser
112
-
113
- Usage:
114
- search-cli Launch interactive TUI
115
- search-cli <query> --json Search, output JSON
116
- search-cli <query> --csv Search, output CSV
117
- search-cli <query> --markdown Search, output Markdown table
118
- search-cli <query> --count Just the result count
119
- search-cli <query> --format <fmt> Format lines (urls|names|ssh-urls|clone-commands|ids)
120
- search-cli <query> --pipe <target> Pipe to clone/open
121
- search-cli --trending --json Trending repos as JSON
122
- search-cli --watch <query> Watch mode (poll every Ns)
123
- search-cli init Run setup wizard
124
- search-cli --completion <shell> Print completion script (bash|zsh|fish)
125
- search-cli --version Print version
126
- search-cli --help Print this help
127
-
128
- Options:
129
- --json, --csv, --markdown, --count Output format
130
- --limit <n> Max results (default: 50)
131
- --sort <strategy> Sort: best-match|stars|updated|forks
132
- --token <token> GitHub API token
133
- --trending Trending mode
134
- --since <period> Trending period: daily|weekly|monthly
135
- --pipe <target> Pipe target: clone|open
136
- --format <fmt> Line format: urls|names|ssh-urls|...
137
- --watch Watch mode (re-run periodically)
138
- --interval <s> Watch interval in seconds (default: 300)
139
- --completion <shell> Generate completions
140
- `);
141
- return;
142
- }
143
-
144
- // Shell completions
145
- if (flags.completion) {
146
- const shell = flags.completion;
147
- try {
148
- const content = readFileSync(`completions/search-cli.${shell}`, "utf-8");
149
- console.log(content);
150
- } catch {
151
- console.error(`Completions not available for shell: ${shell}`);
152
- process.exit(1);
153
- }
154
- return;
155
- }
156
-
157
- // Init wizard
158
- if (flags.init) {
159
- await runInitWizard();
160
- return;
161
- }
162
-
163
- // Determine output format
164
- const format: ExportFormat | undefined =
165
- flags.json ? "json" : flags.csv ? "csv" : flags.markdown ? "markdown" : undefined;
166
- const isNonInteractive = format || flags.count || flags.format || flags.pipe || flags.watch;
167
-
168
- // Non-interactive mode
169
- if (isNonInteractive) {
170
- await runNonInteractive(flags, format);
171
- return;
172
- }
173
-
174
- // Default: launch interactive TUI
175
- await launchBrowser();
176
- }
177
-
178
- async function runNonInteractive(flags: CLIFlags, format?: ExportFormat) {
179
- const query = flags.query;
180
- const provider = new GitHubSearchProvider(undefined, flags.token ? [flags.token] : []);
181
-
182
- // --watch mode
183
- if (flags.watch) {
184
- let tick = 0;
185
- await runWatch(
186
- { query, sort: flags.sort, limit: flags.limit, token: flags.token, intervalMs: flags.interval * 1000, trending: flags.trending },
187
- (repos, delta) => {
188
- tick++;
189
- if (format) {
190
- console.log(`[Watch #${tick}] ${repos.length} results (${delta >= 0 ? "+" : ""}${delta} since last check)`);
191
- console.log(formatRepos(repos, format));
192
- } else {
193
- console.log(`[Watch #${tick}] ${repos.length} results — ${delta >= 0 ? "+" : ""}${delta} since last check`);
194
- }
195
- },
196
- (err) => console.error(`[Watch error] ${err.message}`),
197
- );
198
- return;
199
- }
200
-
201
- // --pipe mode
202
- if (flags.pipe) {
203
- const parsed = applyFlagFilters(parseQuery(query), {});
204
- const response = await provider.search(parsed, { limit: flags.limit, sort: flags.sort, json: false, verbose: false, token: flags.token });
205
- const repos = rankRepos(response.repos, flags.sort);
206
- await pipeExec(repos, flags.pipe);
207
- return;
208
- }
209
-
210
- // --format mode
211
- if (flags.format) {
212
- const parsed = applyFlagFilters(parseQuery(query), {});
213
- const response = await provider.search(parsed, { limit: flags.limit, sort: flags.sort, json: false, verbose: false, token: flags.token });
214
- const repos = rankRepos(response.repos, flags.sort);
215
- console.log(formatLines(repos, flags.format as FormatLine));
216
- return;
217
- }
218
-
219
- // Standard search with format
220
- const parsed = applyFlagFilters(parseQuery(query), {});
221
- const response = await provider.search(parsed, {
222
- limit: flags.limit, sort: flags.sort, json: false, verbose: false, token: flags.token,
223
- });
224
- const repos = rankRepos(response.repos, flags.sort);
225
-
226
- // --count
227
- if (flags.count) {
228
- console.log(response.totalCount);
229
- return;
230
- }
231
-
232
- // --json, --csv, --markdown
233
- if (format) {
234
- console.log(formatRepos(repos, format));
235
- return;
236
- }
237
- }
238
-
239
- main().catch((err) => {
240
- console.error(err instanceof Error ? err.message : String(err));
241
- process.exit(1);
242
- });
1
+ #!/usr/bin/env node
2
+ /** CLI entry point — routes between TUI browser and non-interactive modes. */
3
+ import { parseQuery, applyFlagFilters, rankRepos, createGitHubSearch } from "./search";
4
+ import { formatRepos, type ExportFormat } from "./export";
5
+ import { formatLines, pipeExec, type FormatLine } from "./pipe";
6
+ import { runWatch } from "./watch";
7
+ import { runInitWizard } from "./init";
8
+ import { readFileSync } from "fs";
9
+ import { join, dirname } from "path";
10
+ import { fileURLToPath } from "url";
11
+ import { launchBrowser } from "./tui";
12
+ import type { SearchOptions } from "./types";
13
+
14
+ const __dirname = dirname(fileURLToPath(import.meta.url));
15
+ const PKG_DIR = join(__dirname, "..");
16
+
17
+ interface CLIFlags {
18
+ query: string;
19
+ json: boolean;
20
+ csv: boolean;
21
+ markdown: boolean;
22
+ count: boolean;
23
+ limit: number;
24
+ sort: SearchOptions["sort"];
25
+ token?: string;
26
+ trending: boolean;
27
+ since: string;
28
+ pipe?: string;
29
+ format?: string;
30
+ watch: boolean;
31
+ interval: number;
32
+ completion?: string;
33
+ init: boolean;
34
+ version: boolean;
35
+ help: boolean;
36
+ }
37
+
38
+ function parseArgs(args: string[]): CLIFlags {
39
+ const flags: CLIFlags = {
40
+ query: "",
41
+ json: false,
42
+ csv: false,
43
+ markdown: false,
44
+ count: false,
45
+ limit: 50,
46
+ sort: "best-match",
47
+ token: process.env.GITHUB_TOKEN,
48
+ trending: false,
49
+ since: "daily",
50
+ watch: false,
51
+ interval: 300,
52
+ init: false,
53
+ version: false,
54
+ help: false,
55
+ };
56
+
57
+ let queryParts: string[] = [];
58
+ for (let i = 0; i < args.length; i++) {
59
+ const arg = args[i];
60
+ switch (arg) {
61
+ case "--json": flags.json = true; break;
62
+ case "--csv": flags.csv = true; break;
63
+ case "--markdown": flags.markdown = true; break;
64
+ case "--count": flags.count = true; break;
65
+ case "--trending": flags.trending = true; break;
66
+ case "--watch": flags.watch = true; break;
67
+ case "--version": case "-v": flags.version = true; break;
68
+ case "--help": case "-h": flags.help = true; break;
69
+ case "init": flags.init = true; break;
70
+ case "--limit": {
71
+ const parsed = parseInt(args[++i]);
72
+ if (isNaN(parsed) || parsed < 1 || parsed > 100) {
73
+ console.error(`Invalid limit: must be 1-100. Using default 50.`);
74
+ flags.limit = 50;
75
+ } else {
76
+ flags.limit = parsed;
77
+ }
78
+ break;
79
+ }
80
+ case "--sort": flags.sort = args[++i] as SearchOptions["sort"]; break;
81
+ case "--token": flags.token = args[++i]; break;
82
+ case "--since": flags.since = args[++i]; break;
83
+ case "--pipe": flags.pipe = args[++i]; break;
84
+ case "--format": flags.format = args[++i]; break;
85
+ case "--interval": flags.interval = parseInt(args[++i]) || 300; break;
86
+ case "--completion": flags.completion = args[++i]; break;
87
+ default:
88
+ if (!arg.startsWith("-")) queryParts.push(arg);
89
+ }
90
+ }
91
+ flags.query = queryParts.join(" ");
92
+ return flags;
93
+ }
94
+
95
+ async function main() {
96
+ const flags = parseArgs(process.argv.slice(2));
97
+
98
+ // Version
99
+ if (flags.version) {
100
+ const pkg = JSON.parse(readFileSync(join(PKG_DIR, "package.json"), "utf-8"));
101
+ console.log(`ghfind v${pkg.version}`);
102
+ return;
103
+ }
104
+
105
+ // Help
106
+ if (flags.help) {
107
+ console.log(`
108
+ ghfind — Interactive GitHub repository browser
109
+
110
+ Usage:
111
+ ghfind Launch interactive TUI
112
+ ghfind <query> --json Search, output JSON
113
+ ghfind <query> --csv Search, output CSV
114
+ ghfind <query> --markdown Search, output Markdown table
115
+ ghfind <query> --count Just the result count
116
+ ghfind <query> --format <fmt> Format lines (urls|names|ssh-urls|clone-commands|ids)
117
+ ghfind <query> --pipe <target> Pipe to clone/open
118
+ ghfind --trending --json Trending repos as JSON
119
+ ghfind --watch <query> Watch mode (poll every Ns)
120
+ ghfind init Run setup wizard
121
+ ghfind --completion <shell> Print completion script (bash|zsh|fish)
122
+ ghfind --version Print version
123
+ ghfind --help Print this help
124
+
125
+ Options:
126
+ --json, --csv, --markdown, --count Output format
127
+ --limit <n> Max results (default: 50)
128
+ --sort <strategy> Sort: best-match|stars|updated|forks
129
+ --token <token> GitHub API token
130
+ --trending Trending mode
131
+ --since <period> Trending period: daily|weekly|monthly
132
+ --pipe <target> Pipe target: clone|open
133
+ --format <fmt> Line format: urls|names|ssh-urls|...
134
+ --watch Watch mode (re-run periodically)
135
+ --interval <s> Watch interval in seconds (default: 300)
136
+ --completion <shell> Generate completions
137
+ `);
138
+ return;
139
+ }
140
+
141
+ // Shell completions
142
+ if (flags.completion) {
143
+ const shell = flags.completion;
144
+ try {
145
+ const content = readFileSync(join(PKG_DIR, "completions", `ghfind.${shell}`), "utf-8");
146
+ console.log(content);
147
+ } catch {
148
+ console.error(`Completions not available for shell: ${shell}`);
149
+ process.exit(1);
150
+ }
151
+ return;
152
+ }
153
+
154
+ // Init wizard
155
+ if (flags.init) {
156
+ await runInitWizard();
157
+ return;
158
+ }
159
+
160
+ // Determine output format
161
+ const format: ExportFormat | undefined =
162
+ flags.json ? "json" : flags.csv ? "csv" : flags.markdown ? "markdown" : undefined;
163
+ const isNonInteractive = format || flags.count || flags.format || flags.pipe || flags.watch;
164
+
165
+ // Non-interactive mode
166
+ if (isNonInteractive) {
167
+ await runNonInteractive(flags, format);
168
+ return;
169
+ }
170
+
171
+ // Default: launch interactive TUI
172
+ await launchBrowser();
173
+ }
174
+
175
+ async function runNonInteractive(flags: CLIFlags, format?: ExportFormat) {
176
+ const query = flags.query;
177
+ const provider = createGitHubSearch(undefined, flags.token ? [flags.token] : []);
178
+
179
+ // --watch mode
180
+ if (flags.watch) {
181
+ let tick = 0;
182
+ await runWatch(
183
+ { query, sort: flags.sort, limit: flags.limit, token: flags.token, intervalMs: flags.interval * 1000, trending: flags.trending },
184
+ (repos, delta) => {
185
+ tick++;
186
+ if (format) {
187
+ console.log(`[Watch #${tick}] ${repos.length} results (${delta >= 0 ? "+" : ""}${delta} since last check)`);
188
+ console.log(formatRepos(repos, format));
189
+ } else {
190
+ console.log(`[Watch #${tick}] ${repos.length} results — ${delta >= 0 ? "+" : ""}${delta} since last check`);
191
+ }
192
+ },
193
+ (err) => console.error(`[Watch error] ${err.message}`),
194
+ );
195
+ return;
196
+ }
197
+
198
+ // --pipe mode
199
+ if (flags.pipe) {
200
+ const parsed = applyFlagFilters(parseQuery(query), {});
201
+ const response = await provider.search(parsed, { limit: flags.limit, sort: flags.sort, json: false, verbose: false, token: flags.token });
202
+ const repos = rankRepos(response.repos, flags.sort);
203
+ await pipeExec(repos, flags.pipe);
204
+ return;
205
+ }
206
+
207
+ // --format mode
208
+ if (flags.format) {
209
+ const parsed = applyFlagFilters(parseQuery(query), {});
210
+ const response = await provider.search(parsed, { limit: flags.limit, sort: flags.sort, json: false, verbose: false, token: flags.token });
211
+ const repos = rankRepos(response.repos, flags.sort);
212
+ console.log(formatLines(repos, flags.format as FormatLine));
213
+ return;
214
+ }
215
+
216
+ // Standard search with format
217
+ const parsed = applyFlagFilters(parseQuery(query), {});
218
+ const response = await provider.search(parsed, {
219
+ limit: flags.limit, sort: flags.sort, json: false, verbose: false, token: flags.token,
220
+ });
221
+ const repos = rankRepos(response.repos, flags.sort);
222
+
223
+ // --count
224
+ if (flags.count) {
225
+ console.log(response.totalCount);
226
+ return;
227
+ }
228
+
229
+ // --json, --csv, --markdown
230
+ if (format) {
231
+ console.log(formatRepos(repos, format));
232
+ return;
233
+ }
234
+ }
235
+
236
+ main().catch((err) => {
237
+ console.error(err instanceof Error ? err.message : String(err));
238
+ process.exit(1);
239
+ });