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
43 changes: 39 additions & 4 deletions packages/squad-cli/src/cli/commands/doctor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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',
};
}

Expand Down Expand Up @@ -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)',
};
}

Expand Down Expand Up @@ -417,7 +449,10 @@ export async function runDoctor(cwd?: string): Promise<DoctorCheck[]> {
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+)
Expand Down
89 changes: 87 additions & 2 deletions test/cli/doctor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@ async function scaffold(root: string): Promise<void> {
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', () => {
Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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');
});
});
Loading