ticketlens 0.27.0 → 0.31.0

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.
@@ -42,10 +42,13 @@ export function getPackageMeta() {
42
42
  };
43
43
  }
44
44
 
45
- /** Human-readable relative time from an ISO date string. */
46
- export function timeAgo(dateStr) {
45
+ /**
46
+ * Human-readable relative time from an ISO date string.
47
+ * @param {{ now?: () => Date }} [opts] - injectable clock, for deterministic tests only
48
+ */
49
+ export function timeAgo(dateStr, { now = () => new Date() } = {}) {
47
50
  if (!dateStr) return '';
48
- const diff = Date.now() - new Date(dateStr).getTime();
51
+ const diff = now().getTime() - new Date(dateStr).getTime();
49
52
  const mins = Math.floor(diff / 60000);
50
53
  if (mins < 60) return `${mins}m ago`;
51
54
  const hours = Math.floor(mins / 60);
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Interactive y/N confirmation gate for destructive, unrecoverable actions
3
+ * (deleting a Recall note, removing a stored cloud-provider key, etc).
4
+ * Enter with no input aborts — an accidental keystroke never destroys data.
5
+ * Non-interactive callers must pass forceYes explicitly (--yes/-y at the CLI).
6
+ */
7
+ export async function confirmDestructive(action, opts = {}) {
8
+ const { stdin = process.stdin, stream = process.stderr, forceYes = false } = opts;
9
+
10
+ if (forceYes) return true;
11
+
12
+ if (!stdin.isTTY || !stdin.setRawMode) {
13
+ stream.write(' ✖ Non-interactive mode: pass --yes to confirm without a prompt.\n');
14
+ return false;
15
+ }
16
+
17
+ stream.write(` ${action} — this cannot be restored. Continue? y/N `);
18
+ return new Promise(resolve => {
19
+ stdin.setRawMode(true);
20
+ stdin.resume();
21
+ stdin.once('data', buf => {
22
+ stdin.setRawMode(false);
23
+ stdin.pause();
24
+ const confirmed = buf.toString().toLowerCase() === 'y';
25
+ stream.write(confirmed ? 'y\n' : 'N\n');
26
+ resolve(confirmed);
27
+ });
28
+ });
29
+ }
@@ -53,12 +53,14 @@ export function printHelp({ stream = process.stdout } = {}) {
53
53
  ` ${s.brand('ticketlens')} recall ${s.dim('<query|TICKET-KEY>')} Search your saved Recall notes ${s.dim('[Pro]')}`,
54
54
  ` ${s.brand('ticketlens')} recall sync Retry any notes stuck in the local queue ${s.dim('[Team+]')}`,
55
55
  ` ${s.brand('ticketlens')} recall settings Show effective retry-queue settings, fetched live ${s.dim('[Team+]')}`,
56
- ` ${s.brand('ticketlens')} mcp Start the MCP stdio server for Recall ${s.dim('[Pro]')}`,
56
+ ` ${s.brand('ticketlens')} mcp Start the MCP stdio server (Recall + ticket writes) ${s.dim('[Pro]')}`,
57
57
  ` ${s.brand('ticketlens')} comment ${s.dim('<TICKET-KEY> --body=...')} Post a comment to the tracker ${s.dim('[Pro]')}`,
58
58
  ` ${s.brand('ticketlens')} transition ${s.dim('<TICKET-KEY> [--target=... --confirm]')} Move ticket status ${s.dim('[Pro]')}`,
59
59
  ` ${s.brand('ticketlens')} assign ${s.dim('<TICKET-KEY> --to=me')} Assign a ticket to yourself ${s.dim('[Pro]')}`,
60
60
  ` ${s.brand('ticketlens')} duplicates ${s.dim('<TICKET-KEY> [--threshold=N]')} Find likely duplicate tickets ${s.dim('[Pro]')}`,
61
61
  ` ${s.brand('ticketlens')} link ${s.dim('<SOURCE> <TARGET> [--type=... --confirm]')} Link two tickets ${s.dim('[Pro]')}`,
62
+ ` ${s.brand('ticketlens')} update ${s.dim('<TICKET-KEY> [--title=... --description=... --priority=... --add-labels=... --remove-labels=...]')} Update fields ${s.dim('[Pro]')}`,
63
+ ` ${s.brand('ticketlens')} create ${s.dim('--project=... [--type=...] --summary=... [--description=...]')} Create a new ticket ${s.dim('[Pro]')}`,
62
64
  '',
63
65
  ` ${s.brand('ticketlens')} delete ${s.dim('<PROFILE-NAME>')} Remove a profile`,
64
66
  ` ${s.brand('ticketlens')} activate ${s.dim('<KEY>')} Activate a license key`,
@@ -179,6 +181,7 @@ export function printFetchHelp({ stream = process.stdout } = {}) {
179
181
  ` ${s.brand('--cloud')} Route AI request through TicketLens API ${s.dim('[Pro]')}`,
180
182
  ` ${s.brand('--provider')}=${s.dim('NAME')} Force AI provider ${s.dim('(anthropic|openai|groq)')}`,
181
183
  ` ${s.brand('--template')}=${s.dim('SLUG')} Apply a brief template ${s.dim('(full|quick|code-review, or custom [Team])')}`,
184
+ ` ${s.brand('--budget')}=${s.dim('N')} Trim brief to fit a token budget ${s.dim('[Pro]')}`,
182
185
  ` ${s.brand('-h')}, ${s.brand('--help')} Show this help`,
183
186
  '',
184
187
  ` ${s.bold('EXAMPLES')}`,
@@ -194,6 +197,32 @@ export function printFetchHelp({ stream = process.stdout } = {}) {
194
197
  stream.write(lines.join('\n') + '\n');
195
198
  }
196
199
 
200
+ export function printHistoryHelp({ stream = process.stdout } = {}) {
201
+ const s = createStyler({ isTTY: stream.isTTY });
202
+ const lines = [
203
+ '',
204
+ ` ${s.bold(s.brand('ticketlens'))} ${s.bold('history')} ${s.dim('TICKET-KEY')} ${s.dim('[Pro]')}`,
205
+ '',
206
+ ` Show this ticket's urgency timeline from your local triage history — ${s.dim('[Pro]')}`,
207
+ ` every prior triage scan that surfaced it, with the urgency level and reason`,
208
+ ` computed at that point in time. Read-only, entirely local — no network call.`,
209
+ '',
210
+ ` ${s.bold('ARGUMENTS')}`,
211
+ '',
212
+ ` ${s.brand('TICKET-KEY')} The ticket to show history for ${s.dim('(required)')}`,
213
+ '',
214
+ ` ${s.bold('OPTIONS')}`,
215
+ '',
216
+ ` ${s.brand('-h')}, ${s.brand('--help')} Show this help`,
217
+ '',
218
+ ` ${s.bold('EXAMPLES')}`,
219
+ '',
220
+ ` ${s.dim('$')} ticketlens history PROJ-123`,
221
+ '',
222
+ ];
223
+ stream.write(lines.join('\n') + '\n');
224
+ }
225
+
197
226
  const ANSI_RE_HELP = /\x1b\[[0-9;]*m/g;
198
227
  function padRightVis(str, len) {
199
228
  const vis = str.replace(ANSI_RE_HELP, '').length;
@@ -545,11 +574,12 @@ export function printNoteHelp({ stream = process.stdout } = {}) {
545
574
  ` Code session — not typically invoked by hand. Every note it writes gets the`,
546
575
  ` same structural and secret-scan checks ${s.brand('note add')} applies to user input.`,
547
576
  '',
548
- ` ${s.bold('ticketlens note delete')} ${s.dim('--id="..." [--ticket=KEY]')} ${s.dim('[Pro]')}`,
577
+ ` ${s.bold('ticketlens note delete')} ${s.dim('--id="..." [--ticket=KEY] [--yes]')} ${s.dim('[Pro]')}`,
549
578
  '',
550
579
  ` Removes a note from your local vault. Local only — if this note was already`,
551
580
  ` pushed to a team, teammates who pulled it keep their copy; deleting it there`,
552
- ` too is a manager action from the Console (Admin > Recall).`,
581
+ ` too is a manager action from the Console (Admin > Recall). In TTY mode,`,
582
+ ` prompts for confirmation before deleting. Pass ${s.cyan('--yes')} (or ${s.cyan('-y')}) to skip the prompt.`,
553
583
  '',
554
584
  ];
555
585
  stream.write(lines.join('\n') + '\n');
@@ -599,13 +629,16 @@ export function printMcpHelp({ stream = process.stdout } = {}) {
599
629
  '',
600
630
  ` Start an MCP (Model Context Protocol) stdio server exposing Recall and`,
601
631
  ` ticket writes as native tools — ${s.cyan('recall_add')}, ${s.cyan('recall_search')}, ${s.cyan('ticket_comment')},`,
602
- ` ${s.cyan('ticket_transition')}, ${s.cyan('ticket_assign')}, ${s.cyan('ticket_duplicates')}, ${s.cyan('ticket_link')} — for any MCP-compatible`,
603
- ` AI harness, not just Claude Code. Thin adapter over the same code as`,
604
- ` ${s.cyan('note add')}/${s.cyan('recall')}/${s.cyan('comment')}/${s.cyan('transition')}/${s.cyan('assign')}/${s.cyan('duplicates')}/${s.cyan('link')} above: same Pro gate,`,
605
- ` same local vault/tracker writes, same team sync. ${s.cyan('ticket_transition')} is destructive`,
606
- ` when called with \`target\`+\`confirm: true\`; ${s.cyan('ticket_assign')} is currently self-assign`,
607
- ` only; ${s.cyan('ticket_duplicates')} is read-only; ${s.cyan('ticket_link')} on GitHub closes the source`,
608
- ` issue as a duplicate — different semantics than Jira/Linear's relationship-only add.`,
632
+ ` ${s.cyan('ticket_transition')}, ${s.cyan('ticket_assign')}, ${s.cyan('ticket_duplicates')}, ${s.cyan('ticket_link')}, ${s.cyan('ticket_update')}, ${s.cyan('ticket_create')} — for any`,
633
+ ` MCP-compatible AI harness, not just Claude Code. Thin adapter over the same`,
634
+ ` code as ${s.cyan('note add')}/${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:`,
635
+ ` same Pro gate, same local vault/tracker writes, same team sync. ${s.cyan('ticket_transition')} is`,
636
+ ` destructive when called with \`target\`+\`confirm: true\`; ${s.cyan('ticket_assign')} is currently`,
637
+ ` self-assign only; ${s.cyan('ticket_duplicates')} is read-only; ${s.cyan('ticket_link')} on GitHub closes the`,
638
+ ` source issue as a duplicate — different semantics than Jira/Linear's relationship-only`,
639
+ ` add; ${s.cyan('ticket_update')} has no priority field on GitHub and can partially succeed;`,
640
+ ` ${s.cyan('ticket_create')} has no ticket key to target — --profile/the default profile picks the`,
641
+ ` tracker, and it fabricates a real item, the highest blast radius of this family.`,
609
642
  ` Long-running — exits when the client closes stdin.`,
610
643
  '',
611
644
  ` ${s.bold('OPTIONS')}`,
@@ -765,6 +798,81 @@ export function printLinkHelp({ stream = process.stdout } = {}) {
765
798
  stream.write(lines.join('\n') + '\n');
766
799
  }
767
800
 
801
+ export function printUpdateHelp({ stream = process.stdout } = {}) {
802
+ const s = createStyler({ isTTY: stream.isTTY });
803
+ const lines = [
804
+ '',
805
+ ` ${s.bold(s.brand('ticketlens'))} ${s.bold('update')} ${s.dim('TICKET-KEY [--title=...] [--description=...] [--add-labels=a,b] [--remove-labels=c] [--priority=...]')} ${s.dim('[Pro]')}`,
806
+ '',
807
+ ` Update a narrow, named field set on a ticket (Jira/GitHub/Linear). ${s.dim('[Pro]')}`,
808
+ ` At least one field is required. Labels are add/remove, never a wholesale`,
809
+ ` replace — an unnamed label is left alone, not dropped.`,
810
+ '',
811
+ ` ${s.bold('GitHub has no priority field')}: passing ${s.brand('--priority')} against a GitHub-tracked`,
812
+ ` ticket is refused up front rather than silently ignored.`,
813
+ '',
814
+ ` A write can partially succeed (e.g. title updates but a label doesn't`,
815
+ ` resolve) — the result always reports exactly what landed.`,
816
+ '',
817
+ ` ${s.bold('OPTIONS')}`,
818
+ '',
819
+ ` ${s.brand('--title')}=${s.dim('TEXT')} New title/summary`,
820
+ ` ${s.brand('--description')}=${s.dim('TEXT')} New description`,
821
+ ` ${s.brand('--add-labels')}=${s.dim('a,b')} Labels to add ${s.dim('(comma-separated)')}`,
822
+ ` ${s.brand('--remove-labels')}=${s.dim('c')} Labels to remove ${s.dim('(comma-separated)')}`,
823
+ ` ${s.brand('--priority')}=${s.dim('NAME')} New priority ${s.dim('(not supported on GitHub)')}`,
824
+ ` ${s.brand('--profile')}=${s.dim('NAME')} Connection profile to use ${s.dim('(optional)')}`,
825
+ ` ${s.brand('-h')}, ${s.brand('--help')} Show this help`,
826
+ '',
827
+ ` ${s.bold('EXAMPLES')}`,
828
+ '',
829
+ ` ${s.dim('$')} ticketlens update PROD-123 --title="Fix login on mobile"`,
830
+ ` ${s.dim('$')} ticketlens update PROD-123 --add-labels=urgent,backend --remove-labels=stale`,
831
+ ` ${s.dim('$')} ticketlens update PROD-123 --priority="High"`,
832
+ '',
833
+ ];
834
+ stream.write(lines.join('\n') + '\n');
835
+ }
836
+
837
+ export function printCreateHelp({ stream = process.stdout } = {}) {
838
+ const s = createStyler({ isTTY: stream.isTTY });
839
+ const lines = [
840
+ '',
841
+ ` ${s.bold(s.brand('ticketlens'))} ${s.bold('create')} ${s.dim('--project=... [--type=...] --summary=... [--description=...]')} ${s.dim('[Pro]')}`,
842
+ '',
843
+ ` Create a new ticket in a tracker (Jira/GitHub/Linear) with a fixed minimal`,
844
+ ` field set — no arbitrary custom fields. ${s.dim('[Pro]')} Unlike every other ticket-write`,
845
+ ` command, there is no existing ticket to operate on — the target tracker is`,
846
+ ` picked by ${s.brand('--profile')} (or your default profile), not a ticket key.`,
847
+ '',
848
+ ` ${s.bold('--project is tracker-specific')}: the Jira project key or Linear team key.`,
849
+ ` Required for Jira/Linear. GitHub ignores it — its target repo is already`,
850
+ ` fixed by the profile.`,
851
+ '',
852
+ ` ${s.bold('--type is Jira-only')}: the issue type, e.g. "Task" or "Bug". Required for`,
853
+ ` Jira. GitHub/Linear have no equivalent concept and ignore it if given.`,
854
+ '',
855
+ ` This is the highest-blast-radius command in the ticket-write family — a bad`,
856
+ ` ${s.brand('--project')}/${s.brand('--type')} fabricates a real, hard-to-walk-back item in a live tracker.`,
857
+ '',
858
+ ` ${s.bold('OPTIONS')}`,
859
+ '',
860
+ ` ${s.brand('--project')}=${s.dim('KEY')} Project key (Jira) or team key (Linear) ${s.dim('(required, except GitHub)')}`,
861
+ ` ${s.brand('--type')}=${s.dim('NAME')} Issue type ${s.dim('(Jira only, required there)')}`,
862
+ ` ${s.brand('--summary')}=${s.dim('TEXT')} Ticket title/summary ${s.dim('(required)')}`,
863
+ ` ${s.brand('--description')}=${s.dim('TEXT')} Ticket description`,
864
+ ` ${s.brand('--profile')}=${s.dim('NAME')} Connection profile to use ${s.dim('(optional)')}`,
865
+ ` ${s.brand('-h')}, ${s.brand('--help')} Show this help`,
866
+ '',
867
+ ` ${s.bold('EXAMPLES')}`,
868
+ '',
869
+ ` ${s.dim('$')} ticketlens create --project=PROD --type="Task" --summary="Fix login on mobile"`,
870
+ ` ${s.dim('$')} ticketlens create --project=ENG --summary="New Linear issue" --profile=linear-team`,
871
+ '',
872
+ ];
873
+ stream.write(lines.join('\n') + '\n');
874
+ }
875
+
768
876
  export function printSwitchHelp({ stream = process.stdout } = {}) {
769
877
  const s = createStyler({ isTTY: stream.isTTY });
770
878
  const lines = [
@@ -1156,7 +1264,7 @@ export function printCloudKeysHelp({ stream = process.stdout } = {}) {
1156
1264
  '',
1157
1265
  ` ${s.brand('list')} List configured providers`,
1158
1266
  ` ${s.brand('add')} ${s.dim('<provider> <key>')} Add or replace an API key`,
1159
- ` ${s.brand('remove')} ${s.dim('<provider>')} Remove a provider's key`,
1267
+ ` ${s.brand('remove')} ${s.dim('<provider>')} ${s.dim('[--yes]')} Remove a provider's key — prompts for confirmation`,
1160
1268
  ` ${s.brand('test')} ${s.dim('<provider>')} Send a test request through the provider`,
1161
1269
  ` ${s.brand('priority')} ${s.dim('<provider> <N>')} Set priority (lower = tried first)`,
1162
1270
  ` ${s.brand('timeout')} ${s.dim('<provider> <seconds>')} Set per-request timeout`,
@@ -1164,6 +1272,7 @@ export function printCloudKeysHelp({ stream = process.stdout } = {}) {
1164
1272
  ` ${s.bold('OPTIONS')}`,
1165
1273
  '',
1166
1274
  ` ${s.brand('--timeout')}=${s.dim('N')} Timeout in seconds when adding a key ${s.dim('(default: 5)')}`,
1275
+ ` ${s.brand('--yes')}, ${s.brand('-y')} Skip the confirmation prompt when removing a key`,
1167
1276
  ` ${s.brand('-h')}, ${s.brand('--help')} Show this help`,
1168
1277
  '',
1169
1278
  ` ${s.bold('PROVIDERS')}`,
@@ -587,6 +587,134 @@ export async function assignIssue(ticketKey, assignee, opts = {}) {
587
587
  }
588
588
  }
589
589
 
590
+ /**
591
+ * Updates a narrow, named field set on an issue. `fields` (summary,
592
+ * description, priority) uses plain SET semantics — the same shape Jira's
593
+ * own GET returns, same convention as assignIssue/postIssueLink. Labels use
594
+ * a separate verb-based `update.labels` array (`{add}`/`{remove}`) — Jira's
595
+ * REST API has no plain-replace shape for multi-value fields, only ADD/SET/
596
+ * REMOVE operations, a genuinely different mechanic from fields. Both keys
597
+ * can coexist in one request as long as a given field only appears in one
598
+ * of them — never both — which callers naturally satisfy since labels only
599
+ * ever go through `update`.
600
+ */
601
+ export async function updateIssue(ticketKey, { summary, description, priority, addLabels, removeLabels } = {}, opts = {}) {
602
+ const { env = process.env, fetcher = globalThis.fetch, lookup = defaultLookupFor(fetcher), apiVersion = 2, timeoutMs = 10_000, allowPrivateIp = false } = opts;
603
+ validateBaseUrl(env.JIRA_BASE_URL, allowPrivateIp);
604
+ const baseUrl = env.JIRA_BASE_URL.replace(/\/$/, '');
605
+ const url = `${baseUrl}/rest/api/${apiVersion}/issue/${encodeURIComponent(ticketKey)}`;
606
+
607
+ const fields = {};
608
+ if (summary !== undefined) fields.summary = summary;
609
+ if (description !== undefined) fields.description = apiVersion === 3 ? textToAdf(description) : description;
610
+ if (priority !== undefined) fields.priority = { name: priority };
611
+
612
+ const labelOps = [
613
+ ...(addLabels ?? []).map(label => ({ add: label })),
614
+ ...(removeLabels ?? []).map(label => ({ remove: label })),
615
+ ];
616
+
617
+ const body = {};
618
+ if (Object.keys(fields).length > 0) body.fields = fields;
619
+ if (labelOps.length > 0) body.update = { labels: labelOps };
620
+
621
+ const fetchOpts = {
622
+ method: 'PUT',
623
+ headers: { ...buildAuthHeader(env), 'Content-Type': 'application/json' },
624
+ body: JSON.stringify(body),
625
+ };
626
+ if (timeoutMs) fetchOpts.signal = AbortSignal.timeout(timeoutMs);
627
+
628
+ const response = await guardedFetch(url, fetchOpts, { fetcher, lookup, allowPrivateIp });
629
+ if (!response.ok) {
630
+ let details;
631
+ try { details = await response.json(); } catch { /* body not JSON — fall through with no details */ }
632
+ const err = new Error(`Jira API error ${response.status} updating ${ticketKey}`);
633
+ err.status = response.status;
634
+ err.details = details;
635
+ throw err;
636
+ }
637
+ }
638
+
639
+ /**
640
+ * Discovers the issue types actually configured for a project — used only
641
+ * to enrich a ticket_create failure message with real, current options,
642
+ * never as a client-side pre-validation step (issuetype validity is still
643
+ * surfaced via the tracker's own 400, same design choice as createIssue
644
+ * itself). A single, unpaginated call: real-world Jira projects rarely
645
+ * exceed a handful to a few dozen issue types (well under the endpoint's
646
+ * own default maxResults=50), and a truncated list here would only ever
647
+ * under-inform an error message, never break the actual write path — full
648
+ * pagination (as fetchProjects needs, since an org's total project count
649
+ * has no such natural ceiling) would be disproportionate complexity for
650
+ * an enrichment-only feature.
651
+ *
652
+ * v2 (Server/DC) and v3 (Cloud) return genuinely different response
653
+ * envelopes for this same resource — confirmed directly against
654
+ * Atlassian's own Cloud OpenAPI spec and a live-downloaded Server/DC WADL,
655
+ * not assumed just because the URL pattern matches: v3 nests results under
656
+ * `issueTypes`, v2 under `values` (same distinction fetchProjects already
657
+ * makes for its own project-listing endpoint, for the same underlying
658
+ * reason — the two API generations were never response-shape-compatible).
659
+ */
660
+ export async function fetchIssueTypes(projectKey, opts = {}) {
661
+ const { env = process.env, fetcher = globalThis.fetch, lookup = defaultLookupFor(fetcher), apiVersion = 2, timeoutMs = 10_000, allowPrivateIp = false } = opts;
662
+ validateBaseUrl(env.JIRA_BASE_URL, allowPrivateIp);
663
+ const baseUrl = env.JIRA_BASE_URL.replace(/\/$/, '');
664
+ const headers = { ...buildAuthHeader(env), 'Content-Type': 'application/json' };
665
+
666
+ const url = `${baseUrl}/rest/api/${apiVersion}/issue/createmeta/${encodeURIComponent(projectKey)}/issuetypes`;
667
+ const fetchOpts = { headers };
668
+ if (timeoutMs) fetchOpts.signal = AbortSignal.timeout(timeoutMs);
669
+ const response = await guardedFetch(url, fetchOpts, { fetcher, lookup, allowPrivateIp });
670
+
671
+ if (!response.ok) {
672
+ const err = new Error(`Jira API error ${response.status} fetching issue types for ${projectKey}`);
673
+ err.status = response.status;
674
+ throw err;
675
+ }
676
+
677
+ const raw = await response.json();
678
+ const values = apiVersion >= 3 ? (raw.issueTypes ?? []) : (raw.values ?? []);
679
+ return values.map(t => ({ id: t.id, name: t.name }));
680
+ }
681
+
682
+ /**
683
+ * Creates a new issue. `project`/`type` are passed straight through as
684
+ * Jira's own {key}/{name} references — issue types are project-configurable,
685
+ * so validity is never pre-checked here; an invalid one surfaces Jira's own
686
+ * 400 with details, same design choice already made for updateIssue's
687
+ * priority field. No separate discovery call, no client-side field list.
688
+ */
689
+ export async function createIssue({ project, type, summary, description } = {}, opts = {}) {
690
+ const { env = process.env, fetcher = globalThis.fetch, lookup = defaultLookupFor(fetcher), apiVersion = 2, timeoutMs = 10_000, allowPrivateIp = false } = opts;
691
+ validateBaseUrl(env.JIRA_BASE_URL, allowPrivateIp);
692
+ const baseUrl = env.JIRA_BASE_URL.replace(/\/$/, '');
693
+ const url = `${baseUrl}/rest/api/${apiVersion}/issue`;
694
+
695
+ const fields = { project: { key: project }, issuetype: { name: type }, summary };
696
+ if (description !== undefined) fields.description = apiVersion === 3 ? textToAdf(description) : description;
697
+
698
+ const fetchOpts = {
699
+ method: 'POST',
700
+ headers: { ...buildAuthHeader(env), 'Content-Type': 'application/json' },
701
+ body: JSON.stringify({ fields }),
702
+ };
703
+ if (timeoutMs) fetchOpts.signal = AbortSignal.timeout(timeoutMs);
704
+
705
+ const response = await guardedFetch(url, fetchOpts, { fetcher, lookup, allowPrivateIp });
706
+ if (!response.ok) {
707
+ let details;
708
+ try { details = await response.json(); } catch { /* body not JSON — fall through with no details */ }
709
+ const err = new Error(`Jira API error ${response.status} creating an issue in ${project}`);
710
+ err.status = response.status;
711
+ err.details = details;
712
+ throw err;
713
+ }
714
+ const raw = await response.json();
715
+ return { key: raw.key, id: raw.id, url: raw.self ?? null };
716
+ }
717
+
590
718
  export async function fetchTicket(ticketKey, opts = {}) {
591
719
  const { env = process.env, fetcher = globalThis.fetch, lookup = defaultLookupFor(fetcher), depth = 1, apiVersion = 2, timeoutMs = 10_000, expandChangelog = false, allowPrivateIp = false, _visited = new Set(), _currentDepth = 0 } = opts;
592
720
  validateBaseUrl(env.JIRA_BASE_URL, allowPrivateIp);
@@ -23,7 +23,7 @@ import readline from 'node:readline';
23
23
  import { DEFAULT_CONFIG_DIR, getVersion } from './config.mjs';
24
24
  import { runNoteAdd } from './note-command.mjs';
25
25
  import { runRecall } from './recall-command.mjs';
26
- import { runTicketComment, runTicketTransitionList, runTicketTransition, runTicketAssign, runTicketDuplicates, runTicketLinkList, runTicketLink } from './ticket-command.mjs';
26
+ import { runTicketComment, runTicketTransitionList, runTicketTransition, runTicketAssign, runTicketDuplicates, runTicketLinkList, runTicketLink, runTicketUpdate, runTicketCreate } from './ticket-command.mjs';
27
27
 
28
28
  const PROTOCOL_VERSION = '2025-11-25';
29
29
 
@@ -36,7 +36,7 @@ const TOOLS = [
36
36
  properties: {
37
37
  title: { type: 'string', description: 'Short one-line title.' },
38
38
  ticket: { type: 'string', description: 'Optional ticket key, e.g. PROJ-123.' },
39
- tags: { type: 'array', items: { type: 'string' }, description: 'Optional tags.' },
39
+ tags: { type: 'array', items: { type: 'string' }, description: 'Optional tags derived from this note\'s actual content — the specific technology, error type, root cause, or affected component (e.g. "retry-backoff", "null-pointer", "auth-middleware"). Never the project name or a generic category word like "gotcha" or "bug" — those provide no search signal to someone else looking for this note later.' },
40
40
  body: { type: 'string', description: 'The note body — one or more paragraphs.' },
41
41
  },
42
42
  required: ['title', 'body'],
@@ -116,6 +116,36 @@ const TOOLS = [
116
116
  required: ['ticket', 'target'],
117
117
  },
118
118
  },
119
+ {
120
+ name: 'ticket_update',
121
+ description: 'Update a narrow, named field set on a ticket in its tracker (Jira/GitHub/Linear) — title, description, labels, priority. Labels are add/remove, never a wholesale replace: an unnamed existing label is left alone. No discovery step and no confirm required — these are reversible metadata edits, not workflow-state changes. GitHub has no priority field; passing `priority` for a GitHub-tracked ticket is refused. A call can partially succeed (e.g. title updates but a label does not resolve) — the result reports exactly what landed. Requires a TicketLens Pro license.',
122
+ inputSchema: {
123
+ type: 'object',
124
+ properties: {
125
+ ticket: { type: 'string', description: 'Ticket key, e.g. PROJ-123.' },
126
+ title: { type: 'string', description: 'New title/summary. Omit to leave unchanged.' },
127
+ description: { type: 'string', description: 'New description. Omit to leave unchanged.' },
128
+ addLabels: { type: 'array', items: { type: 'string' }, description: 'Labels to add. Existing labels not named here are left alone.' },
129
+ removeLabels: { type: 'array', items: { type: 'string' }, description: 'Labels to remove.' },
130
+ priority: { type: 'string', description: 'New priority name, e.g. "High". Not supported on GitHub.' },
131
+ },
132
+ required: ['ticket'],
133
+ },
134
+ },
135
+ {
136
+ name: 'ticket_create',
137
+ description: 'Create a new ticket in a tracker (Jira/GitHub/Linear) with a fixed minimal field set — no arbitrary custom fields. Architecturally unlike every other ticket-write tool: there is no existing ticket to target, so the target tracker/project is picked by the connection profile rather than a ticket key. `project` is the Jira project key or Linear team key — required for both, ignored on GitHub (its repo is fixed by the profile). `type` is the Jira issue type — required for Jira only, ignored elsewhere. Highest blast radius of the ticket-write family: a bad project/type fabricates a real, hard-to-walk-back item in a live tracker. Requires a TicketLens Pro license.',
138
+ inputSchema: {
139
+ type: 'object',
140
+ properties: {
141
+ project: { type: 'string', description: 'Jira project key or Linear team key. Required for Jira/Linear; ignored on GitHub.' },
142
+ type: { type: 'string', description: 'Jira issue type, e.g. "Task" or "Bug". Required for Jira only; ignored on GitHub/Linear.' },
143
+ summary: { type: 'string', description: 'Ticket title/summary.' },
144
+ description: { type: 'string', description: 'Ticket description. Omit for none.' },
145
+ },
146
+ required: ['summary'],
147
+ },
148
+ },
119
149
  ];
120
150
 
121
151
  function jsonRpcResult(id, result) {
@@ -273,6 +303,66 @@ async function callTicketLink(args, { configDir, runTicketLinkListFn, runTicketL
273
303
  return ok ? { content } : { isError: true, content };
274
304
  }
275
305
 
306
+ /**
307
+ * `title`/`description`/`priority` each become one opaque cmdArgs element,
308
+ * same reasoning as buildNoteAddArgs/callTicketComment above. `addLabels`/
309
+ * `removeLabels` arrive as arrays per the MCP schema and are comma-joined
310
+ * into a single element (same convention buildNoteAddArgs already uses for
311
+ * `tags`) — parsed back apart by ticket-command.mjs's existing split(',')
312
+ * handling, never by re-splitting a string this function builds itself.
313
+ */
314
+ function buildTicketUpdateArgs(args) {
315
+ const cmdArgs = [args.ticket];
316
+ if (args.title !== undefined) cmdArgs.push(`--title=${args.title}`);
317
+ if (args.description !== undefined) cmdArgs.push(`--description=${args.description}`);
318
+ if (args.priority !== undefined) cmdArgs.push(`--priority=${args.priority}`);
319
+ if (Array.isArray(args.addLabels) && args.addLabels.length > 0) cmdArgs.push(`--add-labels=${args.addLabels.join(',')}`);
320
+ if (Array.isArray(args.removeLabels) && args.removeLabels.length > 0) cmdArgs.push(`--remove-labels=${args.removeLabels.join(',')}`);
321
+ return cmdArgs;
322
+ }
323
+
324
+ /**
325
+ * No list-then-act split, unlike ticket_transition/ticket_link — update has
326
+ * no discovery step, so there is exactly one dispatch path. Whether at
327
+ * least one field was actually given is runTicketUpdateFn's own concern
328
+ * (same "don't pre-empt the underlying command" principle as ticket_link's
329
+ * --confirm check).
330
+ */
331
+ async function callTicketUpdate(args, { configDir, runTicketUpdateFn }) {
332
+ if (!args.ticket) {
333
+ return { isError: true, content: [{ type: 'text', text: 'Missing required argument: ticket' }] };
334
+ }
335
+ const capture = capturingStream();
336
+ const { ok } = await runTicketUpdateFn(buildTicketUpdateArgs(args), { configDir, stream: capture });
337
+ const content = [{ type: 'text', text: capture.text }];
338
+ return ok ? { content } : { isError: true, content };
339
+ }
340
+
341
+ /**
342
+ * `project`/`type`/`description` each become one opaque cmdArgs element,
343
+ * same reasoning as buildNoteAddArgs/callTicketComment above. Unlike every
344
+ * other ticket-write tool, there is no `ticket` argument — creation has no
345
+ * existing ticket to target.
346
+ */
347
+ function buildTicketCreateArgs(args) {
348
+ const cmdArgs = [];
349
+ if (args.project !== undefined) cmdArgs.push(`--project=${args.project}`);
350
+ if (args.type !== undefined) cmdArgs.push(`--type=${args.type}`);
351
+ cmdArgs.push(`--summary=${args.summary}`);
352
+ if (args.description !== undefined) cmdArgs.push(`--description=${args.description}`);
353
+ return cmdArgs;
354
+ }
355
+
356
+ async function callTicketCreate(args, { configDir, runTicketCreateFn }) {
357
+ if (!args.summary) {
358
+ return { isError: true, content: [{ type: 'text', text: 'Missing required argument: summary' }] };
359
+ }
360
+ const capture = capturingStream();
361
+ const { ok } = await runTicketCreateFn(buildTicketCreateArgs(args), { configDir, stream: capture });
362
+ const content = [{ type: 'text', text: capture.text }];
363
+ return ok ? { content } : { isError: true, content };
364
+ }
365
+
276
366
  async function handleToolsCall(params, deps) {
277
367
  const { name, arguments: args = {} } = params ?? {};
278
368
  if (name === 'recall_add') return callRecallAdd(args, deps);
@@ -282,10 +372,12 @@ async function handleToolsCall(params, deps) {
282
372
  if (name === 'ticket_assign') return callTicketAssign(args, deps);
283
373
  if (name === 'ticket_duplicates') return callTicketDuplicates(args, deps);
284
374
  if (name === 'ticket_link') return callTicketLink(args, deps);
375
+ if (name === 'ticket_update') return callTicketUpdate(args, deps);
376
+ if (name === 'ticket_create') return callTicketCreate(args, deps);
285
377
  return { isError: true, content: [{ type: 'text', text: `Unknown tool: ${name}` }] };
286
378
  }
287
379
 
288
- async function handleMessage(raw, { configDir, runNoteAddFn, runRecallFn, runTicketCommentFn, runTicketTransitionListFn, runTicketTransitionFn, runTicketAssignFn, runTicketDuplicatesFn, runTicketLinkListFn, runTicketLinkFn }) {
380
+ async function handleMessage(raw, { configDir, runNoteAddFn, runRecallFn, runTicketCommentFn, runTicketTransitionListFn, runTicketTransitionFn, runTicketAssignFn, runTicketDuplicatesFn, runTicketLinkListFn, runTicketLinkFn, runTicketUpdateFn, runTicketCreateFn }) {
289
381
  let msg;
290
382
  try {
291
383
  msg = JSON.parse(raw);
@@ -315,7 +407,7 @@ async function handleMessage(raw, { configDir, runNoteAddFn, runRecallFn, runTic
315
407
 
316
408
  if (method === 'tools/call') {
317
409
  try {
318
- const result = await handleToolsCall(params, { configDir, runNoteAddFn, runRecallFn, runTicketCommentFn, runTicketTransitionListFn, runTicketTransitionFn, runTicketAssignFn, runTicketDuplicatesFn, runTicketLinkListFn, runTicketLinkFn });
410
+ const result = await handleToolsCall(params, { configDir, runNoteAddFn, runRecallFn, runTicketCommentFn, runTicketTransitionListFn, runTicketTransitionFn, runTicketAssignFn, runTicketDuplicatesFn, runTicketLinkListFn, runTicketLinkFn, runTicketUpdateFn, runTicketCreateFn });
319
411
  return jsonRpcResult(id, result);
320
412
  } catch (err) {
321
413
  return jsonRpcError(id ?? null, -32603, `Internal error: ${err.message}`);
@@ -345,6 +437,8 @@ export function runMcpServer({
345
437
  runTicketDuplicatesFn = runTicketDuplicates,
346
438
  runTicketLinkListFn = runTicketLinkList,
347
439
  runTicketLinkFn = runTicketLink,
440
+ runTicketUpdateFn = runTicketUpdate,
441
+ runTicketCreateFn = runTicketCreate,
348
442
  } = {}) {
349
443
  // A client can disconnect mid-write (EPIPE) at any time on a long-lived
350
444
  // process — an unhandled 'error' event on either stream would otherwise
@@ -364,7 +458,7 @@ export function runMcpServer({
364
458
  // never resolving (a dropped rejection isn't a resolution) — the
365
459
  // server would hang on shutdown instead of exiting.
366
460
  queue = queue.then(async () => {
367
- const response = await handleMessage(line, { configDir, runNoteAddFn, runRecallFn, runTicketCommentFn, runTicketTransitionListFn, runTicketTransitionFn, runTicketAssignFn, runTicketDuplicatesFn, runTicketLinkListFn, runTicketLinkFn });
461
+ const response = await handleMessage(line, { configDir, runNoteAddFn, runRecallFn, runTicketCommentFn, runTicketTransitionListFn, runTicketTransitionFn, runTicketAssignFn, runTicketDuplicatesFn, runTicketLinkListFn, runTicketLinkFn, runTicketUpdateFn, runTicketCreateFn });
368
462
  if (response) stdout.write(response);
369
463
  }).catch(() => {});
370
464
  });
@@ -19,6 +19,7 @@ import { incrementDraftKept, incrementDraftDeleted } from './activity-counter.mj
19
19
  import { extractText } from './attachment-text.mjs';
20
20
  import { TICKET_KEY_PATTERN } from './cli.mjs';
21
21
  import { createStyler } from './ansi.mjs';
22
+ import { confirmDestructive } from './confirm.mjs';
22
23
 
23
24
  function defaultListAttachments(configDir, ticketKey) {
24
25
  const cacheDir = path.join(configDir, 'cache', ticketKey);
@@ -226,8 +227,10 @@ export async function runNotePatch(cmdArgs, {
226
227
  export async function runNoteDelete(cmdArgs, {
227
228
  configDir = DEFAULT_CONFIG_DIR,
228
229
  stream = process.stderr,
230
+ stdin = process.stdin,
229
231
  isLicensedFn = isLicensed,
230
232
  deleteNoteFn = deleteNote,
233
+ confirmFn = confirmDestructive,
231
234
  } = {}) {
232
235
  if (!isLicensedFn('pro', configDir)) {
233
236
  showUpgradePrompt('pro', 'ticketlens note', { stream });
@@ -236,7 +239,7 @@ export async function runNoteDelete(cmdArgs, {
236
239
 
237
240
  const id = parseFlag(cmdArgs, 'id');
238
241
  if (!id) {
239
- stream.write('Usage: ticketlens note delete --id="..." [--ticket=KEY]\n');
242
+ stream.write('Usage: ticketlens note delete --id="..." [--ticket=KEY] [--yes|-y]\n');
240
243
  return { deleted: false };
241
244
  }
242
245
 
@@ -246,6 +249,13 @@ export async function runNoteDelete(cmdArgs, {
246
249
  return { deleted: false };
247
250
  }
248
251
 
252
+ const forceYes = cmdArgs.includes('--yes') || cmdArgs.includes('-y');
253
+ const confirmed = await confirmFn(`Delete note (${id})`, { stdin, stream, forceYes });
254
+ if (!confirmed) {
255
+ stream.write(' Aborted — note was not deleted.\n');
256
+ return { deleted: false };
257
+ }
258
+
249
259
  const { deleted } = deleteNoteFn({ external_id: id, tickets: ticketKey ? [ticketKey] : [] }, { configDir });
250
260
  stream.write(deleted
251
261
  ? ` Deleted note (${id}) — local vault only; see help for team-synced notes.\n`
@@ -14,7 +14,7 @@
14
14
  import fs from 'node:fs';
15
15
  import path from 'node:path';
16
16
  import { randomBytes } from 'node:crypto';
17
- import { DEFAULT_CONFIG_DIR, escapeLeadingHeading } from './config.mjs';
17
+ import { DEFAULT_CONFIG_DIR, escapeLeadingHeading, timeAgo } from './config.mjs';
18
18
  import { TICKET_KEY_PATTERN } from './cli.mjs';
19
19
  import { parseFrontmatter, serializeFrontmatter } from './frontmatter.mjs';
20
20
 
@@ -348,7 +348,7 @@ export function rebuildIndex(prefix, { configDir = DEFAULT_CONFIG_DIR } = {}) {
348
348
  // content is lower trust than a user's own local notes, so it needs the same
349
349
  // heading-injection guard already applied in brief-assembler.mjs/styled-assembler.mjs.
350
350
  const ticketList = n.tickets.length > 0 ? ` — ${escapeLeadingHeading(n.tickets.join(', '))}` : '';
351
- lines.push(`- [[${escapeLeadingHeading(n.title)}]]${ticketList} — ${n.created.split('T')[0]}`);
351
+ lines.push(`- [[${escapeLeadingHeading(n.title)}]]${ticketList} — ${timeAgo(n.created)}`);
352
352
  }
353
353
 
354
354
  writeFileAtomically(indexPath, lines.join('\n') + '\n');
@@ -71,3 +71,12 @@ export async function runCollisions(args = [], opts = {}) {
71
71
  return { ok: false };
72
72
  }
73
73
  }
74
+
75
+ // Run if invoked directly
76
+ const isMain = process.argv[1] && import.meta.url.endsWith(process.argv[1].replace(/.*\//, ''));
77
+ if (isMain) {
78
+ runCollisions(process.argv.slice(2)).catch(err => {
79
+ process.stderr.write(`Error: ${err.message}\n`);
80
+ process.exitCode = 1;
81
+ });
82
+ }
@@ -115,7 +115,7 @@ export function styleRecallResults(digests, opts = {}) {
115
115
  if (!styled) {
116
116
  const entries = digests.map(d => {
117
117
  const ticketList = d.tickets?.length > 0 ? ` (${escapeLeadingHeading(d.tickets.join(', '))})` : '';
118
- const summary = `${escapeLeadingHeading(d.title)}${ticketList} — ${d.created.split('T')[0]} [${d.id}]`;
118
+ const summary = `${escapeLeadingHeading(d.title)}${ticketList} — ${timeAgo(d.created)} [${d.id}]`;
119
119
  return full ? `${summary}\n${escapeLeadingHeading(d.body)}` : summary;
120
120
  });
121
121
  return entries.join(full ? '\n\n' : '\n');
@@ -124,9 +124,9 @@ export function styleRecallResults(digests, opts = {}) {
124
124
  const s = createStyler({ forceColor: true });
125
125
  const entries = digests.map(d => {
126
126
  const ticketList = d.tickets?.length > 0 ? ` ${s.dim(`(${escapeLeadingHeading(d.tickets.join(', '))})`)}` : '';
127
- const date = s.dim(d.created.split('T')[0]);
127
+ const ago = s.dim(timeAgo(d.created));
128
128
  const id = s.dim(`[${d.id}]`);
129
- const summary = `${s.brand('●')} ${s.bold(escapeLeadingHeading(d.title))}${ticketList} ${s.dim('—')} ${date} ${id}`;
129
+ const summary = `${s.brand('●')} ${s.bold(escapeLeadingHeading(d.title))}${ticketList} ${s.dim('—')} ${ago} ${id}`;
130
130
  return full ? `${summary}\n${escapeLeadingHeading(d.body)}` : summary;
131
131
  });
132
132
  return entries.join(full ? '\n\n' : '\n');