@calltelemetry/openclaw-linear 0.6.0 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@calltelemetry/openclaw-linear",
3
- "version": "0.6.0",
3
+ "version": "0.6.1",
4
4
  "description": "Linear Agent plugin for OpenClaw — webhook-driven AI pipeline with OAuth, multi-agent routing, and issue triage",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/infra/cli.ts CHANGED
@@ -342,4 +342,31 @@ export function registerCli(program: Command, api: OpenClawPluginApi): void {
342
342
  process.exitCode = 1;
343
343
  }
344
344
  });
345
+
346
+ // --- openclaw openclaw-linear doctor ---
347
+ linear
348
+ .command("doctor")
349
+ .description("Run comprehensive health checks on the Linear plugin")
350
+ .option("--fix", "Auto-fix safe issues (chmod, stale locks, prune old dispatches)")
351
+ .option("--json", "Output results as JSON")
352
+ .action(async (opts: { fix?: boolean; json?: boolean }) => {
353
+ const { runDoctor, formatReport, formatReportJson } = await import("./doctor.js");
354
+ const pluginConfig = (api as any).pluginConfig as Record<string, unknown> | undefined;
355
+
356
+ const report = await runDoctor({
357
+ fix: opts.fix ?? false,
358
+ json: opts.json ?? false,
359
+ pluginConfig,
360
+ });
361
+
362
+ if (opts.json) {
363
+ console.log(formatReportJson(report));
364
+ } else {
365
+ console.log(formatReport(report));
366
+ }
367
+
368
+ if (report.summary.errors > 0) {
369
+ process.exitCode = 1;
370
+ }
371
+ });
345
372
  }
