Skip to content

Commit 7f135cb

Browse files
dcramerclaude
andcommitted
docs(cli): Add status and doctor command documentation
Also improves dex doctor to: - Use gh CLI auth check (not just GITHUB_TOKEN env var) - Detect missing [sync.github.auto] config in both global and project configs - Offer to add default auto-sync settings with --fix Adds comprehensive test coverage for doctor command. Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 207c718 commit 7f135cb

3 files changed

Lines changed: 267 additions & 3 deletions

File tree

docs/src/pages/cli.astro

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,31 @@ import { Code } from 'astro:components';
99

1010
<h2>Core Commands</h2>
1111

12+
<div class="command-card">
13+
<h3>dex status</h3>
14+
<div class="synopsis">dex status [options]</div>
15+
<p>Show a dashboard overview of your tasks. <strong>This is the default command</strong> — running <code>dex</code> with no arguments runs <code>dex status</code>.</p>
16+
<ul>
17+
<li><code>--json</code> — Output as JSON for scripting</li>
18+
</ul>
19+
<p>The dashboard shows:</p>
20+
<ul>
21+
<li><strong>Stats</strong> — Total, pending, completed, blocked, and ready counts</li>
22+
<li><strong>Ready to Work</strong> — Pending tasks with no blockers (up to 5)</li>
23+
<li><strong>Blocked</strong> — Tasks waiting on dependencies</li>
24+
<li><strong>Recently Completed</strong> — Latest completed tasks (up to 5)</li>
25+
</ul>
26+
<Terminal title="Terminal">
27+
<Code
28+
code={`dex # Show dashboard (default)
29+
dex status # Same as above
30+
dex status --json # Output as JSON`}
31+
lang="bash"
32+
theme="vitesse-black"
33+
/>
34+
</Terminal>
35+
</div>
36+
1237
<div class="command-card">
1338
<h3>dex create</h3>
1439
<div class="synopsis">dex create -d &lt;description&gt; --context &lt;context&gt; [options]</div>
@@ -156,6 +181,30 @@ dex plan feature.md --parent abc123`}
156181
</Terminal>
157182
</div>
158183

184+
<h2>Diagnostics</h2>
185+
186+
<div class="command-card">
187+
<h3>dex doctor</h3>
188+
<div class="synopsis">dex doctor [options]</div>
189+
<p>Check dex configuration and storage for issues, with optional auto-repair.</p>
190+
<ul>
191+
<li><code>--fix</code> — Automatically fix detected issues</li>
192+
</ul>
193+
<p>Checks performed:</p>
194+
<ul>
195+
<li><strong>Config</strong> — Valid TOML syntax, deprecated settings, missing environment variables</li>
196+
<li><strong>Storage</strong> — Task file validity, relationship consistency, orphaned references, depth limits</li>
197+
</ul>
198+
<Terminal title="Terminal">
199+
<Code
200+
code={`dex doctor # Check for issues (read-only)
201+
dex doctor --fix # Check and fix issues`}
202+
lang="bash"
203+
theme="vitesse-black"
204+
/>
205+
</Terminal>
206+
</div>
207+
159208
<h2>Other</h2>
160209

161210
<div class="command-card">

