gh-manager-cli is an interactive CLI tool built with Ink (React for terminals) that helps users manage GitHub repositories. This project provides a terminal-based UI for browsing, searching, and managing personal GitHub repos with real-time API integration.
- ✅ Core repository listing functionality
- ✅ GitHub GraphQL API integration with Apollo Client caching
- ✅ Interactive terminal UI with Ink
- ✅ OAuth and PAT authentication with secure storage
- ✅ Background fetch-all (whole account cached after first page)
- ✅ Repository management (delete, archive, visibility change)
- ✅ GitHub Enterprise support with Internal visibility
- ✅ Organization switching and context management
- ✅ Fork synchronization with upstream
- ✅ Semantic release automation and CI/CD workflows
- ✅ Automated changelog generation and PR title management
- ✅ Bulk Select mode with bulk star, archive/unarchive, visibility, delete, and transfer operations
- ✅
RepoRowmemoized withReact.memo+arePropsEqual(SWR-358) — only 2 rows re-render per cursor move - 🔧 Automated test suite expansion (ongoing)
- 🔧 Cross-terminal rendering optimization
For current version and recent changes, see CHANGELOG.md
gh-manager-cli/
├── src/
│ ├── index.tsx # CLI entry, error boundaries, renders App
│ ├── ui/
│ │ ├── App.tsx # Token bootstrap and routing
│ │ └── RepoList.tsx # Repository list UI, key handling, infinite scroll
│ ├── github.ts # Octokit GraphQL client and queries
│ ├── config.ts # Read/write config and token management
│ └── types.ts # TypeScript type definitions
├── dist/ # Built output (gitignored)
├── package.json # NPM package config with semantic-release
├── tsconfig.json # TypeScript configuration
├── tsup.config.ts # Build configuration (shebang-preserved CJS)
├── CHANGELOG.md # Generated changelog (semantic-release)
├── README.md # User documentation
├── LICENSE # MIT License
├── AGENTS.md # This file - project memory/instructions
├── .gitignore # Git ignore patterns
└── .github/
├── workflows/
│ ├── automated-release.yml # Semantic release on main push
│ └── pr-title-manager.yml # PR title automation
└── scripts/
└── normalize-pr-title.js # PR title normalization logic
- Language: TypeScript with React/Ink
- Dependencies:
@octokit/graphqlfor GitHub APIink(React-based TUI)chalkfor terminal colorsink-spinnerfor loading statesink-text-inputfor user inputenv-pathsfor cross-platform config storage
- Build: tsup with esbuild
- OAuth and PAT authentication: prompt → validate → persist (0600 perms on POSIX)
- List personal and organization repos with metadata (name, description, stars, forks, etc.)
- Full keyboard navigation with extensive shortcuts
- Background fetch-all: whole account loaded into the persisted cache after the first page
- Fuzzy search (local, over full cached set) with fuse.js — instant, no network calls in search path
- Repository actions: delete, archive/unarchive, change visibility, sync forks
- Organization and Enterprise GitHub support
- Modal-based UI for sorting, filtering, and actions
- Persistent UI preferences (sort, density, visibility filter, fork tracking, colour theme)
- Real-time rate limit monitoring for GraphQL and REST APIs
See the living roadmap in TODOs.md for the canonical, up-to-date list. Key near-term items include:
- Repository renaming
- Copy repository URL to clipboard
- Optional OS keychain support (via
keytar)
- Reads token from
process.env.GITHUB_TOKENorprocess.env.GH_TOKENfirst. - Fallback to config file: created on first successful validation.
- Config path via
env-paths('gh-manager-cli').config:- macOS:
~/Library/Preferences/gh-manager-cli/config.json - Linux:
~/.config/gh-manager-cli/config.json - Windows:
%APPDATA%\gh-manager-cli\config.json
- macOS:
- Permissions:
- POSIX:
chmod 600after writing file.
- POSIX:
- Shape:
{ "token": "<pat>", "tokenVersion": 1 } - PAT scopes:
- For listing all personal repos including private: classic PAT with
reposcope (read is sufficient). - If only public repos are needed, a token with public-repo read may suffice, but
repois recommended.
- For listing all personal repos including private: classic PAT with
- GraphQL query against
viewer.repositorieswithownerAffiliations: OWNERandorderBy: UPDATED_AT DESC. - Page size: 100 per request (default; configurable 1-100 via
REPOS_PER_FETCH). - Single pagination model — background fetch-all: the first page renders immediately, then a background loop fetches every remaining page until
hasNextPageis false, appending into the persisted cache. There is no scroll-position prefetch trigger for the owned/starred lists; the load is continuous and driven by the effect re-running as the list grows. - Because the full set is cached, sorting is client-side (
filteredAndSorted) with no server refetch on sort change; archive/visibility (private) filtering is also client-side. - On each page fetch, also read
totalCountto reflect newly created repos and to show background-load progress (loaded/total). - Selected fields: name/nameWithOwner/description/visibility/isPrivate/isFork/isArchived/stargazerCount/forkCount/primaryLanguage/updatedAt/pushedAt/diskUsage, plus
parent { nameWithOwner }anddefaultBranchRef { name }. - Light bulk query (SWR-360): the list/search queries intentionally do NOT fetch per-repo commit history (
history.totalCount). Computing that for each repo and its parent across 100 repos/page exceeds GitHub's per-query budget and returns HTTP 502. Forkparent { nameWithOwner }is still fetched so the "Fork of X" label always shows. - Fork ahead/behind enrichment (SWR-362): after the background fetch-all completes, a separate effect (
useEffectgated on!loading && !loadingMore && !hasNextPage) enriches forks-only with commit counts. It usesenrichForksWithAheadBehindwhich builds a batched aliased GraphQL query (fork_N: node(id:)+parent_N: repository(owner,name)) capped at 5 forks per request (10 history queries). Results are merged directly intoitemsstate. A 200ms delay between batches throttles rate-limit consumption. Already-enriched IDs are tracked inenrichmentDoneRefto avoid re-fetching. Both(N ahead)and(N behind)are displayed inRepoRowand the sync confirmation modal. - Open PR/Issue counts (SWR-357): every list/search/starred query fetches
openPullRequests: pullRequests(states: OPEN) { totalCount }andopenIssues: issues(states: OPEN) { totalCount }inline, always on (no toggle, no enrichment pass).totalCount-only connections add ~0 node cost under GitHub's GraphQL cost formula, so a 100-repo page stays at cost ~1 — unlikehistory.totalCount(SWR-360), these indexed counts are cheap enough to fold into the main page fetches. Responses are normalised vianormalizeRepoNodewhich flattens{ totalCount: N }→RepoNode.openPullRequests/openIssues(plain numbers) so renderers and threshold colouring see a flat shape. Rendered inline on everyRepoRow(line 2) and behind theLkeybinding's chooser modal.
- Up/Down: move selection
- PageUp/PageDown: jump ±10
Ctrl+G: jump to topG: jump to bottom/: fuzzy search mode (instant, typo-tolerant, no minimum length; searches name/owner/description/language over the full cached set; Esc cancels)S: sort modal (updated, pushed, name, stars)D: toggle sort directionT: toggle display density (compact/cozy/comfy)Shift+T: cycle colour theme (Default → Ocean → Forest → Monochrome); persists across restartsF: toggle fork commit tracking (ahead/behind enrichment — unrelated to the fork view filter)V: View Filters modal — a single grouped modal with three sections: Visibility (All / Public / Private[/Internal for enterprise]), Archive (All / Unarchived / Archived), and Fork (All / Forks only / Non-forks only). Move between groups with ↑↓, change a group's value live with ←→ (radio-style), thenEnter(orY/Apply) to apply and close,Esc/Cto cancel. Any combination can be set in one session; selections persist across restarts but reset to All on organisation or scope (own ↔ starred) switch. The Visibility group is hidden in stars mode; Archive and Fork remain available there. Replaces the old separateVvisibility andAarchive modals —Ais no longer bound to a filter.W: organisation switcher- Enter or
O: open selected repo in browser; for forks shows a chooser (This repository / Parent/upstream, Esc cancels) L: open the selected repo's PRs or Issues list — chooser modal (Pull Requests / Issues, Esc/C cancels). Counts are rendered inline on every row from the same fields (SWR-357)P: on a fork — jump cursor to the parent repo if it is already loaded; otherwise fetches the parent repo and shows it in the Info modalI: repository info modalK: cache inspectionCtrl+N: create a new repository in the current context (prompts for name with the personal/organisation slug shown in front;Tabcycles visibility; GitHub errors surfaced inline). Disabled in starred mode.Shift+M: transfer (move) selected repo to another owner. Opens a destination picker (personal account + organisations the token can see) with a manual-entry fallback for owners the token can't list, then requires typing a randomly generated verification code — like delete — followed by a final confirmation step; GitHub errors surfaced inline; transferred repo is removed from the list. Disabled in starred mode.DelorBackspace: delete selected repo (two-stage confirmation)Ctrl+A: archive/unarchive selected repoCtrl+V: change repository visibilityCtrl+F: sync fork with upstream (shows ahead/behind counts)Ctrl+S: star/unstar selected repoCtrl+L: logout (returns to Authentication Required)Shift+S: toggle between own repos and starred repos (personal context only)- Footer hint shows
Shift+S Starredin normal mode andShift+S My Reposin starred mode; hidden in org context
- Footer hint shows
R: refresh list (purges cache)Q: quit (Esc cancels an open modal or exits search mode; does not quit)
B: enter/exit Bulk Select mode (exits and clears selection)Esc: exit bulk select mode (clears selection)
Within bulk select mode (every other shortcut is disabled; only navigation + the keys below work):
Space: toggle selection on the cursor rowX: unselect all (clears selection, stays in bulk select mode)Ctrl+S: bulk star/unstar the selected reposCtrl+A: bulk archive/unarchive the selected reposCtrl+V: bulk visibility update for the selected reposShift+M: bulk transfer (move) the selected repos to another owner/orgDel/Backspace: bulk delete the selected repos- Navigation (arrows, PageUp/Down,
Ctrl+G,G) still works
Bulk actions reuse the same global shortcuts as single-repo mode and require at least one selected repo. There is no separate action-picker modal.
Bulk operation flow:
- Intent/target (only when needed, before review):
- Star and archive are toggles. If all selected repos share the same state, the opposite state is applied directly. If the selection is mixed, an intent modal asks the explicit target (e.g. "Archive all" vs "Unarchive all", "Star all" vs "Unstar all").
- Visibility always shows a target picker (Public / Private / Internal — Internal only for enterprise orgs).
- Review list (Confirmation 1) — scrollable list of all selected repos;
Spaceto unselect; Tab/Enter to proceed. Dismisses on Esc/Cancel or when the list empties. - Destination owner (Transfer only) — after review, opens a destination picker (personal account + organisations the token can see) with a manual-entry fallback; the chosen destination must differ from the current owner.
- Count prompt (Confirmation 2) — "About to {action} {N} repos" (transfer also shows "to {owner}"); Cancel/Proceed, Esc cancels.
- Delete and Transfer only — a separate verification-code modal (type a 4-character code).
- Sequential execution with per-repo progress; partial-failure reporting at the end.
- Selection cleared and bulk select mode exits on completion. Transferred repos are removed from the list.
Persistence: Selections survive search and filter/sort changes (stored as full node objects by id). Cleared on org/scope switch and stars mode toggle.
- Left/Right: move focus between buttons (e.g., Delete, Cancel)
- Enter: run the currently focused button’s action
Y: confirm (applies to any confirmation action)C: cancel (preferred);Escalso cancels
Prereqs:
- Node.js >= 18
- pnpm
Install deps and build:
pnpm install
pnpm buildRun the CLI:
node dist/index.js
# or add to PATH (dev):
pnpm link # then run: gh-manager-cliFirst run prompts for a PAT if not provided via env vars. The token is validated by a quick viewer { login } request; on success it’s stored in the config file with restricted permissions.
- Invalid token:
- Re-run and enter a valid PAT (recommended scope:
repo).
- Re-run and enter a valid PAT (recommended scope:
- Rate-limited:
- Wait or reduce page size; future enhancement will show rate-limit details.
- Network errors:
- Check connectivity and retry with
r.
- Check connectivity and retry with
- The PAT is stored in plaintext in the user config directory with 0600 perms (POSIX). Consider revoking tokens when no longer needed.
- A future enhancement may integrate
keytarto use the OS keychain for secrets.
pnpm build— build todist/pnpm dev— watch modepnpm start— runnode dist/index.js
package.jsondefinesbin: { "gh-manager-cli": "dist/index.js" }.- For local dev:
pnpm linkexposesgh-manager-clion PATH. - For publish:
npm publish(after setting version and adding README).
- Format: Semantic versioning (MAJOR.MINOR.PATCH)
- Automation: semantic-release handles version bumping and git tags
- Release process: Automated via GitHub Actions on main branch push
- Change tracking: All releases documented in CHANGELOG.md
- Do NOT manually edit CHANGELOG.md: it is generated automatically by the semantic-release GitHub Actions workflow (
.github/workflows/automated-release.yml) from conventional commit messages onmain. Manual edits cause merge conflicts and are overwritten on the next release. To influence the changelog, write a well-formed conventional commit / PR title instead. When a feature branch conflicts withmainon CHANGELOG.md, resolve by takingmain's version.
- TypeScript: Strict mode with comprehensive type definitions
- React/Ink: Functional components with hooks
- Terminal colours: Use chalk for pre-colouring to avoid nested Text issues
- Error handling: Try-catch blocks for API calls and network operations
- Language: British English for all user-facing text (e.g., organisation, authorisation, colour)
- Terminal Testing: Test in multiple terminals (iTerm2, Terminal.app, Termius)
- API Testing: Mock data for offline development
- UI Testing: Various window sizes and content lengths
- Build Testing: Ensure production build works correctly
- Runner: Vitest +
ink-testing-library. Run withpnpm test(CI) orpnpm test:watch(dev). Tests live intests/, mirroringsrc/(e.g. modal tests intests/ui/). - Every new feature ships with new test cases. Adding or changing a feature without adding/updating tests is incomplete work — the same PR that introduces behaviour must cover it. At minimum, a new component/modal needs: a render test, a happy-path action test, and a guard/edge-case test. A bug fix needs a test that fails before the fix and passes after.
- Every async action modal MUST ignore input while the request is in flight, and MUST have a test that proves it. For modals that own their loading state, guard the
useInputhandler with a synchronous ref, not the state flag: keep aconst xingRef = useRef(false), flipxingRef.current = trueat the very top of the confirm handler (beforesetXing(true)), reset it in thecatch, and gate input withif (xingRef.current) return;. A ref updates immediately and is read live through the input closure, so a key arriving in the same tick as submit — before React re-renders — is still ignored; a state-based guard races that window. ThesetXingstate still drives the spinner UI. Modals whose loading flag is a parent prop (Star/Unstar/ChangeVisibility) instead gate on the prop (if (isXing) return;) and on anyTextInputonSubmit. Then assert that keypresses during the in-flight state call neitheronCancelnor the action again (seetests/ui/ArchiveModal.test.tsx→ignores input while archiving is in progress, and the matching tests for Delete/Transfer/Rename/Sync/Create/Star/Unstar/ChangeVisibility). The ref-guard idea came from PR #65. - Driving
useInputin tests: mockink'suseInput(vi.fn()) and capture the callback. With the synchronous ref guard above, the in-flight assertion can fire the ignored keys in the same tick as the trigger (no flush) — that is the point of the ref. You still needawait new Promise(r => setTimeout(r, 0))to flush a re-render whenever a later step depends on refreshed state or a refreshed closure (e.g. aTextInputonChangethat must be visible to the next Enter, or a mount effect). Never capture the callback by value into a helper; read the latest via a getter. - Driving
ink-text-inputin tests: the real component enables raw mode and throws under the test stdin, so stub it. To simulate typing, capture its props viavi.hoistedand callonChange/onSubmitdirectly. For modals that generate a random verification code (Delete, Transfer),vi.spyOn(Math, 'random').mockReturnValue(0)makes the code deterministicallyAAAA, and flush once afterrender()so the mount effect generates it. - British English in any user-facing strings asserted by tests, matching the app.
- main: Production-ready code only
- Feature branches: For new features and major changes
Every change should trace back to a Linear issue. To guarantee GitHub ↔ Linear auto-linking:
- Branch name MUST include the Linear issue ID, e.g.
feature/swr-360-background-fetch-all(<type>/swr-<id>-<slug>). - PR title MUST include the Linear issue ID, e.g.
feat: background fetch-all pagination with light bulk query (SWR-360). - If no Linear issue exists for the work yet, create one first, then name the branch and PR with its ID.
- This is what links the PR back to the Linear issue and surfaces progress there — do not open a PR without the ID in both places.
REQUIRED: All commits MUST follow the Conventional Commits specification:
<type>[optional scope]: <description>
[optional body]
[optional footer(s)]
Types: feat, fix, docs, style, refactor, perf, test, build, ci, chore, revert
Examples:
feat: add repository filtering by languagefix: resolve spacing issues in terminal renderingdocs: update installation instructionschore: update dependenciesrefactor: simplify RepoList component logic
Important: Every commit message MUST use semantic format to ensure proper versioning and changelog generation.
- PR Creation: Titles automatically formatted to conventional commits
- Main Branch Push: Triggers semantic-release workflow
- Version Calculation: Based on commit types (feat = minor, fix = patch)
- CHANGELOG.md: Generated automatically from commits
- GitHub Release: Created with release notes
- NPM Publishing: If configured with NPM_TOKEN
- Issue: Spacing and line rendering varies between SSH (Termius) and native macOS Terminal
- Cause: Different ANSI escape sequence handling and Yoga layout engine interpretations
- Current Solution: Using chalk to pre-color strings before passing to single Text component
- Ongoing: Testing various spacing approaches (Box with minHeight, empty components)
// Pre-color strings to avoid nested Text rendering issues
const coloredName = chalk.bold.cyan(repo.name);
const coloredDescription = chalk.gray(repo.description || 'No description');
const fullText = `${coloredName}\n${coloredDescription}\n${metadataLine}`;
// Use Box with minHeight for consistent spacing
<Box minHeight={2}>{/* Empty spacer */}</Box>RepoRow is wrapped in React.memo with a custom arePropsEqual that compares
repo (by reference), selected, dim, forkTracking, starsMode,
multiSelectMode, isChecked, spacingLines, maxWidth, index, and theme.
On each cursor move only the previously-selected and newly-selected rows
re-render; all others are skipped. In Bulk Select mode, toggling a row's
selection re-renders only that row (its isChecked flips).
Chalk formatting is also wrapped in useMemo keyed on the same inputs so the
string-building work is only repeated when a relevant prop actually changes.
Keep arePropsEqual AND the useMemo dependency array in sync whenever you
add a new prop to RepoRow that affects rendering — otherwise memoization will
serve a stale row. Both currently include the Bulk Select props
(multiSelectMode, isChecked) added in SWR-353.
- Create feature branch
- Update relevant components in
src/ - Add/extend automated tests for the new behaviour (
tests/) — required, not optional (see Automated Tests above) - Test across different terminals
- Update TypeScript types if needed
- Run
pnpm testand ensure it passes - Commit with conventional message
- Create PR (title will be auto-formatted)
- The single source of truth for work items is TODOs.md.
- Update TODOs when starting or completing work (use checkboxes).
- Keep README’s “Todo & Roadmap” section brief and point back to TODOs.md.
- Identify issue and create test case
- Fix in relevant source file
- Test fix across multiple terminals
- Commit and push (version bumping and releases are automated)
pnpm update # Update all to latest compatible
pnpm add package@latest # Update specific package
pnpm build # Ensure build still works- Terminal Compatibility: Rendering differences between terminal emulators
- Windows Support: Untested, may have path/color issues
- Large Repositories: Performance with 1000+ repos needs optimization
- Offline Mode: No caching, requires internet connection
- Repository Actions: Clone, create, delete repos from CLI
- Issue Management: View and create issues
- PR Management: List and review pull requests
- Caching: Offline support with local data cache
- Themes: Customizable color schemes
- Config Profiles: Multiple GitHub account support
When working on this project:
- Always test changes in multiple terminals before considering complete
- Always add automated tests for new features/fixes - required in the same change; never ship behaviour without
tests/coverage (see Automated Tests) - Use chalk for colors instead of Ink's color props to avoid nesting issues
- Follow TypeScript strictly - no any types without justification
- ALWAYS use semantic commit messages - This is REQUIRED for every commit
- Update this file when adding major features or changing architecture
- Consider terminal constraints - not all ANSI features work everywhere
- Keep it fast - terminal UIs should feel instant
Every single commit MUST follow semantic format:
feat:for new featuresfix:for bug fixesdocs:for documentation onlystyle:for formatting, missing semicolons, etc.refactor:for code changes that neither fix bugs nor add featuresperf:for performance improvementstest:for adding missing testsbuild:for changes to build system or dependenciesci:for CI configuration changeschore:for other changes that don't modify src or test filesrevert:for reverting previous commits
📋 For version history and release notes, see CHANGELOG.md
This file contains project architecture and development guidelines. Dynamic information like versions and changes are tracked automatically in CHANGELOG.md.