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.
@@ -1,69 +1,65 @@
1
- /**
2
- * Notification center — ephemeral alerts for rate limits, watch changes, etc.
3
- *
4
- * ponytail: in-memory + last-session persistence. Skip 24h auto-clean.
5
- */
6
- import { mkdirSync, readFileSync, writeFileSync } from "fs";
7
- import { join } from "path";
8
- import { stateDir } from "./config.ts";
9
-
10
- export interface Notification {
11
- id: number;
12
- type: "info" | "warning" | "rate-limit" | "watch-change" | "upgrade";
13
- icon: string;
14
- message: string;
15
- timestamp: number;
16
- }
17
-
18
- let nextId = 1;
19
- let notifs: Notification[] = [];
20
- const MAX_NOTIFS = 50;
21
- const FILE = join(stateDir(), "notifications.json");
22
-
23
- function load(): void {
24
- try {
25
- notifs = JSON.parse(readFileSync(FILE, "utf-8"));
26
- nextId = (Math.max(...notifs.map((n) => n.id), 0) + 1);
27
- } catch {
28
- notifs = [];
29
- }
30
- }
31
-
32
- function save(): void {
33
- mkdirSync(stateDir(), { recursive: true });
34
- writeFileSync(FILE, JSON.stringify(notifs));
35
- }
36
-
37
- export function addNotification(type: Notification["type"], message: string): Notification {
38
- if (notifs.length === 0) load();
39
- const n: Notification = { id: nextId++, type, icon: iconFor(type), message, timestamp: Date.now() };
40
- notifs.unshift(n);
41
- if (notifs.length > MAX_NOTIFS) notifs = notifs.slice(0, MAX_NOTIFS);
42
- save();
43
- return n;
44
- }
45
-
46
- function iconFor(type: Notification["type"]): string {
47
- switch (type) {
48
- case "info": return "ℹ";
49
- case "warning": return "⚠";
50
- case "rate-limit": return "🚫";
51
- case "watch-change": return "🔔";
52
- case "upgrade": return "📦";
53
- }
54
- }
55
-
56
- export function getNotifications(): Notification[] {
57
- if (notifs.length === 0) load();
58
- return [...notifs];
59
- }
60
-
61
- export function dismissNotification(id: number): void {
62
- notifs = notifs.filter((n) => n.id !== id);
63
- save();
64
- }
65
-
66
- export function dismissAll(): void {
67
- notifs = [];
68
- save();
69
- }
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";
5
+
6
+ export interface Notification {
7
+ id: number;
8
+ type: "info" | "warning" | "rate-limit" | "watch-change" | "upgrade";
9
+ icon: string;
10
+ message: string;
11
+ timestamp: number;
12
+ }
13
+
14
+ let nextId = 1;
15
+ let notifs: Notification[] = [];
16
+ const MAX_NOTIFS = 50;
17
+ const FILE = join(stateDir(), "notifications.json");
18
+
19
+ 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
+ }
26
+ }
27
+
28
+ function save(): void {
29
+ mkdirSync(stateDir(), { recursive: true });
30
+ writeFileSync(FILE, JSON.stringify(notifs));
31
+ }
32
+
33
+ export function addNotification(type: Notification["type"], message: string): Notification {
34
+ if (notifs.length === 0) load();
35
+ const n: Notification = { id: nextId++, type, icon: iconFor(type), message, timestamp: Date.now() };
36
+ notifs.unshift(n);
37
+ if (notifs.length > MAX_NOTIFS) notifs = notifs.slice(0, MAX_NOTIFS);
38
+ save();
39
+ return n;
40
+ }
41
+
42
+ function iconFor(type: Notification["type"]): string {
43
+ switch (type) {
44
+ case "info": return "ℹ";
45
+ case "warning": return "⚠";
46
+ case "rate-limit": return "🚫";
47
+ case "watch-change": return "🔔";
48
+ case "upgrade": return "📦";
49
+ }
50
+ }
51
+
52
+ export function getNotifications(): Notification[] {
53
+ if (notifs.length === 0) load();
54
+ return [...notifs];
55
+ }
56
+
57
+ export function dismissNotification(id: number): void {
58
+ notifs = notifs.filter((n) => n.id !== id);
59
+ save();
60
+ }
61
+
62
+ export function dismissAll(): void {
63
+ notifs = [];
64
+ save();
65
+ }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Open a URL in the user's default browser — platform-aware.
3
+ *
4
+ * only if the open/xdg-open pattern fails somewhere.
5
+ */
6
+
7
+ export function openUrl(url: string): void {
8
+ try {
9
+ const p = process.platform;
10
+ if (p === "win32") Bun.spawn(["cmd", "/c", "start", "", url]);
11
+ else if (p === "darwin") Bun.spawn(["open", url]);
12
+ else Bun.spawn(["xdg-open", url]);
13
+ } catch {
14
+ // non-critical — user can copy the URL manually
15
+ }
16
+ }
package/src/output.ts CHANGED
@@ -1,74 +1,68 @@
1
- /**
2
- * Output layer: render ranked results either as machine-readable JSON or as a
3
- * concise, human-readable list. This layer owns all presentation concerns so
4
- * the search/ranking logic stays presentation-free.
5
- */
6
- import type { Repo, SearchResponse } from "./types.ts";
7
-
8
- /** Format a number compactly (e.g. 1234 -> "1.2k"). */
9
- export function formatCount(n: number): string {
10
- if (n < 1000) return String(n);
11
- if (n < 1_000_000) return `${(n / 1000).toFixed(1).replace(/\.0$/, "")}k`;
12
- return `${(n / 1_000_000).toFixed(1).replace(/\.0$/, "")}M`;
13
- }
14
-
15
- /** Format an ISO date as YYYY-MM-DD. */
16
- export function formatDate(iso: string): string {
17
- const d = new Date(iso);
18
- if (isNaN(d.getTime())) return "unknown";
19
- return d.toISOString().slice(0, 10);
20
- }
21
-
22
- /** Emit stable, parseable JSON. */
23
- export function renderJson(response: SearchResponse, rawQuery: string): string {
24
- return JSON.stringify(
25
- {
26
- query: rawQuery,
27
- totalCount: response.totalCount,
28
- count: response.repos.length,
29
- rateLimited: response.rateLimited,
30
- results: response.repos,
31
- },
32
- null,
33
- 2,
34
- );
35
- }
36
-
37
- /** Emit a concise, readable list for terminals. */
38
- export function renderHuman(response: SearchResponse, rawQuery: string): string {
39
- const lines: string[] = [];
40
- lines.push(`Query: ${rawQuery}`);
41
- lines.push(`Matches: ${response.repos.length} (total available: ${response.totalCount})`);
42
- if (response.rateLimited) {
43
- lines.push("⚠ Rate limit hit — results may be incomplete. Set GITHUB_TOKEN to raise limits.");
44
- }
45
- lines.push("");
46
-
47
- if (response.repos.length === 0) {
48
- lines.push("No repositories found.");
49
- return lines.join("\n");
50
- }
51
-
52
- response.repos.forEach((repo, i) => {
53
- lines.push(renderRepo(i + 1, repo));
54
- lines.push("");
55
- });
56
-
57
- return lines.join("\n").trimEnd();
58
- }
59
-
60
- function renderRepo(index: number, repo: Repo): string {
61
- const lang = repo.language ?? "—";
62
- const stars = formatCount(repo.stars);
63
- const updated = formatDate(repo.updatedAt);
64
- const tags: string[] = [];
65
- if (repo.archived) tags.push("archived");
66
- if (repo.isFork) tags.push("fork");
67
- const tagStr = tags.length ? ` [${tags.join(", ")}]` : "";
68
-
69
- const header = `${index}. ${repo.fullName}${tagStr}`;
70
- const meta = ` ★ ${stars} • ${lang} • updated ${updated}`;
71
- const desc = repo.description ? ` ${repo.description}` : " (no description)";
72
- const url = ` ${repo.url}`;
73
- return [header, meta, desc, url].join("\n");
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`;
10
+ }
11
+
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);
16
+ }
17
+
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,
29
+ );
30
+ }
31
+
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("");
40
+
41
+ if (response.repos.length === 0) {
42
+ lines.push("No repositories found.");
43
+ return lines.join("\n");
44
+ }
45
+
46
+ response.repos.forEach((repo, i) => {
47
+ lines.push(renderRepo(i + 1, repo));
48
+ lines.push("");
49
+ });
50
+
51
+ return lines.join("\n").trimEnd();
52
+ }
53
+
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");
74
68
  }
package/src/pipe.ts CHANGED
@@ -1,65 +1,54 @@
1
- /**
2
- * Pipe-friendly format modifiers — output repo data as lines for shell piping.
3
- */
4
- import type { Repo } from "./types.ts";
5
-
6
- export type FormatLine = "urls" | "names" | "ssh-urls" | "clone-commands" | "ids";
7
-
8
- /** Format repos as one URL per line. */
9
- export function formatUrls(repos: Repo[]): string {
10
- return repos.map((r) => r.url).join("\n");
11
- }
12
-
13
- /** Format repos as one owner/repo per line. */
14
- export function formatNames(repos: Repo[]): string {
15
- return repos.map((r) => r.fullName).join("\n");
16
- }
17
-
18
- /** Format repos as one git@github.com URL per line. */
19
- export function formatSshUrls(repos: Repo[]): string {
20
- return repos.map((r) => `git@github.com:${r.fullName}.git`).join("\n");
21
- }
22
-
23
- /** Format repos as ready-to-paste git clone commands. */
24
- export function formatCloneCommands(repos: Repo[]): string {
25
- return repos.map((r) => `git clone git@github.com:${r.fullName}.git`).join("\n");
26
- }
27
-
28
- /** Format repos as GitHub repo IDs, one per line. */
29
- export function formatIds(repos: Repo[]): string {
30
- return repos.map((r) => String(r.id)).join("\n");
31
- }
32
-
33
- /** Format repos in the given line format. */
34
- export function formatLines(repos: Repo[], format: FormatLine): string {
35
- switch (format) {
36
- case "urls": return formatUrls(repos);
37
- case "names": return formatNames(repos);
38
- case "ssh-urls": return formatSshUrls(repos);
39
- case "clone-commands": return formatCloneCommands(repos);
40
- case "ids": return formatIds(repos);
41
- }
42
- }
43
-
44
- /** Execute a pipe target (clone, open) on repos. */
45
- export async function pipeExec(repos: Repo[], target: string): Promise<void> {
46
- for (const repo of repos) {
47
- const url = repo.url;
48
- switch (target) {
49
- case "open":
50
- await openUrl(url);
51
- break;
52
- case "clone":
53
- console.log(`git clone git@github.com:${repo.fullName}.git`);
54
- break;
55
- }
56
- }
57
- }
58
-
59
- async function openUrl(url: string): Promise<void> {
60
- const p = process.platform;
61
- const args = p === "win32" ? ["cmd", "/c", "start", "", url]
62
- : p === "darwin" ? ["open", url]
63
- : ["xdg-open", url];
64
- Bun.spawn(args);
65
- }
1
+ /**
2
+ * Pipe-friendly format modifiers — output repo data as lines for shell piping.
3
+ */
4
+ import type { Repo } from "./types";
5
+ import { openUrl } from "./open-url";
6
+
7
+ export type FormatLine = "urls" | "names" | "ssh-urls" | "clone-commands" | "ids";
8
+
9
+ export function formatUrls(repos: Repo[]): string {
10
+ return repos.map((r) => r.url).join("\n");
11
+ }
12
+
13
+ export function formatNames(repos: Repo[]): string {
14
+ return repos.map((r) => r.fullName).join("\n");
15
+ }
16
+
17
+ export function formatSshUrls(repos: Repo[]): string {
18
+ return repos.map((r) => `git@github.com:${r.fullName}.git`).join("\n");
19
+ }
20
+
21
+ export function formatCloneCommands(repos: Repo[]): string {
22
+ return repos.map((r) => `git clone git@github.com:${r.fullName}.git`).join("\n");
23
+ }
24
+
25
+ export function formatIds(repos: Repo[]): string {
26
+ return repos.map((r) => String(r.id)).join("\n");
27
+ }
28
+
29
+ export function formatLines(repos: Repo[], format: FormatLine): string {
30
+ switch (format) {
31
+ case "urls": return formatUrls(repos);
32
+ case "names": return formatNames(repos);
33
+ case "ssh-urls": return formatSshUrls(repos);
34
+ case "clone-commands": return formatCloneCommands(repos);
35
+ case "ids": return formatIds(repos);
36
+ }
37
+ }
38
+
39
+ /** Execute a pipe target (clone, open) on repos. */
40
+ export async function pipeExec(repos: Repo[], target: string): Promise<void> {
41
+ for (const repo of repos) {
42
+ const url = repo.url;
43
+ switch (target) {
44
+ case "open":
45
+ await openUrl(url);
46
+ break;
47
+ case "clone":
48
+ console.log(`git clone git@github.com:${repo.fullName}.git`);
49
+ break;
50
+ }
51
+ }
52
+ }
53
+
54
+
@@ -1,58 +1,58 @@
1
- /**
2
- * Saved searches — named queries you can re-run anytime.
3
- */
4
- import { mkdirSync, readFileSync, writeFileSync } from "fs";
5
- import { join } from "path";
6
- import type { SavedSearch, SortStrategy } from "./types.ts";
7
- import { stateDir } from "./config.ts";
8
-
9
- const SAVED_FILE = join(stateDir(), "saved-searches.json");
10
-
11
- 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
- }
18
- }
19
-
20
- function saveRaw(searches: SavedSearch[]): void {
21
- mkdirSync(stateDir(), { recursive: true });
22
- writeFileSync(SAVED_FILE, JSON.stringify(searches, null, 2));
23
- }
24
-
25
- /** Get all saved searches, sorted by last run time (most recent first). */
26
- export function getSavedSearches(): SavedSearch[] {
27
- return loadRaw().sort((a, b) => (b.lastRunAt ?? b.createdAt) - (a.lastRunAt ?? a.createdAt));
28
- }
29
-
30
- /** Save a new search. Overwrites if name already exists. */
31
- export function saveSearch(name: string, query: string, mode: "search" | "trending", sort: SortStrategy, limit: number, tab?: string): SavedSearch {
32
- const searches = loadRaw();
33
- const now = Date.now();
34
- const existing = searches.findIndex((s) => s.name === name);
35
- const entry: SavedSearch = { name, query, mode, sort, limit, tab, createdAt: now, lastRunAt: now };
36
- if (existing >= 0) {
37
- searches[existing] = { ...searches[existing], ...entry, createdAt: searches[existing].createdAt };
38
- } else {
39
- searches.push(entry);
40
- }
41
- saveRaw(searches);
42
- return entry;
43
- }
44
-
45
- /** Delete a saved search by name. */
46
- export function deleteSavedSearch(name: string): void {
47
- saveRaw(loadRaw().filter((s) => s.name !== name));
48
- }
49
-
50
- /** Update lastRunAt for a saved search. */
51
- export function touchSavedSearch(name: string): void {
52
- const searches = loadRaw();
53
- const found = searches.find((s) => s.name === name);
54
- if (found) {
55
- found.lastRunAt = Date.now();
56
- saveRaw(searches);
57
- }
58
- }
1
+ /**
2
+ * Saved searches — named queries you can re-run anytime.
3
+ */
4
+ import { mkdirSync, readFileSync, writeFileSync } from "fs";
5
+ import { join } from "path";
6
+ import type { SavedSearch, SortStrategy } from "./types";
7
+ import { stateDir } from "./config";
8
+
9
+ const SAVED_FILE = join(stateDir(), "saved-searches.json");
10
+
11
+ 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
+ }
18
+ }
19
+
20
+ function saveRaw(searches: SavedSearch[]): void {
21
+ mkdirSync(stateDir(), { recursive: true });
22
+ writeFileSync(SAVED_FILE, JSON.stringify(searches, null, 2));
23
+ }
24
+
25
+ /** Get all saved searches, sorted by last run time (most recent first). */
26
+ export function getSavedSearches(): SavedSearch[] {
27
+ return loadRaw().sort((a, b) => (b.lastRunAt ?? b.createdAt) - (a.lastRunAt ?? a.createdAt));
28
+ }
29
+
30
+ /** Save a new search. Overwrites if name already exists. */
31
+ export function saveSearch(name: string, query: string, mode: "search" | "trending", sort: SortStrategy, limit: number, tab?: string): SavedSearch {
32
+ const searches = loadRaw();
33
+ const now = Date.now();
34
+ const existing = searches.findIndex((s) => s.name === name);
35
+ const entry: SavedSearch = { name, query, mode, sort, limit, tab, createdAt: now, lastRunAt: now };
36
+ if (existing >= 0) {
37
+ searches[existing] = { ...searches[existing], ...entry, createdAt: searches[existing].createdAt };
38
+ } else {
39
+ searches.push(entry);
40
+ }
41
+ saveRaw(searches);
42
+ return entry;
43
+ }
44
+
45
+ /** Delete a saved search by name. */
46
+ export function deleteSavedSearch(name: string): void {
47
+ saveRaw(loadRaw().filter((s) => s.name !== name));
48
+ }
49
+
50
+ /** Update lastRunAt for a saved search. */
51
+ export function touchSavedSearch(name: string): void {
52
+ const searches = loadRaw();
53
+ const found = searches.find((s) => s.name === name);
54
+ if (found) {
55
+ found.lastRunAt = Date.now();
56
+ saveRaw(searches);
57
+ }
58
+ }