src/cli/doctor.test.ts

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
2+
import * as fs from "node:fs";
3+
import * as path from "node:path";
4+
import { FileStorage } from "../core/storage.js";
5+
import { runCli } from "./index.js";
6+
import { captureOutput, createTempStorage, CapturedOutput } from "./test-helpers.js";
7+
8+
describe("doctor command", () => {
9+
let storage: FileStorage;
10+
let cleanup: () => void;
11+
let output: CapturedOutput;
12+
let mockExit: ReturnType<typeof vi.spyOn>;
13+
14+
beforeEach(() => {
15+
const temp = createTempStorage();
16+
storage = temp.storage;
17+
cleanup = temp.cleanup;
18+
output = captureOutput();
19+
mockExit = vi.spyOn(process, "exit").mockImplementation((() => {
20+
throw new Error("process.exit called");
21+
}) as () => never);
22+
});
23+
24+
afterEach(() => {
25+
output.restore();
26+
cleanup();
27+
mockExit.mockRestore();
28+
});
29+
30+
it("shows help with --help flag", async () => {
31+
await runCli(["doctor", "--help"], { storage });
32+
33+
const out = output.stdout.join("\n");
34+
expect(out).toContain("dex doctor");
35+
expect(out).toContain("Check and repair");
36+
expect(out).toContain("--fix");
37+
});
38+
39+
it("reports no issues when config and storage are valid", async () => {
40+
await runCli(["doctor"], { storage });
41+
42+
const out = output.stdout.join("\n");
43+
expect(out).toContain("Checking config");
44+
expect(out).toContain("Config valid");
45+
expect(out).toContain("Checking storage");
46+
expect(out).toContain("No issues found");
47+
});
48+
49+
it("detects orphaned parent references", async () => {
50+
// Create a task and manually corrupt its parent_id
51+
const storagePath = storage.getIdentifier();
52+
const taskId = "test1234";
53+
const taskPath = path.join(storagePath, "tasks", `${taskId}.json`);
54+
55+
fs.mkdirSync(path.dirname(taskPath), { recursive: true });
56+
fs.writeFileSync(taskPath, JSON.stringify({
57+
id: taskId,
58+
parent_id: "nonexistent",
59+
description: "Test task",
60+
context: "ctx",
61+
priority: 1,
62+
completed: false,
63+
result: null,
64+
blockedBy: [],
65+
blocks: [],
66+
children: [],
67+
created_at: new Date().toISOString(),
68+
updated_at: new Date().toISOString(),
69+
completed_at: null,
70+
}));
71+
72+
await runCli(["doctor"], { storage });
73+
74+
const out = output.stdout.join("\n");
75+
expect(out).toContain("parent_id 'nonexistent' does not exist");
76+
expect(out).toContain("orphaned");
77+
});
78+
79+
it("detects missing auto-sync config when github sync is enabled", async () => {
80+
// Create a config file with github sync enabled but no auto section
81+
const storagePath = storage.getIdentifier();
82+
const configPath = path.join(storagePath, "config.toml");
83+
84+
fs.writeFileSync(configPath, `[sync.github]
85+
enabled = true
86+
`);
87+
88+
await runCli(["doctor"], { storage });
89+
90+
const out = output.stdout.join("\n");
91+
expect(out).toContain("Missing [sync.github.auto]");
92+
expect(out).toContain("project config");
93+
});
94+
95+
it("fixes missing auto-sync config with --fix", async () => {
96+
// Create a config file with github sync enabled but no auto section
97+
const storagePath = storage.getIdentifier();
98+
const configPath = path.join(storagePath, "config.toml");
99+
100+
fs.writeFileSync(configPath, `[sync.github]
101+
enabled = true
102+
`);
103+
104+
await runCli(["doctor", "--fix"], { storage });
105+
106+
const out = output.stdout.join("\n");
107+
expect(out).toContain("Fixed:");
108+
expect(out).toContain("[sync.github.auto]");
109+
110+
// Verify the config was updated
111+
const updatedConfig = fs.readFileSync(configPath, "utf-8");
112+
expect(updatedConfig).toContain("on_change");
113+
});
114+
115+
it("does not warn about auto-sync when it's already present", async () => {
116+
// Create a config file with github sync and auto section
117+
const storagePath = storage.getIdentifier();
118+
const configPath = path.join(storagePath, "config.toml");
119+
120+
fs.writeFileSync(configPath, `[sync.github]
121+
enabled = true
122+
123+
[sync.github.auto]
124+
on_change = false
125+
`);
126+
127+
await runCli(["doctor"], { storage });
128+
129+
const out = output.stdout.join("\n");
130+
// Should NOT warn about missing auto-sync since it's present
131+
expect(out).not.toContain("Missing [sync.github.auto]");
132+
// May still warn about GITHUB_TOKEN, which is a separate check
133+
});
134+
});

src/cli/doctor.ts

