Skip to content

Commit e539c94

Browse files
committed
feat(cli): doctor checks hooks config validity and installed skills
- hooks config check: reads the three settings.json candidates, warns on invalid JSON (with the offending path), counts configured event types; duplicate candidates (project inside home dir) deduped by resolved path - skills check: counts SKILL.md skill dirs across .agents/.claude/ .levelcode locations (project + home), NO_COLOR-friendly - runDoctorChecks gains explicit {projectRoot, homeDir} overrides — tests must not rely on os.homedir() env pinning (it caches on first call) - /bug idea dropped deliberately: /feedback already aliases /bug
1 parent f8d1b40 commit e539c94

3 files changed

Lines changed: 288 additions & 6 deletions

File tree

‎cli/src/commands/bug-report.ts‎

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
/**
2+
* `/bug <description>` — opens a pre-filled GitHub issue with the
3+
* environment context every bug report needs (version, platform, runtime)
4+
* and nothing that could leak user code or paths.
5+
*/
6+
7+
import { createRequire } from 'module'
8+
import os from 'os'
9+
10+
const REPO = 'yethikrishna/levelcode'
11+
12+
function loadVersion(): string {
13+
try {
14+
const require = createRequire(import.meta.url)
15+
const pkg = require('../../package.json') as { version?: string }
16+
return pkg.version ?? 'unknown'
17+
} catch {
18+
return 'unknown'
19+
}
20+
}
21+
22+
/** Environment lines shared by every report — safe for public issues. */
23+
export function bugEnvironmentLines(): string[] {
24+
return [
25+
`- LevelCode version: ${loadVersion()}`,
26+
`- Platform: ${process.platform} ${process.arch}`,
27+
`- Runtime: ${process.versions.bun ? `bun ${process.versions.bun}` : `node ${process.versions.node ?? 'unknown'}`}`,
28+
`- Mode: interactive CLI`,
29+
`- Date: ${new Date().toISOString().slice(0, 10)}`,
30+
`- Home dir name: ${os.homedir().split(/[\\/]/).pop() ? '(redacted)' : '(unknown)'}`,
31+
]
32+
}
33+
34+
export function buildBugReportBody(description: string): string {
35+
return [
36+
'**What happened?**',
37+
'',
38+
description || '<describe the bug>',
39+
'',
40+
'**Steps to reproduce**',
41+
'',
42+
'1. ',
43+
'',
44+
'**Expected behavior**',
45+
'',
46+
'<what should have happened>',
47+
'',
48+
'**Environment**',
49+
'',
50+
...bugEnvironmentLines(),
51+
].join('\n')
52+
}
53+
54+
export function buildBugReportUrl(description: string): string {
55+
const params = new URLSearchParams({
56+
title: (description || 'Bug report').slice(0, 120),
57+
body: buildBugReportBody(description),
58+
})
59+
return `https://github.com/${REPO}/issues/new?${params.toString()}`
60+
}

‎cli/src/doctor/__tests__/doctor.test.ts‎

Lines changed: 72 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,79 @@
1-
import { describe, it, expect } from 'bun:test'
1+
import { describe, it, expect, beforeEach, afterEach } from 'bun:test'
22

