ticketlens 0.38.30 → 0.38.32
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 +1 -1
- package/package.json +1 -1
- package/skills/jtb/SKILL.md +67 -1
- package/skills/jtb/scripts/fetch-my-tickets.mjs +22 -16
- package/skills/jtb/scripts/fetch-ticket.mjs +106 -67
- package/skills/jtb/scripts/lib/api-utils.mjs +1 -1
- package/skills/jtb/scripts/lib/help.mjs +20 -15
- package/skills/jtb/scripts/lib/mcp-server.mjs +283 -3
package/README.md
CHANGED
|
@@ -453,7 +453,7 @@ Every note is scanned before saving — anything shaped like a real secret (API
|
|
|
453
453
|
|
|
454
454
|
**Removing a note:** `ticketlens note delete --id="..." [--ticket=KEY]` removes a note from your local vault. Local only — if it was already pushed to a team, teammates who pulled it keep their copy; deleting it there too is a manager action from the Console (Admin > Recall).
|
|
455
455
|
|
|
456
|
-
**Any MCP-capable AI harness:** `ticketlens mcp` starts a stdio [MCP](https://modelcontextprotocol.io) server exposing `recall_add`, `recall_search`, `ticket_comment`, `ticket_transition`, `ticket_assign`, `ticket_duplicates`, `ticket_link`, `ticket_update`, and `ticket_create` as native tools — any MCP-compatible AI assistant, not just Claude Code, can call them directly instead of constructing a shell command. It's a thin adapter over the exact same code as the CLI commands above — same
|
|
456
|
+
**Any MCP-capable AI harness:** `ticketlens mcp` starts a stdio [MCP](https://modelcontextprotocol.io) server exposing `fetch`, `triage`, `compliance`, `review`, `standup`, `pr`, `doctor`, `recall_add`, `recall_search`, `ticket_comment`, `ticket_transition`, `ticket_assign`, `ticket_duplicates`, `ticket_link`, `ticket_update`, and `ticket_create` as native tools — any MCP-compatible AI assistant, not just Claude Code, can call them directly instead of constructing a shell command. It's a thin adapter over the exact same code as the CLI commands above — same license gate per tool, same secret scan/local vault/tracker writes, same team sync — nothing is reimplemented. `fetch`, `doctor`, and `standup` are Free; `triage`'s base scan is Free with some options gated Pro/Team, same as the CLI (`ticketlens triage --help`); `compliance` and `pr` are Free, sharing a 3-checks/month cap on their requirements-coverage section, Pro unlimited; `review` is Free for branch/files/ticket context, with its coverage/focus section requiring Pro as a plain license check — it does not draw from that same monthly counter; every other tool needs Pro. Point your harness's MCP config at it: `{ "command": "ticketlens", "args": ["mcp"] }` — or run `ticketlens mcp install` in a project to write that entry into its `.mcp.json` for you (creates the file if it doesn't exist, merges in if it does — never touches any other entry already there; `--dry-run` to preview first).
|
|
457
457
|
|
|
458
458
|
`note add`'s save confirmation and `recall`'s search results are styled by default in a terminal; add `--plain` to either for bare, pipe-safe output. `recall` always shows each note's file ID (e.g. `[1784135399545-fe01c4.md]`) so you can open it directly (`cat ~/.ticketlens/recall/<PREFIX>/<id>`), or pass `--full` to print the full body content inline instead. Each result shows a relative time (`2h ago`, `3d ago`) rather than a bare date — the full-precision timestamp is always in the note file's own frontmatter.
|
|
459
459
|
|
package/package.json
CHANGED
package/skills/jtb/SKILL.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
<!-- jtb-skill-version: 0.
|
|
1
|
+
<!-- jtb-skill-version: 0.37.0 -->
|
|
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.
|
|
@@ -106,6 +106,8 @@ Where `$EXTRA_ARGS` are any flags passed (e.g. `--stale=3 --status=QA --profile=
|
|
|
106
106
|
|
|
107
107
|
**IMPORTANT:** Copy the script's stdout and display it directly as your response text (not inside a tool result). This ensures the markdown table renders visibly and URLs are clickable in the terminal. No VCS enrichment, no plan mode. Stop here.
|
|
108
108
|
|
|
109
|
+
If this harness has TicketLens's MCP server configured (a tool named `triage` — often shown as `mcp__ticketlens__triage` — visible in your tool list), prefer it over the bash form: same license gate per option (Free base scan, Pro `save`/`all`/`digest`, Team `assignee`/`sprint`/`export`/`project`/`label`/`priority`) — just no shell command to construct. It accepts `profile`/`stale`/`status`/`sort` plus those gated options; `--push`/`--share` aren't exposed yet — fall back to the bash form for those.
|
|
110
|
+
|
|
109
111
|
---
|
|
110
112
|
|
|
111
113
|
### Collisions subcommand
|
|
@@ -125,6 +127,63 @@ Display the script's stdout directly. No plan mode. Stop here.
|
|
|
125
127
|
|
|
126
128
|
---
|
|
127
129
|
|
|
130
|
+
### Standup subcommand
|
|
131
|
+
|
|
132
|
+
If the first argument is `standup`:
|
|
133
|
+
|
|
134
|
+
Run:
|
|
135
|
+
```bash
|
|
136
|
+
ticketlens standup $EXTRA_ARGS
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
Where `$EXTRA_ARGS` are any flags passed (e.g. `--since=48 --format=pr --profile=acme`).
|
|
140
|
+
|
|
141
|
+
Summarizes recent git commits grouped by linked ticket key. `--since=N` (hours, default 24) or a git-compatible date expression (`--since="3 days ago"`); `--format=standup` (default) renders a per-ticket standup update, `--format=pr` renders the same grouped commits as PR-body-style markdown. Read-only, fully free tier — no license gate on any option.
|
|
142
|
+
|
|
143
|
+
Display the script's stdout directly. No plan mode. Stop here.
|
|
144
|
+
|
|
145
|
+
If this harness has TicketLens's MCP server configured (a tool named `standup` — often shown as `mcp__ticketlens__standup` — visible in your tool list), prefer it over the bash form: same output, no license gate, no shell command to construct. It accepts `since`/`format`/`profile`.
|
|
146
|
+
|
|
147
|
+
---
|
|
148
|
+
|
|
149
|
+
### PR subcommand
|
|
150
|
+
|
|
151
|
+
If the first argument is `pr`:
|
|
152
|
+
|
|
153
|
+
Run:
|
|
154
|
+
```bash
|
|
155
|
+
ticketlens pr PROJ-123 $EXTRA_ARGS
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
Where `$EXTRA_ARGS` are any flags passed (e.g. `--profile=acme`). Requires a ticket key as the first positional argument.
|
|
159
|
+
|
|
160
|
+
Assembles a ready-to-paste PR description: what changed (from commits linked to the ticket), linked tickets, and — if the ticket has acceptance criteria — a requirements-coverage section. Read-only. The requirements-coverage section reuses the same Free-tier 3-checks/month counter as `--compliance`/`ticketlens compliance` — running `pr` on a ticket with acceptance criteria counts against that shared monthly limit; Pro removes the cap.
|
|
161
|
+
|
|
162
|
+
Display the script's stdout directly. No plan mode. Stop here.
|
|
163
|
+
|
|
164
|
+
If this harness has TicketLens's MCP server configured (a tool named `pr` — often shown as `mcp__ticketlens__pr` — visible in your tool list), prefer it over the bash form: same output, same shared compliance-counter caveat, no shell command to construct. It accepts `ticket`/`profile`.
|
|
165
|
+
|
|
166
|
+
---
|
|
167
|
+
|
|
168
|
+
### Review subcommand
|
|
169
|
+
|
|
170
|
+
If the first argument is `review`:
|
|
171
|
+
|
|
172
|
+
Run:
|
|
173
|
+
```bash
|
|
174
|
+
ticketlens review $EXTRA_ARGS
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
Where `$EXTRA_ARGS` are any flags passed (e.g. `--base=develop --profile=acme`). No required positional argument — operates on the current branch.
|
|
178
|
+
|
|
179
|
+
Assembles PR review context from the current git branch: changed files, linked-ticket summaries from commits/branch name, and (Pro) a requirements-coverage / review-focus section extracted from the diff against acceptance criteria. `--base=BRANCH` (or its alias `--branch=BRANCH`) sets the diff base, auto-detecting `main`/`master`/`develop` when omitted. Read-only — never modifies the tracker or the repo. Free tier gets branch info, changed files, and ticket context; Pro adds the requirements-coverage and review-focus sections — same split as `--compliance`.
|
|
180
|
+
|
|
181
|
+
Display the script's stdout directly. No plan mode. Stop here.
|
|
182
|
+
|
|
183
|
+
If this harness has TicketLens's MCP server configured (a tool named `review` — often shown as `mcp__ticketlens__review` — visible in your tool list), prefer it over the bash form: same tier gate (Free: branch/files/tickets, Pro: +coverage/focus), no shell command to construct. It accepts `base`/`branch`/`profile` — `base` and `branch` are aliases; if both are given, `base` wins.
|
|
184
|
+
|
|
185
|
+
---
|
|
186
|
+
|
|
128
187
|
### Fetch ticket workflow (default)
|
|
129
188
|
|
|
130
189
|
### Step 1: Validate environment
|
|
@@ -145,6 +204,8 @@ Where `$TICKET_KEY` is the first argument (e.g. `PROD-1234`) and `$EXTRA_ARGS` a
|
|
|
145
204
|
|
|
146
205
|
The script outputs a structured markdown TicketBrief to stdout. If it fails (exit code 1), show the stderr message to the user.
|
|
147
206
|
|
|
207
|
+
If this harness has TicketLens's MCP server configured (a tool named `fetch` — often shown as `mcp__ticketlens__fetch` — visible in your tool list), prefer it over the bash form: same license gate (Free), same cache — just no shell command to construct or stdout to parse. It accepts `ticket`/`profile`/`depth`, matching `$TICKET_KEY`/`--profile`/`--depth` above; other CLI flags (`--summarize`, `--handoff`, `--no-cache`, etc.) aren't exposed yet — fall back to the bash form for those.
|
|
208
|
+
|
|
148
209
|
### Step 2b: Read attached files
|
|
149
210
|
|
|
150
211
|
Check if the TicketBrief contains an `## Attachments` section. If it does, for each line containing a backtick-quoted absolute path, call the Read tool on that path based on file type:
|
|
@@ -400,6 +461,11 @@ Use this evaluation order:
|
|
|
400
461
|
### Privacy
|
|
401
462
|
`--compliance` never sends data anywhere. The diff stays local. All analysis is performed by Claude Code within your session context.
|
|
402
463
|
|
|
464
|
+
### Standalone command
|
|
465
|
+
The same tier-gated check also runs as its own command — `ticketlens compliance PROJ-123` — independent of a full ticket fetch. This is what `ticketlens install-hooks` wires into a pre-push git hook (`ticketlens compliance "$KEY" || exit 1`, gated on a configurable coverage threshold). It shares the same `FREE_LIMIT`/Pro gate and the same compliance ledger as the `--compliance` flag above.
|
|
466
|
+
|
|
467
|
+
If this harness has TicketLens's MCP server configured (a tool named `compliance` — often shown as `mcp__ticketlens__compliance` — visible in your tool list), prefer it over the bash form: same tier gate (Free: 3 checks/month, Pro: unlimited), same report — just no shell command to construct or stdout to parse. It accepts `ticket`/`profile`, matching the standalone command's arguments above.
|
|
468
|
+
|
|
403
469
|
---
|
|
404
470
|
|
|
405
471
|
## Advanced Options
|
|
@@ -57,6 +57,12 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
|
|
|
57
57
|
env = envOrOpts;
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
+
// Injectable stderr-equivalent — defaults to the real stream so every existing
|
|
61
|
+
// CLI caller (which never sets opts.stream) is byte-identical to before. Lets
|
|
62
|
+
// a long-lived host (the MCP server) capture banners/errors instead of losing
|
|
63
|
+
// them to a real process.stderr it never reads.
|
|
64
|
+
const stream = opts.stream ?? process.stderr;
|
|
65
|
+
|
|
60
66
|
// Strip leading 'triage' subcommand if present (when called via CLI router)
|
|
61
67
|
if (args[0] === 'triage') args = args.slice(1);
|
|
62
68
|
|
|
@@ -99,7 +105,7 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
|
|
|
99
105
|
const saveArg = args.find(a => a.startsWith('--save='))?.split('=').slice(1).join('=') ?? null;
|
|
100
106
|
|
|
101
107
|
if (exportArg && exportArg !== 'csv' && exportArg !== 'json') {
|
|
102
|
-
|
|
108
|
+
stream.write(`Error: --export must be csv or json, got: ${exportArg}\n`);
|
|
103
109
|
process.exitCode = 1;
|
|
104
110
|
return;
|
|
105
111
|
}
|
|
@@ -120,14 +126,14 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
|
|
|
120
126
|
// --save=FILE: Pro gate + validate path is not a directory
|
|
121
127
|
if (saveArg) {
|
|
122
128
|
if (!licensedFn('pro', configDir)) {
|
|
123
|
-
upgradeFn('pro', '--save');
|
|
129
|
+
upgradeFn('pro', '--save', { stream });
|
|
124
130
|
process.exitCode = 1;
|
|
125
131
|
return;
|
|
126
132
|
}
|
|
127
133
|
const resolvedSave = resolvePath(saveArg);
|
|
128
134
|
try {
|
|
129
135
|
if (statSync(resolvedSave).isDirectory()) {
|
|
130
|
-
|
|
136
|
+
stream.write(`Error: --save path must be a file, not a directory: ${resolvedSave}\n`);
|
|
131
137
|
process.exitCode = 1;
|
|
132
138
|
return;
|
|
133
139
|
}
|
|
@@ -138,7 +144,7 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
|
|
|
138
144
|
if (projectArg || labelArg || priorityArg) {
|
|
139
145
|
if (!licensedFn('team', configDir)) {
|
|
140
146
|
const flag = projectArg ? '--project' : labelArg ? '--label' : '--priority';
|
|
141
|
-
upgradeFn('team', flag);
|
|
147
|
+
upgradeFn('team', flag, { stream });
|
|
142
148
|
process.exitCode = 1;
|
|
143
149
|
return;
|
|
144
150
|
}
|
|
@@ -147,14 +153,14 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
|
|
|
147
153
|
// --all: triage all configured profiles in parallel with live status block
|
|
148
154
|
if (allFlag) {
|
|
149
155
|
if (!licensedFn('pro', configDir)) {
|
|
150
|
-
upgradeFn('pro', '--all');
|
|
156
|
+
upgradeFn('pro', '--all', { stream });
|
|
151
157
|
process.exitCode = 1;
|
|
152
158
|
return;
|
|
153
159
|
}
|
|
154
160
|
const profilesConfig = loadProfiles(configDir);
|
|
155
161
|
const profileNames = profilesConfig?.profiles ? Object.keys(profilesConfig.profiles) : [];
|
|
156
162
|
if (profileNames.length === 0) {
|
|
157
|
-
|
|
163
|
+
stream.write('Error: No profiles configured. Run `ticketlens init` first.\n');
|
|
158
164
|
process.exitCode = 1;
|
|
159
165
|
return;
|
|
160
166
|
}
|
|
@@ -234,14 +240,14 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
|
|
|
234
240
|
|
|
235
241
|
// Team-tier gate: --assignee and --sprint require a Team license
|
|
236
242
|
if ((assigneeArg || sprintArg) && !licensedFn('team', configDir)) {
|
|
237
|
-
upgradeFn('team', assigneeArg ? '--assignee' : '--sprint');
|
|
243
|
+
upgradeFn('team', assigneeArg ? '--assignee' : '--sprint', { stream });
|
|
238
244
|
process.exitCode = 1;
|
|
239
245
|
return;
|
|
240
246
|
}
|
|
241
247
|
|
|
242
248
|
// Team-tier gate: --export requires a Team license
|
|
243
249
|
if (exportArg && !licensedFn('team', configDir)) {
|
|
244
|
-
upgradeFn('team', '--export');
|
|
250
|
+
upgradeFn('team', '--export', { stream });
|
|
245
251
|
process.exitCode = 1;
|
|
246
252
|
return;
|
|
247
253
|
}
|
|
@@ -256,14 +262,14 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
|
|
|
256
262
|
configDir,
|
|
257
263
|
profileName,
|
|
258
264
|
cwd,
|
|
259
|
-
onWarning: (w) =>
|
|
265
|
+
onWarning: (w) => stream.write(w + '\n'),
|
|
260
266
|
onProfileNotFound: (info) => { profileError = info; },
|
|
261
267
|
});
|
|
262
268
|
|
|
263
269
|
const hasAuth = conn.pat || (conn.email && conn.apiToken);
|
|
264
270
|
if (!conn.baseUrl || !hasAuth) {
|
|
265
271
|
if (profileError) {
|
|
266
|
-
const picked = await promptProfileSelect(profileError);
|
|
272
|
+
const picked = await promptProfileSelect(profileError, { stream });
|
|
267
273
|
if (picked) {
|
|
268
274
|
// Re-run with the selected profile
|
|
269
275
|
const newArgs = args.filter(a => !a.startsWith('--profile='));
|
|
@@ -275,7 +281,7 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
|
|
|
275
281
|
const msg = noProfiles
|
|
276
282
|
? 'Error: Could not determine Jira profile.\nRun `ticketlens init` to set up your connection.'
|
|
277
283
|
: 'Error: Could not determine Jira profile. Use --profile=NAME or add projectPaths to ~/.ticketlens/profiles.json';
|
|
278
|
-
|
|
284
|
+
stream.write(msg + '\n');
|
|
279
285
|
}
|
|
280
286
|
process.exitCode = 1;
|
|
281
287
|
return;
|
|
@@ -304,7 +310,7 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
|
|
|
304
310
|
const priorityClause = priorityArg ? ` AND priority = "${escapeJql(priorityArg.split('=')[1])}"` : '';
|
|
305
311
|
const jql = `${assigneeClause} AND status IN (${statusList})${sprintClause}${projectClause}${labelClause}${priorityClause} ORDER BY updated DESC`;
|
|
306
312
|
|
|
307
|
-
const session = createSession(conn);
|
|
313
|
+
const session = createSession(conn, { stream });
|
|
308
314
|
session.spin(`Connecting to ${session.label}…`);
|
|
309
315
|
|
|
310
316
|
// Fire both requests concurrently — they are independent of each other
|
|
@@ -329,7 +335,7 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
|
|
|
329
335
|
}
|
|
330
336
|
|
|
331
337
|
session.connected();
|
|
332
|
-
|
|
338
|
+
stream.write('\n');
|
|
333
339
|
|
|
334
340
|
const scanSpinner = createSpinner('Scanning tickets…');
|
|
335
341
|
scanSpinner.start();
|
|
@@ -341,7 +347,7 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
|
|
|
341
347
|
scanSpinner.stop();
|
|
342
348
|
if (err.status === 400 && err.detail && /does not exist for the field 'status'/.test(err.detail)) {
|
|
343
349
|
const s = session.styler;
|
|
344
|
-
const out =
|
|
350
|
+
const out = stream;
|
|
345
351
|
out.write(`\n ${s.yellow('○')} Status mismatch — checking Jira...\n`);
|
|
346
352
|
try {
|
|
347
353
|
const available = await adapter.fetchStatuses();
|
|
@@ -424,7 +430,7 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
|
|
|
424
430
|
: currentUser;
|
|
425
431
|
|
|
426
432
|
if (assigneeName) {
|
|
427
|
-
|
|
433
|
+
stream.write(`Viewing ${assigneeName}'s tickets\n\n`);
|
|
428
434
|
}
|
|
429
435
|
|
|
430
436
|
const scored = tickets.map(t => scoreAttention(t, effectiveUser, { staleDays, customRules: conn.attentionRules, staleRule: conn.staleRule ?? null }));
|
|
@@ -445,7 +451,7 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
|
|
|
445
451
|
// --digest: POST scored results to the digest backend endpoint
|
|
446
452
|
if (digestFlag) {
|
|
447
453
|
if (!licensedFn('pro', configDir)) {
|
|
448
|
-
upgradeFn('pro', '--digest');
|
|
454
|
+
upgradeFn('pro', '--digest', { stream });
|
|
449
455
|
process.exitCode = 1;
|
|
450
456
|
return;
|
|
451
457
|
}
|
|
@@ -336,17 +336,20 @@ async function countRecallInjection(recallNotes, configDir, opts) {
|
|
|
336
336
|
}
|
|
337
337
|
}
|
|
338
338
|
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
339
|
+
// setInterval won't fire while spawnSync blocks the event loop, so we draw
|
|
340
|
+
// synchronously on update() and only use setInterval during async fetch phases.
|
|
341
|
+
// Both callers pass run()'s errStream, so the spinner shares the same isTTY and
|
|
342
|
+
// write target as everything else the command emits: the real process.stderr for
|
|
343
|
+
// a CLI user, the captured printErr under MCP (where the draws must not leak).
|
|
344
|
+
function makeSpinner(s, { isTTY, write }) {
|
|
345
|
+
if (!isTTY) return { update: () => {}, startAnim: () => {}, done: () => {} };
|
|
343
346
|
const frames = ['⠋','⠙','⠹','⠸','⠼','⠴','⠦','⠧','⠇','⠏'];
|
|
344
347
|
let fi = 0, msg = '', lastW = 0, timerId = null, finished = false;
|
|
345
348
|
|
|
346
349
|
const draw = () => {
|
|
347
350
|
const raw = ` ${s.brand(frames[fi % frames.length])} ${s.dim(msg)}`;
|
|
348
351
|
const vis = raw.replace(/\x1b\[[0-9;]*m/g, '');
|
|
349
|
-
|
|
352
|
+
write(`\r${raw}${' '.repeat(Math.max(0, lastW - vis.length))}`);
|
|
350
353
|
lastW = vis.length;
|
|
351
354
|
};
|
|
352
355
|
|
|
@@ -360,7 +363,7 @@ function makeSpinner(s) {
|
|
|
360
363
|
if (finished) return;
|
|
361
364
|
finished = true;
|
|
362
365
|
if (timerId) { clearInterval(timerId); timerId = null; }
|
|
363
|
-
|
|
366
|
+
write('\r\x1b[2K');
|
|
364
367
|
},
|
|
365
368
|
};
|
|
366
369
|
}
|
|
@@ -472,9 +475,9 @@ const RETRY_OPTIONS = [
|
|
|
472
475
|
];
|
|
473
476
|
|
|
474
477
|
export async function run(args, envOrOpts = process.env, fetcher = globalThis.fetch, configDir = undefined) {
|
|
475
|
-
// Support opts-object injection: run(args, { env, fetcher, configDir, detectVcs, getDiff, print })
|
|
478
|
+
// Support opts-object injection: run(args, { env, fetcher, configDir, detectVcs, getDiff, print, printErr })
|
|
476
479
|
let env, opts;
|
|
477
|
-
if (envOrOpts && typeof envOrOpts === 'object' && !Array.isArray(envOrOpts) && ('env' in envOrOpts || 'fetcher' in envOrOpts || 'print' in envOrOpts || 'detectVcs' in envOrOpts || 'getDiff' in envOrOpts)) {
|
|
480
|
+
if (envOrOpts && typeof envOrOpts === 'object' && !Array.isArray(envOrOpts) && ('env' in envOrOpts || 'fetcher' in envOrOpts || 'print' in envOrOpts || 'printErr' in envOrOpts || 'detectVcs' in envOrOpts || 'getDiff' in envOrOpts)) {
|
|
478
481
|
opts = envOrOpts;
|
|
479
482
|
env = opts.env ?? process.env;
|
|
480
483
|
fetcher = opts.fetcher ?? globalThis.fetch;
|
|
@@ -485,6 +488,27 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
|
|
|
485
488
|
}
|
|
486
489
|
|
|
487
490
|
const printFn = opts.print ?? ((chunk) => process.stdout.write(chunk));
|
|
491
|
+
// Mirrors printFn above — the bare ticket-fetch, compliance, pr, review, and standup
|
|
492
|
+
// paths all use this now (each has an MCP tool). `ledger` above still writes directly
|
|
493
|
+
// to the real process.stderr (no MCP tool yet); `install-hooks` is CLI-only and never
|
|
494
|
+
// gets one.
|
|
495
|
+
const printErrFn = opts.printErr ?? ((chunk) => process.stderr.write(chunk));
|
|
496
|
+
// Reused on every recursive self-call in the bare-fetch path (profile-prompt retries)
|
|
497
|
+
// so an injected print/printErr survives the retry instead of silently reverting to
|
|
498
|
+
// the real stdout/stderr.
|
|
499
|
+
const selfOpts = { env, fetcher, configDir, print: printFn, printErr: printErrFn };
|
|
500
|
+
// Passed to every stream-shaped helper (createSession, the profile-prompt pickers,
|
|
501
|
+
// handleUnknownFlags) in the bare-fetch path below. isTTY MUST reflect the real
|
|
502
|
+
// process.stderr.isTTY, not a hardcoded false: these helpers use it to decide
|
|
503
|
+
// whether to render an animated/interactive UI at all, for real CLI users too, not
|
|
504
|
+
// just MCP calls. Their own separate `!process.stdin.setRawMode` gate — always true
|
|
505
|
+
// under the MCP server, since its stdin is the JSON-RPC channel, never a TTY — is
|
|
506
|
+
// what actually keeps the interactive branch from firing under MCP; isTTY here only
|
|
507
|
+
// controls real-terminal UX and must stay accurate for that to keep working. Injectable
|
|
508
|
+
// via opts.isTTY so a test can assert deterministic behavior without mutating the real,
|
|
509
|
+
// process-global process.stderr.isTTY (unsafe to flip mid-suite — see fetch-ticket.test.mjs).
|
|
510
|
+
const isTTY = opts.isTTY ?? process.stderr.isTTY;
|
|
511
|
+
const errStream = { write: printErrFn, isTTY };
|
|
488
512
|
|
|
489
513
|
if (args.includes('--help') || args.includes('-h')) {
|
|
490
514
|
printFetchHelp();
|
|
@@ -528,12 +552,12 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
|
|
|
528
552
|
const { assemblePr } = await import('./lib/pr-assembler.mjs');
|
|
529
553
|
const ticketKeyArg = args[1];
|
|
530
554
|
if (!ticketKeyArg) {
|
|
531
|
-
|
|
555
|
+
printErrFn('Error: "pr" requires a ticket key. Usage: ticketlens pr PROJ-123\n');
|
|
532
556
|
process.exitCode = 1;
|
|
533
557
|
return;
|
|
534
558
|
}
|
|
535
559
|
if (!TICKET_KEY_PATTERN.test(ticketKeyArg)) {
|
|
536
|
-
|
|
560
|
+
printErrFn(`Error: "${ticketKeyArg}" is not a valid ticket key. Expected format: PROJ-123\n`);
|
|
537
561
|
process.exitCode = 1;
|
|
538
562
|
return;
|
|
539
563
|
}
|
|
@@ -547,27 +571,33 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
|
|
|
547
571
|
configDir: resolvedConfigDir,
|
|
548
572
|
profileName: profileNamePr,
|
|
549
573
|
cwd: process.cwd(),
|
|
550
|
-
onWarning: (w) =>
|
|
574
|
+
onWarning: (w) => printErrFn(w + '\n'),
|
|
551
575
|
onProfileNotFound: () => {},
|
|
552
576
|
});
|
|
553
577
|
|
|
554
578
|
const hasAuthPr = connPr.pat || (connPr.email && connPr.apiToken);
|
|
555
579
|
if (!connPr.baseUrl || !hasAuthPr) {
|
|
556
|
-
|
|
580
|
+
printErrFn('Error: No Jira credentials found. Run \'ticketlens init\' or set JIRA_BASE_URL + JIRA_API_TOKEN.\n');
|
|
557
581
|
process.exitCode = 1;
|
|
558
582
|
return;
|
|
559
583
|
}
|
|
560
584
|
|
|
561
585
|
const adapterPr = resolveAdapter(connPr, { fetcher });
|
|
586
|
+
const complianceRunnerPr = opts.runComplianceCheck ?? runComplianceCheck;
|
|
562
587
|
|
|
563
588
|
try {
|
|
564
589
|
const md = await assemblePr(ticketKeyArg, {
|
|
565
590
|
configDir: resolvedConfigDir,
|
|
566
591
|
fetchTicketFn: (key, fOpts = {}) => adapterPr.fetchTicket(key, fOpts),
|
|
592
|
+
// assemblePr's own runComplianceCheckFn default has no stream override
|
|
593
|
+
// (compliance-checker.mjs's `stream = process.stderr`), so the Pro-gate
|
|
594
|
+
// upgrade prompt would leak to real stderr unless threaded here — same
|
|
595
|
+
// treatment as the compliance dispatch block above (`stream: errStream`).
|
|
596
|
+
runComplianceCheckFn: (o) => complianceRunnerPr({ ...o, stream: errStream }),
|
|
567
597
|
});
|
|
568
598
|
printFn(md + '\n');
|
|
569
599
|
} catch (err) {
|
|
570
|
-
|
|
600
|
+
printErrFn(`Error: ${err.message}\n`);
|
|
571
601
|
process.exitCode = 1;
|
|
572
602
|
}
|
|
573
603
|
return;
|
|
@@ -597,12 +627,12 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
|
|
|
597
627
|
if (args[0] === 'compliance') {
|
|
598
628
|
const ticketKeyArg = args[1];
|
|
599
629
|
if (!ticketKeyArg) {
|
|
600
|
-
|
|
630
|
+
printErrFn('Error: "compliance" requires a ticket key. Usage: ticketlens compliance PROJ-123\n');
|
|
601
631
|
process.exitCode = 1;
|
|
602
632
|
return;
|
|
603
633
|
}
|
|
604
634
|
if (!TICKET_KEY_PATTERN.test(ticketKeyArg)) {
|
|
605
|
-
|
|
635
|
+
printErrFn(`Error: "${ticketKeyArg}" is not a valid ticket key. Expected format: PROJ-123\n`);
|
|
606
636
|
process.exitCode = 1;
|
|
607
637
|
return;
|
|
608
638
|
}
|
|
@@ -626,13 +656,13 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
|
|
|
626
656
|
configDir: resolvedConfigDir,
|
|
627
657
|
profileName: profileNameC,
|
|
628
658
|
cwd: process.cwd(),
|
|
629
|
-
onWarning: (w) =>
|
|
659
|
+
onWarning: (w) => printErrFn(w + '\n'),
|
|
630
660
|
onProfileNotFound: () => {},
|
|
631
661
|
});
|
|
632
662
|
|
|
633
663
|
const hasAuthC = connC.pat || (connC.email && connC.apiToken);
|
|
634
664
|
if (!connC.baseUrl || !hasAuthC) {
|
|
635
|
-
|
|
665
|
+
printErrFn('Error: No Jira credentials found. Run \'ticketlens init\' or set JIRA_BASE_URL + JIRA_API_TOKEN.\n');
|
|
636
666
|
process.exitCode = 1;
|
|
637
667
|
return;
|
|
638
668
|
}
|
|
@@ -643,7 +673,7 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
|
|
|
643
673
|
try {
|
|
644
674
|
ticketC = await adapterC.fetchTicket(ticketKeyArg, { depth: 0 });
|
|
645
675
|
} catch (err) {
|
|
646
|
-
|
|
676
|
+
printErrFn(`Error fetching ${ticketKeyArg}: ${err.message}\n`);
|
|
647
677
|
process.exitCode = 1;
|
|
648
678
|
return;
|
|
649
679
|
}
|
|
@@ -658,6 +688,7 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
|
|
|
658
688
|
description: ticketC.description,
|
|
659
689
|
ticketKey: ticketKeyArg,
|
|
660
690
|
configDir: resolvedConfigDir,
|
|
691
|
+
stream: errStream,
|
|
661
692
|
});
|
|
662
693
|
|
|
663
694
|
if (complianceResult === null) {
|
|
@@ -688,7 +719,7 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
|
|
|
688
719
|
const execFn = opts.execFn ?? spawnSync;
|
|
689
720
|
const cwd = process.cwd();
|
|
690
721
|
|
|
691
|
-
const sErr = createStyler({ isTTY
|
|
722
|
+
const sErr = createStyler({ isTTY });
|
|
692
723
|
|
|
693
724
|
// Validate flags before any git work
|
|
694
725
|
const reviewFlags = args.slice(1);
|
|
@@ -699,28 +730,28 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
|
|
|
699
730
|
// Detect --profile-NAME typo (dash instead of =)
|
|
700
731
|
const profileDashM = flag.match(/^--profile-(.+)$/);
|
|
701
732
|
if (profileDashM) {
|
|
702
|
-
|
|
733
|
+
printErrFn(`${sErr.red('✖')} Unknown flag: ${sErr.bold(flag)}\n Did you mean ${sErr.cyan(`--profile=${profileDashM[1]}`)}?\n`);
|
|
703
734
|
process.exitCode = 1;
|
|
704
735
|
return;
|
|
705
736
|
}
|
|
706
737
|
const baseDashM = flag.match(/^--base-(.+)$/);
|
|
707
738
|
if (baseDashM) {
|
|
708
|
-
|
|
739
|
+
printErrFn(`${sErr.red('✖')} Unknown flag: ${sErr.bold(flag)}\n Did you mean ${sErr.cyan(`--base=${baseDashM[1]}`)}?\n`);
|
|
709
740
|
process.exitCode = 1;
|
|
710
741
|
return;
|
|
711
742
|
}
|
|
712
743
|
const branchDashM = flag.match(/^--branch-(.+)$/);
|
|
713
744
|
if (branchDashM) {
|
|
714
|
-
|
|
745
|
+
printErrFn(`${sErr.red('✖')} Unknown flag: ${sErr.bold(flag)}\n Did you mean ${sErr.cyan(`--branch=${branchDashM[1]}`)}?\n`);
|
|
715
746
|
process.exitCode = 1;
|
|
716
747
|
return;
|
|
717
748
|
}
|
|
718
|
-
|
|
749
|
+
printErrFn(`${sErr.red('✖')} Unknown flag: ${sErr.bold(flag)}\n Usage: ${sErr.cyan('ticketlens review [--base=BRANCH] [--branch=BRANCH] [--profile=NAME]')}\n`);
|
|
719
750
|
process.exitCode = 1;
|
|
720
751
|
return;
|
|
721
752
|
}
|
|
722
753
|
|
|
723
|
-
const spinner = makeSpinner(sErr);
|
|
754
|
+
const spinner = makeSpinner(sErr, errStream);
|
|
724
755
|
const stderrNotes = [];
|
|
725
756
|
|
|
726
757
|
// Resolve base branch: --base=BRANCH or auto-detect main/master/develop
|
|
@@ -734,7 +765,7 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
|
|
|
734
765
|
const verifyR = execFn('git', ['rev-parse', '--verify', baseBranch], { encoding: 'utf8', cwd, timeout: 5_000 });
|
|
735
766
|
if (verifyR.status !== 0) {
|
|
736
767
|
spinner.done();
|
|
737
|
-
|
|
768
|
+
printErrFn(`${sErr.red('✖')} Branch "${baseBranch}" not found in this repository.\n`);
|
|
738
769
|
process.exitCode = 1;
|
|
739
770
|
return;
|
|
740
771
|
}
|
|
@@ -772,10 +803,10 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
|
|
|
772
803
|
const profiles = loadProfiles(resolvedConfigDir);
|
|
773
804
|
if (!profiles?.profiles?.[profileNameR]) {
|
|
774
805
|
spinner.done();
|
|
775
|
-
|
|
806
|
+
printErrFn(`${sErr.red('✖')} Profile "${profileNameR}" not found.\n`);
|
|
776
807
|
const names = Object.keys(profiles?.profiles ?? {});
|
|
777
|
-
if (names.length > 0)
|
|
778
|
-
else
|
|
808
|
+
if (names.length > 0) printErrFn(` ${sErr.dim('Available:')} ${names.join(', ')}\n`);
|
|
809
|
+
else printErrFn(` Run ${sErr.cyan('ticketlens init')} to configure a profile.\n`);
|
|
779
810
|
process.exitCode = 1;
|
|
780
811
|
return;
|
|
781
812
|
}
|
|
@@ -817,8 +848,8 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
|
|
|
817
848
|
}
|
|
818
849
|
|
|
819
850
|
spinner.done();
|
|
820
|
-
for (const note of stderrNotes)
|
|
821
|
-
if (stderrNotes.length > 0)
|
|
851
|
+
for (const note of stderrNotes) printErrFn(note + '\n');
|
|
852
|
+
if (stderrNotes.length > 0) printErrFn('\n');
|
|
822
853
|
|
|
823
854
|
const isLic = opts.isLicensedFn ?? ((tier) => isLicensed(tier, resolvedConfigDir));
|
|
824
855
|
const assembleFn = opts.assemblePrReviewFn ?? assemblePrReview;
|
|
@@ -845,7 +876,7 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
|
|
|
845
876
|
const resolvedConfigDir = configDir ?? (await import('./lib/config.mjs')).DEFAULT_CONFIG_DIR;
|
|
846
877
|
const execFn = opts.execFn ?? spawnSync;
|
|
847
878
|
const cwd = process.cwd();
|
|
848
|
-
const sErr = createStyler({ isTTY
|
|
879
|
+
const sErr = createStyler({ isTTY });
|
|
849
880
|
|
|
850
881
|
const standupFlags = args.slice(1);
|
|
851
882
|
|
|
@@ -856,29 +887,29 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
|
|
|
856
887
|
|
|
857
888
|
const sinceDashM = flag.match(/^--since-(.+)$/);
|
|
858
889
|
if (sinceDashM) {
|
|
859
|
-
|
|
890
|
+
printErrFn(`${sErr.red('✖')} Unknown flag: ${sErr.bold(flag)}\n Did you mean ${sErr.cyan(`--since=${sinceDashM[1]}`)}?\n`);
|
|
860
891
|
process.exitCode = 1;
|
|
861
892
|
return;
|
|
862
893
|
}
|
|
863
894
|
const formatDashM = flag.match(/^--format-(.+)$/);
|
|
864
895
|
if (formatDashM) {
|
|
865
|
-
|
|
896
|
+
printErrFn(`${sErr.red('✖')} Unknown flag: ${sErr.bold(flag)}\n Did you mean ${sErr.cyan(`--format=${formatDashM[1]}`)}?\n`);
|
|
866
897
|
process.exitCode = 1;
|
|
867
898
|
return;
|
|
868
899
|
}
|
|
869
900
|
const profileDashM = flag.match(/^--profile-(.+)$/);
|
|
870
901
|
if (profileDashM) {
|
|
871
|
-
|
|
902
|
+
printErrFn(`${sErr.red('✖')} Unknown flag: ${sErr.bold(flag)}\n Did you mean ${sErr.cyan(`--profile=${profileDashM[1]}`)}?\n`);
|
|
872
903
|
process.exitCode = 1;
|
|
873
904
|
return;
|
|
874
905
|
}
|
|
875
906
|
const formatValueM = flag.match(/^--format=(.+)$/);
|
|
876
907
|
if (formatValueM) {
|
|
877
|
-
|
|
908
|
+
printErrFn(`${sErr.red('✖')} Invalid --format value: "${formatValueM[1]}". Expected: standup or pr\n`);
|
|
878
909
|
process.exitCode = 1;
|
|
879
910
|
return;
|
|
880
911
|
}
|
|
881
|
-
|
|
912
|
+
printErrFn(`${sErr.red('✖')} Unknown flag: ${sErr.bold(flag)}\n Usage: ${sErr.cyan('ticketlens standup [--since=N] [--format=standup|pr] [--profile=NAME]')}\n`);
|
|
882
913
|
process.exitCode = 1;
|
|
883
914
|
return;
|
|
884
915
|
}
|
|
@@ -899,16 +930,16 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
|
|
|
899
930
|
if (profileNameS) {
|
|
900
931
|
const profiles = loadProfiles(resolvedConfigDir);
|
|
901
932
|
if (!profiles?.profiles?.[profileNameS]) {
|
|
902
|
-
|
|
933
|
+
printErrFn(`${sErr.red('✖')} Profile "${profileNameS}" not found.\n`);
|
|
903
934
|
const names = Object.keys(profiles?.profiles ?? {});
|
|
904
|
-
if (names.length > 0)
|
|
905
|
-
else
|
|
935
|
+
if (names.length > 0) printErrFn(` ${sErr.dim('Available:')} ${names.join(', ')}\n`);
|
|
936
|
+
else printErrFn(` Run ${sErr.cyan('ticketlens init')} to configure a profile.\n`);
|
|
906
937
|
process.exitCode = 1;
|
|
907
938
|
return;
|
|
908
939
|
}
|
|
909
940
|
}
|
|
910
941
|
|
|
911
|
-
const spinner = makeSpinner(sErr);
|
|
942
|
+
const spinner = makeSpinner(sErr, errStream);
|
|
912
943
|
const stderrNotes = [];
|
|
913
944
|
|
|
914
945
|
spinner.update('Scanning git log…');
|
|
@@ -955,8 +986,8 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
|
|
|
955
986
|
}
|
|
956
987
|
|
|
957
988
|
spinner.done();
|
|
958
|
-
for (const note of stderrNotes)
|
|
959
|
-
if (stderrNotes.length > 0)
|
|
989
|
+
for (const note of stderrNotes) printErrFn(note + '\n');
|
|
990
|
+
if (stderrNotes.length > 0) printErrFn('\n');
|
|
960
991
|
|
|
961
992
|
const isPlain = standupFlags.includes('--plain');
|
|
962
993
|
const assembleStandupFn = opts.assembleStandupFn ?? assembleStandup;
|
|
@@ -969,12 +1000,12 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
|
|
|
969
1000
|
|
|
970
1001
|
const ticketKey = args.find(a => !a.startsWith('--'));
|
|
971
1002
|
if (!ticketKey) {
|
|
972
|
-
printFetchHelp({ stream:
|
|
1003
|
+
printFetchHelp({ stream: errStream });
|
|
973
1004
|
process.exitCode = 1;
|
|
974
1005
|
return;
|
|
975
1006
|
}
|
|
976
1007
|
if (!TICKET_KEY_PATTERN.test(ticketKey)) {
|
|
977
|
-
|
|
1008
|
+
printErrFn(`Error: "${ticketKey}" is not a valid ticket key. Expected format: PROJ-123\n`);
|
|
978
1009
|
process.exitCode = 1;
|
|
979
1010
|
return;
|
|
980
1011
|
}
|
|
@@ -982,7 +1013,7 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
|
|
|
982
1013
|
// Normalize --project= alias once at entry so all recursive calls only see --profile=
|
|
983
1014
|
const projectArg = args.find(a => a.startsWith('--project='));
|
|
984
1015
|
if (projectArg) {
|
|
985
|
-
|
|
1016
|
+
printErrFn(`Hint: --project recognized as alias for --profile=${projectArg.split('=')[1]}\n\n`);
|
|
986
1017
|
args = args.map(a => a.startsWith('--project=') ? `--profile=${a.split('=')[1]}` : a);
|
|
987
1018
|
}
|
|
988
1019
|
|
|
@@ -992,7 +1023,7 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
|
|
|
992
1023
|
const validatedArgs = await handleUnknownFlags(
|
|
993
1024
|
args,
|
|
994
1025
|
['--help', '-h', '--plain', '--styled', '--no-attachments', '--no-cache', '--profile=', '--depth=', '--check', '--summarize', '--cloud', '--compliance', '--budget=', '--handoff', '--provider=', '--template='],
|
|
995
|
-
{ hints: ['--stale=', '--status=', '--static'] } // triage-only flags — shown as hints, not applied
|
|
1026
|
+
{ hints: ['--stale=', '--status=', '--static'], stream: errStream } // triage-only flags — shown as hints, not applied
|
|
996
1027
|
);
|
|
997
1028
|
if (validatedArgs === null) { process.exitCode = 1; return; }
|
|
998
1029
|
args = validatedArgs;
|
|
@@ -1006,7 +1037,7 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
|
|
|
1006
1037
|
const tpl = await resolveTemplate(templateSlug, { token: readCliToken(configDir), fetcher });
|
|
1007
1038
|
templateSections = tpl.sections;
|
|
1008
1039
|
} catch (err) {
|
|
1009
|
-
|
|
1040
|
+
printErrFn(`Error: ${err.message}\n`);
|
|
1010
1041
|
process.exitCode = 1;
|
|
1011
1042
|
return;
|
|
1012
1043
|
}
|
|
@@ -1021,10 +1052,11 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
|
|
|
1021
1052
|
.filter(([, p]) => p.ticketPrefixes?.includes(prefix))
|
|
1022
1053
|
.map(([name, p]) => ({ name, baseUrl: p.baseUrl || null }));
|
|
1023
1054
|
if (multiMatches.length > 1) {
|
|
1024
|
-
const
|
|
1055
|
+
const promptMultipleMatchesFn = opts.promptMultipleMatchesFn ?? promptMultipleMatches;
|
|
1056
|
+
const picked = await promptMultipleMatchesFn(ticketKey, multiMatches, { stream: errStream });
|
|
1025
1057
|
if (!picked) { process.exitCode = 1; return; }
|
|
1026
1058
|
const withProfile = [...args.filter(a => !a.startsWith('--profile=')), `--profile=${picked}`];
|
|
1027
|
-
return run(withProfile,
|
|
1059
|
+
return run(withProfile, selfOpts);
|
|
1028
1060
|
}
|
|
1029
1061
|
}
|
|
1030
1062
|
|
|
@@ -1034,18 +1066,19 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
|
|
|
1034
1066
|
configDir,
|
|
1035
1067
|
profileName,
|
|
1036
1068
|
cwd: process.cwd(),
|
|
1037
|
-
onWarning: (w) =>
|
|
1069
|
+
onWarning: (w) => printErrFn(w + '\n'),
|
|
1038
1070
|
onProfileNotFound: (info) => { profileError = info; },
|
|
1039
1071
|
});
|
|
1040
1072
|
|
|
1041
1073
|
const hasAuth = conn.pat || (conn.email && conn.apiToken);
|
|
1042
1074
|
if (!conn.baseUrl || !hasAuth) {
|
|
1043
1075
|
if (profileError) {
|
|
1044
|
-
const
|
|
1076
|
+
const promptProfileSelectFn = opts.promptProfileSelectFn ?? promptProfileSelect;
|
|
1077
|
+
const picked = await promptProfileSelectFn(profileError, { stream: errStream });
|
|
1045
1078
|
if (picked) {
|
|
1046
1079
|
const newArgs = args.filter(a => !a.startsWith('--profile='));
|
|
1047
1080
|
newArgs.push(`--profile=${picked}`);
|
|
1048
|
-
return run(newArgs,
|
|
1081
|
+
return run(newArgs, selfOpts);
|
|
1049
1082
|
}
|
|
1050
1083
|
} else {
|
|
1051
1084
|
const missing = [];
|
|
@@ -1056,7 +1089,7 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
|
|
|
1056
1089
|
: `Missing config in profile "${conn.profileName}": ${missing.join(', ')}`;
|
|
1057
1090
|
const noProfiles = !loadProfiles(configDir)?.profiles;
|
|
1058
1091
|
const initHint = noProfiles ? '\nRun `ticketlens init` to set up your connection.' : '';
|
|
1059
|
-
|
|
1092
|
+
printErrFn(`Error: ${hint}${initHint}\n`);
|
|
1060
1093
|
}
|
|
1061
1094
|
process.exitCode = 1;
|
|
1062
1095
|
return;
|
|
@@ -1072,10 +1105,11 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
|
|
|
1072
1105
|
const allProfiles = Object.entries(config?.profiles ?? {})
|
|
1073
1106
|
.map(([name, p]) => ({ name, baseUrl: p.baseUrl || null }));
|
|
1074
1107
|
if (allProfiles.length > 1) {
|
|
1075
|
-
const
|
|
1108
|
+
const promptProfileMismatchFn = opts.promptProfileMismatchFn ?? promptProfileMismatch;
|
|
1109
|
+
const picked = await promptProfileMismatchFn(ticketKey, conn.profileName, allProfiles, { stream: errStream });
|
|
1076
1110
|
if (picked && picked !== conn.profileName) {
|
|
1077
1111
|
const withProfile = [...args.filter(a => !a.startsWith('--profile=')), `--profile=${picked}`];
|
|
1078
|
-
return run(withProfile,
|
|
1112
|
+
return run(withProfile, selfOpts);
|
|
1079
1113
|
}
|
|
1080
1114
|
}
|
|
1081
1115
|
}
|
|
@@ -1113,7 +1147,7 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
|
|
|
1113
1147
|
if (cached) {
|
|
1114
1148
|
const s = createStyler({ isTTY: process.stderr.isTTY });
|
|
1115
1149
|
const age = briefCacheAge(cached.fetchedAt);
|
|
1116
|
-
|
|
1150
|
+
printErrFn(` ${s.dim('○')} ${s.dim(`${ticketKey} · from cache (${age}) · --no-cache to refresh`)}\n\n`);
|
|
1117
1151
|
|
|
1118
1152
|
const allText = [cached.ticket.description, ...cached.ticket.comments.map(c => c.body)].filter(Boolean).join('\n');
|
|
1119
1153
|
const codeRefs = extractCodeReferences(allText);
|
|
@@ -1177,7 +1211,7 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
|
|
|
1177
1211
|
}
|
|
1178
1212
|
// ──────────────────────────────────────────────────────────────────────────
|
|
1179
1213
|
|
|
1180
|
-
const session = createSession(conn);
|
|
1214
|
+
const session = createSession(conn, { stream: errStream });
|
|
1181
1215
|
|
|
1182
1216
|
// Load all profiles once for use in the switch-profile retry option.
|
|
1183
1217
|
const allProfiles = Object.entries(loadProfiles(configDir)?.profiles ?? {})
|
|
@@ -1212,23 +1246,28 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
|
|
|
1212
1246
|
}
|
|
1213
1247
|
|
|
1214
1248
|
if (RETRY_OPTIONS[retryIndex].value === 'switch') {
|
|
1215
|
-
const
|
|
1249
|
+
const promptSwitchProfileFn = opts.promptSwitchProfileFn ?? promptSwitchProfile;
|
|
1250
|
+
const picked = await promptSwitchProfileFn(conn.profileName, allProfiles, { stream: errStream });
|
|
1216
1251
|
if (picked && picked !== conn.profileName) {
|
|
1217
1252
|
const withProfile = [...args.filter(a => !a.startsWith('--profile=')), `--profile=${picked}`];
|
|
1218
|
-
return run(withProfile,
|
|
1253
|
+
return run(withProfile, selfOpts);
|
|
1219
1254
|
}
|
|
1220
1255
|
// Cancelled switch — exit
|
|
1221
1256
|
process.exitCode = 1;
|
|
1222
1257
|
return;
|
|
1223
1258
|
}
|
|
1224
1259
|
|
|
1225
|
-
// 'retry' — loop with updated spinner message
|
|
1260
|
+
// 'retry' — loop with updated spinner message. This whole retry-prompt
|
|
1261
|
+
// branch only runs when `!process.stderr.isTTY || !process.stdin.setRawMode`
|
|
1262
|
+
// is false — i.e. never under the MCP server (stdin is always piped there) —
|
|
1263
|
+
// so its remaining process.stderr.write calls are intentionally left
|
|
1264
|
+
// unconverted, dead code under MCP by construction, not an oversight.
|
|
1226
1265
|
isRetry = true;
|
|
1227
1266
|
process.stderr.write('\n');
|
|
1228
1267
|
}
|
|
1229
1268
|
}
|
|
1230
1269
|
session.connected();
|
|
1231
|
-
|
|
1270
|
+
printErrFn('\n');
|
|
1232
1271
|
|
|
1233
1272
|
// ── Confluence page fetching ───────────────────────────────────────────────
|
|
1234
1273
|
// Fetch pages referenced via Jira Remote Links (Confluence-only, non-blocking).
|
|
@@ -1276,7 +1315,7 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
|
|
|
1276
1315
|
requirements: extractRequirements(desc),
|
|
1277
1316
|
};
|
|
1278
1317
|
const result = dtm.detectDrift(current, prior);
|
|
1279
|
-
if (result.drifted)
|
|
1318
|
+
if (result.drifted) printErrFn(dtm.formatDriftWarning(ticketKey, result.changes));
|
|
1280
1319
|
}
|
|
1281
1320
|
dtm.writeSnapshot(ticketKey, ticket, { profile: profileName, configDir: resolvedConfigDir, branch });
|
|
1282
1321
|
}
|
|
@@ -1287,14 +1326,14 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
|
|
|
1287
1326
|
const downloadable = (ticket.attachments ?? []).filter(a => a.content);
|
|
1288
1327
|
if (downloadable.length > 0) {
|
|
1289
1328
|
const noun = downloadable.length === 1 ? 'attachment' : 'attachments';
|
|
1290
|
-
|
|
1329
|
+
printErrFn(`Downloading ${downloadable.length} ${noun}…\n`);
|
|
1291
1330
|
// attachment-downloader is Jira-specific — it needs raw auth headers from buildJiraEnv
|
|
1292
1331
|
const jiraEnv = buildJiraEnv(conn);
|
|
1293
1332
|
ticket.localAttachments = await downloadAttachments(ticket, {
|
|
1294
1333
|
env: jiraEnv,
|
|
1295
1334
|
fetcher,
|
|
1296
1335
|
noCache: args.includes('--no-cache'),
|
|
1297
|
-
onProgress: (msg) =>
|
|
1336
|
+
onProgress: (msg) => printErrFn(msg + '\n'),
|
|
1298
1337
|
allowPrivateIp: conn.allowPrivateIp,
|
|
1299
1338
|
});
|
|
1300
1339
|
const downloaded = ticket.localAttachments.filter(r => !r.skipped).length;
|
|
@@ -1302,8 +1341,8 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
|
|
|
1302
1341
|
const parts = [];
|
|
1303
1342
|
if (downloaded > 0) parts.push(`${downloaded} downloaded`);
|
|
1304
1343
|
if (cached > 0) parts.push(`${cached} cached`);
|
|
1305
|
-
if (parts.length > 0)
|
|
1306
|
-
|
|
1344
|
+
if (parts.length > 0) printErrFn(` ✓ ${parts.join(', ')}\n`);
|
|
1345
|
+
printErrFn('\n');
|
|
1307
1346
|
}
|
|
1308
1347
|
}
|
|
1309
1348
|
|
|
@@ -1369,7 +1408,7 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
|
|
|
1369
1408
|
// Contextual upsell: after a deep traversal with a substantial graph, nudge toward --summarize
|
|
1370
1409
|
if (depth > 1 && !args.includes('--summarize') && (ticket.linked?.length ?? 0) >= 2) {
|
|
1371
1410
|
const s = createStyler({ isTTY: process.stderr.isTTY });
|
|
1372
|
-
|
|
1411
|
+
printErrFn(` ${s.dim('○')} ${s.dim('Tip: large briefs compress further — `--summarize` condenses this to a single AI digest ($8/mo)')}\n`);
|
|
1373
1412
|
}
|
|
1374
1413
|
}
|
|
1375
1414
|
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* Centralised here to avoid triplicating the regex and warning logic.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
export const DEFAULT_API_BASE = '
|
|
6
|
+
export const DEFAULT_API_BASE = 'https://api.ticketlens.app';
|
|
7
7
|
export const DEFAULT_SITE_BASE = 'https://ticketlens.app';
|
|
8
8
|
|
|
9
9
|
// Matches localhost, 127.0.0.1, and any hostname ending in .test or .local,
|
|
@@ -642,18 +642,24 @@ export function printMcpHelp({ stream = process.stdout } = {}) {
|
|
|
642
642
|
const s = createStyler({ isTTY: stream.isTTY });
|
|
643
643
|
const lines = [
|
|
644
644
|
'',
|
|
645
|
-
` ${s.bold(s.brand('ticketlens'))} ${s.bold('mcp')}
|
|
646
|
-
'',
|
|
647
|
-
` Start an MCP (Model Context Protocol) stdio server exposing
|
|
648
|
-
`
|
|
649
|
-
` ${s.cyan('
|
|
650
|
-
` MCP-compatible AI harness, not just Claude Code. Thin
|
|
651
|
-
` code as ${s.cyan('
|
|
652
|
-
`
|
|
653
|
-
`
|
|
654
|
-
`
|
|
655
|
-
`
|
|
656
|
-
`
|
|
645
|
+
` ${s.bold(s.brand('ticketlens'))} ${s.bold('mcp')}`,
|
|
646
|
+
'',
|
|
647
|
+
` Start an MCP (Model Context Protocol) stdio server exposing CLI actions as`,
|
|
648
|
+
` native tools — ${s.cyan('fetch')}, ${s.cyan('triage')}, ${s.cyan('compliance')}, ${s.cyan('review')}, ${s.cyan('standup')}, ${s.cyan('pr')}, ${s.cyan('doctor')}, ${s.cyan('recall_add')}, ${s.cyan('recall_search')},`,
|
|
649
|
+
` ${s.cyan('ticket_comment')}, ${s.cyan('ticket_transition')}, ${s.cyan('ticket_assign')}, ${s.cyan('ticket_duplicates')}, ${s.cyan('ticket_link')}, ${s.cyan('ticket_update')},`,
|
|
650
|
+
` ${s.cyan('ticket_create')} — for any MCP-compatible AI harness, not just Claude Code. Thin`,
|
|
651
|
+
` adapter over the same code as ${s.cyan('TICKET-KEY')}/${s.cyan('doctor')}/${s.cyan('triage')}/${s.cyan('compliance')}/${s.cyan('review')}/${s.cyan('standup')}/${s.cyan('pr')}/${s.cyan('note add')}/`,
|
|
652
|
+
` ${s.cyan('recall')}/${s.cyan('comment')}/${s.cyan('transition')}/${s.cyan('assign')}/${s.cyan('duplicates')}/${s.cyan('link')}/${s.cyan('update')}/${s.cyan('create')} above.`,
|
|
653
|
+
` ${s.cyan('fetch')}, ${s.cyan('doctor')}, and ${s.cyan('standup')} are Free; ${s.cyan('triage')} is Free with some Pro/Team-gated`,
|
|
654
|
+
` options (see ${s.cyan('ticketlens triage --help')}); ${s.cyan('compliance')} and ${s.cyan('pr')} are Free, sharing a`,
|
|
655
|
+
` 3-checks/month cap on their requirements-coverage section, Pro unlimited;`,
|
|
656
|
+
` ${s.cyan('review')} is Free for branch/files/ticket context — its coverage/focus section`,
|
|
657
|
+
` requires Pro as a plain license check, not a draw on that same counter; every`,
|
|
658
|
+
` other tool needs Pro. ${s.cyan('ticket_transition')} is destructive when called with`,
|
|
659
|
+
` \`target\`+\`confirm: true\`; ${s.cyan('ticket_assign')} is currently self-assign only;`,
|
|
660
|
+
` ${s.cyan('ticket_duplicates')} is read-only; ${s.cyan('ticket_link')} on GitHub closes the source issue as a`,
|
|
661
|
+
` duplicate — different semantics than Jira/Linear's relationship-only add;`,
|
|
662
|
+
` ${s.cyan('ticket_update')} has no priority field on GitHub and can partially succeed;`,
|
|
657
663
|
` ${s.cyan('ticket_create')} has no ticket key to target — --profile/the default profile picks the`,
|
|
658
664
|
` tracker, and it fabricates a real item, the highest blast radius of this family.`,
|
|
659
665
|
` Long-running — exits when the client closes stdin.`,
|
|
@@ -663,9 +669,8 @@ export function printMcpHelp({ stream = process.stdout } = {}) {
|
|
|
663
669
|
` ${s.dim('restart or reconnect the MCP client to pick up the new schema. The running')}`,
|
|
664
670
|
` ${s.dim('server is always current; only the client-side copy goes stale.')}`,
|
|
665
671
|
'',
|
|
666
|
-
` ${s.dim('
|
|
667
|
-
` ${s.dim('
|
|
668
|
-
` ${s.dim('own license check at call time.')}`,
|
|
672
|
+
` ${s.dim('Starting the server itself is ungated, same as `mcp install`; each tool still')}`,
|
|
673
|
+
` ${s.dim('enforces its own license check at call time.')}`,
|
|
669
674
|
'',
|
|
670
675
|
` ${s.bold('OPTIONS')}`,
|
|
671
676
|
'',
|
|
@@ -25,10 +25,95 @@ import { runDoctor } from './doctor-command.mjs';
|
|
|
25
25
|
import { runNoteAdd } from './note-command.mjs';
|
|
26
26
|
import { runRecall } from './recall-command.mjs';
|
|
27
27
|
import { runTicketComment, runTicketTransitionList, runTicketTransition, runTicketAssign, runTicketDuplicates, runTicketLinkList, runTicketLink, runTicketUpdate, runTicketCreate } from './ticket-command.mjs';
|
|
28
|
+
import { run as runFetchTicket } from '../fetch-ticket.mjs';
|
|
29
|
+
import { run as runTriage } from '../fetch-my-tickets.mjs';
|
|
28
30
|
|
|
29
31
|
const PROTOCOL_VERSION = '2025-11-25';
|
|
30
32
|
|
|
31
33
|
const TOOLS = [
|
|
34
|
+
{
|
|
35
|
+
name: 'fetch',
|
|
36
|
+
description: 'Fetch a ticket\'s full context brief (Jira/GitHub/Linear) — description, comments, linked tickets, code references, attachments. The core read action; free tier. Not a discovery tool — requires a known ticket key.',
|
|
37
|
+
inputSchema: {
|
|
38
|
+
type: 'object',
|
|
39
|
+
properties: {
|
|
40
|
+
ticket: { type: 'string', description: 'Ticket key, e.g. PROJ-123.' },
|
|
41
|
+
profile: { type: 'string', description: 'Connection profile to target, overriding folder-based inference and the default profile.' },
|
|
42
|
+
depth: { type: 'number', description: 'How many hops of linked tickets to traverse. Defaults to 1 (direct links only). 0 disables traversal.' },
|
|
43
|
+
},
|
|
44
|
+
required: ['ticket'],
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
name: 'triage',
|
|
49
|
+
description: 'Scan assigned tickets and surface what needs attention — replies owed, aging tickets, stale-status tickets. The base scan is free tier; some options require a TicketLens Pro or Team license.',
|
|
50
|
+
inputSchema: {
|
|
51
|
+
type: 'object',
|
|
52
|
+
properties: {
|
|
53
|
+
profile: { type: 'string', description: 'Connection profile to target, overriding folder-based inference and the default profile.' },
|
|
54
|
+
stale: { type: 'number', description: 'Days before an untouched ticket counts as aging. Defaults to 5.' },
|
|
55
|
+
status: { type: 'array', items: { type: 'string' }, description: 'Statuses to include, overriding the profile default / built-in defaults (In Progress, Code Review, QA).' },
|
|
56
|
+
sort: { type: 'string', description: 'Sort order for results, overriding the profile default.' },
|
|
57
|
+
save: { type: 'string', description: 'Write the plain-text summary to this local file path instead of (in addition to) returning it. Requires a TicketLens Pro license.' },
|
|
58
|
+
all: { type: 'boolean', description: 'Triage every configured profile, not just the resolved one. Requires a TicketLens Pro license.' },
|
|
59
|
+
digest: { type: 'boolean', description: 'Deliver the scored results to the digest backend instead of returning them as text — on success, no summary is returned, only a delivery confirmation. Requires a TicketLens Pro license.' },
|
|
60
|
+
assignee: { type: 'string', description: 'View another user\'s tickets instead of your own. Requires a TicketLens Team license.' },
|
|
61
|
+
sprint: { type: 'string', description: 'Scope to a named sprint. Requires a TicketLens Team license.' },
|
|
62
|
+
export: { type: 'string', enum: ['csv', 'json'], description: 'Write results to a file in this format instead of returning the summary text, returning the written file path instead. Requires a TicketLens Team license.' },
|
|
63
|
+
project: { type: 'string', description: 'Scope to a project/team key. Requires a TicketLens Team license.' },
|
|
64
|
+
label: { type: 'array', items: { type: 'string' }, description: 'Scope to one or more labels. Requires a TicketLens Team license.' },
|
|
65
|
+
priority: { type: 'string', description: 'Scope to a priority name, e.g. "High". Requires a TicketLens Team license.' },
|
|
66
|
+
},
|
|
67
|
+
},
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
name: 'compliance',
|
|
71
|
+
description: 'Check a ticket\'s acceptance-criteria coverage against the current git diff — extracts requirements from the ticket description, matches them against code changes, and reports a coverage percentage plus what\'s missing. Read-only; the same check `ticketlens install-hooks` runs automatically. Free tier: 3 checks per month; TicketLens Pro removes the limit.',
|
|
72
|
+
inputSchema: {
|
|
73
|
+
type: 'object',
|
|
74
|
+
properties: {
|
|
75
|
+
ticket: { type: 'string', description: 'Ticket key, e.g. PROJ-123.' },
|
|
76
|
+
profile: { type: 'string', description: 'Connection profile to target, overriding folder-based inference and the default profile.' },
|
|
77
|
+
},
|
|
78
|
+
required: ['ticket'],
|
|
79
|
+
},
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
name: 'review',
|
|
83
|
+
description: 'Assemble PR review context from the current git branch — changed files, linked-ticket summaries, and (Pro) a requirements-coverage / review-focus section extracted from the diff against acceptance criteria. Read-only; never modifies the tracker or the repo. Free tier gets branch info, changed files, and ticket context; TicketLens Pro adds the requirements-coverage and review-focus sections — same split as the `compliance` tool.',
|
|
84
|
+
inputSchema: {
|
|
85
|
+
type: 'object',
|
|
86
|
+
properties: {
|
|
87
|
+
base: { type: 'string', description: 'Base branch to diff against. Auto-detects main/master/develop when omitted. Alias of `branch` — if both are given, `base` wins.' },
|
|
88
|
+
branch: { type: 'string', description: 'Alias for `base` — same effect, provided for parity with the CLI\'s `--branch=` flag.' },
|
|
89
|
+
profile: { type: 'string', description: 'Connection profile to target, overriding folder-based inference and the default profile.' },
|
|
90
|
+
},
|
|
91
|
+
},
|
|
92
|
+
},
|
|
93
|
+
{
|
|
94
|
+
name: 'standup',
|
|
95
|
+
description: 'Summarize recent git commits grouped by linked ticket — a standup update or, with format:"pr", PR-body-style formatting. Read-only, fully free tier — no license gate on any option.',
|
|
96
|
+
inputSchema: {
|
|
97
|
+
type: 'object',
|
|
98
|
+
properties: {
|
|
99
|
+
since: { type: 'string', description: 'How far back to scan — an integer number of hours ("24") or a git-compatible date expression ("3 days ago"). Defaults to 24 hours.' },
|
|
100
|
+
format: { type: 'string', enum: ['standup', 'pr'], description: 'Output shape: "standup" (default) groups commits under a per-ticket standup update; "pr" renders the same grouped commits as PR-body-style markdown.' },
|
|
101
|
+
profile: { type: 'string', description: 'Connection profile to target, overriding folder-based inference and the default profile.' },
|
|
102
|
+
},
|
|
103
|
+
},
|
|
104
|
+
},
|
|
105
|
+
{
|
|
106
|
+
name: 'pr',
|
|
107
|
+
description: 'Assemble a ready-to-paste PR description for a ticket — what changed (from linked commits), linked tickets, and (if the ticket has acceptance criteria) a requirements-coverage section. Read-only. The requirements-coverage section reuses the same Free-tier 3-checks/month counter as the `compliance` tool — calling `pr` on a ticket with acceptance criteria counts against that shared monthly limit; TicketLens Pro removes the cap.',
|
|
108
|
+
inputSchema: {
|
|
109
|
+
type: 'object',
|
|
110
|
+
properties: {
|
|
111
|
+
ticket: { type: 'string', description: 'Ticket key, e.g. PROJ-123.' },
|
|
112
|
+
profile: { type: 'string', description: 'Connection profile to target, overriding folder-based inference and the default profile.' },
|
|
113
|
+
},
|
|
114
|
+
required: ['ticket'],
|
|
115
|
+
},
|
|
116
|
+
},
|
|
32
117
|
{
|
|
33
118
|
name: 'doctor',
|
|
34
119
|
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.',
|
|
@@ -180,6 +265,193 @@ function capturingStream() {
|
|
|
180
265
|
};
|
|
181
266
|
}
|
|
182
267
|
|
|
268
|
+
/**
|
|
269
|
+
* Deliberately v1-minimal — only the three flags with zero cost/AI-provider
|
|
270
|
+
* implications (see the 49b scoping decision). If `--summarize`/`--handoff`/
|
|
271
|
+
* `--budget=`/`--compliance`/`--template=` are ever added here, note that
|
|
272
|
+
* their error/progress output inside fetch-ticket.mjs's bare-fetch path
|
|
273
|
+
* (applySummarize/applyHandoff/budgetPruner.pruneBrief/showUpgradePrompt)
|
|
274
|
+
* still writes to the real process.stderr, not the injected printErr —
|
|
275
|
+
* unlike every path reachable through this function today. Thread printErr
|
|
276
|
+
* through those call sites first, or their failures will silently collapse
|
|
277
|
+
* to callFetch's generic 'fetch failed' instead of the real reason.
|
|
278
|
+
*/
|
|
279
|
+
function buildFetchArgs({ ticket, profile, depth }) {
|
|
280
|
+
const args = [ticket];
|
|
281
|
+
if (profile) args.push(`--profile=${profile}`);
|
|
282
|
+
if (depth !== undefined) args.push(`--depth=${depth}`);
|
|
283
|
+
return args;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Shared by every dispatch that goes through fetch-ticket.mjs's `run()`
|
|
288
|
+
* (currently `fetch`, `compliance`, `review`, `standup`, and `pr` — all but
|
|
289
|
+
* `fetch` are subcommands of that same function). `run()` has no {ok} return value
|
|
290
|
+
* (unlike every other wrapped function) — failure is signaled by mutating
|
|
291
|
+
* process.exitCode, which is unsafe to read in a long-lived server (one
|
|
292
|
+
* failed call would poison the whole process's exit code forever). Success
|
|
293
|
+
* is instead determined by whether `print` ever received real content —
|
|
294
|
+
* every success path (cache hit, fresh fetch, handoff, a printed report
|
|
295
|
+
* regardless of pass/fail) calls it exactly once; every failure path
|
|
296
|
+
* returns before reaching it. errCapture may contain informational
|
|
297
|
+
* chatter (cache notice, download progress) even on success — only read
|
|
298
|
+
* on the failure branch, where it carries the actual error message.
|
|
299
|
+
*/
|
|
300
|
+
async function callFetchTicketRun(buildArgsFn, args, { configDir, runFetchTicketFn }, fallbackErrorText) {
|
|
301
|
+
const capture = capturingStream();
|
|
302
|
+
const errCapture = capturingStream();
|
|
303
|
+
await runFetchTicketFn(buildArgsFn(args), {
|
|
304
|
+
configDir,
|
|
305
|
+
env: process.env,
|
|
306
|
+
fetcher: globalThis.fetch,
|
|
307
|
+
print: capture.write,
|
|
308
|
+
printErr: errCapture.write,
|
|
309
|
+
});
|
|
310
|
+
if (capture.text) {
|
|
311
|
+
return { content: [{ type: 'text', text: capture.text }] };
|
|
312
|
+
}
|
|
313
|
+
return { isError: true, content: [{ type: 'text', text: errCapture.text || fallbackErrorText }] };
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
async function callFetch(args, deps) {
|
|
317
|
+
if (!args.ticket) {
|
|
318
|
+
return { isError: true, content: [{ type: 'text', text: 'Missing required argument: ticket' }] };
|
|
319
|
+
}
|
|
320
|
+
return callFetchTicketRun(buildFetchArgs, args, deps, 'fetch failed');
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Deliberately excludes --push/--share — those sync/share a snapshot as a
|
|
325
|
+
* human-collaboration side effect (Console notification queue, shareable
|
|
326
|
+
* link), not "what needs attention" read value. See the 49b scoping memory.
|
|
327
|
+
*/
|
|
328
|
+
function buildTriageArgs({ profile, stale, status, sort, save, all, digest, assignee, sprint, export: exportFormat, project, label, priority }) {
|
|
329
|
+
const args = ['--plain'];
|
|
330
|
+
if (profile) args.push(`--profile=${profile}`);
|
|
331
|
+
if (stale !== undefined) args.push(`--stale=${stale}`);
|
|
332
|
+
if (Array.isArray(status) && status.length > 0) args.push(`--status=${status.join(',')}`);
|
|
333
|
+
if (sort) args.push(`--sort=${sort}`);
|
|
334
|
+
if (save) args.push(`--save=${save}`);
|
|
335
|
+
if (all === true) args.push('--all');
|
|
336
|
+
if (digest === true) args.push('--digest');
|
|
337
|
+
if (assignee) args.push(`--assignee=${assignee}`);
|
|
338
|
+
if (sprint) args.push(`--sprint=${sprint}`);
|
|
339
|
+
if (exportFormat) args.push(`--export=${exportFormat}`);
|
|
340
|
+
if (project) args.push(`--project=${project}`);
|
|
341
|
+
if (Array.isArray(label) && label.length > 0) args.push(`--label=${label.join(',')}`);
|
|
342
|
+
if (priority) args.push(`--priority=${priority}`);
|
|
343
|
+
return args;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* runTriage has the same no-{ok}-return architecture as runFetchTicket —
|
|
348
|
+
* success is "did print receive the summary," failure is whatever landed in
|
|
349
|
+
* the injected stream. One deliberate exception: `--digest` delivers to the
|
|
350
|
+
* backend and prints NOTHING to `print` on success (locked by
|
|
351
|
+
* fetch-my-tickets.test.mjs's own "stdout should be empty" test) — an empty
|
|
352
|
+
* capture there means delivery succeeded, not that it failed. Only a gate
|
|
353
|
+
* rejection (Pro license, captured in `stream`) or a thrown delivery error
|
|
354
|
+
* (caught below) signal an actual digest failure.
|
|
355
|
+
*/
|
|
356
|
+
async function callTriage(args, { configDir, runTriageFn }) {
|
|
357
|
+
const capture = capturingStream();
|
|
358
|
+
const errCapture = capturingStream();
|
|
359
|
+
try {
|
|
360
|
+
await runTriageFn(buildTriageArgs(args), {
|
|
361
|
+
configDir,
|
|
362
|
+
env: process.env,
|
|
363
|
+
fetcher: globalThis.fetch,
|
|
364
|
+
print: capture.write,
|
|
365
|
+
stream: errCapture,
|
|
366
|
+
});
|
|
367
|
+
} catch (err) {
|
|
368
|
+
return { isError: true, content: [{ type: 'text', text: err.message }] };
|
|
369
|
+
}
|
|
370
|
+
if (args.digest === true) {
|
|
371
|
+
if (errCapture.text) {
|
|
372
|
+
return { isError: true, content: [{ type: 'text', text: errCapture.text }] };
|
|
373
|
+
}
|
|
374
|
+
return { content: [{ type: 'text', text: 'Digest delivered.' }] };
|
|
375
|
+
}
|
|
376
|
+
if (capture.text) {
|
|
377
|
+
return { content: [{ type: 'text', text: capture.text }] };
|
|
378
|
+
}
|
|
379
|
+
return { isError: true, content: [{ type: 'text', text: errCapture.text || 'triage failed' }] };
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* `compliance` is a subcommand of the same fetch-ticket.mjs `run()` that
|
|
384
|
+
* `callFetch` already wraps — reuses `runFetchTicketFn`, no new dependency
|
|
385
|
+
* or import. See fetch-ticket.mjs's `compliance` dispatch block (thread
|
|
386
|
+
* printErr through it before this tool existed — see the fetch tool's own
|
|
387
|
+
* shipping notes for why that treatment was deferred per-tool).
|
|
388
|
+
*/
|
|
389
|
+
function buildComplianceArgs({ ticket, profile }) {
|
|
390
|
+
const args = ['compliance', ticket];
|
|
391
|
+
if (profile) args.push(`--profile=${profile}`);
|
|
392
|
+
return args;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* Uses the shared callFetchTicketRun — a below-threshold result is still a
|
|
397
|
+
* successful check: `run()` prints the report (via `printFn`) before
|
|
398
|
+
* evaluating the threshold, so a failing coverage percentage is real,
|
|
399
|
+
* useful report content, not a tool failure. Only the license/usage-gate
|
|
400
|
+
* case (`runComplianceCheck` returns null) skips the report print entirely
|
|
401
|
+
* — that's the one path that surfaces as `isError`.
|
|
402
|
+
*/
|
|
403
|
+
async function callCompliance(args, deps) {
|
|
404
|
+
if (!args.ticket) {
|
|
405
|
+
return { isError: true, content: [{ type: 'text', text: 'Missing required argument: ticket' }] };
|
|
406
|
+
}
|
|
407
|
+
return callFetchTicketRun(buildComplianceArgs, args, deps, 'compliance check failed');
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* `review`/`standup`/`pr` are all subcommands of the same fetch-ticket.mjs
|
|
412
|
+
* `run()` that `callFetch`/`callCompliance` already wrap — reuse
|
|
413
|
+
* `runFetchTicketFn`, no new dependency. Each subcommand's error/progress
|
|
414
|
+
* paths (including makeSpinner's writes) were threaded through opts.printErr
|
|
415
|
+
* as part of adding these tools — see fetch-ticket.mjs's `review`/`standup`/
|
|
416
|
+
* `pr` dispatch blocks and makeSpinner's injectable {isTTY, write}.
|
|
417
|
+
*/
|
|
418
|
+
function buildReviewArgs({ base, branch, profile }) {
|
|
419
|
+
const args = ['review'];
|
|
420
|
+
if (base) args.push(`--base=${base}`);
|
|
421
|
+
else if (branch) args.push(`--branch=${branch}`);
|
|
422
|
+
if (profile) args.push(`--profile=${profile}`);
|
|
423
|
+
return args;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
async function callReview(args, deps) {
|
|
427
|
+
return callFetchTicketRun(buildReviewArgs, args, deps, 'review failed');
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
function buildStandupArgs({ since, format, profile }) {
|
|
431
|
+
const args = ['standup'];
|
|
432
|
+
if (since !== undefined) args.push(`--since=${since}`);
|
|
433
|
+
if (format) args.push(`--format=${format}`);
|
|
434
|
+
if (profile) args.push(`--profile=${profile}`);
|
|
435
|
+
return args;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
async function callStandup(args, deps) {
|
|
439
|
+
return callFetchTicketRun(buildStandupArgs, args, deps, 'standup failed');
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
function buildPrArgs({ ticket, profile }) {
|
|
443
|
+
const args = ['pr', ticket];
|
|
444
|
+
if (profile) args.push(`--profile=${profile}`);
|
|
445
|
+
return args;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
async function callPr(args, deps) {
|
|
449
|
+
if (!args.ticket) {
|
|
450
|
+
return { isError: true, content: [{ type: 'text', text: 'Missing required argument: ticket' }] };
|
|
451
|
+
}
|
|
452
|
+
return callFetchTicketRun(buildPrArgs, args, deps, 'pr failed');
|
|
453
|
+
}
|
|
454
|
+
|
|
183
455
|
function buildDoctorArgs({ fix, profile }) {
|
|
184
456
|
const args = ['--format=json'];
|
|
185
457
|
if (fix === true) args.push('--fix');
|
|
@@ -403,6 +675,12 @@ async function callTicketCreate(args, { configDir, runTicketCreateFn }) {
|
|
|
403
675
|
|
|
404
676
|
async function handleToolsCall(params, deps) {
|
|
405
677
|
const { name, arguments: args = {} } = params ?? {};
|
|
678
|
+
if (name === 'fetch') return callFetch(args, deps);
|
|
679
|
+
if (name === 'triage') return callTriage(args, deps);
|
|
680
|
+
if (name === 'compliance') return callCompliance(args, deps);
|
|
681
|
+
if (name === 'review') return callReview(args, deps);
|
|
682
|
+
if (name === 'standup') return callStandup(args, deps);
|
|
683
|
+
if (name === 'pr') return callPr(args, deps);
|
|
406
684
|
if (name === 'doctor') return callDoctor(args, deps);
|
|
407
685
|
if (name === 'recall_add') return callRecallAdd(args, deps);
|
|
408
686
|
if (name === 'recall_search') return callRecallSearch(args, deps);
|
|
@@ -416,7 +694,7 @@ async function handleToolsCall(params, deps) {
|
|
|
416
694
|
return { isError: true, content: [{ type: 'text', text: `Unknown tool: ${name}` }] };
|
|
417
695
|
}
|
|
418
696
|
|
|
419
|
-
async function handleMessage(raw, { configDir, runDoctorFn, runNoteAddFn, runRecallFn, runTicketCommentFn, runTicketTransitionListFn, runTicketTransitionFn, runTicketAssignFn, runTicketDuplicatesFn, runTicketLinkListFn, runTicketLinkFn, runTicketUpdateFn, runTicketCreateFn }) {
|
|
697
|
+
async function handleMessage(raw, { configDir, runFetchTicketFn, runTriageFn, runDoctorFn, runNoteAddFn, runRecallFn, runTicketCommentFn, runTicketTransitionListFn, runTicketTransitionFn, runTicketAssignFn, runTicketDuplicatesFn, runTicketLinkListFn, runTicketLinkFn, runTicketUpdateFn, runTicketCreateFn }) {
|
|
420
698
|
let msg;
|
|
421
699
|
try {
|
|
422
700
|
msg = JSON.parse(raw);
|
|
@@ -446,7 +724,7 @@ async function handleMessage(raw, { configDir, runDoctorFn, runNoteAddFn, runRec
|
|
|
446
724
|
|
|
447
725
|
if (method === 'tools/call') {
|
|
448
726
|
try {
|
|
449
|
-
const result = await handleToolsCall(params, { configDir, runDoctorFn, runNoteAddFn, runRecallFn, runTicketCommentFn, runTicketTransitionListFn, runTicketTransitionFn, runTicketAssignFn, runTicketDuplicatesFn, runTicketLinkListFn, runTicketLinkFn, runTicketUpdateFn, runTicketCreateFn });
|
|
727
|
+
const result = await handleToolsCall(params, { configDir, runFetchTicketFn, runTriageFn, runDoctorFn, runNoteAddFn, runRecallFn, runTicketCommentFn, runTicketTransitionListFn, runTicketTransitionFn, runTicketAssignFn, runTicketDuplicatesFn, runTicketLinkListFn, runTicketLinkFn, runTicketUpdateFn, runTicketCreateFn });
|
|
450
728
|
return jsonRpcResult(id, result);
|
|
451
729
|
} catch (err) {
|
|
452
730
|
return jsonRpcError(id ?? null, -32603, `Internal error: ${err.message}`);
|
|
@@ -467,6 +745,8 @@ export function runMcpServer({
|
|
|
467
745
|
configDir = DEFAULT_CONFIG_DIR,
|
|
468
746
|
stdin = process.stdin,
|
|
469
747
|
stdout = process.stdout,
|
|
748
|
+
runFetchTicketFn = runFetchTicket,
|
|
749
|
+
runTriageFn = runTriage,
|
|
470
750
|
runDoctorFn = runDoctor,
|
|
471
751
|
runNoteAddFn = runNoteAdd,
|
|
472
752
|
runRecallFn = runRecall,
|
|
@@ -498,7 +778,7 @@ export function runMcpServer({
|
|
|
498
778
|
// never resolving (a dropped rejection isn't a resolution) — the
|
|
499
779
|
// server would hang on shutdown instead of exiting.
|
|
500
780
|
queue = queue.then(async () => {
|
|
501
|
-
const response = await handleMessage(line, { configDir, runDoctorFn, runNoteAddFn, runRecallFn, runTicketCommentFn, runTicketTransitionListFn, runTicketTransitionFn, runTicketAssignFn, runTicketDuplicatesFn, runTicketLinkListFn, runTicketLinkFn, runTicketUpdateFn, runTicketCreateFn });
|
|
781
|
+
const response = await handleMessage(line, { configDir, runFetchTicketFn, runTriageFn, runDoctorFn, runNoteAddFn, runRecallFn, runTicketCommentFn, runTicketTransitionListFn, runTicketTransitionFn, runTicketAssignFn, runTicketDuplicatesFn, runTicketLinkListFn, runTicketLinkFn, runTicketUpdateFn, runTicketCreateFn });
|
|
502
782
|
if (response) stdout.write(response);
|
|
503
783
|
}).catch(() => {});
|
|
504
784
|
});
|