Skip to content

Commit 15b0ee8

Browse files
feat(doctor): add GitHub CLI check (#384)
Add doctor check for GitHub CLI (gh) that verifies: - Binary installation status - Authentication status with GitHub - Account details and token scopes when authenticated Closes #374 Co-authored-by: sisyphus-dev-ai <[email protected]>
1 parent 2cab836 commit 15b0ee8

File tree

4 files changed

+282
-0
lines changed

4 files changed

+282
-0
lines changed

src/cli/doctor/checks/gh.test.ts

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
import { describe, it, expect, spyOn, afterEach } from "bun:test"
2+
import * as gh from "./gh"
3+
4+
describe("gh cli check", () => {
5+
describe("getGhCliInfo", () => {
6+
it("returns gh cli info structure", async () => {
7+
// #given
8+
// #when checking gh cli info
9+
const info = await gh.getGhCliInfo()
10+
11+
// #then should return valid info structure
12+
expect(typeof info.installed).toBe("boolean")
13+
expect(info.authenticated === true || info.authenticated === false).toBe(true)
14+
expect(Array.isArray(info.scopes)).toBe(true)
15+
})
16+
})
17+
18+
describe("checkGhCli", () => {
19+
let getInfoSpy: ReturnType<typeof spyOn>
20+
21+
afterEach(() => {
22+
getInfoSpy?.mockRestore()
23+
})
24+
25+
it("returns warn when gh is not installed", async () => {
26+
// #given gh not installed
27+
getInfoSpy = spyOn(gh, "getGhCliInfo").mockResolvedValue({
28+
installed: false,
29+
version: null,
30+
path: null,
31+
authenticated: false,
32+
username: null,
33+
scopes: [],
34+
error: null,
35+
})
36+
37+
// #when checking
38+
const result = await gh.checkGhCli()
39+
40+
// #then should warn (optional)
41+
expect(result.status).toBe("warn")
42+
expect(result.message).toContain("Not installed")
43+
expect(result.details).toContain("Install: https://cli.github.com/")
44+
})
45+
46+
it("returns warn when gh is installed but not authenticated", async () => {
47+
// #given gh installed but not authenticated
48+
getInfoSpy = spyOn(gh, "getGhCliInfo").mockResolvedValue({
49+
installed: true,
50+
version: "2.40.0",
51+
path: "/usr/local/bin/gh",
52+
authenticated: false,
53+
username: null,
54+
scopes: [],
55+
error: "not logged in",
56+
})
57+
58+
// #when checking
59+
const result = await gh.checkGhCli()
60+
61+
// #then should warn about auth
62+
expect(result.status).toBe("warn")
63+
expect(result.message).toContain("2.40.0")
64+
expect(result.message).toContain("not authenticated")
65+
expect(result.details).toContain("Authenticate: gh auth login")
66+
})
67+
68+
it("returns pass when gh is installed and authenticated", async () => {
69+
// #given gh installed and authenticated
70+
getInfoSpy = spyOn(gh, "getGhCliInfo").mockResolvedValue({
71+
installed: true,
72+
version: "2.40.0",
73+
path: "/usr/local/bin/gh",
74+
authenticated: true,
75+
username: "octocat",
76+
scopes: ["repo", "read:org"],
77+
error: null,
78+
})
79+
80+
// #when checking
81+
const result = await gh.checkGhCli()
82+
83+
// #then should pass
84+
expect(result.status).toBe("pass")
85+
expect(result.message).toContain("2.40.0")
86+
expect(result.message).toContain("octocat")
87+
expect(result.details).toContain("Account: octocat")
88+
expect(result.details).toContain("Scopes: repo, read:org")
89+
})
90+
})
91+
92+
describe("getGhCliCheckDefinition", () => {
93+
it("returns correct check definition", () => {
94+
// #given
95+
// #when getting definition
96+
const def = gh.getGhCliCheckDefinition()
97+
98+
// #then should have correct properties
99+
expect(def.id).toBe("gh-cli")
100+
expect(def.name).toBe("GitHub CLI")
101+
expect(def.category).toBe("tools")
102+
expect(def.critical).toBe(false)
103+
expect(typeof def.check).toBe("function")
104+
})
105+
})
106+
})

src/cli/doctor/checks/gh.ts

Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
import type { CheckResult, CheckDefinition } from "../types"
2+
import { CHECK_IDS, CHECK_NAMES } from "../constants"
3+
4+
export interface GhCliInfo {
5+
installed: boolean
6+
version: string | null
7+
path: string | null
8+
authenticated: boolean
9+
username: string | null
10+
scopes: string[]
11+
error: string | null
12+
}
13+
14+
async function checkBinaryExists(binary: string): Promise<{ exists: boolean; path: string | null }> {
15+
try {
16+
const proc = Bun.spawn(["which", binary], { stdout: "pipe", stderr: "pipe" })
17+
const output = await new Response(proc.stdout).text()
18+
await proc.exited
19+
if (proc.exitCode === 0) {
20+
return { exists: true, path: output.trim() }
21+
}
22+
} catch {
23+
// intentionally empty - binary not found
24+
}
25+
return { exists: false, path: null }
26+
}
27+
28+
async function getGhVersion(): Promise<string | null> {
29+
try {
30+
const proc = Bun.spawn(["gh", "--version"], { stdout: "pipe", stderr: "pipe" })
31+
const output = await new Response(proc.stdout).text()
32+
await proc.exited
33+
if (proc.exitCode === 0) {
34+
const match = output.match(/gh version (\S+)/)
35+
return match?.[1] ?? output.trim().split("\n")[0]
36+
}
37+
} catch {
38+
// intentionally empty - version unavailable
39+
}
40+
return null
41+
}
42+
43+
async function getGhAuthStatus(): Promise<{
44+
authenticated: boolean
45+
username: string | null
46+
scopes: string[]
47+
error: string | null
48+
}> {
49+
try {
50+
const proc = Bun.spawn(["gh", "auth", "status"], {
51+
stdout: "pipe",
52+
stderr: "pipe",
53+
env: { ...process.env, GH_NO_UPDATE_NOTIFIER: "1" },
54+
})
55+
const stdout = await new Response(proc.stdout).text()
56+
const stderr = await new Response(proc.stderr).text()
57+
await proc.exited
58+
59+
const output = stderr || stdout
60+
61+
if (proc.exitCode === 0) {
62+
const usernameMatch = output.match(/Logged in to github\.com account (\S+)/)
63+
const username = usernameMatch?.[1]?.replace(/[()]/g, "") ?? null
64+
65+
const scopesMatch = output.match(/Token scopes?:\s*(.+)/i)
66+
const scopes = scopesMatch?.[1]
67+
? scopesMatch[1]
68+
.split(/,\s*/)
69+
.map((s) => s.replace(/['"]/g, "").trim())
70+
.filter(Boolean)
71+
: []
72+
73+
return { authenticated: true, username, scopes, error: null }
74+
}
75+
76+
const errorMatch = output.match(/error[:\s]+(.+)/i)
77+
return {
78+
authenticated: false,
79+
username: null,
80+
scopes: [],
81+
error: errorMatch?.[1]?.trim() ?? "Not authenticated",
82+
}
83+
} catch (err) {
84+
return {
85+
authenticated: false,
86+
username: null,
87+
scopes: [],
88+
error: err instanceof Error ? err.message : "Failed to check auth status",
89+
}
90+
}
91+
}
92+
93+
export async function getGhCliInfo(): Promise<GhCliInfo> {
94+
const binaryCheck = await checkBinaryExists("gh")
95+
96+
if (!binaryCheck.exists) {
97+
return {
98+
installed: false,
99+
version: null,
100+
path: null,
101+
authenticated: false,
102+
username: null,
103+
scopes: [],
104+
error: null,
105+
}
106+
}
107+
108+
const [version, authStatus] = await Promise.all([getGhVersion(), getGhAuthStatus()])
109+
110+
return {
111+
installed: true,
112+
version,
113+
path: binaryCheck.path,
114+
authenticated: authStatus.authenticated,
115+
username: authStatus.username,
116+
scopes: authStatus.scopes,
117+
error: authStatus.error,
118+
}
119+
}
120+
121+
export async function checkGhCli(): Promise<CheckResult> {
122+
const info = await getGhCliInfo()
123+
const name = CHECK_NAMES[CHECK_IDS.GH_CLI]
124+
125+
if (!info.installed) {
126+
return {
127+
name,
128+
status: "warn",
129+
message: "Not installed (optional)",
130+
details: [
131+
"GitHub CLI is used by librarian agent and scripts",
132+
"Install: https://cli.github.com/",
133+
],
134+
}
135+
}
136+
137+
if (!info.authenticated) {
138+
return {
139+
name,
140+
status: "warn",
141+
message: `${info.version ?? "installed"} - not authenticated`,
142+
details: [
143+
info.path ? `Path: ${info.path}` : null,
144+
"Authenticate: gh auth login",
145+
info.error ? `Error: ${info.error}` : null,
146+
].filter((d): d is string => d !== null),
147+
}
148+
}
149+
150+
const details: string[] = []
151+
if (info.path) details.push(`Path: ${info.path}`)
152+
if (info.username) details.push(`Account: ${info.username}`)
153+
if (info.scopes.length > 0) details.push(`Scopes: ${info.scopes.join(", ")}`)
154+
155+
return {
156+
name,
157+
status: "pass",
158+
message: `${info.version ?? "installed"} - authenticated as ${info.username ?? "unknown"}`,
159+
details: details.length > 0 ? details : undefined,
160+
}
161+
}
162+
163+
export function getGhCliCheckDefinition(): CheckDefinition {
164+
return {
165+
id: CHECK_IDS.GH_CLI,
166+
name: CHECK_NAMES[CHECK_IDS.GH_CLI],
167+
category: "tools",
168+
check: checkGhCli,
169+
critical: false,
170+
}
171+
}

src/cli/doctor/checks/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { getPluginCheckDefinition } from "./plugin"
44
import { getConfigCheckDefinition } from "./config"
55
import { getAuthCheckDefinitions } from "./auth"
66
import { getDependencyCheckDefinitions } from "./dependencies"
7+
import { getGhCliCheckDefinition } from "./gh"
78
import { getLspCheckDefinition } from "./lsp"
89
import { getMcpCheckDefinitions } from "./mcp"
910
import { getVersionCheckDefinition } from "./version"
@@ -13,6 +14,7 @@ export * from "./plugin"
1314
export * from "./config"
1415
export * from "./auth"
1516
export * from "./dependencies"
17+
export * from "./gh"
1618
export * from "./lsp"
1719
export * from "./mcp"
1820
export * from "./version"
@@ -24,6 +26,7 @@ export function getAllCheckDefinitions(): CheckDefinition[] {
2426
getConfigCheckDefinition(),
2527
...getAuthCheckDefinitions(),
2628
...getDependencyCheckDefinitions(),
29+
getGhCliCheckDefinition(),
2730
getLspCheckDefinition(),
2831
...getMcpCheckDefinitions(),
2932
getVersionCheckDefinition(),

src/cli/doctor/constants.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ export const CHECK_IDS = {
2727
DEP_AST_GREP_CLI: "dep-ast-grep-cli",
2828
DEP_AST_GREP_NAPI: "dep-ast-grep-napi",
2929
DEP_COMMENT_CHECKER: "dep-comment-checker",
30+
GH_CLI: "gh-cli",
3031
LSP_SERVERS: "lsp-servers",
3132
MCP_BUILTIN: "mcp-builtin",
3233
MCP_USER: "mcp-user",
@@ -43,6 +44,7 @@ export const CHECK_NAMES: Record<string, string> = {
4344
[CHECK_IDS.DEP_AST_GREP_CLI]: "AST-Grep CLI",
4445
[CHECK_IDS.DEP_AST_GREP_NAPI]: "AST-Grep NAPI",
4546
[CHECK_IDS.DEP_COMMENT_CHECKER]: "Comment Checker",
47+
[CHECK_IDS.GH_CLI]: "GitHub CLI",
4648
[CHECK_IDS.LSP_SERVERS]: "LSP Servers",
4749
[CHECK_IDS.MCP_BUILTIN]: "Built-in MCP Servers",
4850
[CHECK_IDS.MCP_USER]: "User MCP Configuration",

0 commit comments

Comments
 (0)