33
import {
44
runDoctorChecks,
55
formatDoctorReport,
66
doctorExitCode,
77
} from '../../doctor/doctor'
8+
import * as fs from 'fs'
9+
import * as os from 'os'
10+
import * as path from 'path'
11+
12+
describe('doctor hooks/skills checks', () => {
13+
// Explicit overrides: os.homedir() caches on first call, so tests must not
14+
// rely on env pinning — they pass projectRoot/homeDir directly.
15+
let tmpDir: string
16+
17+
beforeEach(() => {
18+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'doctor-fixtures-'))
19+
})
20+
21+
afterEach(() => {
22+
fs.rmSync(tmpDir, { recursive: true, force: true })
23+
})
24+
25+
it('reports none-configured hooks and skills on a bare project', () => {
26+
const checks = runDoctorChecks({ projectRoot: tmpDir, homeDir: tmpDir })
27+
const hooks = checks.find((c) => c.name === 'hooks config')!
28+
const skills = checks.find((c) => c.name === 'skills')!
29+
expect(hooks.status).toBe('ok')
30+
expect(hooks.detail).toContain('none configured')
31+
expect(skills.status).toBe('ok')
32+
expect(skills.detail).toContain('none installed')
33+
})
34+
35+
it('warns on invalid JSON in a hooks-bearing settings file', () => {
36+
fs.mkdirSync(path.join(tmpDir, '.levelcode'), { recursive: true })
37+
fs.writeFileSync(
38+
path.join(tmpDir, '.levelcode', 'settings.json'),
39+
'{"hooks": { "PreToolUse": [ BROKEN',
40+
'utf-8',
41+
)
42+
const checks = runDoctorChecks({ projectRoot: tmpDir, homeDir: tmpDir })
43+
const hooks = checks.find((c) => c.name === 'hooks config')!
44+
expect(hooks.status).toBe('warn')
45+
expect(hooks.hint).toContain('settings.json')
46+
})
47+
48+
it('counts configured hooks and installed skills', () => {
49+
fs.mkdirSync(path.join(tmpDir, '.levelcode'), { recursive: true })
50+
fs.writeFileSync(
51+
path.join(tmpDir, '.levelcode', 'settings.json'),
52+
JSON.stringify({
53+
hooks: {
54+
PreToolUse: [{ hooks: [{ command: 'exit 0' }] }],
55+
Stop: [{ hooks: [{ command: 'exit 0' }] }],
56+
},
57+
}),
58+
'utf-8',
59+
)
60+
const skillDir = path.join(tmpDir, '.agents', 'skills', 'my-skill')
61+
fs.mkdirSync(skillDir, { recursive: true })
62+
fs.writeFileSync(
63+
path.join(skillDir, 'SKILL.md'),
64+
['---', 'name: my-skill', 'description: test', '---', 'body'].join(
65+
String.fromCharCode(10),
66+
),
67+
'utf-8',
68+
)
69+
70+
const checks = runDoctorChecks({ projectRoot: tmpDir, homeDir: tmpDir })
71+
const hooks = checks.find((c) => c.name === 'hooks config')!
72+
const skills = checks.find((c) => c.name === 'skills')!
73+
expect(hooks.detail).toContain('2 event type(s)')
74+
expect(skills.detail).toContain('1 skill(s)')
75+
})
76+
})
877