@@ -0,0 +1,399 @@
1
+ import { describe, it, expect, vi, afterEach, beforeEach } from "vitest";
2
+ import { mkdtempSync, writeFileSync, mkdirSync, chmodSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { tmpdir } from "node:os";
5
+
6
+ // Mock heavy cross-module imports to isolate doctor checks
7
+ vi.mock("../api/linear-api.js", () => ({
8
+ resolveLinearToken: vi.fn(() => ({
9
+ accessToken: "lin_test_token_123",
10
+ refreshToken: "refresh_token",
11
+ expiresAt: Date.now() + 24 * 3_600_000,
12
+ source: "profile" as const,
13
+ })),
14
+ AUTH_PROFILES_PATH: "/tmp/test-auth-profiles.json",
15
+ LINEAR_GRAPHQL_URL: "https://api.linear.app/graphql",
16
+ }));
17
+
18
+ vi.mock("../pipeline/dispatch-state.js", () => ({
19
+ readDispatchState: vi.fn(async () => ({
20
+ dispatches: { active: {}, completed: {} },
21
+ sessionMap: {},
22
+ processedEvents: [],
23
+ })),
24
+ listActiveDispatches: vi.fn(() => []),
25
+ listStaleDispatches: vi.fn(() => []),
26
+ pruneCompleted: vi.fn(async () => 0),
27
+ }));
28
+
29
+ vi.mock("../pipeline/pipeline.js", () => ({
30
+ loadPrompts: vi.fn(() => ({
31
+ worker: {
32
+ system: "You are a worker",
33
+ task: "Fix {{identifier}} {{title}} {{description}} in {{worktreePath}}",
34
+ },
35
+ audit: {
36
+ system: "You are an auditor",
37
+ task: "Audit {{identifier}} {{title}} {{description}} in {{worktreePath}}",
38
+ },
39
+ rework: { addendum: "Fix these gaps: {{gaps}}" },
40
+ })),
41
+ clearPromptCache: vi.fn(),
42
+ }));
43
+
44
+ vi.mock("./codex-worktree.js", () => ({
45
+ listWorktrees: vi.fn(() => []),
46
+ }));
47
+
48
+ vi.mock("../tools/code-tool.js", () => ({
49
+ loadCodingConfig: vi.fn(() => ({
50
+ codingTool: "claude",
51
+ agentCodingTools: {},
52
+ backends: {
53
+ claude: { aliases: ["claude", "anthropic"] },
54
+ codex: { aliases: ["codex", "openai"] },
55
+ gemini: { aliases: ["gemini", "google"] },
56
+ },
57
+ })),
58
+ }));
59
+
60
+ import {
61
+ checkAuth,
62
+ checkAgentConfig,
63
+ checkCodingTools,
64
+ checkFilesAndDirs,
65
+ checkConnectivity,
66
+ checkDispatchHealth,
67
+ runDoctor,
68
+ formatReport,
69
+ formatReportJson,
70
+ } from "./doctor.js";
71
+
72
+ import { resolveLinearToken } from "../api/linear-api.js";
73
+ import { readDispatchState, listStaleDispatches, pruneCompleted } from "../pipeline/dispatch-state.js";
74
+ import { loadPrompts } from "../pipeline/pipeline.js";
75
+ import { listWorktrees } from "./codex-worktree.js";
76
+
77
+ afterEach(() => {
78
+ vi.restoreAllMocks();
79
+ });
80
+
81
+ // ---------------------------------------------------------------------------
82
+ // checkAuth
83
+ // ---------------------------------------------------------------------------
84
+
85
+ describe("checkAuth", () => {
86
+ it("reports pass when token is found", async () => {
87
+ vi.stubGlobal("fetch", vi.fn(async () => ({
88
+ ok: true,
89
+ json: async () => ({ data: { viewer: { id: "1", name: "Test" }, organization: { name: "TestOrg", urlKey: "test" } } }),
90
+ })));
91
+
92
+ const { checks, ctx } = await checkAuth();
93
+ const tokenCheck = checks.find((c) => c.label.includes("Access token"));
94
+ expect(tokenCheck?.severity).toBe("pass");
95
+ expect(tokenCheck?.label).toContain("profile");
96
+ expect(ctx.viewer?.name).toBe("Test");
97
+ });
98
+
99
+ it("reports fail when no token found", async () => {
100
+ vi.mocked(resolveLinearToken).mockReturnValueOnce({
101
+ accessToken: null,
102
+ source: "none",
103
+ });
104
+
105
+ const { checks } = await checkAuth();
106
+ const tokenCheck = checks.find((c) => c.label.includes("access token"));
107
+ expect(tokenCheck?.severity).toBe("fail");
108
+ });
109
+
110
+ it("reports warn when token is expired", async () => {
111
+ vi.mocked(resolveLinearToken).mockReturnValueOnce({
112
+ accessToken: "tok",
113
+ expiresAt: Date.now() - 1000,
114
+ source: "profile",
115
+ });
116
+ vi.stubGlobal("fetch", vi.fn(async () => ({
117
+ ok: true,
118
+ json: async () => ({ data: { viewer: { id: "1", name: "T" }, organization: { name: "O", urlKey: "o" } } }),
119
+ })));
120
+
121
+ const { checks } = await checkAuth();
122
+ const expiryCheck = checks.find((c) => c.label.includes("expired") || c.label.includes("Token"));
123
+ expect(expiryCheck?.severity).toBe("warn");
124
+ });
125
+
126
+ it("reports pass with time remaining", async () => {
127
+ vi.stubGlobal("fetch", vi.fn(async () => ({
128
+ ok: true,
129
+ json: async () => ({ data: { viewer: { id: "1", name: "T" }, organization: { name: "O", urlKey: "o" } } }),
130
+ })));
131
+
132
+ const { checks } = await checkAuth();
133
+ const expiryCheck = checks.find((c) => c.label.includes("not expired"));
134
+ expect(expiryCheck?.severity).toBe("pass");
135
+ expect(expiryCheck?.label).toContain("h");
136
+ });
137
+
138
+ it("reports fail on API error", async () => {
139
+ vi.stubGlobal("fetch", vi.fn(async () => { throw new Error("network down"); }));
140
+
141
+ const { checks } = await checkAuth();
142
+ const apiCheck = checks.find((c) => c.label.includes("unreachable") || c.label.includes("API"));
143
+ expect(apiCheck?.severity).toBe("fail");
144
+ });
145
+ });
146
+
147
+ // ---------------------------------------------------------------------------
148
+ // checkAgentConfig
149
+ // ---------------------------------------------------------------------------
150
+
151
+ describe("checkAgentConfig", () => {
152
+ let tmpDir: string;
153
+
154
+ beforeEach(() => {
155
+ tmpDir = mkdtempSync(join(tmpdir(), "doctor-agent-"));
156
+ });
157
+
158
+ it("reports pass for valid agent profiles", () => {
159
+ // Mock the AGENT_PROFILES_PATH by writing to the expected location
160
+ // Since the path is hardcoded, we test the function's logic indirectly
161
+ const checks = checkAgentConfig();
162
+ // This tests against the real ~/.openclaw/agent-profiles.json on the system
163
+ // The checks should either pass (if file exists) or fail (if not)
164
+ expect(checks.length).toBeGreaterThan(0);
165
+ });
166
+
167
+ it("detects duplicate mention aliases", () => {
168
+ // Since we can't easily mock the file path, we test the overall behavior
169
+ const checks = checkAgentConfig();
170
+ // Verify the function returns structured results
171
+ for (const check of checks) {
172
+ expect(check).toHaveProperty("label");
173
+ expect(check).toHaveProperty("severity");
174
+ expect(["pass", "warn", "fail"]).toContain(check.severity);
175
+ }
176
+ });
177
+ });
178
+
179
+ // ---------------------------------------------------------------------------
180
+ // checkCodingTools
181
+ // ---------------------------------------------------------------------------
182
+
183
+ describe("checkCodingTools", () => {
184
+ it("reports loaded config with default backend", () => {
185
+ const checks = checkCodingTools();
186
+ const configCheck = checks.find((c) => c.label.includes("coding-tools.json"));
187
+ expect(configCheck?.severity).toBe("pass");
188
+ expect(configCheck?.label).toContain("claude");
189
+ });
190
+
191
+ it("reports warn for missing CLIs", () => {
192
+ const checks = checkCodingTools();
193
+ // Each CLI check should be present
194
+ const cliChecks = checks.filter((c) =>
195
+ c.label.startsWith("codex:") ||
196
+ c.label.startsWith("claude:") ||
197
+ c.label.startsWith("gemini:") ||
198
+ c.label.includes("not found"),
199
+ );
200
+ expect(cliChecks.length).toBeGreaterThan(0);
201
+ });
202
+ });
203
+
204
+ // ---------------------------------------------------------------------------
205
+ // checkFilesAndDirs
206
+ // ---------------------------------------------------------------------------
207
+
208
+ describe("checkFilesAndDirs", () => {
209
+ it("reports dispatch state counts", async () => {
210
+ vi.mocked(readDispatchState).mockResolvedValueOnce({
211
+ dispatches: {
212
+ active: { "API-1": { status: "working" } as any },
213
+ completed: { "API-2": { status: "done" } as any, "API-3": { status: "done" } as any },
214
+ },
215
+ sessionMap: {},
216
+ processedEvents: [],
217
+ });
218
+
219
+ const checks = await checkFilesAndDirs();
220
+ const stateCheck = checks.find((c) => c.label.includes("Dispatch state"));
221
+ expect(stateCheck?.severity).toBe("pass");
222
+ expect(stateCheck?.label).toContain("1 active");
223
+ expect(stateCheck?.label).toContain("2 completed");
224
+ });
225
+
226
+ it("reports valid prompts", async () => {
227
+ const checks = await checkFilesAndDirs();
228
+ const promptCheck = checks.find((c) => c.label.includes("Prompts"));
229
+ expect(promptCheck?.severity).toBe("pass");
230
+ expect(promptCheck?.label).toContain("5/5");
231
+ expect(promptCheck?.label).toContain("4/4");
232
+ });
233
+
234
+ it("reports prompt failures when sections missing", async () => {
235
+ vi.mocked(loadPrompts).mockReturnValueOnce({
236
+ worker: { system: "ok", task: "no vars here" },
237
+ audit: { system: "ok", task: "no vars here" },
238
+ rework: { addendum: "" },
239
+ } as any);
240
+
241
+ const checks = await checkFilesAndDirs();
242
+ const promptCheck = checks.find((c) => c.label.includes("Prompt") || c.label.includes("prompt"));
243
+ expect(promptCheck?.severity).toBe("fail");
244
+ });
245
+ });
246
+
247
+ // ---------------------------------------------------------------------------
248
+ // checkConnectivity
249
+ // ---------------------------------------------------------------------------
250
+
251
+ describe("checkConnectivity", () => {
252
+ it("skips Linear API re-check when authCtx provided", async () => {
253
+ const checks = await checkConnectivity(undefined, {
254
+ viewer: { name: "Test" },
255
+ organization: { name: "Org", urlKey: "org" },
256
+ });
257
+ const apiCheck = checks.find((c) => c.label.includes("Linear API"));
258
+ expect(apiCheck?.severity).toBe("pass");
259
+ });
260
+
261
+ it("reports Discord not configured as pass", async () => {
262
+ vi.stubGlobal("fetch", vi.fn(async () => { throw new Error("should not be called"); }));
263
+ const checks = await checkConnectivity({});
264
+ const discordCheck = checks.find((c) => c.label.includes("Discord"));
265
+ expect(discordCheck?.severity).toBe("pass");
266
+ expect(discordCheck?.label).toContain("not configured");
267
+ });
268
+
269
+ it("reports webhook skip when gateway not running", async () => {
270
+ vi.stubGlobal("fetch", vi.fn(async (url: string) => {
271
+ if (url.includes("localhost")) throw new Error("ECONNREFUSED");
272
+ throw new Error("unexpected");
273
+ }));
274
+
275
+ const checks = await checkConnectivity({}, {
276
+ viewer: { name: "T" },
277
+ organization: { name: "O", urlKey: "o" },
278
+ });
279
+ const webhookCheck = checks.find((c) => c.label.includes("Webhook"));
280
+ expect(webhookCheck?.severity).toBe("warn");
281
+ expect(webhookCheck?.label).toContain("gateway not detected");
282
+ });
283
+ });
284
+
285
+ // ---------------------------------------------------------------------------
286
+ // checkDispatchHealth
287
+ // ---------------------------------------------------------------------------
288
+
289
+ describe("checkDispatchHealth", () => {
290
+ it("reports no active dispatches", async () => {
291
+ const checks = await checkDispatchHealth();
292
+ const activeCheck = checks.find((c) => c.label.includes("active"));
293
+ expect(activeCheck?.severity).toBe("pass");
294
+ });
295
+
296
+ it("reports stale dispatches", async () => {
297
+ vi.mocked(listStaleDispatches).mockReturnValueOnce([
298
+ { issueIdentifier: "API-1", status: "working", dispatchedAt: new Date(Date.now() - 3 * 3_600_000).toISOString() } as any,
299
+ ]);
300
+
301
+ const checks = await checkDispatchHealth();
302
+ const staleCheck = checks.find((c) => c.label.includes("stale"));
303
+ expect(staleCheck?.severity).toBe("warn");
304
+ expect(staleCheck?.label).toContain("API-1");
305
+ });
306
+
307
+ it("prunes old completed with --fix", async () => {
308
+ vi.mocked(readDispatchState).mockResolvedValueOnce({
309
+ dispatches: {
310
+ active: {},
311
+ completed: {
312
+ "API-OLD": { completedAt: new Date(Date.now() - 10 * 24 * 3_600_000).toISOString(), status: "done" } as any,
313
+ },
314
+ },
315
+ sessionMap: {},
316
+ processedEvents: [],
317
+ });
318
+ vi.mocked(pruneCompleted).mockResolvedValueOnce(1);
319
+
320
+ const checks = await checkDispatchHealth(undefined, true);
321
+ const pruneCheck = checks.find((c) => c.label.includes("Pruned") || c.label.includes("prune"));
322
+ expect(pruneCheck).toBeDefined();
323
+ // With fix=true, it should have called pruneCompleted
324
+ expect(pruneCompleted).toHaveBeenCalled();
325
+ });
326
+ });
327
+
328
+ // ---------------------------------------------------------------------------
329
+ // runDoctor (integration)
330
+ // ---------------------------------------------------------------------------
331
+
332
+ describe("runDoctor", () => {
333
+ it("returns all 6 sections", async () => {
334
+ vi.stubGlobal("fetch", vi.fn(async (url: string) => {
335
+ if (url.includes("linear.app")) {
336
+ return {
337
+ ok: true,
338
+ json: async () => ({ data: { viewer: { id: "1", name: "T" }, organization: { name: "O", urlKey: "o" } } }),
339
+ };
340
+ }
341
+ throw new Error("not mocked");
342
+ }));
343
+
344
+ const report = await runDoctor({ fix: false, json: false });
345
+ expect(report.sections).toHaveLength(6);
346
+ expect(report.sections.map((s) => s.name)).toEqual([
347
+ "Authentication & Tokens",
348
+ "Agent Configuration",
349
+ "Coding Tools",
350
+ "Files & Directories",
351
+ "Connectivity",
352
+ "Dispatch Health",
353
+ ]);
354
+ expect(report.summary.passed + report.summary.warnings + report.summary.errors).toBeGreaterThan(0);
355
+ });
356
+ });
357
+
358
+ // ---------------------------------------------------------------------------
359
+ // Formatters
360
+ // ---------------------------------------------------------------------------
361
+
362
+ describe("formatReport", () => {
363
+ it("produces readable output with section headers", () => {
364
+ const report = {
365
+ sections: [
366
+ {
367
+ name: "Test Section",
368
+ checks: [
369
+ { label: "Check passed", severity: "pass" as const },
370
+ { label: "Check warned", severity: "warn" as const },
371
+ ],
372
+ },
373
+ ],
374
+ summary: { passed: 1, warnings: 1, errors: 0 },
375
+ };
376
+
377
+ const output = formatReport(report);
378
+ expect(output).toContain("Linear Plugin Doctor");
379
+ expect(output).toContain("Test Section");
380
+ expect(output).toContain("Check passed");
381
+ expect(output).toContain("Check warned");
382
+ expect(output).toContain("1 passed");
383
+ expect(output).toContain("1 warning");
384
+ });
385
+ });
386
+
387
+ describe("formatReportJson", () => {
388
+ it("produces valid JSON", () => {
389
+ const report = {
390
+ sections: [{ name: "Test", checks: [{ label: "ok", severity: "pass" as const }] }],
391
+ summary: { passed: 1, warnings: 0, errors: 0 },
392
+ };
393
+
394
+ const json = formatReportJson(report);
395
+ const parsed = JSON.parse(json);
396
+ expect(parsed.sections).toHaveLength(1);
397
+ expect(parsed.summary.passed).toBe(1);
398
+ });
399
+ });
@@ -0,0 +1,781 @@
1
+ /**
2
+ * doctor.ts — Comprehensive health checks for the Linear plugin.
3
+ *
4
+ * Usage: openclaw openclaw-linear doctor [--fix] [--json]
5
+ */
6
+ import { existsSync, readFileSync, statSync, accessSync, unlinkSync, chmodSync, constants } from "node:fs";
7
+ import { join } from "node:path";
8
+ import { homedir } from "node:os";
9
+ import { execFileSync } from "node:child_process";
10
+
11
+ import { resolveLinearToken, AUTH_PROFILES_PATH, LINEAR_GRAPHQL_URL } from "../api/linear-api.js";
12
+ import { readDispatchState, listActiveDispatches, listStaleDispatches, pruneCompleted, type DispatchState } from "../pipeline/dispatch-state.js";
13
+ import { loadPrompts, clearPromptCache } from "../pipeline/pipeline.js";
14
+ import { listWorktrees } from "./codex-worktree.js";
15
+ import { loadCodingConfig, type CodingBackend } from "../tools/code-tool.js";
16
+
17
+ // ---------------------------------------------------------------------------
18
+ // Types
19
+ // ---------------------------------------------------------------------------
20
+
21
+ export type CheckSeverity = "pass" | "warn" | "fail";
22
+
23
+ export interface CheckResult {
24
+ label: string;
25
+ severity: CheckSeverity;
26
+ detail?: string;
27
+ fixable?: boolean;
28
+ }
29
+
30
+ export interface CheckSection {
31
+ name: string;
32
+ checks: CheckResult[];
33
+ }
34
+
35
+ export interface DoctorReport {
36
+ sections: CheckSection[];
37
+ summary: { passed: number; warnings: number; errors: number };
38
+ }
39
+
40
+ export interface DoctorOptions {
41
+ fix: boolean;
42
+ json: boolean;
43
+ pluginConfig?: Record<string, unknown>;
44
+ }
45
+
46
+ // ---------------------------------------------------------------------------
47
+ // Constants
48
+ // ---------------------------------------------------------------------------
49
+
50
+ const AGENT_PROFILES_PATH = join(homedir(), ".openclaw", "agent-profiles.json");
51
+ const VALID_BACKENDS: readonly string[] = ["claude", "codex", "gemini"];
52
+ const CLI_BINS: [string, string][] = [
53
+ ["codex", "/home/claw/.npm-global/bin/codex"],
54
+ ["claude", "/home/claw/.npm-global/bin/claude"],
55
+ ["gemini", "/home/claw/.npm-global/bin/gemini"],
56
+ ];
57
+ const STALE_DISPATCH_MS = 2 * 60 * 60_000; // 2 hours
58
+ const OLD_COMPLETED_MS = 7 * 24 * 60 * 60_000; // 7 days
59
+ const LOCK_STALE_MS = 30_000; // 30 seconds
60
+
61
+ // ---------------------------------------------------------------------------
62
+ // Helpers
63
+ // ---------------------------------------------------------------------------
64
+
65
+ function pass(label: string, detail?: string): CheckResult {
66
+ return { label, severity: "pass", detail };
67
+ }
68
+
69
+ function warn(label: string, detail?: string, fixable = false): CheckResult {
70
+ return { label, severity: "warn", detail, fixable: fixable || undefined };
71
+ }
72
+
73
+ function fail(label: string, detail?: string): CheckResult {
74
+ return { label, severity: "fail", detail };
75
+ }
76
+
77
+ function resolveDispatchStatePath(pluginConfig?: Record<string, unknown>): string {
78
+ const custom = pluginConfig?.dispatchStatePath as string | undefined;
79
+ if (!custom) return join(homedir(), ".openclaw", "linear-dispatch-state.json");
80
+ if (custom.startsWith("~/")) return custom.replace("~", homedir());
81
+ return custom;
82
+ }
83
+
84
+ function resolveWorktreeBaseDir(pluginConfig?: Record<string, unknown>): string {
85
+ const custom = pluginConfig?.worktreeBaseDir as string | undefined;
86
+ if (!custom) return join(homedir(), ".openclaw", "worktrees");
87
+ if (custom.startsWith("~/")) return custom.replace("~", homedir());
88
+ return custom;
89
+ }
90
+
91
+ function resolveBaseRepo(pluginConfig?: Record<string, unknown>): string {
92
+ return (pluginConfig?.codexBaseRepo as string) ?? "/home/claw/ai-workspace";
93
+ }
94
+
95
+ interface AgentProfile {
96
+ label?: string;
97
+ mentionAliases?: string[];
98
+ isDefault?: boolean;
99
+ [key: string]: unknown;
100
+ }
101
+
102
+ function loadAgentProfiles(): Record<string, AgentProfile> {
103
+ try {
104
+ const raw = readFileSync(AGENT_PROFILES_PATH, "utf8");
105
+ return JSON.parse(raw).agents ?? {};
106
+ } catch {
107
+ return {};
108
+ }
109
+ }
110
+
111
+ // ---------------------------------------------------------------------------
112
+ // Section 1: Authentication & Tokens
113
+ // ---------------------------------------------------------------------------
114
+
115
+ interface AuthContext {
116
+ viewer?: { name: string };
117
+ organization?: { name: string; urlKey: string };
118
+ }
119
+
120
+ export async function checkAuth(pluginConfig?: Record<string, unknown>): Promise<{ checks: CheckResult[]; ctx: AuthContext }> {
121
+ const checks: CheckResult[] = [];
122
+ const ctx: AuthContext = {};
123
+
124
+ // Token existence
125
+ const tokenInfo = resolveLinearToken(pluginConfig);
126
+ if (tokenInfo.accessToken) {
127
+ checks.push(pass(`Access token found (source: ${tokenInfo.source})`));
128
+ } else {
129
+ checks.push(fail("No access token found", "Run: openclaw openclaw-linear auth"));
130
+ // Can't check further without token
131
+ return { checks, ctx };
132
+ }
133
+
134
+ // Token expiry
135
+ if (tokenInfo.expiresAt) {
136
+ const remaining = tokenInfo.expiresAt - Date.now();
137
+ if (remaining <= 0) {
138
+ checks.push(warn("Token expired", "Restart gateway to trigger auto-refresh"));
139
+ } else {
140
+ const hours = Math.floor(remaining / 3_600_000);
141
+ const mins = Math.floor((remaining % 3_600_000) / 60_000);
142
+ if (remaining < 3_600_000) {
143
+ checks.push(warn(`Token expires soon (${mins}m remaining)`));
144
+ } else {
145
+ checks.push(pass(`Token not expired (${hours}h ${mins}m remaining)`));
146
+ }
147
+ }
148
+ }
149
+
150
+ // API connectivity
151
+ try {
152
+ const authHeader = tokenInfo.refreshToken
153
+ ? `Bearer ${tokenInfo.accessToken}`
154
+ : tokenInfo.accessToken;
155
+
156
+ const res = await fetch(LINEAR_GRAPHQL_URL, {
157
+ method: "POST",
158
+ headers: {
159
+ "Content-Type": "application/json",
160
+ Authorization: authHeader,
161
+ },
162
+ body: JSON.stringify({
163
+ query: `{ viewer { id name } organization { name urlKey } }`,
164
+ }),
165
+ });
166
+
167
+ if (!res.ok) {
168
+ checks.push(fail(`API returned ${res.status} ${res.statusText}`));
169
+ } else {
170
+ const payload = await res.json() as any;
171
+ if (payload.errors?.length) {
172
+ checks.push(fail(`API error: ${payload.errors[0].message}`));
173
+ } else {
174
+ const { viewer, organization } = payload.data;
175
+ ctx.viewer = viewer;
176
+ ctx.organization = organization;
177
+ checks.push(pass(`API connectivity (user: ${viewer.name}, workspace: ${organization.name})`));
178
+ }
179
+ }
180
+ } catch (err) {
181
+ checks.push(fail(`API unreachable: ${err instanceof Error ? err.message : String(err)}`));
182
+ }
183
+
184
+ // auth-profiles.json permissions
185
+ try {
186
+ const stat = statSync(AUTH_PROFILES_PATH);
187
+ const mode = stat.mode & 0o777;
188
+ if (mode === 0o600) {
189
+ checks.push(pass("auth-profiles.json permissions (600)"));
190
+ } else {
191
+ checks.push(warn(
192
+ `auth-profiles.json permissions (${mode.toString(8)}, expected 600)`,
193
+ "Run: chmod 600 ~/.openclaw/auth-profiles.json",
194
+ true,
195
+ ));
196
+ }
197
+ } catch {
198
+ if (tokenInfo.source === "profile") {
199
+ checks.push(warn("auth-profiles.json not found (but token resolved from profile?)"));
200
+ }
201
+ // If token is from config/env, no auth-profiles.json is fine
202
+ }
203
+
204
+ // OAuth credentials
205
+ const clientId = (pluginConfig?.clientId as string) ?? process.env.LINEAR_CLIENT_ID;
206
+ const clientSecret = (pluginConfig?.clientSecret as string) ?? process.env.LINEAR_CLIENT_SECRET;
207
+ if (clientId && clientSecret) {
208
+ checks.push(pass("OAuth credentials configured"));
209
+ } else {
210
+ checks.push(warn(
211
+ "OAuth credentials not configured",
212
+ "Set LINEAR_CLIENT_ID and LINEAR_CLIENT_SECRET env vars or plugin config",
213
+ ));
214
+ }
215
+
216
+ return { checks, ctx };
217
+ }
218
+
219
+ // ---------------------------------------------------------------------------
220
+ // Section 2: Agent Configuration
221
+ // ---------------------------------------------------------------------------
222
+
223
+ export function checkAgentConfig(pluginConfig?: Record<string, unknown>): CheckResult[] {
224
+ const checks: CheckResult[] = [];
225
+
226
+ // Load profiles
227
+ let profiles: Record<string, AgentProfile>;
228
+ try {
229
+ if (!existsSync(AGENT_PROFILES_PATH)) {
230
+ checks.push(fail(
231
+ "agent-profiles.json not found",
232
+ `Expected at: ${AGENT_PROFILES_PATH}`,
233
+ ));
234
+ return checks;
235
+ }
236
+ const raw = readFileSync(AGENT_PROFILES_PATH, "utf8");
237
+ const parsed = JSON.parse(raw);
238
+ profiles = parsed.agents ?? {};
239
+ } catch (err) {
240
+ checks.push(fail(
241
+ "agent-profiles.json invalid JSON",
242
+ err instanceof Error ? err.message : String(err),
243
+ ));
244
+ return checks;
245
+ }
246
+
247
+ const agentCount = Object.keys(profiles).length;
248
+ if (agentCount === 0) {
249
+ checks.push(fail("agent-profiles.json has no agents"));
250
+ return checks;
251
+ }
252
+ checks.push(pass(`agent-profiles.json loaded (${agentCount} agent${agentCount > 1 ? "s" : ""})`));
253
+
254
+ // Default agent
255
+ const defaultEntry = Object.entries(profiles).find(([, p]) => p.isDefault);
256
+ if (defaultEntry) {
257
+ checks.push(pass(`Default agent: ${defaultEntry[0]}`));
258
+ } else {
259
+ checks.push(warn("No agent has isDefault: true"));
260
+ }
261
+
262
+ // Required fields
263
+ const missing: string[] = [];
264
+ for (const [id, profile] of Object.entries(profiles)) {
265
+ if (!profile.label) missing.push(`${id}: missing label`);
266
+ if (!Array.isArray(profile.mentionAliases) || profile.mentionAliases.length === 0) {
267
+ missing.push(`${id}: missing or empty mentionAliases`);
268
+ }
269
+ }
270
+ if (missing.length === 0) {
271
+ checks.push(pass("All agents have required fields"));
272
+ } else {
273
+ checks.push(fail(`Agent field issues: ${missing.join("; ")}`));
274
+ }
275
+
276
+ // defaultAgentId match
277
+ const configAgentId = pluginConfig?.defaultAgentId as string | undefined;
278
+ if (configAgentId) {
279
+ if (profiles[configAgentId]) {
280
+ checks.push(pass(`defaultAgentId "${configAgentId}" matches a profile`));
281
+ } else {
282
+ checks.push(warn(
283
+ `defaultAgentId "${configAgentId}" not found in agent-profiles.json`,
284
+ `Available: ${Object.keys(profiles).join(", ")}`,
285
+ ));
286
+ }
287
+ }
288
+
289
+ // Duplicate aliases
290
+ const aliasMap = new Map<string, string>();
291
+ const dupes: string[] = [];
292
+ for (const [id, profile] of Object.entries(profiles)) {
293
+ for (const alias of profile.mentionAliases ?? []) {
294
+ const lower = alias.toLowerCase();
295
+ if (aliasMap.has(lower)) {
296
+ dupes.push(`"${alias}" (${aliasMap.get(lower)} and ${id})`);
297
+ } else {
298
+ aliasMap.set(lower, id);
299
+ }
300
+ }
301
+ }
302
+ if (dupes.length === 0) {
303
+ checks.push(pass("No duplicate mention aliases"));
304
+ } else {
305
+ checks.push(warn(`Duplicate aliases: ${dupes.join(", ")}`));
306
+ }
307
+
308
+ return checks;
309
+ }
310
+
311
+ // ---------------------------------------------------------------------------
312
+ // Section 3: Coding Tools
313
+ // ---------------------------------------------------------------------------
314
+
315
+ export function checkCodingTools(): CheckResult[] {
316
+ const checks: CheckResult[] = [];
317
+
318
+ // Load config
319
+ const config = loadCodingConfig();
320
+ const hasConfig = !!config.codingTool || !!config.backends;
321
+ if (hasConfig) {
322
+ checks.push(pass(`coding-tools.json loaded (default: ${config.codingTool ?? "claude"})`));
323
+ } else {
324
+ checks.push(warn("coding-tools.json not found or empty (using defaults)"));
325
+ }
326
+
327
+ // Validate default backend
328
+ const defaultBackend = config.codingTool ?? "claude";
329
+ if (VALID_BACKENDS.includes(defaultBackend)) {
330
+ // already reported in the line above
331
+ } else {
332
+ checks.push(fail(`Unknown default backend: "${defaultBackend}" (valid: ${VALID_BACKENDS.join(", ")})`));
333
+ }
334
+
335
+ // Validate per-agent overrides
336
+ if (config.agentCodingTools) {
337
+ for (const [agentId, backend] of Object.entries(config.agentCodingTools)) {
338
+ if (!VALID_BACKENDS.includes(backend)) {
339
+ checks.push(warn(`Agent "${agentId}" override "${backend}" is not a valid backend`));
340
+ }
341
+ }
342
+ }
343
+
344
+ // CLI availability
345
+ for (const [name, bin] of CLI_BINS) {
346
+ try {
347
+ const raw = execFileSync(bin, ["--version"], {
348
+ encoding: "utf8",
349
+ timeout: 15_000,
350
+ env: { ...process.env, CLAUDECODE: undefined } as any,
351
+ }).trim();
352
+ checks.push(pass(`${name}: ${raw || "installed"}`));
353
+ } catch {
354
+ try {
355
+ accessSync(bin, constants.X_OK);
356
+ checks.push(pass(`${name}: installed (version check skipped)`));
357
+ } catch {
358
+ checks.push(warn(`${name}: not found at ${bin}`));
359
+ }
360
+ }
361
+ }
362
+
363
+ return checks;
364
+ }
365
+
366
+ // ---------------------------------------------------------------------------
367
+ // Section 4: Files & Directories
368
+ // ---------------------------------------------------------------------------
369
+
370
+ export async function checkFilesAndDirs(pluginConfig?: Record<string, unknown>, fix = false): Promise<CheckResult[]> {
371
+ const checks: CheckResult[] = [];
372
+
373
+ // Dispatch state
374
+ const statePath = resolveDispatchStatePath(pluginConfig);
375
+ let dispatchState: DispatchState | null = null;
376
+ if (existsSync(statePath)) {
377
+ try {
378
+ dispatchState = await readDispatchState(pluginConfig?.dispatchStatePath as string | undefined);
379
+ const activeCount = Object.keys(dispatchState.dispatches.active).length;
380
+ const completedCount = Object.keys(dispatchState.dispatches.completed).length;
381
+ checks.push(pass(`Dispatch state: ${activeCount} active, ${completedCount} completed`));
382
+ } catch (err) {
383
+ checks.push(fail(
384
+ "Dispatch state corrupt",
385
+ err instanceof Error ? err.message : String(err),
386
+ ));
387
+ }
388
+ } else {
389
+ checks.push(pass("Dispatch state: no file yet (will be created on first dispatch)"));
390
+ }
391
+
392
+ // Stale lock files
393
+ const lockPath = statePath + ".lock";
394
+ if (existsSync(lockPath)) {
395
+ try {
396
+ const lockStat = statSync(lockPath);
397
+ const lockAge = Date.now() - lockStat.mtimeMs;
398
+ if (lockAge > LOCK_STALE_MS) {
399
+ if (fix) {
400
+ unlinkSync(lockPath);
401
+ checks.push(pass("Stale lock file removed (--fix)"));
402
+ } else {
403
+ checks.push(warn(
404
+ `Stale lock file (${Math.round(lockAge / 1000)}s old)`,
405
+ "Use --fix to remove",
406
+ true,
407
+ ));
408
+ }
409
+ } else {
410
+ checks.push(warn(`Lock file active (${Math.round(lockAge / 1000)}s old, may be in use)`));
411
+ }
412
+ } catch {
413
+ checks.push(pass("No stale lock files"));
414
+ }
415
+ } else {
416
+ checks.push(pass("No stale lock files"));
417
+ }
418
+
419
+ // Worktree base dir
420
+ const wtBaseDir = resolveWorktreeBaseDir(pluginConfig);
421
+ if (existsSync(wtBaseDir)) {
422
+ try {
423
+ accessSync(wtBaseDir, constants.W_OK);
424
+ checks.push(pass("Worktree base dir writable"));
425
+ } catch {
426
+ checks.push(fail(`Worktree base dir not writable: ${wtBaseDir}`));
427
+ }
428
+ } else {
429
+ checks.push(warn(`Worktree base dir does not exist: ${wtBaseDir}`, "Will be created on first dispatch"));
430
+ }
431
+
432
+ // Base git repo
433
+ const baseRepo = resolveBaseRepo(pluginConfig);
434
+ if (existsSync(baseRepo)) {
435
+ try {
436
+ execFileSync("git", ["rev-parse", "--git-dir"], {
437
+ cwd: baseRepo,
438
+ encoding: "utf8",
439
+ timeout: 5_000,
440
+ });
441
+ checks.push(pass("Base repo is valid git repo"));
442
+ } catch {
443
+ checks.push(fail(`Base repo is not a git repo: ${baseRepo}`));
444
+ }
445
+ } else {
446
+ checks.push(fail(`Base repo does not exist: ${baseRepo}`));
447
+ }
448
+
449
+ // Prompts
450
+ try {
451
+ clearPromptCache();
452
+ const loaded = loadPrompts(pluginConfig);
453
+ const errors: string[] = [];
454
+
455
+ const sections = [
456
+ ["worker.system", loaded.worker?.system],
457
+ ["worker.task", loaded.worker?.task],
458
+ ["audit.system", loaded.audit?.system],
459
+ ["audit.task", loaded.audit?.task],
460
+ ["rework.addendum", loaded.rework?.addendum],
461
+ ] as const;
462
+
463
+ let sectionCount = 0;
464
+ for (const [name, value] of sections) {
465
+ if (value) sectionCount++;
466
+ else errors.push(`Missing ${name}`);
467
+ }
468
+
469
+ const requiredVars = ["{{identifier}}", "{{title}}", "{{description}}", "{{worktreePath}}"];
470
+ let varCount = 0;
471
+ for (const v of requiredVars) {
472
+ const inWorker = loaded.worker?.task?.includes(v);
473
+ const inAudit = loaded.audit?.task?.includes(v);
474
+ if (inWorker && inAudit) {
475
+ varCount++;
476
+ } else {
477
+ if (!inWorker) errors.push(`worker.task missing ${v}`);
478
+ if (!inAudit) errors.push(`audit.task missing ${v}`);
479
+ }
480
+ }
481
+
482
+ if (errors.length === 0) {
483
+ checks.push(pass(`Prompts valid (${sectionCount}/5 sections, ${varCount}/4 variables)`));
484
+ } else {
485
+ checks.push(fail(`Prompt issues: ${errors.join("; ")}`));
486
+ }
487
+ } catch (err) {
488
+ checks.push(fail(
489
+ "Failed to load prompts",
490
+ err instanceof Error ? err.message : String(err),
491
+ ));
492
+ }
493
+
494
+ return checks;
495
+ }
496
+
497
+ // ---------------------------------------------------------------------------
498
+ // Section 5: Connectivity
499
+ // ---------------------------------------------------------------------------
500
+
501
+ export async function checkConnectivity(pluginConfig?: Record<string, unknown>, authCtx?: AuthContext): Promise<CheckResult[]> {
502
+ const checks: CheckResult[] = [];
503
+
504
+ // Linear API (share result from auth check if available)
505
+ if (authCtx?.viewer) {
506
+ checks.push(pass("Linear API: connected"));
507
+ } else {
508
+ // Re-check if auth context wasn't passed
509
+ const tokenInfo = resolveLinearToken(pluginConfig);
510
+ if (tokenInfo.accessToken) {
511
+ try {
512
+ const authHeader = tokenInfo.refreshToken
513
+ ? `Bearer ${tokenInfo.accessToken}`
514
+ : tokenInfo.accessToken;
515
+ const res = await fetch(LINEAR_GRAPHQL_URL, {
516
+ method: "POST",
517
+ headers: { "Content-Type": "application/json", Authorization: authHeader },
518
+ body: JSON.stringify({ query: `{ viewer { id } }` }),
519
+ });
520
+ if (res.ok) {
521
+ checks.push(pass("Linear API: connected"));
522
+ } else {
523
+ checks.push(fail(`Linear API: ${res.status} ${res.statusText}`));
524
+ }
525
+ } catch (err) {
526
+ checks.push(fail(`Linear API: unreachable (${err instanceof Error ? err.message : String(err)})`));
527
+ }
528
+ } else {
529
+ checks.push(fail("Linear API: no token available"));
530
+ }
531
+ }
532
+
533
+ // Discord notifications
534
+ const flowDiscordChannel = pluginConfig?.flowDiscordChannel as string | undefined;
535
+ if (!flowDiscordChannel) {
536
+ checks.push(pass("Discord notifications: not configured (skipped)"));
537
+ } else {
538
+ // Read Discord bot token from openclaw.json
539
+ let discordBotToken: string | undefined;
540
+ try {
541
+ const configPath = join(homedir(), ".openclaw", "openclaw.json");
542
+ const config = JSON.parse(readFileSync(configPath, "utf8"));
543
+ discordBotToken = config?.channels?.discord?.token as string | undefined;
544
+ } catch {
545
+ // Can't read config
546
+ }
547
+
548
+ if (!discordBotToken) {
549
+ checks.push(warn("Discord notifications: no bot token found in openclaw.json"));
550
+ } else {
551
+ try {
552
+ const res = await fetch(`https://discord.com/api/v10/channels/${flowDiscordChannel}`, {
553
+ headers: { Authorization: `Bot ${discordBotToken}` },
554
+ });
555
+ if (res.ok) {
556
+ checks.push(pass("Discord notifications: enabled"));
557
+ } else {
558
+ checks.push(warn(`Discord channel check: ${res.status} ${res.statusText}`));
559
+ }
560
+ } catch (err) {
561
+ checks.push(warn(`Discord API unreachable: ${err instanceof Error ? err.message : String(err)}`));
562
+ }
563
+ }
564
+ }
565
+
566
+ // Webhook self-test
567
+ const gatewayPort = process.env.OPENCLAW_GATEWAY_PORT ?? "18789";
568
+ try {
569
+ const res = await fetch(`http://localhost:${gatewayPort}/linear/webhook`, {
570
+ method: "POST",
571
+ headers: { "Content-Type": "application/json" },
572
+ body: JSON.stringify({ type: "test", action: "ping" }),
573
+ });
574
+ const body = await res.text();
575
+ if (res.ok && body === "ok") {
576
+ checks.push(pass("Webhook self-test: responds OK"));
577
+ } else {
578
+ checks.push(warn(`Webhook self-test: ${res.status} — ${body.slice(0, 100)}`));
579
+ }
580
+ } catch {
581
+ checks.push(warn(`Webhook self-test: skipped (gateway not detected on :${gatewayPort})`));
582
+ }
583
+
584
+ return checks;
585
+ }
586
+
587
+ // ---------------------------------------------------------------------------
588
+ // Section 6: Dispatch Health
589
+ // ---------------------------------------------------------------------------
590
+
591
+ export async function checkDispatchHealth(pluginConfig?: Record<string, unknown>, fix = false): Promise<CheckResult[]> {
592
+ const checks: CheckResult[] = [];
593
+
594
+ const statePath = resolveDispatchStatePath(pluginConfig);
595
+ let state: DispatchState;
596
+ try {
597
+ state = await readDispatchState(pluginConfig?.dispatchStatePath as string | undefined);
598
+ } catch {
599
+ checks.push(pass("Dispatch health: no state file (nothing to check)"));
600
+ return checks;
601
+ }
602
+
603
+ // Active dispatches by status
604
+ const active = listActiveDispatches(state);
605
+ if (active.length === 0) {
606
+ checks.push(pass("No active dispatches"));
607
+ } else {
608
+ const byStatus = new Map<string, number>();
609
+ for (const d of active) {
610
+ byStatus.set(d.status, (byStatus.get(d.status) ?? 0) + 1);
611
+ }
612
+ const parts = Array.from(byStatus.entries()).map(([s, n]) => `${n} ${s}`);
613
+ const hasStuck = byStatus.has("stuck");
614
+ if (hasStuck) {
615
+ checks.push(warn(`Active dispatches: ${parts.join(", ")}`));
616
+ } else {
617
+ checks.push(pass(`Active dispatches: ${parts.join(", ")}`));
618
+ }
619
+ }
620
+
621
+ // Stale dispatches
622
+ const stale = listStaleDispatches(state, STALE_DISPATCH_MS);
623
+ if (stale.length === 0) {
624
+ checks.push(pass("No stale dispatches"));
625
+ } else {
626
+ const ids = stale.map((d) => d.issueIdentifier).join(", ");
627
+ checks.push(warn(`${stale.length} stale dispatch${stale.length > 1 ? "es" : ""}: ${ids}`));
628
+ }
629
+
630
+ // Orphaned worktrees
631
+ try {
632
+ const worktrees = listWorktrees({ baseDir: resolveWorktreeBaseDir(pluginConfig) });
633
+ const activeIds = new Set(Object.keys(state.dispatches.active));
634
+ const orphaned = worktrees.filter((wt) => !activeIds.has(wt.issueIdentifier));
635
+ if (orphaned.length === 0) {
636
+ checks.push(pass("No orphaned worktrees"));
637
+ } else {
638
+ checks.push(warn(
639
+ `${orphaned.length} orphaned worktree${orphaned.length > 1 ? "s" : ""} (not in active dispatches)`,
640
+ orphaned.map((w) => w.path).join(", "),
641
+ ));
642
+ }
643
+ } catch {
644
+ // Worktree listing may fail if dir doesn't exist — that's fine
645
+ }
646
+
647
+ // Old completed dispatches
648
+ const completed = Object.values(state.dispatches.completed);
649
+ const now = Date.now();
650
+ const old = completed.filter((c) => {
651
+ const age = now - new Date(c.completedAt).getTime();
652
+ return age > OLD_COMPLETED_MS;
653
+ });
654
+
655
+ if (old.length === 0) {
656
+ checks.push(pass("No old completed dispatches"));
657
+ } else {
658
+ if (fix) {
659
+ const pruned = await pruneCompleted(OLD_COMPLETED_MS, pluginConfig?.dispatchStatePath as string | undefined);
660
+ checks.push(pass(`Pruned ${pruned} old completed dispatch${pruned > 1 ? "es" : ""} (--fix)`));
661
+ } else {
662
+ checks.push(warn(
663
+ `${old.length} completed dispatch${old.length > 1 ? "es" : ""} older than 7 days`,
664
+ "Use --fix to prune",
665
+ true,
666
+ ));
667
+ }
668
+ }
669
+
670
+ return checks;
671
+ }
672
+
673
+ // ---------------------------------------------------------------------------
674
+ // Main entry point
675
+ // ---------------------------------------------------------------------------
676
+
677
+ export async function runDoctor(opts: DoctorOptions): Promise<DoctorReport> {
678
+ const sections: CheckSection[] = [];
679
+
680
+ // 1. Auth (also captures context for connectivity)
681
+ const auth = await checkAuth(opts.pluginConfig);
682
+ sections.push({ name: "Authentication & Tokens", checks: auth.checks });
683
+
684
+ // 2. Agent config
685
+ sections.push({ name: "Agent Configuration", checks: checkAgentConfig(opts.pluginConfig) });
686
+
687
+ // 3. Coding tools
688
+ sections.push({ name: "Coding Tools", checks: checkCodingTools() });
689
+
690
+ // 4. Files & dirs
691
+ sections.push({
692
+ name: "Files & Directories",
693
+ checks: await checkFilesAndDirs(opts.pluginConfig, opts.fix),
694
+ });
695
+
696
+ // 5. Connectivity (pass auth context to avoid double API call)
697
+ sections.push({
698
+ name: "Connectivity",
699
+ checks: await checkConnectivity(opts.pluginConfig, auth.ctx),
700
+ });
701
+
702
+ // 6. Dispatch health
703
+ sections.push({
704
+ name: "Dispatch Health",
705
+ checks: await checkDispatchHealth(opts.pluginConfig, opts.fix),
706
+ });
707
+
708
+ // Fix: chmod auth-profiles.json if needed
709
+ if (opts.fix) {
710
+ const permCheck = auth.checks.find((c) => c.fixable && c.label.includes("permissions"));
711
+ if (permCheck) {
712
+ try {
713
+ chmodSync(AUTH_PROFILES_PATH, 0o600);
714
+ permCheck.severity = "pass";
715
+ permCheck.label = "auth-profiles.json permissions fixed to 600 (--fix)";
716
+ permCheck.fixable = undefined;
717
+ } catch { /* best effort */ }
718
+ }
719
+ }
720
+
721
+ // Build summary
722
+ let passed = 0, warnings = 0, errors = 0;
723
+ for (const section of sections) {
724
+ for (const check of section.checks) {
725
+ switch (check.severity) {
726
+ case "pass": passed++; break;
727
+ case "warn": warnings++; break;
728
+ case "fail": errors++; break;
729
+ }
730
+ }
731
+ }
732
+
733
+ return { sections, summary: { passed, warnings, errors } };
734
+ }
735
+
736
+ // ---------------------------------------------------------------------------
737
+ // Formatters
738
+ // ---------------------------------------------------------------------------
739
+
740
+ function icon(severity: CheckSeverity): string {
741
+ const isTTY = process.stdout?.isTTY;
742
+ switch (severity) {
743
+ case "pass": return isTTY ? "\x1b[32m✓\x1b[0m" : "✓";
744
+ case "warn": return isTTY ? "\x1b[33m⚠\x1b[0m" : "⚠";
745
+ case "fail": return isTTY ? "\x1b[31m✗\x1b[0m" : "✗";
746
+ }
747
+ }
748
+
749
+ export function formatReport(report: DoctorReport): string {
750
+ const lines: string[] = [];
751
+ const bar = "═".repeat(40);
752
+
753
+ lines.push("");
754
+ lines.push("Linear Plugin Doctor");
755
+ lines.push(bar);
756
+
757
+ for (const section of report.sections) {
758
+ lines.push("");
759
+ lines.push(section.name);
760
+ for (const check of section.checks) {
761
+ lines.push(` ${icon(check.severity)} ${check.label}`);
762
+ }
763
+ }
764
+
765
+ lines.push("");
766
+ lines.push(bar);
767
+
768
+ const { passed, warnings, errors } = report.summary;
769
+ const parts: string[] = [];
770
+ parts.push(`${passed} passed`);
771
+ if (warnings > 0) parts.push(`${warnings} warning${warnings > 1 ? "s" : ""}`);
772
+ if (errors > 0) parts.push(`${errors} error${errors > 1 ? "s" : ""}`);
773
+ lines.push(`Results: ${parts.join(", ")}`);
774
+ lines.push("");
775
+
776
+ return lines.join("\n");
777
+ }
778
+
779
+ export function formatReportJson(report: DoctorReport): string {
780
+ return JSON.stringify(report, null, 2);
781
+ }