diff --git a/packages/squad-cli/src/cli/commands/doctor.ts b/packages/squad-cli/src/cli/commands/doctor.ts index 717156be6..6cb5305da 100644 --- a/packages/squad-cli/src/cli/commands/doctor.ts +++ b/packages/squad-cli/src/cli/commands/doctor.ts @@ -135,7 +135,7 @@ function checkAbsoluteTeamRoot(squadDir: string): DoctorCheck | undefined { return { name: 'absolute path warning', status: 'warn', - message: `teamRoot is absolute (${teamRoot}) — prefer relative paths for portability`, + message: `teamRoot is absolute (${teamRoot}) — prefer relative paths for portability. Edit .squad/config.json to use a relative path.`, }; } return undefined; @@ -330,7 +330,7 @@ function checkVscodeJsonrpcExports(cwd: string): DoctorCheck { return { name: 'vscode-jsonrpc exports field', status: 'warn', - message: 'vscode-jsonrpc not found in node_modules', + message: 'vscode-jsonrpc not found in node_modules — expected for global CLI installs. For local development, run: npm install', }; } @@ -375,7 +375,39 @@ function checkCopilotSdkSessionPatch(cwd: string): DoctorCheck { return { name: 'copilot-sdk session.js ESM patch', status: 'warn', - message: '@github/copilot-sdk not found in node_modules', + message: '@github/copilot-sdk not found in node_modules — expected for global CLI installs. For local development, run: npm install', + }; +} + +function checkSquadAgentMd(cwd: string): DoctorCheck { + const agentMdPath = path.join(cwd, '.github', 'agents', 'squad.agent.md'); + if (!fileExists(agentMdPath)) { + return { + name: '.github/agents/squad.agent.md', + status: 'fail', + message: "file not found — run 'squad upgrade' to restore it", + }; + } + try { + const content = fs.readFileSync(agentMdPath, 'utf8'); + if (content.trim().length === 0) { + return { + name: '.github/agents/squad.agent.md', + status: 'warn', + message: "file is empty — run 'squad upgrade' to restore it", + }; + } + } catch { + return { + name: '.github/agents/squad.agent.md', + status: 'warn', + message: "file is empty — run 'squad upgrade' to restore it", + }; + } + return { + name: '.github/agents/squad.agent.md', + status: 'pass', + message: 'file present (Copilot agent discovery file)', }; } @@ -417,7 +449,10 @@ export async function runDoctor(cwd?: string): Promise { if (rateLimitCheck) checks.push(rateLimitCheck); } - // 10. Node.js version (node:sqlite availability) + // 10. Copilot agent discovery file (relative to cwd, not squadDir) + checks.push(checkSquadAgentMd(resolvedCwd)); + + // 11. Node.js version (node:sqlite availability) checks.push(checkNodeVersion()); // 11-12. ESM compatibility (Node 22/24+) diff --git a/test/cli/doctor.test.ts b/test/cli/doctor.test.ts index f2d630499..68cebb4c0 100644 --- a/test/cli/doctor.test.ts +++ b/test/cli/doctor.test.ts @@ -27,6 +27,9 @@ async function scaffold(root: string): Promise { join(sq, 'casting', 'registry.json'), JSON.stringify({ agents: [] }, null, 2), ); + // Copilot agent discovery file (#533) + await mkdir(join(root, '.github', 'agents'), { recursive: true }); + await writeFile(join(root, '.github', 'agents', 'squad.agent.md'), '# Squad Agent\n'); } describe('squad doctor', () => { @@ -65,8 +68,8 @@ describe('squad doctor', () => { const squadDirCheck = checks.find((c: DoctorCheck) => c.name === '.squad/ directory exists'); expect(squadDirCheck?.status).toBe('fail'); - // When .squad/ is missing the file checks are skipped — only .squad/ + Node version + 2 ESM checks - expect(checks.length).toBe(4); + // When .squad/ is missing the file checks are skipped — .squad/ + squad.agent.md + Node version + 2 ESM checks + expect(checks.length).toBe(5); }); it('detects remote mode from config.json with teamRoot', async () => { @@ -204,4 +207,86 @@ describe('squad doctor', () => { expect(configCheck).toBeDefined(); expect(configCheck?.status).toBe('fail'); }); + + // ── #565 — Actionable resolution hints in warnings ──────────────── + + it('vscode-jsonrpc warn says "expected for global CLI installs" when not in node_modules', async () => { + await scaffold(TEST_ROOT); + + const checks = await runDoctor(TEST_ROOT); + const jsonrpcCheck = checks.find((c: DoctorCheck) => c.name === 'vscode-jsonrpc exports field'); + expect(jsonrpcCheck).toBeDefined(); + expect(jsonrpcCheck?.status).toBe('warn'); + expect(jsonrpcCheck?.message).toContain('expected for global CLI installs'); + }); + + it('copilot-sdk warn says "expected for global CLI installs" when not in node_modules', async () => { + await scaffold(TEST_ROOT); + + const checks = await runDoctor(TEST_ROOT); + const sdkCheck = checks.find((c: DoctorCheck) => c.name === 'copilot-sdk session.js ESM patch'); + expect(sdkCheck).toBeDefined(); + expect(sdkCheck?.status).toBe('warn'); + expect(sdkCheck?.message).toContain('expected for global CLI installs'); + }); + + it('absolute teamRoot warning includes "Edit .squad/config.json"', async () => { + await scaffold(TEST_ROOT); + const abs = process.platform === 'win32' ? 'C:\\some\\absolute\\path' : '/some/absolute/path'; + await writeFile( + join(TEST_ROOT, '.squad', 'config.json'), + JSON.stringify({ teamRoot: abs }), + ); + + const checks = await runDoctor(TEST_ROOT); + const absWarn = checks.find((c: DoctorCheck) => c.name === 'absolute path warning'); + expect(absWarn).toBeDefined(); + expect(absWarn?.status).toBe('warn'); + expect(absWarn?.message).toContain('Edit .squad/config.json'); + }); + + // ── #533 — squad.agent.md check ────────────────────────────────── + + it('reports FAIL when .github/agents/squad.agent.md is missing', async () => { + await scaffold(TEST_ROOT); + // Remove the file that scaffold created so the check reports fail + await rm(join(TEST_ROOT, '.github'), { recursive: true, force: true }); + + const checks = await runDoctor(TEST_ROOT); + const agentMdCheck = checks.find((c: DoctorCheck) => c.name.includes('squad.agent.md')); + expect(agentMdCheck).toBeDefined(); + expect(agentMdCheck?.status).toBe('fail'); + }); + + it('reports PASS when .github/agents/squad.agent.md exists and is non-empty', async () => { + await scaffold(TEST_ROOT); + // scaffold already creates .github/agents/squad.agent.md with content + + const checks = await runDoctor(TEST_ROOT); + const agentMdCheck = checks.find((c: DoctorCheck) => c.name.includes('squad.agent.md')); + expect(agentMdCheck).toBeDefined(); + expect(agentMdCheck?.status).toBe('pass'); + }); + + it('reports WARN when .github/agents/squad.agent.md exists but is empty', async () => { + await scaffold(TEST_ROOT); + // Overwrite with empty content + await writeFile(join(TEST_ROOT, '.github', 'agents', 'squad.agent.md'), ''); + + const checks = await runDoctor(TEST_ROOT); + const agentMdCheck = checks.find((c: DoctorCheck) => c.name.includes('squad.agent.md')); + expect(agentMdCheck).toBeDefined(); + expect(agentMdCheck?.status).toBe('warn'); + }); + + it('squad.agent.md fail message includes "squad upgrade" as resolution step', async () => { + await scaffold(TEST_ROOT); + await rm(join(TEST_ROOT, '.github'), { recursive: true, force: true }); + + const checks = await runDoctor(TEST_ROOT); + const agentMdCheck = checks.find((c: DoctorCheck) => c.name.includes('squad.agent.md')); + expect(agentMdCheck).toBeDefined(); + expect(agentMdCheck?.status).toBe('fail'); + expect(agentMdCheck?.message).toContain('squad upgrade'); + }); });