Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/skills/documentation.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ Custom components live in `docs/.vitepress/theme/`:
1. Locate the `.vue` file above.
2. Follow the existing scoped `<style>` conventions (no global selectors inside `<style scoped>`).
3. Add responsive styles inside the component's `<style>` or in `custom.css` if the rule is global.
4. Never import additional NPM packages for styling — only `picocolors` (CLI) and VitePress built-ins.
4. Never import additional NPM packages for styling — only `src/style.ts` (CLI, wraps `node:util`'s `styleText`) and VitePress built-ins.

---

Expand Down
6 changes: 5 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,9 @@ src/
output.ts # Text (markdown) and JSON output formatters
upgrade.ts # Auto-upgrade logic (fetch latest GitHub release, replace binary)
# + refreshCompletions() — overwrites existing completion file
style.ts # Sole call site for node:util's styleText — dim/bold/italic/
# underline/colors + style(names[], text) composer for combined
# styles — no I/O

render/
terminal.ts # Bun 1.4+ native API wrappers (stringWidth, stripANSI, sliceAnsi)
Expand Down Expand Up @@ -121,6 +124,7 @@ src/
- **Side effects are isolated.** API calls (`api.ts`, `api-utils.ts`), TTY interaction (`tui.ts`) and CLI parsing (`github-code-search.ts`) are the only side-effectful surfaces. `api-utils.ts` hosts shared retry/pagination helpers that perform network I/O and must not be used outside `api.ts`. `cache.ts` hosts disk-cache helpers that perform filesystem I/O and must not be used outside `api.ts`.
- **`render.ts` is a façade.** It re-exports everything from `render/` and adds two top-level rendering functions. Consumers import from `render.ts`, not directly from sub-modules. Exceptions: `render/team-pick.ts`, `render/mouse.ts` and `render/mouse-hit.ts` are pure modules imported **directly** by their sole consumer (`render.ts` for `team-pick.ts`, `tui.ts` for the mouse modules) and are not re-exported publicly (knip would flag unused re-exports otherwise).
- **`render/terminal.ts` is the sole Bun API call site.** All calls to `Bun.stringWidth()`, `Bun.stripANSI()`, and `Bun.sliceAnsi()` must go through the `terminal.ts` wrapper functions (`visibleWidth()`, `stripAnsi()`, `clipToWidth()`, `hasAnsi()`). This centralizes terminal handling logic and makes it easy to verify correct Unicode handling (graphemes, emoji, CJK, ZWJ sequences).
- **`src/style.ts` is the sole call site for `node:util`'s `styleText`.** All ANSI styling across the codebase (`render.ts`, `render/highlight.ts`, `render/summary.ts`, `render/team-pick.ts`, `tui.ts`, `upgrade.ts`, `api.ts`, `github-code-search.ts`) goes through this facade instead of importing `styleText` directly or reintroducing a styling npm package. Multi-style compositions (e.g. bold+yellow) must use the `style(names[], text)` array form, not chained/nested calls — `bold` and `dim` share the same SGR reset code, so naive chaining can produce incorrect output when there is trailing content after a nested style.
- **`types.ts` is the single source of truth** for all shared interfaces. Any new shared type must go there.
- **No classes** — the codebase uses plain TypeScript interfaces and functions throughout.

Expand Down Expand Up @@ -266,7 +270,7 @@ For minor/major releases update `docs/blog/index.md` to add a row in the version
- After a successful upgrade, `refreshCompletions()` (in `src/upgrade.ts`) silently overwrites the existing completion file if one is already present. It never creates a file from scratch — installation is the user's responsibility (via `install.sh` or the `completions` subcommand).
- The `completions` subcommand (in `github-code-search.ts`) prints the completion script for the detected (or specified) shell to stdout. It is a thin wrapper around `generateCompletion()` in `src/completions.ts`.
- Shell-integration tests for `install.sh` live in `install.test.bats` and require `bats-core`. Run them with `bun run test:bats`. The CI runs them in a dedicated `test-bats` job using `bats-core/bats-action`.
- `picocolors` is the only styling dependency; do not add `chalk` or similar.
- `src/style.ts` is the sole call site for `node:util`'s `styleText` and the only styling dependency-free abstraction in the codebase; do not import `styleText` directly elsewhere, and do not add `chalk`/`picocolors`/or similar external styling packages.
- Keep `knip` clean: every exported symbol must be used; every import must resolve.
- The `--pick-team` option is repeatable (Commander collect function); each assignment resolves one combined section label to a single team. A warning is emitted on stderr when a label is not found.
- `src/render/team-pick.ts` is a pure module (no I/O) and must be consumed only via the `src/render.ts` façade — it is imported **directly** inside `render.ts` for internal use but is not re-exported publicly (knip would flag it).
Expand Down
1 change: 0 additions & 1 deletion bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

47 changes: 24 additions & 23 deletions github-code-search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
import { Command, program } from "commander";
import { writeFileSync } from "node:fs";
import { resolve } from "node:path";
import pc from "picocolors";
import * as style from "./src/style.ts";
import { aggregate, normaliseExtractRef, normaliseRepo } from "./src/aggregate.ts";
import { fetchAllResults, fetchRepoTeams } from "./src/api.ts";
import { formatRetryWait } from "./src/api-utils.ts";
Expand Down Expand Up @@ -62,27 +62,28 @@ function colorDesc(s: string): string {
.split("\n")
.map((line) => {
const docsMatch = line.match(/^(\s*Docs:\s*)(https?:\/\/\S+)$/);
if (docsMatch) return pc.dim(docsMatch[1]) + pc.cyan(pc.underline(docsMatch[2]));
if (docsMatch)
return style.dim(docsMatch[1]) + style.style(["cyan", "underline"], docsMatch[2]);
const exampleMatch = line.match(/^(\s*Example:\s*)(.+)$/);
if (exampleMatch) return pc.dim(exampleMatch[1]) + pc.italic(exampleMatch[2]);
if (/^\s+(e\.g\.|repoA|myorg\/|squad-|chapter-)/.test(line)) return pc.dim(line);
if (exampleMatch) return style.dim(exampleMatch[1]) + style.italic(exampleMatch[2]);
if (/^\s+(e\.g\.|repoA|myorg\/|squad-|chapter-)/.test(line)) return style.dim(line);
// Colorize any remaining bare URL (http/https) anywhere in the line
return line.replace(/(https?:\/\/\S+)/g, (url) => pc.cyan(pc.underline(url)));
return line.replace(/(https?:\/\/\S+)/g, (url) => style.style(["cyan", "underline"], url));
})
.join("\n");
}

/** Colored hyperlink (cyan + underline), falls back to plain when not a TTY. */
function helpLink(url: string): string {
return HAS_COLOR ? pc.cyan(pc.underline(url)) : url;
return HAS_COLOR ? style.style(["cyan", "underline"], url) : url;
}

/**
* Builds the `addHelpText("after", ...)` footer block with a labelled link.
* The label is bold when color is supported.
*/
function helpSection(label: string, url: string): string {
const t = HAS_COLOR ? pc.bold(label) : label;
const t = HAS_COLOR ? style.bold(label) : label;
return `\n${t}\n ${helpLink(url)}`;
}

Expand All @@ -92,21 +93,21 @@ function helpSection(label: string, url: string): string {
*/
const helpFormatConfig = {
// Section headings: "Usage:", "Options:", "Commands:" …
styleTitle: (s: string) => (HAS_COLOR ? pc.bold(pc.yellow(s)) : s),
styleTitle: (s: string) => (HAS_COLOR ? style.style(["bold", "yellow"], s) : s),
// Command name in the usage line
styleCommandText: (s: string) => (HAS_COLOR ? pc.bold(s) : s),
styleCommandText: (s: string) => (HAS_COLOR ? style.bold(s) : s),
// Subcommand names in the command listing
styleSubcommandText: (s: string) => (HAS_COLOR ? pc.cyan(s) : s),
styleSubcommandText: (s: string) => (HAS_COLOR ? style.cyan(s) : s),
// Argument placeholders (<query>)
styleArgumentText: (s: string) => (HAS_COLOR ? pc.yellow(s) : s),
styleArgumentText: (s: string) => (HAS_COLOR ? style.yellow(s) : s),
// Option flags in the usage line (--org, --format …)
styleOptionText: (s: string) => (HAS_COLOR ? pc.green(s) : s),
styleOptionText: (s: string) => (HAS_COLOR ? style.green(s) : s),
// Option terms in the options table
styleOptionTerm: (s: string) => (HAS_COLOR ? pc.green(s) : s),
styleOptionTerm: (s: string) => (HAS_COLOR ? style.green(s) : s),
// Subcommand terms in the commands table
styleSubcommandTerm: (s: string) => (HAS_COLOR ? pc.cyan(s) : s),
styleSubcommandTerm: (s: string) => (HAS_COLOR ? style.cyan(s) : s),
// Argument terms in the arguments table
styleArgumentTerm: (s: string) => (HAS_COLOR ? pc.yellow(s) : s),
styleArgumentTerm: (s: string) => (HAS_COLOR ? style.yellow(s) : s),
// Descriptions — color "Docs:", "Example:" and code-example lines
styleOptionDescription: colorDesc,
styleSubcommandDescription: colorDesc,
Expand Down Expand Up @@ -231,14 +232,14 @@ async function searchAction(
// ─── GitHub API token ───────────────────────────────────────────────────────
const GITHUB_TOKEN = process.env.GITHUB_TOKEN;
if (!GITHUB_TOKEN) {
console.error(pc.red("Error: GITHUB_TOKEN environment variable is not set."));
console.error(style.red("Error: GITHUB_TOKEN environment variable is not set."));
process.exit(1);
}

// Fail fast on unbalanced quotes rather than surfacing a raw GitHub 422 — see issue #149
const quoteError = validateQuoteBalance(query);
if (quoteError) {
console.error(pc.red(`Error: ${quoteError}`));
console.error(style.red(`Error: ${quoteError}`));
process.exit(1);
}

Expand Down Expand Up @@ -290,12 +291,12 @@ async function searchAction(
const remaining = cooldownUntil - Date.now();
if (remaining <= 0) break;
process.stderr.write(
`\r ${pc.yellow("Rate limited")} — resuming in ${formatRetryWait(remaining)}\u2026${" ".repeat(10)}`,
`\r ${style.yellow("Rate limited")} — resuming in ${formatRetryWait(remaining)}\u2026${" ".repeat(10)}`,
);
await new Promise((r) => setTimeout(r, 1_000));
}
// Leave cursor at line start; the next \r progress update will overwrite cleanly
process.stderr.write(`\r ${pc.dim("Rate limited")} — resuming\u2026${" ".repeat(40)}`);
process.stderr.write(`\r ${style.dim("Rate limited")} — resuming\u2026${" ".repeat(40)}`);
})().finally(() => {
activeCooldown = null;
cooldownUntil = 0;
Expand All @@ -311,18 +312,18 @@ async function searchAction(
if (rf === null) {
// Compile error — always fatal, even if --regex-hint is provided,
// because no local regex filter can be applied.
console.error(pc.yellow(`⚠ Regex mode — ${warn}`));
console.error(style.yellow(`⚠ Regex mode — ${warn}`));
process.exit(1);
}
if (warn && !opts.regexHint) {
// warn already contains the --regex-hint guidance; print it as-is.
console.error(pc.yellow(`⚠ Regex mode — ${warn}`));
console.error(style.yellow(`⚠ Regex mode — ${warn}`));
process.exit(1);
}
effectiveQuery = opts.regexHint ?? apiQuery;
regexFilter = rf ?? undefined;
process.stderr.write(
pc.dim(` ℹ Regex mode — GitHub query: "${effectiveQuery}", local filter: ${query}\n`),
style.dim(` ℹ Regex mode — GitHub query: "${effectiveQuery}", local filter: ${query}\n`),
);
}

Expand Down Expand Up @@ -494,7 +495,7 @@ async function searchAction(
const bottomBar = "─".repeat(totalWidth - 2);
const pad = (s: string) => s + " ".repeat(Math.max(0, w - s.length));
process.stderr.write(
pc.yellow(
style.yellow(
[
`${headerPrefix}${headerLabel}${headerDashes}╮`,
`│ ${pad(`github-code-search ${VERSION} → ${latestTag}`)} │`,
Expand Down
3 changes: 1 addition & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -52,8 +52,7 @@
"docs:test:responsive": "playwright test"
},
"dependencies": {
"commander": "^15.0.0",
"picocolors": "^1.1.1"
"commander": "^15.0.0"
},
"devDependencies": {
"@lhci/cli": "^0.15.1",
Expand Down
8 changes: 4 additions & 4 deletions src/api.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import pc from "picocolors";
import * as style from "./style.ts";
import type { CodeMatch } from "./types.ts";
import { concurrentMap, fetchWithRetry, formatRetryWait, paginatedFetch } from "./api-utils.ts";
import { getCacheKey, readCache, writeCache } from "./cache.ts";
Expand Down Expand Up @@ -214,7 +214,7 @@ export async function fetchAllResults(
keepFileContent = false,
): Promise<CodeMatch[]> {
// Write the initial progress line (no newline — will be overwritten by \r).
process.stderr.write(pc.dim(" Fetching results from GitHub…"));
process.stderr.write(style.dim(" Fetching results from GitHub…"));
let totalPages = 0;
// GitHub code search is capped at 1000 results; paginatedFetch stops naturally
// when a page returns fewer than 100 items. When total_count is an exact
Expand Down Expand Up @@ -339,7 +339,7 @@ export async function fetchRepoTeams(
if (useCache) {
const cached = readCache<[string, string[]][]>(cacheKey);
if (cached !== null) {
process.stderr.write(pc.dim("Using cached team data (— use --no-cache to refresh)\n"));
process.stderr.write(style.dim("Using cached team data (— use --no-cache to refresh)\n"));
return new Map(cached);
}
}
Expand Down Expand Up @@ -375,7 +375,7 @@ export async function fetchRepoTeams(
}

process.stderr.write(
pc.dim(
style.dim(
`Fetching repos for ${matchingTeamSlugs.length} team${matchingTeamSlugs.length !== 1 ? "s" : ""} matching prefix${prefixes.length !== 1 ? "es" : ""} [${prefixes.join(", ")}]…\n`,
),
);
Expand Down
Loading
Loading