github-search-cli 0.8.2 → 9.1.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/README.md +109 -50
- package/bin/ghfind.js +3 -13
- package/dist/chunk-TUMYR2IC.js +57 -0
- package/dist/cli.js +3761 -0
- package/dist/config-DFL44OVL.js +14 -0
- package/package.json +3 -1
- package/scripts/postinstall.js +106 -106
- package/src/bookmarks.ts +4 -17
- package/src/cli.ts +90 -61
- package/src/config.ts +2 -2
- package/src/history.ts +16 -52
- package/src/notifications.ts +5 -12
- package/src/output.ts +124 -55
- package/src/saved-searches.ts +4 -12
- package/src/search-state.ts +229 -0
- package/src/search.ts +99 -2
- package/src/session.ts +6 -18
- package/src/storage.ts +106 -0
- package/src/themes.ts +47 -7
- package/src/trending.ts +27 -150
- package/src/tui.ts +1087 -270
- package/src/types.ts +2 -0
- package/src/update-check.ts +162 -0
- package/src/watch.ts +23 -11
- package/vendor/bun.exe +0 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "github-search-cli",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "9.1.0",
|
|
4
4
|
"description": "Terminal-native GitHub search — search, trending, bookmarks, history, deep-dive, compare, export, and more. All keyboard-driven, no browser needed.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"packageManager": "bun@1",
|
|
@@ -15,6 +15,8 @@
|
|
|
15
15
|
},
|
|
16
16
|
"files": [
|
|
17
17
|
"bin/",
|
|
18
|
+
"dist/",
|
|
19
|
+
"vendor/",
|
|
18
20
|
"src/",
|
|
19
21
|
"scripts/",
|
|
20
22
|
"completions/",
|
package/scripts/postinstall.js
CHANGED
|
@@ -1,106 +1,106 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
/**
|
|
3
|
-
* Postinstall script — ensures a Bun binary is available for ghfind.
|
|
4
|
-
*
|
|
5
|
-
* If vendor/bun or vendor/bun.exe already exists (bundled with the package),
|
|
6
|
-
* this is a no-op. Otherwise, it attempts to download the correct binary
|
|
7
|
-
* for the user's platform.
|
|
8
|
-
*/
|
|
9
|
-
import { existsSync, mkdirSync, chmodSync, writeFileSync } from "fs";
|
|
10
|
-
import { join, dirname } from "path";
|
|
11
|
-
import { fileURLToPath } from "url";
|
|
12
|
-
import { platform, arch } from "os";
|
|
13
|
-
import { inflateRawSync } from "zlib";
|
|
14
|
-
|
|
15
|
-
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
16
|
-
const ROOT = join(__dirname, "..");
|
|
17
|
-
const VENDOR_DIR = join(ROOT, "vendor");
|
|
18
|
-
|
|
19
|
-
const bunName = platform() === "win32" ? "bun.exe" : "bun";
|
|
20
|
-
const bunPath = join(VENDOR_DIR, bunName);
|
|
21
|
-
|
|
22
|
-
// Already bundled — nothing to do
|
|
23
|
-
if (existsSync(bunPath)) {
|
|
24
|
-
process.exit(0);
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
// Skip in CI/test environments
|
|
28
|
-
if (process.env.GHFIND_SKIP_BUN === "1" || process.env.CI === "true") {
|
|
29
|
-
process.exit(0);
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
// Try to download
|
|
33
|
-
const map = {
|
|
34
|
-
"win32-x64": "bun-windows-x64.zip",
|
|
35
|
-
"win32-arm64": "bun-windows-aarch64.zip",
|
|
36
|
-
"darwin-x64": "bun-darwin-x64.zip",
|
|
37
|
-
"darwin-arm64": "bun-darwin-aarch64.zip",
|
|
38
|
-
"linux-x64": "bun-linux-x64-musl.zip",
|
|
39
|
-
"linux-arm64": "bun-linux-aarch64-musl.zip",
|
|
40
|
-
};
|
|
41
|
-
|
|
42
|
-
const key = `${platform()}-${arch}`;
|
|
43
|
-
const assetName = map[key];
|
|
44
|
-
const version = "1.3.
|
|
45
|
-
|
|
46
|
-
if (!assetName) {
|
|
47
|
-
console.error(`[ghfind] Unsupported platform: ${key}. Install Bun manually: https://bun.sh`);
|
|
48
|
-
process.exit(0);
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
const url = `https://github.com/oven-sh/bun/releases/download/bun-v${version}/${assetName}`;
|
|
52
|
-
|
|
53
|
-
try {
|
|
54
|
-
mkdirSync(VENDOR_DIR, { recursive: true });
|
|
55
|
-
console.error(`[ghfind] Downloading Bun ${version} for ${key}...`);
|
|
56
|
-
|
|
57
|
-
const res = await fetch(url);
|
|
58
|
-
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
59
|
-
|
|
60
|
-
const buffer = Buffer.from(await res.arrayBuffer());
|
|
61
|
-
|
|
62
|
-
// Find the central directory file header signature (PK\x01\x02)
|
|
63
|
-
const cdSig = "PK\x01\x02";
|
|
64
|
-
let cdOffset = buffer.indexOf(cdSig, 0);
|
|
65
|
-
while (cdOffset !== -1) {
|
|
66
|
-
const nameLen = buffer.readUInt16LE(cdOffset + 28);
|
|
67
|
-
const extraLen = buffer.readUInt16LE(cdOffset + 30);
|
|
68
|
-
const commentLen = buffer.readUInt16LE(cdOffset + 32);
|
|
69
|
-
const localHeaderOffset = buffer.readUInt32LE(cdOffset + 42);
|
|
70
|
-
const compSize = buffer.readUInt32LE(cdOffset + 20);
|
|
71
|
-
const compMethod = buffer.readUInt16LE(cdOffset + 10);
|
|
72
|
-
const nameStart = cdOffset + 46;
|
|
73
|
-
const fileName = buffer.subarray(nameStart, nameStart + nameLen).toString("utf8");
|
|
74
|
-
|
|
75
|
-
if (fileName.endsWith(bunName)) {
|
|
76
|
-
// Read the local file header to get the actual data offset
|
|
77
|
-
const localSig = buffer.readUInt32LE(localHeaderOffset);
|
|
78
|
-
if (localSig !== 0x04034b50) throw new Error("Invalid local file header");
|
|
79
|
-
const localNameLen = buffer.readUInt16LE(localHeaderOffset + 26);
|
|
80
|
-
const localExtraLen = buffer.readUInt16LE(localHeaderOffset + 28);
|
|
81
|
-
const dataOffset = localHeaderOffset + 30 + localNameLen + localExtraLen;
|
|
82
|
-
|
|
83
|
-
let fileData;
|
|
84
|
-
if (compMethod === 0) {
|
|
85
|
-
// Stored (no compression)
|
|
86
|
-
fileData = buffer.subarray(dataOffset, dataOffset + compSize);
|
|
87
|
-
} else {
|
|
88
|
-
// Deflated
|
|
89
|
-
fileData = inflateRawSync(buffer.subarray(dataOffset, dataOffset + compSize));
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
writeFileSync(bunPath, fileData);
|
|
93
|
-
if (platform() !== "win32") chmodSync(bunPath, 0o755);
|
|
94
|
-
console.error(`[ghfind] Bun installed to vendor/${bunName}`);
|
|
95
|
-
process.exit(0);
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
cdOffset = buffer.indexOf(cdSig, cdOffset + 4);
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
throw new Error("Bun binary not found in archive");
|
|
102
|
-
} catch (err) {
|
|
103
|
-
console.error(`[ghfind] Failed to download Bun: ${err instanceof Error ? err.message : String(err)}`);
|
|
104
|
-
console.error("[ghfind] Install Bun manually: https://bun.sh/docs/install");
|
|
105
|
-
process.exit(
|
|
106
|
-
}
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Postinstall script — ensures a Bun binary is available for ghfind.
|
|
4
|
+
*
|
|
5
|
+
* If vendor/bun or vendor/bun.exe already exists (bundled with the package),
|
|
6
|
+
* this is a no-op. Otherwise, it attempts to download the correct binary
|
|
7
|
+
* for the user's platform.
|
|
8
|
+
*/
|
|
9
|
+
import { existsSync, mkdirSync, chmodSync, writeFileSync } from "fs";
|
|
10
|
+
import { join, dirname } from "path";
|
|
11
|
+
import { fileURLToPath } from "url";
|
|
12
|
+
import { platform, arch } from "os";
|
|
13
|
+
import { inflateRawSync } from "zlib";
|
|
14
|
+
|
|
15
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
16
|
+
const ROOT = join(__dirname, "..");
|
|
17
|
+
const VENDOR_DIR = join(ROOT, "vendor");
|
|
18
|
+
|
|
19
|
+
const bunName = platform() === "win32" ? "bun.exe" : "bun";
|
|
20
|
+
const bunPath = join(VENDOR_DIR, bunName);
|
|
21
|
+
|
|
22
|
+
// Already bundled — nothing to do
|
|
23
|
+
if (existsSync(bunPath)) {
|
|
24
|
+
process.exit(0);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Skip in CI/test environments
|
|
28
|
+
if (process.env.GHFIND_SKIP_BUN === "1" || process.env.CI === "true") {
|
|
29
|
+
process.exit(0);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Try to download
|
|
33
|
+
const map = {
|
|
34
|
+
"win32-x64": "bun-windows-x64.zip",
|
|
35
|
+
"win32-arm64": "bun-windows-aarch64.zip",
|
|
36
|
+
"darwin-x64": "bun-darwin-x64.zip",
|
|
37
|
+
"darwin-arm64": "bun-darwin-aarch64.zip",
|
|
38
|
+
"linux-x64": "bun-linux-x64-musl.zip",
|
|
39
|
+
"linux-arm64": "bun-linux-aarch64-musl.zip",
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const key = `${platform()}-${arch}`;
|
|
43
|
+
const assetName = map[key];
|
|
44
|
+
const version = "1.3.14";
|
|
45
|
+
|
|
46
|
+
if (!assetName) {
|
|
47
|
+
console.error(`[ghfind] Unsupported platform: ${key}. Install Bun manually: https://bun.sh`);
|
|
48
|
+
process.exit(0);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const url = `https://github.com/oven-sh/bun/releases/download/bun-v${version}/${assetName}`;
|
|
52
|
+
|
|
53
|
+
try {
|
|
54
|
+
mkdirSync(VENDOR_DIR, { recursive: true });
|
|
55
|
+
console.error(`[ghfind] Downloading Bun ${version} for ${key}...`);
|
|
56
|
+
|
|
57
|
+
const res = await fetch(url);
|
|
58
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
59
|
+
|
|
60
|
+
const buffer = Buffer.from(await res.arrayBuffer());
|
|
61
|
+
|
|
62
|
+
// Find the central directory file header signature (PK\x01\x02)
|
|
63
|
+
const cdSig = "PK\x01\x02";
|
|
64
|
+
let cdOffset = buffer.indexOf(cdSig, 0);
|
|
65
|
+
while (cdOffset !== -1) {
|
|
66
|
+
const nameLen = buffer.readUInt16LE(cdOffset + 28);
|
|
67
|
+
const extraLen = buffer.readUInt16LE(cdOffset + 30);
|
|
68
|
+
const commentLen = buffer.readUInt16LE(cdOffset + 32);
|
|
69
|
+
const localHeaderOffset = buffer.readUInt32LE(cdOffset + 42);
|
|
70
|
+
const compSize = buffer.readUInt32LE(cdOffset + 20);
|
|
71
|
+
const compMethod = buffer.readUInt16LE(cdOffset + 10);
|
|
72
|
+
const nameStart = cdOffset + 46;
|
|
73
|
+
const fileName = buffer.subarray(nameStart, nameStart + nameLen).toString("utf8");
|
|
74
|
+
|
|
75
|
+
if (fileName.endsWith(bunName)) {
|
|
76
|
+
// Read the local file header to get the actual data offset
|
|
77
|
+
const localSig = buffer.readUInt32LE(localHeaderOffset);
|
|
78
|
+
if (localSig !== 0x04034b50) throw new Error("Invalid local file header");
|
|
79
|
+
const localNameLen = buffer.readUInt16LE(localHeaderOffset + 26);
|
|
80
|
+
const localExtraLen = buffer.readUInt16LE(localHeaderOffset + 28);
|
|
81
|
+
const dataOffset = localHeaderOffset + 30 + localNameLen + localExtraLen;
|
|
82
|
+
|
|
83
|
+
let fileData;
|
|
84
|
+
if (compMethod === 0) {
|
|
85
|
+
// Stored (no compression)
|
|
86
|
+
fileData = buffer.subarray(dataOffset, dataOffset + compSize);
|
|
87
|
+
} else {
|
|
88
|
+
// Deflated
|
|
89
|
+
fileData = inflateRawSync(buffer.subarray(dataOffset, dataOffset + compSize));
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
writeFileSync(bunPath, fileData);
|
|
93
|
+
if (platform() !== "win32") chmodSync(bunPath, 0o755);
|
|
94
|
+
console.error(`[ghfind] Bun installed to vendor/${bunName}`);
|
|
95
|
+
process.exit(0);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
cdOffset = buffer.indexOf(cdSig, cdOffset + 4);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
throw new Error("Bun binary not found in archive");
|
|
102
|
+
} catch (err) {
|
|
103
|
+
console.error(`[ghfind] Failed to download Bun: ${err instanceof Error ? err.message : String(err)}`);
|
|
104
|
+
console.error("[ghfind] Install Bun manually: https://bun.sh/docs/install");
|
|
105
|
+
process.exit(1);
|
|
106
|
+
}
|
package/src/bookmarks.ts
CHANGED
|
@@ -1,30 +1,17 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Bookmarks — saved repos with tags, persisted as JSON.
|
|
3
3
|
*/
|
|
4
|
-
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
|
|
5
|
-
import { join } from "path";
|
|
6
4
|
import type { Bookmark, Repo } from "./types";
|
|
7
|
-
import {
|
|
5
|
+
import { readJSON, writeJSON } from "./storage";
|
|
8
6
|
|
|
9
|
-
const BOOKMARK_FILE =
|
|
10
|
-
|
|
11
|
-
function ensureDir() {
|
|
12
|
-
mkdirSync(stateDir(), { recursive: true });
|
|
13
|
-
}
|
|
7
|
+
const BOOKMARK_FILE = "bookmarks.json";
|
|
14
8
|
|
|
15
9
|
function loadRaw(): Bookmark[] {
|
|
16
|
-
|
|
17
|
-
try {
|
|
18
|
-
const raw = readFileSync(BOOKMARK_FILE, "utf-8");
|
|
19
|
-
return JSON.parse(raw) as Bookmark[];
|
|
20
|
-
} catch {
|
|
21
|
-
return [];
|
|
22
|
-
}
|
|
10
|
+
return readJSON<Bookmark[]>(BOOKMARK_FILE, []);
|
|
23
11
|
}
|
|
24
12
|
|
|
25
13
|
function saveRaw(bookmarks: Bookmark[]): void {
|
|
26
|
-
|
|
27
|
-
writeFileSync(BOOKMARK_FILE, JSON.stringify(bookmarks, null, 2));
|
|
14
|
+
writeJSON(BOOKMARK_FILE, bookmarks);
|
|
28
15
|
}
|
|
29
16
|
|
|
30
17
|
/** Get all bookmarks, newest first. */
|
package/src/cli.ts
CHANGED
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/** CLI entry point — routes between TUI browser and non-interactive modes. */
|
|
3
|
-
import { parseQuery, applyFlagFilters, rankRepos, createGitHubSearch } from "./search";
|
|
4
|
-
import {
|
|
5
|
-
import { formatLines, pipeExec, type FormatLine } from "./pipe";
|
|
3
|
+
import { parseQuery, applyFlagFilters, rankRepos, createGitHubSearch, createTrendingSearch } from "./search";
|
|
4
|
+
import { format as formatOutput, exportToFile, pipeExec, type Format, type ExportFormat, type FormatLine } from "./output";
|
|
6
5
|
import { runWatch } from "./watch";
|
|
7
6
|
import { runInitWizard } from "./init";
|
|
8
7
|
import { readFileSync } from "fs";
|
|
@@ -158,13 +157,13 @@ Options:
|
|
|
158
157
|
}
|
|
159
158
|
|
|
160
159
|
// Determine output format
|
|
161
|
-
const
|
|
160
|
+
const outputFormat: ExportFormat | undefined =
|
|
162
161
|
flags.json ? "json" : flags.csv ? "csv" : flags.markdown ? "markdown" : undefined;
|
|
163
|
-
const isNonInteractive =
|
|
162
|
+
const isNonInteractive = outputFormat || flags.count || flags.format || flags.pipe || flags.watch;
|
|
164
163
|
|
|
165
164
|
// Non-interactive mode
|
|
166
165
|
if (isNonInteractive) {
|
|
167
|
-
await runNonInteractive(flags,
|
|
166
|
+
await runNonInteractive(flags, outputFormat);
|
|
168
167
|
return;
|
|
169
168
|
}
|
|
170
169
|
|
|
@@ -172,65 +171,95 @@ Options:
|
|
|
172
171
|
await launchBrowser();
|
|
173
172
|
}
|
|
174
173
|
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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
|
-
}
|
|
174
|
+
interface SearchContext {
|
|
175
|
+
query: string;
|
|
176
|
+
parsed: ReturnType<typeof parseQuery> & { raw: string };
|
|
177
|
+
provider: ReturnType<typeof createGitHubSearch>;
|
|
178
|
+
}
|
|
206
179
|
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
180
|
+
function buildSearchContext(flags: CLIFlags): SearchContext {
|
|
181
|
+
const parsed = applyFlagFilters(parseQuery(flags.query), {});
|
|
182
|
+
return {
|
|
183
|
+
query: flags.query,
|
|
184
|
+
parsed,
|
|
185
|
+
provider: createGitHubSearch(undefined, flags.token ? [flags.token] : []),
|
|
186
|
+
};
|
|
187
|
+
}
|
|
215
188
|
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
const
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
189
|
+
async function runNonInteractive(flags: CLIFlags, outputFormat?: ExportFormat) {
|
|
190
|
+
// Dispatch table: each handler receives shared context + flags, returns void.
|
|
191
|
+
const handlers: Record<string, (ctx: SearchContext) => Promise<void>> = {
|
|
192
|
+
trending: async (ctx) => {
|
|
193
|
+
const since = flags.since === "weekly" ? "weekly" : flags.since === "monthly" ? "monthly" : "daily";
|
|
194
|
+
const trendingSearch = createTrendingSearch();
|
|
195
|
+
const response = await trendingSearch.search(
|
|
196
|
+
{ keywords: [], qualifiers: [], raw: "trending" },
|
|
197
|
+
{ limit: 25, sort: "stars", json: false, verbose: false, trendingSince: since },
|
|
198
|
+
);
|
|
199
|
+
if (outputFormat) {
|
|
200
|
+
console.log(formatOutput(response.repos, outputFormat));
|
|
201
|
+
} else if (flags.count) {
|
|
202
|
+
console.log(response.totalCount);
|
|
203
|
+
} else {
|
|
204
|
+
for (const r of response.repos) {
|
|
205
|
+
console.log(r.fullName + " ★ " + r.stars + " ▲ +" + r.score + " " + since + " ● " + (r.language ?? ""));
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
},
|
|
209
|
+
watch: async (ctx) => {
|
|
210
|
+
let tick = 0;
|
|
211
|
+
await runWatch(
|
|
212
|
+
{ query: ctx.query, sort: flags.sort, limit: flags.limit, token: flags.token, intervalMs: flags.interval * 1000, trending: flags.trending },
|
|
213
|
+
(repos, delta) => {
|
|
214
|
+
tick++;
|
|
215
|
+
if (outputFormat) {
|
|
216
|
+
console.log(`[Watch #${tick}] ${repos.length} results (${delta >= 0 ? "+" : ""}${delta} since last check)`);
|
|
217
|
+
console.log(formatOutput(repos, outputFormat));
|
|
218
|
+
} else {
|
|
219
|
+
console.log(`[Watch #${tick}] ${repos.length} results — ${delta >= 0 ? "+" : ""}${delta} since last check`);
|
|
220
|
+
}
|
|
221
|
+
},
|
|
222
|
+
(err) => console.error(`[Watch error] ${err.message}`),
|
|
223
|
+
);
|
|
224
|
+
},
|
|
225
|
+
pipe: async (ctx) => {
|
|
226
|
+
const response = await ctx.provider.search(ctx.parsed, {
|
|
227
|
+
limit: flags.limit, sort: flags.sort, json: false, verbose: false, token: flags.token,
|
|
228
|
+
});
|
|
229
|
+
const repos = rankRepos(response.repos, flags.sort);
|
|
230
|
+
await pipeExec(repos, flags.pipe!);
|
|
231
|
+
},
|
|
232
|
+
format: async (ctx) => {
|
|
233
|
+
const response = await ctx.provider.search(ctx.parsed, {
|
|
234
|
+
limit: flags.limit, sort: flags.sort, json: false, verbose: false, token: flags.token,
|
|
235
|
+
});
|
|
236
|
+
const repos = rankRepos(response.repos, flags.sort);
|
|
237
|
+
console.log(formatOutput(repos, flags.format as Format));
|
|
238
|
+
},
|
|
239
|
+
search: async (ctx) => {
|
|
240
|
+
const response = await ctx.provider.search(ctx.parsed, {
|
|
241
|
+
limit: flags.limit, sort: flags.sort, json: false, verbose: false, token: flags.token,
|
|
242
|
+
});
|
|
243
|
+
const repos = rankRepos(response.repos, flags.sort);
|
|
244
|
+
if (flags.count) {
|
|
245
|
+
console.log(response.totalCount);
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
if (outputFormat) {
|
|
249
|
+
console.log(formatOutput(repos, outputFormat));
|
|
250
|
+
}
|
|
251
|
+
},
|
|
252
|
+
};
|
|
222
253
|
|
|
223
|
-
//
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
254
|
+
// Determine which handler to run
|
|
255
|
+
const key = flags.trending && !flags.watch ? "trending"
|
|
256
|
+
: flags.watch ? "watch"
|
|
257
|
+
: flags.pipe ? "pipe"
|
|
258
|
+
: flags.format ? "format"
|
|
259
|
+
: "search";
|
|
228
260
|
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
console.log(formatRepos(repos, format));
|
|
232
|
-
return;
|
|
233
|
-
}
|
|
261
|
+
const ctx = buildSearchContext(flags);
|
|
262
|
+
await handlers[key](ctx);
|
|
234
263
|
}
|
|
235
264
|
|
|
236
265
|
main().catch((err) => {
|
package/src/config.ts
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
import type { Config } from "./types";
|
|
10
10
|
import { homedir } from "os";
|
|
11
11
|
import { join } from "path";
|
|
12
|
-
import {
|
|
12
|
+
import { mkdirSync, readFileSync, writeFileSync } from "fs";
|
|
13
13
|
|
|
14
14
|
/** Directory for persistent state (history, bookmarks, session). */
|
|
15
15
|
export function stateDir(): string {
|
|
@@ -27,7 +27,7 @@ export function cacheDir(): string {
|
|
|
27
27
|
const DEFAULTS: Config = {
|
|
28
28
|
defaultSort: "best-match",
|
|
29
29
|
defaultLimit: 50,
|
|
30
|
-
theme: "
|
|
30
|
+
theme: "premium-dark", // New default: premium dark with sharp edges
|
|
31
31
|
cacheTtlSeconds: 300,
|
|
32
32
|
defaultTab: "search",
|
|
33
33
|
};
|
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 {
|
|
5
|
+
import { readJSONL, appendJSONL, writeJSON, writeRaw } from "./storage";
|
|
8
6
|
|
|
9
|
-
const HISTORY_FILE =
|
|
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
|
-
|
|
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
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/src/notifications.ts
CHANGED
|
@@ -1,7 +1,5 @@
|
|
|
1
1
|
/** Notification center — ephemeral alerts for rate limits, watch changes, etc. */
|
|
2
|
-
import {
|
|
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 =
|
|
15
|
+
const FILE = "notifications.json";
|
|
18
16
|
|
|
19
17
|
function load(): void {
|
|
20
|
-
|
|
21
|
-
|
|
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
|
-
|
|
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 {
|