ticketlens 0.38.20 → 0.38.22
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/README.md +5 -3
- package/package.json +1 -1
- package/skills/jtb/SKILL.md +6 -5
- package/skills/jtb/scripts/lib/banner.mjs +2 -2
- package/skills/jtb/scripts/lib/doctor-checks.mjs +84 -0
- package/skills/jtb/scripts/lib/doctor-command.mjs +103 -25
- package/skills/jtb/scripts/lib/help.mjs +7 -3
- package/skills/jtb/scripts/lib/mcp-handshake-checker.mjs +82 -0
- package/skills/jtb/scripts/lib/mcp-install.mjs +5 -4
- package/skills/jtb/scripts/lib/mcp-server.mjs +1 -1
package/README.md
CHANGED
|
@@ -529,11 +529,12 @@ A one-line summary footer is also appended automatically to `ticketlens triage`
|
|
|
529
529
|
### Doctor
|
|
530
530
|
|
|
531
531
|
```bash
|
|
532
|
-
ticketlens doctor # Diagnose profile/license/connectivity/cache/queue problems
|
|
533
|
-
ticketlens doctor --fix # Attempt safe automatic fixes (license revalidation, corrupt cache cleanup, queue flush)
|
|
532
|
+
ticketlens doctor # Diagnose profile/license/connectivity/cache/MCP-registration/queue problems
|
|
533
|
+
ticketlens doctor --fix # Attempt safe automatic fixes (license revalidation, corrupt cache cleanup, MCP registration, queue flush)
|
|
534
534
|
ticketlens doctor --profile=acme # Scope checks to a single profile
|
|
535
535
|
ticketlens doctor --format=json # JSON output for scripting/piping
|
|
536
536
|
ticketlens doctor --format=json | jq '.ok'
|
|
537
|
+
ticketlens doctor --mcp # Also check the MCP server handshake (spawns a subprocess)
|
|
537
538
|
```
|
|
538
539
|
|
|
539
540
|
Runs five checks — profile configuration, license freshness, tracker connectivity, attachment cache health, and the Recall sync queue — and reports pass/fail with an actionable hint per failure, instead of a raw stack trace. Free tier, fully unrestricted; no license required.
|
|
@@ -817,10 +818,11 @@ ticketlens stats --days=14 # Extend lookback window (Pro, max
|
|
|
817
818
|
ticketlens stats --format=json # JSON output for scripting
|
|
818
819
|
|
|
819
820
|
# ── Doctor ────────────────────────────────────────────────────────────────────
|
|
820
|
-
ticketlens doctor # Diagnose profile/license/connectivity/cache/queue problems
|
|
821
|
+
ticketlens doctor # Diagnose profile/license/connectivity/cache/MCP-registration/queue problems
|
|
821
822
|
ticketlens doctor --fix # Attempt safe automatic fixes
|
|
822
823
|
ticketlens doctor --profile=acme # Scope checks to a single profile
|
|
823
824
|
ticketlens doctor --format=json # JSON output for scripting/piping
|
|
825
|
+
ticketlens doctor --mcp # Also check the MCP server handshake (spawns a subprocess)
|
|
824
826
|
|
|
825
827
|
# ── Compliance ────────────────────────────────────────────────────────────────
|
|
826
828
|
ticketlens compliance <TICKET-KEY> # Check ticket requirements against local diff [Pro/Free 3/mo]
|
package/package.json
CHANGED
package/skills/jtb/SKILL.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
<!-- jtb-skill-version: 0.34.
|
|
1
|
+
<!-- jtb-skill-version: 0.34.1 -->
|
|
2
2
|
---
|
|
3
3
|
name: jtb
|
|
4
4
|
description: Fetch a Jira ticket's full context (description, comments, linked issues, code references) and assemble a structured TicketBrief for implementation planning. Use when user types /jtb, mentions a Jira ticket key, or wants to plan work from a Jira ticket.
|
|
@@ -314,16 +314,17 @@ Requires a Pro license — on Free, all seven no-op with an upgrade hint on stde
|
|
|
314
314
|
|
|
315
315
|
## Doctor — diagnose local/tracker problems (Free)
|
|
316
316
|
|
|
317
|
-
`ticketlens doctor` runs
|
|
317
|
+
`ticketlens doctor` runs six fixed checks by default — profile configuration, license freshness, tracker connectivity, attachment cache health, MCP registration, and the Recall sync queue — and returns a pass/fail report with an actionable hint per failure, instead of a raw stack trace. Pass `--mcp` to also run a seventh, opt-in check: a real MCP server handshake (spawns `ticketlens mcp`, confirms it answers the JSON-RPC `initialize` request). Free tier, fully unrestricted; nothing here is gated.
|
|
318
318
|
|
|
319
319
|
```bash
|
|
320
|
-
ticketlens doctor # run
|
|
321
|
-
ticketlens doctor --fix # attempt safe automatic fixes (license revalidation, corrupt cache cleanup, queue flush)
|
|
320
|
+
ticketlens doctor # run the six default checks
|
|
321
|
+
ticketlens doctor --fix # attempt safe automatic fixes (license revalidation, corrupt cache cleanup, MCP registration, queue flush)
|
|
322
322
|
ticketlens doctor --profile=acme # scope checks to a single profile
|
|
323
323
|
ticketlens doctor --format=json # structured output for scripting/piping
|
|
324
|
+
ticketlens doctor --mcp # also check the MCP server handshake (spawns a subprocess)
|
|
324
325
|
```
|
|
325
326
|
|
|
326
|
-
If this harness has TicketLens's MCP server configured (a tool named `doctor` — often shown as `mcp__ticketlens__doctor` — visible in your tool list), prefer it over the bash form: it always requests the JSON report internally and returns it as the tool's text content, so you get a structured result to reason over directly instead of parsing CLI stdout. It accepts the same `fix`/`profile` options as the CLI flags above.
|
|
327
|
+
If this harness has TicketLens's MCP server configured (a tool named `doctor` — often shown as `mcp__ticketlens__doctor` — visible in your tool list), prefer it over the bash form: it always requests the JSON report internally and returns it as the tool's text content, so you get a structured result to reason over directly instead of parsing CLI stdout. It accepts the same `fix`/`profile` options as the CLI flags above (the `--mcp` handshake check is CLI-only — deliberately not exposed as an MCP tool option, since a successful MCP tool call already proves the handshake works).
|
|
327
328
|
|
|
328
329
|
A report with `ok: false` is a successful tool call describing failures, not a tool error — read the `checks[]` array for what's failing and act on each entry's `hint`, don't treat the call itself as having failed.
|
|
329
330
|
|
|
@@ -10,8 +10,8 @@ import { getVersion } from './config.mjs';
|
|
|
10
10
|
const ANSI_RE = /\x1b\[[0-9;]*m/g;
|
|
11
11
|
const visibleLength = (str) => str.replace(ANSI_RE, '').length;
|
|
12
12
|
|
|
13
|
-
const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
|
14
|
-
const SPINNER_INTERVAL = 80;
|
|
13
|
+
export const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
|
14
|
+
export const SPINNER_INTERVAL = 80;
|
|
15
15
|
|
|
16
16
|
|
|
17
17
|
function buildBox(lines, { s, borderColor = 'cyan' }) {
|
|
@@ -12,6 +12,8 @@
|
|
|
12
12
|
* before any public (CLI plain/JSON or MCP) output.
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
|
+
import { existsSync } from 'node:fs';
|
|
16
|
+
import { join } from 'node:path';
|
|
15
17
|
import { DEFAULT_CONFIG_DIR } from './config.mjs';
|
|
16
18
|
import { resolveProfile, loadCredentials, loadProfiles } from './profile-resolver.mjs';
|
|
17
19
|
import { checkLicense, GRACE_DAYS } from './license.mjs';
|
|
@@ -21,6 +23,8 @@ import { testConnections } from './connection-tester.mjs';
|
|
|
21
23
|
import { formatSize } from './attachment-downloader.mjs';
|
|
22
24
|
import { getCacheEntries, filterEntriesByProfile } from './cache-manager.mjs';
|
|
23
25
|
import { readQueue } from './recall-queue.mjs';
|
|
26
|
+
import { readMcpConfig, ENTRY_NAME, DESIRED_MCP_ENTRY } from './mcp-install.mjs';
|
|
27
|
+
import { testMcpHandshake, DEFAULT_HANDSHAKE_TIMEOUT_MS } from './mcp-handshake-checker.mjs';
|
|
24
28
|
|
|
25
29
|
const NOOP_STREAM = { write: () => true };
|
|
26
30
|
|
|
@@ -217,3 +221,83 @@ export function checkRecallQueue({
|
|
|
217
221
|
fixable: true,
|
|
218
222
|
};
|
|
219
223
|
}
|
|
224
|
+
|
|
225
|
+
export function checkMcpRegistration({
|
|
226
|
+
cwd = process.cwd(),
|
|
227
|
+
existsSyncFn = existsSync,
|
|
228
|
+
readMcpConfigFn = readMcpConfig,
|
|
229
|
+
} = {}) {
|
|
230
|
+
const configPath = join(cwd, '.mcp.json');
|
|
231
|
+
const read = readMcpConfigFn(configPath);
|
|
232
|
+
|
|
233
|
+
if (!read.ok) {
|
|
234
|
+
return {
|
|
235
|
+
id: 'mcp-registration', label: 'MCP registration', ok: false,
|
|
236
|
+
message: read.reason, hint: null, fixable: false,
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const existing = read.config.mcpServers?.[ENTRY_NAME];
|
|
241
|
+
const registered = existing !== undefined && JSON.stringify(existing) === JSON.stringify(DESIRED_MCP_ENTRY);
|
|
242
|
+
|
|
243
|
+
if (registered) {
|
|
244
|
+
return {
|
|
245
|
+
id: 'mcp-registration', label: 'MCP registration', ok: true,
|
|
246
|
+
message: '"ticketlens" is registered in .mcp.json with the correct command and args.',
|
|
247
|
+
hint: null, fixable: false,
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
if (!existsSyncFn(configPath)) {
|
|
252
|
+
return {
|
|
253
|
+
id: 'mcp-registration', label: 'MCP registration', ok: false,
|
|
254
|
+
message: 'No .mcp.json file found in the current directory.',
|
|
255
|
+
hint: 'Run `ticketlens mcp install` to create it and register "ticketlens".',
|
|
256
|
+
fixable: true,
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
return {
|
|
261
|
+
id: 'mcp-registration', label: 'MCP registration', ok: false,
|
|
262
|
+
message: '"ticketlens" is not registered in .mcp.json.',
|
|
263
|
+
hint: 'Run `ticketlens mcp install` to register it.',
|
|
264
|
+
fixable: true,
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// 'timeout' is handled separately below (its message embeds the actual
|
|
269
|
+
// timeoutMs), so it has no entry here.
|
|
270
|
+
const MCP_HANDSHAKE_MESSAGES = {
|
|
271
|
+
'spawn-error': 'Could not start "ticketlens mcp" — command not found or failed to launch.',
|
|
272
|
+
'invalid-response': '"ticketlens mcp" responded, but not with a valid initialize result.',
|
|
273
|
+
};
|
|
274
|
+
|
|
275
|
+
const MCP_HANDSHAKE_HINTS = {
|
|
276
|
+
'spawn-error': 'Confirm `ticketlens` is installed and on PATH, then run `ticketlens doctor --mcp` again.',
|
|
277
|
+
'timeout': 'Run `ticketlens doctor --mcp` again — if it keeps timing out, check for a hung `ticketlens mcp` process.',
|
|
278
|
+
'invalid-response': 'Run `ticketlens --version` to check the installed build; reinstall if the response looks corrupted.',
|
|
279
|
+
};
|
|
280
|
+
|
|
281
|
+
export async function checkMcpHandshake({
|
|
282
|
+
timeoutMs = DEFAULT_HANDSHAKE_TIMEOUT_MS,
|
|
283
|
+
testMcpHandshakeFn = testMcpHandshake,
|
|
284
|
+
} = {}) {
|
|
285
|
+
const result = await testMcpHandshakeFn({ timeoutMs });
|
|
286
|
+
|
|
287
|
+
if (result.ok) {
|
|
288
|
+
return {
|
|
289
|
+
id: 'mcp-handshake', label: 'MCP server handshake', ok: true,
|
|
290
|
+
message: `MCP server handshake succeeded, protocol version ${result.protocolVersion}.`,
|
|
291
|
+
hint: null, fixable: false,
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const message = result.reason === 'timeout'
|
|
296
|
+
? `No response from "ticketlens mcp" within ${timeoutMs}ms.`
|
|
297
|
+
: MCP_HANDSHAKE_MESSAGES[result.reason] ?? 'MCP server handshake failed.';
|
|
298
|
+
|
|
299
|
+
return {
|
|
300
|
+
id: 'mcp-handshake', label: 'MCP server handshake', ok: false,
|
|
301
|
+
message, hint: MCP_HANDSHAKE_HINTS[result.reason] ?? null, fixable: false,
|
|
302
|
+
};
|
|
303
|
+
}
|
|
@@ -8,43 +8,59 @@ import fs from 'node:fs';
|
|
|
8
8
|
import { DEFAULT_CONFIG_DIR } from './config.mjs';
|
|
9
9
|
import { handleUnknownFlags } from './arg-validator.mjs';
|
|
10
10
|
import { createStyler } from './ansi.mjs';
|
|
11
|
+
import { SPINNER_FRAMES, SPINNER_INTERVAL } from './banner.mjs';
|
|
11
12
|
import {
|
|
12
13
|
checkProfileConfig, checkLicenseFreshness, checkConnectivity,
|
|
13
|
-
checkCacheHealth, checkRecallQueue,
|
|
14
|
+
checkCacheHealth, checkRecallQueue, checkMcpRegistration, checkMcpHandshake,
|
|
14
15
|
} from './doctor-checks.mjs';
|
|
15
16
|
import { revalidateLicense } from './license.mjs';
|
|
16
17
|
import { flushQueue } from './recall-queue.mjs';
|
|
17
18
|
import { readCliToken } from './cli-auth.mjs';
|
|
19
|
+
import { mcpInstall } from './mcp-install.mjs';
|
|
18
20
|
|
|
19
|
-
const KNOWN_FLAGS = ['--format=', '--fix', '--profile=', '--help', '-h'];
|
|
21
|
+
const KNOWN_FLAGS = ['--format=', '--fix', '--profile=', '--mcp', '--help', '-h'];
|
|
22
|
+
const NOOP_STREAM = { write: () => true };
|
|
20
23
|
|
|
21
|
-
|
|
24
|
+
// A leading run of 2+ capitals is an acronym (e.g. "MCP registration") and
|
|
25
|
+
// must not be partially lowercased — only a genuine single leading capital
|
|
26
|
+
// (e.g. "Profile configuration") gets folded to start a sentence mid-line.
|
|
27
|
+
const lowerFirst = (str) => (/^[A-Z]{2,}/.test(str) ? str : str.charAt(0).toLowerCase() + str.slice(1));
|
|
22
28
|
|
|
23
|
-
function
|
|
24
|
-
const
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
const
|
|
28
|
-
stream.write(` ${icon} ${check.label}: ${check.message}\n`);
|
|
29
|
-
if (!check.ok && check.hint) {
|
|
30
|
-
for (const line of check.hint.split('\n')) stream.write(` ${s.dim(line)}\n`);
|
|
31
|
-
}
|
|
29
|
+
function renderCheckRow(check, s) {
|
|
30
|
+
const icon = check.ok ? s.green('✔') : s.red('✖');
|
|
31
|
+
let text = ` ${icon} ${check.label}: ${check.message}\n`;
|
|
32
|
+
if (!check.ok && check.hint) {
|
|
33
|
+
for (const line of check.hint.split('\n')) text += ` ${s.dim(line)}\n`;
|
|
32
34
|
}
|
|
35
|
+
return text;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function renderTrailer({ fixed, skipped }, s) {
|
|
39
|
+
let text = '';
|
|
33
40
|
if (fixed.length > 0) {
|
|
34
|
-
|
|
41
|
+
text += `\n ${s.green('Fixed:')} ${fixed.join(', ')}\n`;
|
|
35
42
|
}
|
|
36
43
|
if (skipped.length > 0) {
|
|
37
|
-
|
|
38
|
-
for (const sk of skipped)
|
|
44
|
+
text += `\n ${s.yellow('Skipped:')}\n`;
|
|
45
|
+
for (const sk of skipped) text += ` ${sk.id}: ${sk.reason}\n`;
|
|
39
46
|
}
|
|
47
|
+
return text;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function renderPlain(checks, { fixed, skipped, stream }) {
|
|
51
|
+
const s = createStyler({ isTTY: stream.isTTY });
|
|
52
|
+
stream.write('\n');
|
|
53
|
+
for (const check of checks) stream.write(renderCheckRow(check, s));
|
|
54
|
+
stream.write(renderTrailer({ fixed, skipped }, s));
|
|
40
55
|
stream.write('\n');
|
|
41
56
|
}
|
|
42
57
|
|
|
43
58
|
async function applyFixes(rawResults, {
|
|
44
|
-
configDir, profileName, format, stream,
|
|
59
|
+
configDir, profileName, cwd, format, stream,
|
|
45
60
|
revalidateLicenseFn, checkLicenseFreshnessFn,
|
|
46
61
|
unlinkFn, checkCacheHealthFn,
|
|
47
62
|
flushQueueFn, checkRecallQueueFn, readCliTokenFn,
|
|
63
|
+
mcpInstallFn, checkMcpRegistrationFn,
|
|
48
64
|
}) {
|
|
49
65
|
const fixed = [];
|
|
50
66
|
const skipped = [];
|
|
@@ -81,6 +97,14 @@ async function applyFixes(rawResults, {
|
|
|
81
97
|
}
|
|
82
98
|
}
|
|
83
99
|
|
|
100
|
+
if (byId['mcp-registration'] && !byId['mcp-registration'].ok && byId['mcp-registration'].fixable) {
|
|
101
|
+
if (format === 'plain') stream.write('Registering ticketlens as an MCP server...\n');
|
|
102
|
+
mcpInstallFn({ cwd, stream: NOOP_STREAM, dryRun: false });
|
|
103
|
+
const recheck = checkMcpRegistrationFn({ cwd });
|
|
104
|
+
byId['mcp-registration'] = recheck;
|
|
105
|
+
if (recheck.ok) fixed.push('mcp-registration');
|
|
106
|
+
}
|
|
107
|
+
|
|
84
108
|
return { results: Object.values(byId), fixed, skipped };
|
|
85
109
|
}
|
|
86
110
|
|
|
@@ -94,10 +118,13 @@ export async function runDoctor(args, {
|
|
|
94
118
|
checkConnectivityFn = checkConnectivity,
|
|
95
119
|
checkCacheHealthFn = checkCacheHealth,
|
|
96
120
|
checkRecallQueueFn = checkRecallQueue,
|
|
121
|
+
checkMcpRegistrationFn = checkMcpRegistration,
|
|
122
|
+
checkMcpHandshakeFn = checkMcpHandshake,
|
|
97
123
|
revalidateLicenseFn = revalidateLicense,
|
|
98
124
|
unlinkFn = (p) => fs.unlinkSync(p),
|
|
99
125
|
flushQueueFn = flushQueue,
|
|
100
126
|
readCliTokenFn = readCliToken,
|
|
127
|
+
mcpInstallFn = mcpInstall,
|
|
101
128
|
} = {}) {
|
|
102
129
|
const validated = await handleUnknownFlags(args, KNOWN_FLAGS, { stream });
|
|
103
130
|
if (validated === null) return { ok: false };
|
|
@@ -112,6 +139,7 @@ export async function runDoctor(args, {
|
|
|
112
139
|
const profileArg = validated.find(a => a.startsWith('--profile='));
|
|
113
140
|
const profileName = profileArg ? profileArg.split('=')[1] : null;
|
|
114
141
|
const shouldFix = validated.includes('--fix');
|
|
142
|
+
const shouldCheckMcpHandshake = validated.includes('--mcp');
|
|
115
143
|
|
|
116
144
|
const checkList = [
|
|
117
145
|
{ label: 'Profile configuration', run: () => checkProfileConfigFn({ configDir, profileName, cwd }) },
|
|
@@ -119,17 +147,57 @@ export async function runDoctor(args, {
|
|
|
119
147
|
{ label: 'Tracker connectivity', run: () => checkConnectivityFn({ configDir, profileName, cwd }) },
|
|
120
148
|
{ label: 'Attachment cache', run: () => checkCacheHealthFn({ configDir, profileName }) },
|
|
121
149
|
{ label: 'Recall sync queue', run: () => checkRecallQueueFn({ configDir }) },
|
|
150
|
+
{ label: 'MCP registration', run: () => checkMcpRegistrationFn({ cwd }) },
|
|
151
|
+
...(shouldCheckMcpHandshake ? [{ label: 'MCP server handshake', run: () => checkMcpHandshakeFn({}) }] : []),
|
|
122
152
|
];
|
|
123
153
|
|
|
124
154
|
const showProgress = format === 'plain' && stream.isTTY;
|
|
125
|
-
const
|
|
155
|
+
const loaderStyler = createStyler({ isTTY: stream.isTTY });
|
|
156
|
+
const outStyler = createStyler({ isTTY: out.isTTY });
|
|
126
157
|
const rawResults = [];
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
158
|
+
|
|
159
|
+
const writeSpinnerLine = (label, frame) => {
|
|
160
|
+
stream.write(` ${loaderStyler.brand(SPINNER_FRAMES[frame])} ${loaderStyler.dim(`Checking ${lowerFirst(label)}…`)}\n`);
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
// Ctrl+C during a slow check (e.g. tracker connectivity) must not leave the
|
|
164
|
+
// user's real terminal cursor permanently hidden — Node's default SIGINT
|
|
165
|
+
// handling terminates before pending finally blocks on an in-flight await
|
|
166
|
+
// run, so this needs its own listener. Same pattern as init-wizard.mjs.
|
|
167
|
+
const onSigint = () => { stream.write('\x1b[?25h'); process.exit(130); };
|
|
168
|
+
if (showProgress) {
|
|
169
|
+
out.write('\n');
|
|
170
|
+
stream.write('\x1b[?25l'); // hide cursor for the whole check-running phase
|
|
171
|
+
process.on('SIGINT', onSigint);
|
|
172
|
+
}
|
|
173
|
+
try {
|
|
174
|
+
for (const { label, run } of checkList) {
|
|
175
|
+
let frame = 0;
|
|
176
|
+
let timer;
|
|
177
|
+
if (showProgress) {
|
|
178
|
+
writeSpinnerLine(label, frame);
|
|
179
|
+
timer = setInterval(() => {
|
|
180
|
+
frame = (frame + 1) % SPINNER_FRAMES.length;
|
|
181
|
+
stream.write('\x1b[A\r\x1b[2K');
|
|
182
|
+
writeSpinnerLine(label, frame);
|
|
183
|
+
}, SPINNER_INTERVAL);
|
|
184
|
+
}
|
|
185
|
+
let result;
|
|
186
|
+
try {
|
|
187
|
+
result = await run();
|
|
188
|
+
} finally {
|
|
189
|
+
if (showProgress) {
|
|
190
|
+
clearInterval(timer);
|
|
191
|
+
stream.write('\x1b[A\r\x1b[2K');
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
rawResults.push(result);
|
|
195
|
+
if (showProgress) out.write(renderCheckRow(result, outStyler));
|
|
196
|
+
}
|
|
197
|
+
} finally {
|
|
198
|
+
if (showProgress) {
|
|
199
|
+
stream.write('\x1b[?25h'); // restore cursor
|
|
200
|
+
process.removeListener('SIGINT', onSigint);
|
|
133
201
|
}
|
|
134
202
|
}
|
|
135
203
|
|
|
@@ -138,10 +206,11 @@ export async function runDoctor(args, {
|
|
|
138
206
|
let finalResults = rawResults;
|
|
139
207
|
if (shouldFix) {
|
|
140
208
|
const applied = await applyFixes(rawResults, {
|
|
141
|
-
configDir, profileName, format, stream,
|
|
209
|
+
configDir, profileName, cwd, format, stream,
|
|
142
210
|
revalidateLicenseFn, checkLicenseFreshnessFn,
|
|
143
211
|
unlinkFn, checkCacheHealthFn,
|
|
144
212
|
flushQueueFn, checkRecallQueueFn, readCliTokenFn,
|
|
213
|
+
mcpInstallFn, checkMcpRegistrationFn,
|
|
145
214
|
});
|
|
146
215
|
finalResults = applied.results;
|
|
147
216
|
fixed = applied.fixed;
|
|
@@ -156,6 +225,15 @@ export async function runDoctor(args, {
|
|
|
156
225
|
return { ok };
|
|
157
226
|
}
|
|
158
227
|
|
|
159
|
-
|
|
228
|
+
if (showProgress) {
|
|
229
|
+
// Rows already streamed to `out` progressively during the check loop
|
|
230
|
+
// (each check's raw, first-observed state — a later `--fix` never
|
|
231
|
+
// rewrites an already-shown row; the trailing Fixed:/Skipped: block
|
|
232
|
+
// below is the sole signal of what got repaired).
|
|
233
|
+
out.write(renderTrailer({ fixed, skipped }, outStyler));
|
|
234
|
+
out.write('\n');
|
|
235
|
+
} else {
|
|
236
|
+
renderPlain(checks, { fixed, skipped, stream: out });
|
|
237
|
+
}
|
|
160
238
|
return { ok };
|
|
161
239
|
}
|
|
@@ -1230,18 +1230,21 @@ export function printDoctorHelp({ stream = process.stdout } = {}) {
|
|
|
1230
1230
|
const s = createStyler({ isTTY: stream.isTTY });
|
|
1231
1231
|
const lines = [
|
|
1232
1232
|
'',
|
|
1233
|
-
` ${s.bold(s.brand('ticketlens'))} ${s.bold('doctor')} ${s.dim('[--fix] [--format=plain|json] [--profile=NAME]')}`,
|
|
1233
|
+
` ${s.bold(s.brand('ticketlens'))} ${s.bold('doctor')} ${s.dim('[--fix] [--format=plain|json] [--profile=NAME] [--mcp]')}`,
|
|
1234
1234
|
'',
|
|
1235
1235
|
` Diagnose common TicketLens problems: profile configuration, license`,
|
|
1236
|
-
` freshness, tracker connectivity, attachment cache health,
|
|
1237
|
-
` sync queue.
|
|
1236
|
+
` freshness, tracker connectivity, attachment cache health, MCP`,
|
|
1237
|
+
` registration, and the Recall sync queue. Pass --mcp to also check the`,
|
|
1238
|
+
` MCP server handshake. Free tier, fully unrestricted.`,
|
|
1238
1239
|
'',
|
|
1239
1240
|
` ${s.bold('OPTIONS')}`,
|
|
1240
1241
|
'',
|
|
1241
1242
|
` ${s.brand('--fix')} Attempt safe, non-destructive repairs for failing checks`,
|
|
1243
|
+
` ${s.dim('(rows show pre-fix status; see the Fixed: summary for what was repaired)')}`,
|
|
1242
1244
|
` ${s.brand('--format')}=${s.dim('plain')} Human-readable output ${s.dim('(default)')}`,
|
|
1243
1245
|
` ${s.brand('--format')}=${s.dim('json')} JSON output for scripting/piping`,
|
|
1244
1246
|
` ${s.brand('--profile')}=${s.dim('NAME')} Scope profile/connectivity/cache checks to one profile`,
|
|
1247
|
+
` ${s.brand('--mcp')} Also run the MCP server handshake check (spawns a subprocess)`,
|
|
1245
1248
|
` ${s.brand('-h')}, ${s.brand('--help')} Show this help`,
|
|
1246
1249
|
'',
|
|
1247
1250
|
` ${s.bold('EXAMPLES')}`,
|
|
@@ -1250,6 +1253,7 @@ export function printDoctorHelp({ stream = process.stdout } = {}) {
|
|
|
1250
1253
|
` ${s.dim('$')} ticketlens doctor --fix`,
|
|
1251
1254
|
` ${s.dim('$')} ticketlens doctor --profile=work`,
|
|
1252
1255
|
` ${s.dim('$')} ticketlens doctor --format=json`,
|
|
1256
|
+
` ${s.dim('$')} ticketlens doctor --mcp`,
|
|
1253
1257
|
'',
|
|
1254
1258
|
];
|
|
1255
1259
|
stream.write(lines.join('\n') + '\n');
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Spawns `ticketlens mcp` as a child process and speaks one round of the
|
|
3
|
+
* MCP stdio JSON-RPC handshake to it — the same `initialize` request an AI
|
|
4
|
+
* harness sends per the MCP stdio transport spec (see mcp-server.mjs's own
|
|
5
|
+
* `initialize` handler). Pure diagnostic probe: the child is always killed
|
|
6
|
+
* before this resolves, on every path (success, timeout, or error) — never
|
|
7
|
+
* left running.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { spawn } from 'node:child_process';
|
|
11
|
+
|
|
12
|
+
export const DEFAULT_HANDSHAKE_TIMEOUT_MS = 5000;
|
|
13
|
+
const PROTOCOL_VERSION = '2025-11-25';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* @returns {Promise<{ok: boolean, reason?: 'spawn-error'|'timeout'|'invalid-response', error?: Error, protocolVersion?: string}>}
|
|
17
|
+
*/
|
|
18
|
+
export function testMcpHandshake({
|
|
19
|
+
spawnFn = spawn,
|
|
20
|
+
timeoutMs = DEFAULT_HANDSHAKE_TIMEOUT_MS,
|
|
21
|
+
} = {}) {
|
|
22
|
+
return new Promise((resolve) => {
|
|
23
|
+
let settled = false;
|
|
24
|
+
let buffer = '';
|
|
25
|
+
let child;
|
|
26
|
+
|
|
27
|
+
const settle = (value) => {
|
|
28
|
+
if (settled) return;
|
|
29
|
+
settled = true;
|
|
30
|
+
clearTimeout(timer);
|
|
31
|
+
try { child?.kill(); } catch { /* already exited */ }
|
|
32
|
+
resolve(value);
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const timer = setTimeout(() => settle({ ok: false, reason: 'timeout' }), timeoutMs);
|
|
36
|
+
|
|
37
|
+
try {
|
|
38
|
+
child = spawnFn('ticketlens', ['mcp'], { stdio: ['pipe', 'pipe', 'pipe'] });
|
|
39
|
+
} catch (error) {
|
|
40
|
+
settle({ ok: false, reason: 'spawn-error', error });
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
child.on('error', (error) => settle({ ok: false, reason: 'spawn-error', error }));
|
|
45
|
+
// A write can fail asynchronously (e.g. EPIPE if the child already died) —
|
|
46
|
+
// an unhandled 'error' on a Writable stream crashes the process. The
|
|
47
|
+
// child's own 'error'/'exit' already attributes the real failure reason;
|
|
48
|
+
// this only needs to stop the crash, not settle a second time.
|
|
49
|
+
child.stdin.on('error', () => {});
|
|
50
|
+
|
|
51
|
+
child.stdout.on('data', (chunk) => {
|
|
52
|
+
buffer += chunk.toString();
|
|
53
|
+
const newlineIdx = buffer.indexOf('\n');
|
|
54
|
+
if (newlineIdx === -1) return;
|
|
55
|
+
const line = buffer.slice(0, newlineIdx);
|
|
56
|
+
let msg;
|
|
57
|
+
try {
|
|
58
|
+
msg = JSON.parse(line);
|
|
59
|
+
} catch {
|
|
60
|
+
settle({ ok: false, reason: 'invalid-response' });
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
if (msg?.id === 1 && msg.result?.protocolVersion) {
|
|
64
|
+
settle({ ok: true, protocolVersion: msg.result.protocolVersion });
|
|
65
|
+
} else {
|
|
66
|
+
settle({ ok: false, reason: 'invalid-response' });
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
const request = JSON.stringify({
|
|
71
|
+
jsonrpc: '2.0',
|
|
72
|
+
id: 1,
|
|
73
|
+
method: 'initialize',
|
|
74
|
+
params: {
|
|
75
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
76
|
+
capabilities: {},
|
|
77
|
+
clientInfo: { name: 'ticketlens-doctor', version: '1.0' },
|
|
78
|
+
},
|
|
79
|
+
}) + '\n';
|
|
80
|
+
child.stdin.write(request);
|
|
81
|
+
});
|
|
82
|
+
}
|
|
@@ -20,9 +20,10 @@ import { DEFAULT_CONFIG_DIR } from './config.mjs';
|
|
|
20
20
|
import { isLicensed } from './license.mjs';
|
|
21
21
|
import { createStyler } from './ansi.mjs';
|
|
22
22
|
|
|
23
|
-
const ENTRY_NAME = 'ticketlens';
|
|
23
|
+
export const ENTRY_NAME = 'ticketlens';
|
|
24
|
+
export const DESIRED_MCP_ENTRY = { command: ENTRY_NAME, args: ['mcp'] };
|
|
24
25
|
|
|
25
|
-
function
|
|
26
|
+
export function readMcpConfig(configPath) {
|
|
26
27
|
if (!existsSync(configPath)) return { ok: true, config: {} };
|
|
27
28
|
let parsed;
|
|
28
29
|
try {
|
|
@@ -55,7 +56,7 @@ export function mcpInstall({
|
|
|
55
56
|
const configPath = join(cwd, '.mcp.json');
|
|
56
57
|
const s = createStyler({ isTTY: stream.isTTY });
|
|
57
58
|
|
|
58
|
-
const read =
|
|
59
|
+
const read = readMcpConfig(configPath);
|
|
59
60
|
if (!read.ok) {
|
|
60
61
|
stream.write(` ${s.dim('✗')} ${read.reason}\n`);
|
|
61
62
|
return { written: false, dryRun, path: configPath, reason: read.reason };
|
|
@@ -63,7 +64,7 @@ export function mcpInstall({
|
|
|
63
64
|
|
|
64
65
|
const config = read.config;
|
|
65
66
|
config.mcpServers ??= {};
|
|
66
|
-
const desired =
|
|
67
|
+
const desired = DESIRED_MCP_ENTRY;
|
|
67
68
|
const existing = config.mcpServers[ENTRY_NAME];
|
|
68
69
|
const alreadyCorrect = existing !== undefined && JSON.stringify(existing) === JSON.stringify(desired);
|
|
69
70
|
config.mcpServers[ENTRY_NAME] = desired;
|
|
@@ -31,7 +31,7 @@ const PROTOCOL_VERSION = '2025-11-25';
|
|
|
31
31
|
const TOOLS = [
|
|
32
32
|
{
|
|
33
33
|
name: 'doctor',
|
|
34
|
-
description: 'Diagnose common TicketLens problems: profile configuration, license freshness, tracker connectivity, attachment cache health, and the Recall sync queue. Always returns structured JSON. Free tier, fully unrestricted — including fix.',
|
|
34
|
+
description: 'Diagnose common TicketLens problems: profile configuration, license freshness, tracker connectivity, attachment cache health, MCP registration, and the Recall sync queue. Always returns structured JSON. Free tier, fully unrestricted — including fix.',
|
|
35
35
|
inputSchema: {
|
|
36
36
|
type: 'object',
|
|
37
37
|
properties: {
|