github-search-cli 0.6.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/LICENSE +21 -0
- package/README.md +188 -0
- package/bin/search-cli.js +17 -0
- package/completions/search-cli.bash +43 -0
- package/completions/search-cli.fish +19 -0
- package/completions/search-cli.zsh +27 -0
- package/package.json +55 -0
- package/src/bookmarks.ts +69 -0
- package/src/cache.ts +64 -0
- package/src/cli.ts +242 -0
- package/src/compare.ts +46 -0
- package/src/config.ts +66 -0
- package/src/deepdive.ts +124 -0
- package/src/errors.ts +62 -0
- package/src/explore.ts +45 -0
- package/src/export.ts +56 -0
- package/src/help.ts +46 -0
- package/src/history.ts +91 -0
- package/src/init.ts +47 -0
- package/src/normalizer.ts +66 -0
- package/src/notifications.ts +69 -0
- package/src/output.ts +74 -0
- package/src/pipe.ts +65 -0
- package/src/provider.ts +142 -0
- package/src/query.ts +251 -0
- package/src/ranking.ts +55 -0
- package/src/saved-searches.ts +58 -0
- package/src/session.ts +35 -0
- package/src/share.ts +49 -0
- package/src/status.ts +61 -0
- package/src/themes.ts +52 -0
- package/src/tips.ts +27 -0
- package/src/trending.ts +588 -0
- package/src/tui.ts +1463 -0
- package/src/types.ts +148 -0
- package/src/watch.ts +53 -0
package/src/cli.ts
ADDED
|
@@ -0,0 +1,242 @@
|
|
|
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
|
+
});
|
package/src/compare.ts
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Repo comparison — side-by-side table for 2+ repos.
|
|
3
|
+
*/
|
|
4
|
+
import type { Repo } from "./types.ts";
|
|
5
|
+
|
|
6
|
+
interface CompareRow {
|
|
7
|
+
label: string;
|
|
8
|
+
values: string[];
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** Build a comparison table as an array of text rows. */
|
|
12
|
+
export function buildComparisonTable(repos: Repo[]): string {
|
|
13
|
+
if (repos.length < 2) return "Select at least 2 repos to compare (press c to add).";
|
|
14
|
+
|
|
15
|
+
const rows: CompareRow[] = [];
|
|
16
|
+
const names = repos.map((r) => r.fullName);
|
|
17
|
+
|
|
18
|
+
rows.push({ label: "Stars", values: repos.map((r) => `★ ${r.stars.toLocaleString()}`) });
|
|
19
|
+
rows.push({ label: "Forks", values: repos.map((r) => r.forks.toLocaleString()) });
|
|
20
|
+
rows.push({ label: "Language", values: repos.map((r) => r.language ?? "—") });
|
|
21
|
+
rows.push({ label: "Created", values: repos.map((r) => r.createdAt?.slice(0, 7) ?? "—") });
|
|
22
|
+
rows.push({ label: "Updated", values: repos.map((r) => r.updatedAt?.slice(0, 10) ?? "—") });
|
|
23
|
+
rows.push({ label: "Topics", values: repos.map((r) => (r.topics?.slice(0, 3).join(", ") ?? "—") || "—") });
|
|
24
|
+
|
|
25
|
+
// Calculate the max width for each column
|
|
26
|
+
const colW = Math.max(15, ...names.map((n) => n.length));
|
|
27
|
+
const valW = Math.max(8, ...rows.flatMap((r) => r.values.map((v) => v.length)));
|
|
28
|
+
|
|
29
|
+
// Header row
|
|
30
|
+
const header = ["".padEnd(14), ...names.map((n) => n.padEnd(colW))];
|
|
31
|
+
const sep = ["".padEnd(14, "─"), ...names.map(() => "".padEnd(colW, "─"))];
|
|
32
|
+
|
|
33
|
+
const lines: string[] = [
|
|
34
|
+
"┌" + sep.map((s) => "─" + s + "─").join("┬") + "┐",
|
|
35
|
+
"│" + header.map((h) => ` ${h} `).join("│") + "│",
|
|
36
|
+
"├" + sep.map((s) => "─" + s + "─").join("┼") + "┤",
|
|
37
|
+
];
|
|
38
|
+
|
|
39
|
+
for (const row of rows) {
|
|
40
|
+
const cells = [row.label.padEnd(14), ...row.values.map((v) => v.padEnd(colW))];
|
|
41
|
+
lines.push("│" + cells.map((c) => ` ${c} `).join("│") + "│");
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
lines.push("└" + sep.map((s) => "─" + s + "─").join("┴") + "┘");
|
|
45
|
+
return lines.join("\n");
|
|
46
|
+
}
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Config system: load/save config from XDG paths, merge with env vars.
|
|
3
|
+
*
|
|
4
|
+
* Lookup order:
|
|
5
|
+
* 1. SEARCH_CLI_CONFIG env var
|
|
6
|
+
* 2. $XDG_CONFIG_HOME/search-cli/config.json
|
|
7
|
+
* 3. ~/.config/search-cli/config.json
|
|
8
|
+
*/
|
|
9
|
+
import { homedir } from "os";
|
|
10
|
+
import { join } from "path";
|
|
11
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
|
|
12
|
+
|
|
13
|
+
/** Directory for persistent state (history, bookmarks, session). */
|
|
14
|
+
export function stateDir(): string {
|
|
15
|
+
const xdg = process.env.XDG_STATE_HOME;
|
|
16
|
+
const base = xdg ? join(xdg, "search-cli") : join(homedir(), ".local", "share", "search-cli");
|
|
17
|
+
return base;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Directory for cache files. */
|
|
21
|
+
export function cacheDir(): string {
|
|
22
|
+
const xdg = process.env.XDG_CACHE_HOME;
|
|
23
|
+
const base = xdg ? join(xdg, "search-cli") : join(homedir(), ".cache", "search-cli");
|
|
24
|
+
return base;
|
|
25
|
+
}
|
|
26
|
+
import type { Config } from "./types.ts";
|
|
27
|
+
|
|
28
|
+
const DEFAULTS: Config = {
|
|
29
|
+
defaultSort: "best-match",
|
|
30
|
+
defaultLimit: 50,
|
|
31
|
+
theme: "tokyo-night",
|
|
32
|
+
cacheTtlSeconds: 300,
|
|
33
|
+
defaultTab: "search",
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export function configPath(): string {
|
|
37
|
+
const envPath = process.env.SEARCH_CLI_CONFIG;
|
|
38
|
+
if (envPath) return envPath;
|
|
39
|
+
|
|
40
|
+
const xdg = process.env.XDG_CONFIG_HOME;
|
|
41
|
+
const base = xdg ? join(xdg, "search-cli") : join(homedir(), ".config", "search-cli");
|
|
42
|
+
return join(base, "config.json");
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function loadConfig(): Config {
|
|
46
|
+
const path = configPath();
|
|
47
|
+
try {
|
|
48
|
+
const raw = readFileSync(path, "utf-8");
|
|
49
|
+
const parsed = JSON.parse(raw);
|
|
50
|
+
return mergeWithEnv({ ...DEFAULTS, ...parsed });
|
|
51
|
+
} catch {
|
|
52
|
+
return mergeWithEnv({ ...DEFAULTS });
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function saveConfig(config: Config): void {
|
|
57
|
+
const path = configPath();
|
|
58
|
+
const dir = path.slice(0, Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\")));
|
|
59
|
+
mkdirSync(dir, { recursive: true });
|
|
60
|
+
writeFileSync(path, JSON.stringify(config, null, 2));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function mergeWithEnv(config: Config): Config {
|
|
64
|
+
if (process.env.GITHUB_TOKEN) config.githubToken = process.env.GITHUB_TOKEN;
|
|
65
|
+
return config;
|
|
66
|
+
}
|
package/src/deepdive.ts
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Repo deep-dive — fetch and format rich repo details.
|
|
3
|
+
*
|
|
4
|
+
* ponytail: single fetch-all function, no section tab navigation.
|
|
5
|
+
* Shows: summary, languages bar, top contributors, README excerpt.
|
|
6
|
+
* Skip: releases (YAGNI), dependency graph (auth-gated).
|
|
7
|
+
*/
|
|
8
|
+
import type { Repo } from "./types.ts";
|
|
9
|
+
import { NetworkError } from "./errors.ts";
|
|
10
|
+
|
|
11
|
+
const USER_AGENT = "search-cli/1.0";
|
|
12
|
+
|
|
13
|
+
/** Language breakdown: { [lang]: bytes } */
|
|
14
|
+
type LanguageMap = Record<string, number>;
|
|
15
|
+
|
|
16
|
+
/** Contributor summary */
|
|
17
|
+
interface Contributor {
|
|
18
|
+
login: string;
|
|
19
|
+
contributions: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** All deep-dive data for one repo. */
|
|
23
|
+
export interface DeepDiveData {
|
|
24
|
+
summary: string;
|
|
25
|
+
languages: string;
|
|
26
|
+
contributors: string;
|
|
27
|
+
readme: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Fetch all deep-dive data for a repo. Returns formatted sections. */
|
|
31
|
+
export async function fetchDeepDive(repo: Repo, token?: string): Promise<DeepDiveData> {
|
|
32
|
+
const auth = token ? { Authorization: `Bearer ${token}` } : {};
|
|
33
|
+
const headers = { "User-Agent": USER_AGENT, ...auth };
|
|
34
|
+
|
|
35
|
+
const [languagesRes, contributorsRes] = await Promise.all([
|
|
36
|
+
fetch(`https://api.github.com/repos/${repo.owner}/${repo.name}/languages`, { headers }),
|
|
37
|
+
fetch(`https://api.github.com/repos/${repo.owner}/${repo.name}/contributors?per_page=5`, { headers }),
|
|
38
|
+
]);
|
|
39
|
+
|
|
40
|
+
// README with branch fallback: main → master → README.rst
|
|
41
|
+
let readmeText = "";
|
|
42
|
+
for (const path of [`${repo.owner}/${repo.name}/main/README.md`, `${repo.owner}/${repo.name}/master/README.md`, `${repo.owner}/${repo.name}/main/README.rst`]) {
|
|
43
|
+
const r = await fetch(`https://raw.githubusercontent.com/${path}`, { headers });
|
|
44
|
+
if (r.ok) { readmeText = await r.text(); break; }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return {
|
|
48
|
+
summary: formatSummary(repo),
|
|
49
|
+
languages: formatLanguages(languagesRes.ok ? await languagesRes.json().catch(() => ({})) : {}),
|
|
50
|
+
contributors: formatContributors(contributorsRes.ok ? await contributorsRes.json().catch(() => []) : []),
|
|
51
|
+
readme: formatReadme(readmeText),
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function formatSummary(repo: Repo): string {
|
|
56
|
+
const lines = [
|
|
57
|
+
` ${repo.fullName}`,
|
|
58
|
+
` ★ ${repo.stars.toLocaleString()} ◆ ${repo.forks.toLocaleString()} ${repo.language ?? "—"}`,
|
|
59
|
+
repo.description ? ` ${repo.description}` : "",
|
|
60
|
+
repo.topics.length ? ` topics: ${repo.topics.slice(0, 8).join(", ")}` : "",
|
|
61
|
+
` ${repo.url}`,
|
|
62
|
+
];
|
|
63
|
+
return lines.filter(Boolean).join("\n");
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function formatLanguages(langs: LanguageMap): string {
|
|
67
|
+
const entries = Object.entries(langs);
|
|
68
|
+
if (entries.length === 0) return " (no language data)";
|
|
69
|
+
const total = entries.reduce((s, [, v]) => s + v, 0);
|
|
70
|
+
const barW = 20;
|
|
71
|
+
return entries
|
|
72
|
+
.sort(([, a], [, b]) => b - a)
|
|
73
|
+
.slice(0, 8)
|
|
74
|
+
.map(([name, bytes]) => {
|
|
75
|
+
const pct = ((bytes / total) * 100).toFixed(1);
|
|
76
|
+
const filled = Math.round((bytes / total) * barW);
|
|
77
|
+
const bar = "█".repeat(filled) + "░".repeat(barW - filled);
|
|
78
|
+
return ` ${name.padEnd(16)} ${bar} ${pct}%`;
|
|
79
|
+
})
|
|
80
|
+
.join("\n");
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function formatContributors(contributors: Contributor[]): string {
|
|
84
|
+
if (contributors.length === 0) return " (no contributor data)";
|
|
85
|
+
return contributors
|
|
86
|
+
.slice(0, 5)
|
|
87
|
+
.map((c) => ` ▲ ${c.login.padEnd(20)} ${c.contributions} commits`)
|
|
88
|
+
.join("\n");
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function formatReadme(readme: string): string {
|
|
92
|
+
if (!readme) return " (no README)";
|
|
93
|
+
// Strip markdown formatting for terminal display
|
|
94
|
+
const stripped = readme
|
|
95
|
+
.replace(/```[\s\S]*?```/g, "[code block]")
|
|
96
|
+
.replace(/#{1,6}\s/g, "")
|
|
97
|
+
.replace(/\*\*(.+?)\*\*/g, "$1")
|
|
98
|
+
.replace(/\[(.+?)\]\(.+?\)/g, "$1")
|
|
99
|
+
.replace(/`([^`]+)`/g, "$1")
|
|
100
|
+
.split("\n")
|
|
101
|
+
.map((l) => l.trim())
|
|
102
|
+
.filter(Boolean);
|
|
103
|
+
|
|
104
|
+
const lines = stripped.slice(0, 30);
|
|
105
|
+
if (stripped.length > 30) lines.push("... (more lines, open in browser to read full)");
|
|
106
|
+
return lines.map((l) => ` ${l}`).join("\n");
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Build the full deep-dive display text from data. */
|
|
110
|
+
export function buildDeepDiveText(data: DeepDiveData): string {
|
|
111
|
+
return [
|
|
112
|
+
"── Summary ──────────────────────────────────────",
|
|
113
|
+
data.summary,
|
|
114
|
+
"",
|
|
115
|
+
"── Languages ────────────────────────────────────",
|
|
116
|
+
data.languages,
|
|
117
|
+
"",
|
|
118
|
+
"── Top Contributors ─────────────────────────────",
|
|
119
|
+
data.contributors,
|
|
120
|
+
"",
|
|
121
|
+
"── README ───────────────────────────────────────",
|
|
122
|
+
data.readme,
|
|
123
|
+
].join("\n");
|
|
124
|
+
}
|
package/src/errors.ts
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Structured error types with user-facing messages.
|
|
3
|
+
* Every error knows what to tell the user and (optionally) what to do next.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export class SearchCliError extends Error {
|
|
7
|
+
constructor(
|
|
8
|
+
message: string,
|
|
9
|
+
public readonly userMessage: string,
|
|
10
|
+
public readonly recoverable: boolean = true,
|
|
11
|
+
) {
|
|
12
|
+
super(message);
|
|
13
|
+
this.name = "SearchCliError";
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export class NetworkError extends SearchCliError {
|
|
18
|
+
constructor() {
|
|
19
|
+
super(
|
|
20
|
+
"Network request failed",
|
|
21
|
+
"⚠ Network error — check your connection. Retrying in 5s... [r]etry now",
|
|
22
|
+
);
|
|
23
|
+
this.name = "NetworkError";
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export class RateLimitError extends SearchCliError {
|
|
28
|
+
constructor(public readonly hasToken: boolean, resetSeconds?: number) {
|
|
29
|
+
const msg = hasToken
|
|
30
|
+
? `⚠ API rate limit exceeded. Resets in ${resetSeconds ?? "?"}s. [r]etry [c]hange token`
|
|
31
|
+
: "⚠ Rate limited (10/min). Set GITHUB_TOKEN for 30/min, or wait. [r]etry";
|
|
32
|
+
super(msg, msg);
|
|
33
|
+
this.name = "RateLimitError";
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export class BadQueryError extends SearchCliError {
|
|
38
|
+
constructor(public readonly detail: string) {
|
|
39
|
+
super(`Invalid query: ${detail}`, `⚠ Invalid query: ${detail}`);
|
|
40
|
+
this.name = "BadQueryError";
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export class NoResultsError extends SearchCliError {
|
|
45
|
+
constructor(public readonly query: string) {
|
|
46
|
+
super(
|
|
47
|
+
`No results for "${query}"`,
|
|
48
|
+
`🔍 No results for "${query}". Tips: check spelling, try fewer qualifiers, or [b]rowse trending`,
|
|
49
|
+
);
|
|
50
|
+
this.name = "NoResultsError";
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export class ParseError extends SearchCliError {
|
|
55
|
+
constructor(source: string, detail: string) {
|
|
56
|
+
super(
|
|
57
|
+
`Parse error from ${source}: ${detail}`,
|
|
58
|
+
`⚠ Could not parse ${source}. GitHub may have changed the layout. [r]etry`,
|
|
59
|
+
);
|
|
60
|
+
this.name = "ParseError";
|
|
61
|
+
}
|
|
62
|
+
}
|
package/src/explore.ts
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Topic explorer — browse popular GitHub topics.
|
|
3
|
+
*
|
|
4
|
+
* ponytail: fetch flat topic list, no hierarchy. Selecting a topic
|
|
5
|
+
* triggers a search. Hierarchy adds ~60 lines for marginal benefit.
|
|
6
|
+
*/
|
|
7
|
+
import { ParseError } from "./errors.ts";
|
|
8
|
+
|
|
9
|
+
export interface TopicItem {
|
|
10
|
+
name: string;
|
|
11
|
+
description: string;
|
|
12
|
+
repoCount: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Fetch popular topics from github.com/topics. */
|
|
16
|
+
export async function fetchTopics(): Promise<TopicItem[]> {
|
|
17
|
+
const res = await fetch("https://github.com/topics", {
|
|
18
|
+
headers: { "User-Agent": "search-cli/1.0" },
|
|
19
|
+
});
|
|
20
|
+
if (!res.ok) throw new ParseError("GitHub topics", `HTTP ${res.status}`);
|
|
21
|
+
const html = await res.text();
|
|
22
|
+
|
|
23
|
+
const topics: TopicItem[] = [];
|
|
24
|
+
// Parse topic cards from the HTML
|
|
25
|
+
const cards = html.split('<article class="border');
|
|
26
|
+
for (let i = 1; i < cards.length; i++) {
|
|
27
|
+
const block = cards[i];
|
|
28
|
+
|
|
29
|
+
const nameMatch = block.match(/href="\/topics\/([^"]+)"/);
|
|
30
|
+
if (!nameMatch) continue;
|
|
31
|
+
const name = decodeURIComponent(nameMatch[1]);
|
|
32
|
+
|
|
33
|
+
const descMatch = block.match(/<p[^>]*>([\s\S]*?)<\/p>/);
|
|
34
|
+
const description = descMatch
|
|
35
|
+
? descMatch[1].replace(/<[^>]*>/g, "").replace(/\s+/g, " ").trim()
|
|
36
|
+
: "";
|
|
37
|
+
|
|
38
|
+
const countMatch = block.match(/([\d,]+)\s+repositories/);
|
|
39
|
+
const repoCount = countMatch ? parseInt(countMatch[1].replace(/,/g, "")) : 0;
|
|
40
|
+
|
|
41
|
+
topics.push({ name, description, repoCount });
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return topics.slice(0, 30);
|
|
45
|
+
}
|
package/src/export.ts
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Export — format repos as JSON, CSV, Markdown, or plain text.
|
|
3
|
+
*/
|
|
4
|
+
import { writeFileSync } from "fs";
|
|
5
|
+
import type { Repo } from "./types.ts";
|
|
6
|
+
|
|
7
|
+
export type ExportFormat = "json" | "csv" | "markdown" | "text";
|
|
8
|
+
|
|
9
|
+
/** Format repos as JSON string. */
|
|
10
|
+
export function formatJson(repos: Repo[]): string {
|
|
11
|
+
return JSON.stringify(repos, null, 2);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Format repos as CSV string. */
|
|
15
|
+
export function formatCsv(repos: Repo[]): string {
|
|
16
|
+
const header = "rank,full_name,stars,forks,language,url";
|
|
17
|
+
const rows = repos.map((r, i) =>
|
|
18
|
+
`${i + 1},"${r.fullName}",${r.stars},${r.forks},"${r.language ?? ""}","${r.url}"`,
|
|
19
|
+
);
|
|
20
|
+
return [header, ...rows].join("\n");
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Format repos as Markdown table. */
|
|
24
|
+
export function formatMarkdown(repos: Repo[]): string {
|
|
25
|
+
const header = "| # | Repo | Stars | Forks | Language |";
|
|
26
|
+
const sep = "|---|------|-------|-------|----------|";
|
|
27
|
+
const rows = repos.map(
|
|
28
|
+
(r, i) =>
|
|
29
|
+
`| ${i + 1} | [${r.fullName}](${r.url}) | ${r.stars.toLocaleString()} | ${r.forks.toLocaleString()} | ${r.language ?? "—"} |`,
|
|
30
|
+
);
|
|
31
|
+
return [header, sep, ...rows].join("\n");
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Format repos as plain text, one per line. */
|
|
35
|
+
export function formatText(repos: Repo[]): string {
|
|
36
|
+
return repos.map((r, i) => `${i + 1}. ${r.fullName} ★ ${r.stars.toLocaleString()} ${r.url}`).join("\n");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Format repos in the given format. */
|
|
40
|
+
export function formatRepos(repos: Repo[], format: ExportFormat): string {
|
|
41
|
+
switch (format) {
|
|
42
|
+
case "json": return formatJson(repos);
|
|
43
|
+
case "csv": return formatCsv(repos);
|
|
44
|
+
case "markdown": return formatMarkdown(repos);
|
|
45
|
+
case "text": return formatText(repos);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Export repos to a file. Returns the file path. */
|
|
50
|
+
export function exportToFile(repos: Repo[], format: ExportFormat): string {
|
|
51
|
+
const ext = format === "markdown" ? "md" : format;
|
|
52
|
+
const ts = Date.now();
|
|
53
|
+
const path = `./search-cli-export-${ts}.${ext}`;
|
|
54
|
+
writeFileSync(path, formatRepos(repos, format), "utf-8");
|
|
55
|
+
return path;
|
|
56
|
+
}
|
package/src/help.ts
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Help screen: full keyboard shortcut reference for the TUI.
|
|
3
|
+
*/
|
|
4
|
+
export function buildHelpText(): string {
|
|
5
|
+
return [
|
|
6
|
+
"┌─ search-cli Help ──────────────────────────── [q] close ─┐",
|
|
7
|
+
"│ │",
|
|
8
|
+
"│ NAVIGATION │",
|
|
9
|
+
"│ ↑ ↓ / j k Move selection │",
|
|
10
|
+
"│ Enter / o Open repo in browser │",
|
|
11
|
+
"│ / Focus search input │",
|
|
12
|
+
"│ t Toggle search / trending │",
|
|
13
|
+
"│ ? / Ctrl+H Toggle this help screen │",
|
|
14
|
+
"│ q Quit │",
|
|
15
|
+
"│ │",
|
|
16
|
+
"│ SEARCH MODE │",
|
|
17
|
+
"│ s Cycle sort: best-match → stars → ... │",
|
|
18
|
+
"│ l Cycle limit: 10 → 25 → 50 → 100 │",
|
|
19
|
+
"│ r Refresh / re-run last search │",
|
|
20
|
+
"│ g Toggle activity graph fullscreen │",
|
|
21
|
+
"│ Tab Auto-complete qualifier │",
|
|
22
|
+
"│ │",
|
|
23
|
+
"│ TRENDING MODE │",
|
|
24
|
+
"│ 1-5 Switch tab: Today → Week → Month → ...│",
|
|
25
|
+
"│ ← → / h l Navigate tabs │",
|
|
26
|
+
"│ r Refresh │",
|
|
27
|
+
"│ │",
|
|
28
|
+
"│ SEARCH SYNTAX │",
|
|
29
|
+
"│ keywords Free-text search │",
|
|
30
|
+
"│ key:value Qualifier filter │",
|
|
31
|
+
"│ -key:value Negated qualifier │",
|
|
32
|
+
"│ \"phrase\" Exact phrase match │",
|
|
33
|
+
"│ │",
|
|
34
|
+
"│ QUALIFIERS │",
|
|
35
|
+
"│ language: Stars: Fork: Archived: Topic: │",
|
|
36
|
+
"│ user: Org: Repo: Updated: Pushed: │",
|
|
37
|
+
"│ visibility: License: Created: Size: In: │",
|
|
38
|
+
"│ │",
|
|
39
|
+
"│ RATE LIMITS │",
|
|
40
|
+
"│ No token 10 req/min │",
|
|
41
|
+
"│ GITHUB_TOKEN 30 req/min (or your plan limit) │",
|
|
42
|
+
"│ │",
|
|
43
|
+
"│ Set GITHUB_TOKEN in your environment or add to config. │",
|
|
44
|
+
"└───────────────────────────────────────────────────────────┘",
|
|
45
|
+
].join("\n");
|
|
46
|
+
}
|