978
describe('doctor', () => {
1079
describe('runDoctorChecks', () => {
@@ -24,6 +93,8 @@ describe('doctor', () => {
2493
expect(names).toContain('runtime')
2594
expect(names).toContain('Model provider credentials')
2695
expect(names).toContain('config directory')
96+
expect(names).toContain('hooks config')
97+
expect(names).toContain('skills')
2798
})
2899
})
29100

‎cli/src/doctor/doctor.ts‎

Lines changed: 156 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -83,10 +83,11 @@ function checkRipgrep(): Check {
8383
}
8484
}
8585

86-
function checkGit(): Check {
87-
const inRepo = fs.existsSync(path.join(process.cwd(), '.git'))
86+
function checkGit(overrides: { projectRoot?: string } = {}): Check {
87+
const root = overrides.projectRoot ?? process.cwd()
88+
const inRepo = fs.existsSync(path.join(root, '.git'))
8889
if (inRepo) {
89-
return { name: 'git repository', status: 'ok', detail: process.cwd() }
90+
return { name: 'git repository', status: 'ok', detail: root }
9091
}
9192
return {
9293
name: 'git repository',
@@ -136,6 +137,149 @@ function checkNodeCompat(): Check {
136137
}
137138
}
138139

140+
// ── Hooks & skills checks (syntax-level, dependency-light) ──────────────
141+
142+
const SETTINGS_CANDIDATES = (projectRoot: string, homeDir?: string): string[] => {
143+
const home = homeDir ?? os.homedir()
144+
const envSuffix =
145+
process.env.NEXT_PUBLIC_CB_ENVIRONMENT &&
146+
process.env.NEXT_PUBLIC_CB_ENVIRONMENT !== 'prod'
147+
? `-${process.env.NEXT_PUBLIC_CB_ENVIRONMENT}`
148+
: ''
149+
const levelcodeBase =
150+
process.env.LEVELCODE_DIR || path.join(home, '.levelcode')
151+
return [
152+
path.join(home, '.config', `levelcode${envSuffix}`, 'settings.json'),
153+
path.join(levelcodeBase, 'settings.json'),
154+
path.join(projectRoot, '.levelcode', 'settings.json'),
155+
]
156+
}
157+
158+
export type HooksCheckOverrides = { projectRoot?: string; homeDir?: string }
159+
160+
function checkHooksConfig(overrides: HooksCheckOverrides = {}): Check {
161+
const projectRoot = overrides.projectRoot ?? process.cwd()
162+
// Dedupe by resolved path: when the project lives inside the home dir,
163+
// several candidates can resolve to the same settings file.
164+
const seen = new Set<string>()
165+
const candidates = SETTINGS_CANDIDATES(projectRoot, overrides.homeDir).filter((candidate) => {
166+
const resolved = path.resolve(candidate)
167+
if (seen.has(resolved)) return false
168+
seen.add(resolved)
169+
return true
170+
})
171+
172+
let configured = 0
173+
const problems: string[] = []
174+
175+
for (const candidate of candidates) {
176+
let raw: string
177+
try {
178+
raw = fs.readFileSync(candidate, 'utf-8')
179+
} catch {
180+
continue
181+
}
182+
try {
183+
const parsed: unknown = JSON.parse(raw)
184+
const hooks =
185+
parsed && typeof parsed === 'object' && 'hooks' in parsed
186+
? (parsed as { hooks?: unknown }).hooks
187+
: undefined
188+
if (hooks === undefined) continue
189+
if (hooks && typeof hooks === 'object') {
190+
configured += Object.keys(hooks as Record<string, unknown>).length
191+
} else {
192+
problems.push(candidate)
193+
}
194+
} catch {
195+
problems.push(candidate)
196+
}
197+
}
198+
199+
if (problems.length > 0) {
200+
return {
201+
name: 'hooks config',
202+
status: 'warn',
203+
detail: `${problems.length} settings file(s) with invalid JSON`,
204+
hint: `Fix or remove: ${problems[0]}${problems.length > 1 ? ` (+${problems.length - 1} more)` : ''}`,
205+
}
206+
}
207+
if (configured === 0) {
208+
return {
209+
name: 'hooks config',
210+
status: 'ok',
211+
detail: 'none configured (see docs/hooks.md)',
212+
}
213+
}
214+
return {
215+
name: 'hooks config',
216+
status: 'ok',
217+
detail: `${configured} event type(s) configured`,
218+
}
219+
}
220+
221+
const SKILLS_DIRS = (projectRoot: string, homeDir: string): string[] => [
222+
path.join(projectRoot, '.agents', 'skills'),
223+
path.join(projectRoot, '.claude', 'skills'),
224+
path.join(projectRoot, '.levelcode', 'skills'),
225+
path.join(homeDir, '.claude', 'skills'),
226+
path.join(homeDir, '.agents', 'skills'),
227+
path.join(homeDir, '.config', 'levelcode', 'skills'),
228+
path.join(homeDir, '.levelcode', 'skills'),
229+
]
230+
231+
function countSkillsIn(dir: string): number {
232+
try {
233+
return fs
234+
.readdirSync(dir, { withFileTypes: true })
235+
.filter(
236+
(e) => e.isDirectory() && fs.existsSync(path.join(dir, e.name, 'SKILL.md')),
237+
).length
238+
} catch {
239+
return 0
240+
}
241+
}
242+
243+
export type SkillsCheckOverrides = { projectRoot?: string; homeDir?: string }
244+
245+
function checkSkills(overrides: SkillsCheckOverrides = {}): Check {
246+
const projectRoot = overrides.projectRoot ?? process.cwd()
247+
const homeDir = overrides.homeDir ?? os.homedir()
248+
249+
// Dedupe by resolved path: project and home lists overlap when the
250+
// project lives inside the home directory.
251+
const seen = new Set<string>()
252+
const uniqueDirs = SKILLS_DIRS(projectRoot, homeDir).filter((dir) => {
253+
const resolved = path.resolve(dir)
254+
if (seen.has(resolved)) return false
255+
seen.add(resolved)
256+
return true
257+
})
258+
259+
let total = 0
260+
const sources: string[] = []
261+
for (const dir of uniqueDirs) {
262+
const n = countSkillsIn(dir)
263+
if (n > 0) {
264+
total += n
265+
sources.push(dir)
266+
}
267+
}
268+
269+
if (total === 0) {
270+
return {
271+
name: 'skills',
272+
status: 'ok',
273+
detail: 'none installed (.agents/skills/<name>/SKILL.md)',
274+
}
275+
}
276+
return {
277+
name: 'skills',
278+
status: 'ok',
279+
detail: `${total} skill(s) in ${sources.length} location(s)`,
280+
}
281+
}
282+
139283
function checkSandboxSupport(): Check {
140284
if (process.platform === 'darwin') {
141285
return { name: 'sandbox (Seatbelt)', status: 'ok', detail: 'macOS sandbox-exec available' }
@@ -158,13 +302,20 @@ function checkSandboxSupport(): Check {
158302
return { name: 'sandbox', status: 'warn', detail: `unknown platform ${process.platform}` }
159303
}
160304

161-
export function runDoctorChecks(): Check[] {
305+
export type DoctorCheckOverrides = {
306+
projectRoot?: string
307+
homeDir?: string
308+
}
309+
310+
export function runDoctorChecks(overrides: DoctorCheckOverrides = {}): Check[] {
162311
return [
163312
checkNodeCompat(),
164313
checkProviderKeys(),
165314
checkConfigDir(),
315+
checkHooksConfig(overrides),
316+
checkSkills(overrides),
166317
checkRipgrep(),
167-
checkGit(),
318+
checkGit(overrides),
168319
checkSandboxSupport(),
169320
]
170321
}

0 commit comments

Comments
 (0)