Lines changed: 84 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import * as fs from "node:fs";
2-
import { parse as parseToml } from "smol-toml";
2+
import * as path from "node:path";
3+
import { parse as parseToml, stringify as stringifyToml } from "smol-toml";
34
import {
45
CliOptions,
56
colors,
@@ -12,6 +13,14 @@ import {
1213
getProjectConfigPath,
1314
loadConfig,
1415
} from "../core/config.js";
16+
import { getGitHubToken } from "../core/github-sync.js";
17+
18+
/**
19+
* Default auto-sync configuration to add when missing.
20+
*/
21+
const DEFAULT_AUTO_SYNC_CONFIG = {
22+
on_change: true,
23+
};
1524

1625
export interface DoctorIssue {
1726
type: "error" | "warning";
@@ -197,18 +206,90 @@ async function checkConfig(options: CliOptions): Promise<DoctorIssue[]> {
197206
// Check GitHub sync config if enabled
198207
if (config.sync?.github?.enabled) {
199208
const tokenEnv = config.sync.github.token_env || "GITHUB_TOKEN";
200-
if (!process.env[tokenEnv]) {
209+
const token = getGitHubToken(tokenEnv);
210+
if (!token) {
211+
issues.push({
212+
type: "warning",
213+
category: "config",
214+
message: `GitHub sync enabled but no token found (checked ${tokenEnv} env var and gh CLI)`,
215+
});
216+
}
217+
218+
// Check for missing auto-sync configuration in both global and project configs
219+
// We check each file that has sync.github.enabled and warn if it's missing the auto section
220+
const configsToCheck: { path: string; label: string }[] = [];
221+
222+
// Check global config
223+
if (fs.existsSync(globalConfigPath)) {
224+
const globalParsed = parseConfigFileRaw(globalConfigPath);
225+
if (globalParsed?.sync?.github?.enabled && !globalParsed?.sync?.github?.auto) {
226+
configsToCheck.push({ path: globalConfigPath, label: "global" });
227+
}
228+
}
229+
230+
// Check project config
231+
const projectConfigPath = storagePath ? getProjectConfigPath(storagePath) : null;
232+
if (projectConfigPath && fs.existsSync(projectConfigPath)) {
233+
const projectParsed = parseConfigFileRaw(projectConfigPath);
234+
if (projectParsed?.sync?.github?.enabled && !projectParsed?.sync?.github?.auto) {
235+
configsToCheck.push({ path: projectConfigPath, label: "project" });
236+
}
237+
}
238+
239+
for (const { path: configPath, label } of configsToCheck) {
201240
issues.push({
202241
type: "warning",
203242
category: "config",
204-
message: `GitHub sync enabled but ${tokenEnv} environment variable not set`,
243+
message: `Missing [sync.github.auto] in ${label} config (${configPath})`,
244+
fix: async () => {
245+
await addAutoSyncConfig(configPath);
246+
},
205247
});
206248
}
207249
}
208250

209251
return issues;
210252
}
211253

254+
/**
255+
* Parse a TOML config file and return raw parsed object.
256+
*/
257+
function parseConfigFileRaw(configPath: string): any | null {
258+
if (!fs.existsSync(configPath)) {
259+
return null;
260+
}
261+
try {
262+
const content = fs.readFileSync(configPath, "utf-8");
263+
return parseToml(content);
264+
} catch {
265+
return null;
266+
}
267+
}
268+
269+
/**
270+
* Add auto-sync config section to an existing config file.
271+
* Preserves existing content and appends the new section.
272+
*/
273+
async function addAutoSyncConfig(configPath: string): Promise<void> {
274+
const content = fs.readFileSync(configPath, "utf-8");
275+
const parsed = parseToml(content) as any;
276+
277+
// Ensure sync.github exists
278+
if (!parsed.sync) {
279+
parsed.sync = {};
280+
}
281+
if (!parsed.sync.github) {
282+
parsed.sync.github = { enabled: true };
283+
}
284+
285+
// Add auto section with defaults
286+
parsed.sync.github.auto = { ...DEFAULT_AUTO_SYNC_CONFIG };
287+
288+
// Write back as TOML
289+
const newContent = stringifyToml(parsed);
290+
fs.writeFileSync(configPath, newContent, "utf-8");
291+
}
292+
212293
async function checkStorage(
213294
options: CliOptions,
214295
service: ReturnType<typeof createService>

0 commit comments

Comments
 (0)