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.
- package/README.md +24 -6
- package/bin/ticketlens.mjs +33 -6
- package/package.json +1 -1
- package/skills/jtb/SKILL.md +10 -6
- package/skills/jtb/scripts/lib/adapters/github-adapter.mjs +98 -0
- package/skills/jtb/scripts/lib/adapters/jira-adapter.mjs +40 -1
- package/skills/jtb/scripts/lib/adapters/linear-adapter.mjs +151 -0
- package/skills/jtb/scripts/lib/api-utils.mjs +1 -1
- package/skills/jtb/scripts/lib/cli.mjs +12 -0
- package/skills/jtb/scripts/lib/cloud-keys.mjs +25 -0
- package/skills/jtb/scripts/lib/config.mjs +6 -3
- package/skills/jtb/scripts/lib/confirm.mjs +29 -0
- package/skills/jtb/scripts/lib/help.mjs +120 -11
- package/skills/jtb/scripts/lib/jira-client.mjs +128 -0
- package/skills/jtb/scripts/lib/mcp-server.mjs +99 -5
- package/skills/jtb/scripts/lib/note-command.mjs +11 -1
- package/skills/jtb/scripts/lib/recall-vault.mjs +2 -2
- package/skills/jtb/scripts/lib/run-collisions.mjs +9 -0
- package/skills/jtb/scripts/lib/styled-assembler.mjs +3 -3
- package/skills/jtb/scripts/lib/ticket-command.mjs +257 -1
- package/skills/jtb/scripts/lib/ticket-create-enrichment.mjs +80 -0
- package/skills/jtb/scripts/lib/ticket-metadata-cache.mjs +84 -0
package/README.md
CHANGED
|
@@ -411,15 +411,17 @@ Every note is scanned before saving — anything shaped like a real secret (API
|
|
|
411
411
|
|
|
412
412
|
**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).
|
|
413
413
|
|
|
414
|
-
**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`, and `
|
|
414
|
+
**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 Pro gate, same secret scan/local vault/tracker writes, same team sync — nothing is reimplemented. 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).
|
|
415
415
|
|
|
416
|
-
`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.
|
|
416
|
+
`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.
|
|
417
|
+
|
|
418
|
+
**Tags matter for search relevance.** `--tags=a,b` accepts anything, but a generic tag (the project name, "gotcha", "bug") gives future search almost nothing to match on. Tag with what the note is actually *about* — the specific technology, error type, or root cause (`retry-backoff`, `null-pointer`, `auth-middleware`) — so it surfaces when someone else hits the same problem.
|
|
417
419
|
|
|
418
420
|
**Gaps** — every `ticketlens PROJ-123` brief also diffs the ticket's own description against its linked tickets (from the depth traversal you already requested) and its own downloaded attachments, looking for requirements mentioned there but missing here. Anything uncovered shows up under a `## Gaps` section, citing exactly where it came from — a linked ticket key or an attachment filename — as evidence, never an instruction to act on. Nothing is saved anywhere; it's recomputed fresh on every fetch. Requires a Pro license, same as Recall. No network call beyond what the brief already made.
|
|
419
421
|
|
|
420
422
|
---
|
|
421
423
|
|
|
422
|
-
### Comment, Transition, Assign, Duplicates &
|
|
424
|
+
### Comment, Transition, Assign, Duplicates, Link, Update & Create
|
|
423
425
|
|
|
424
426
|
```bash
|
|
425
427
|
ticketlens comment PROJ-123 --body="Looks good, merging." # Post a comment to the tracker
|
|
@@ -429,9 +431,13 @@ ticketlens assign PROJ-123 --to=me # Assign the ticket
|
|
|
429
431
|
ticketlens duplicates PROJ-123 # Find likely duplicates (read-only)
|
|
430
432
|
ticketlens link PROJ-123 PROJ-456 # List valid link types (read-only)
|
|
431
433
|
ticketlens link PROJ-123 PROJ-456 --type="Duplicate" --confirm # Execute the link
|
|
434
|
+
ticketlens update PROJ-123 --title="Fix login on mobile" # Update title/description/labels/priority
|
|
435
|
+
ticketlens update PROJ-123 --add-labels=urgent,backend --remove-labels=stale
|
|
436
|
+
ticketlens create --project=PROJ --type="Task" --summary="Fix login on mobile" # Create a new ticket
|
|
437
|
+
ticketlens create --project=ENG --summary="New Linear issue" --profile=linear-team
|
|
432
438
|
```
|
|
433
439
|
|
|
434
|
-
Write directly to the ticket in its real tracker — Jira, GitHub, or Linear — from your terminal or an AI session via `ticket_comment`/`ticket_transition`/`ticket_assign`/`ticket_duplicates`/`ticket_link` MCP tools. Requires a Pro license.
|
|
440
|
+
Write directly to the ticket in its real tracker — Jira, GitHub, or Linear — from your terminal or an AI session via `ticket_comment`/`ticket_transition`/`ticket_assign`/`ticket_duplicates`/`ticket_link`/`ticket_update`/`ticket_create` MCP tools. Requires a Pro license.
|
|
435
441
|
|
|
436
442
|
`ticketlens transition` with just a ticket key lists the tracker's current valid options without changing anything (Jira: real workflow transitions for that issue; GitHub: open/closed; Linear: team-scoped workflow states). Add both `--target` and `--confirm` to execute — `--confirm` is a deliberate two-step gate: a behavioral nudge and forensic trail, not a hard security guarantee. Every write, once resolved, is re-validated against the tracker's current state immediately before executing — never a blind write against a stale option.
|
|
437
443
|
|
|
@@ -441,7 +447,13 @@ Write directly to the ticket in its real tracker — Jira, GitHub, or Linear —
|
|
|
441
447
|
|
|
442
448
|
`ticketlens link SOURCE-KEY TARGET-KEY` links two tickets — direction matters: SOURCE "types" TARGET (e.g. `link A B --type=Duplicate` means A duplicates B, not the other way around). With just the two keys it lists the tracker's current valid link types without changing anything — always fetched live for Jira, since link type names are per-instance configurable there. GitHub is different from Jira/Linear: it has no generic link relationship, so linking on a GitHub-tracked ticket *closes SOURCE as a duplicate of TARGET* — a state change, not just a relationship add — and prints an explicit warning immediately before that happens, on top of the same `--confirm` gate.
|
|
443
449
|
|
|
444
|
-
|
|
450
|
+
`ticketlens update TICKET-KEY` updates a narrow, named field set — title, description, labels, priority. At least one field is required. Labels are always add/remove (`--add-labels=a,b` / `--remove-labels=c`), never a wholesale replace — an unnamed existing label is left alone, never silently dropped. No `--confirm` needed: unlike transition/link, update has no discovery step and only makes reversible metadata edits, the same risk tier as `assign`. GitHub has no priority field on issues, so `--priority` against a GitHub-tracked ticket is refused up front. Each tracker's label mechanics genuinely differ — Jira and Linear apply everything in one atomic call; GitHub's title/description and each label operation are independent, so a call can partially succeed (e.g. the title updates but one label name doesn't resolve) — the result always reports exactly which fields landed.
|
|
451
|
+
|
|
452
|
+
`ticketlens create` creates a new ticket with a fixed minimal field set — no arbitrary custom fields. Unlike every other write command, there's no existing ticket to target, so `--profile` (or your default profile) picks the tracker instead of a ticket key. `--project` is the Jira project key or Linear team key — required for both, ignored on GitHub since its target repo is already fixed by the profile. `--type` is Jira's issue type (e.g. `"Task"`, `"Bug"`) — required for Jira, ignored elsewhere. No `--confirm` gate, same risk tier as `update`/`assign` — but this is the highest-blast-radius command in the whole family: a bad `--project`/`--type` fabricates a real, hard-to-walk-back item in a live tracker, so an invalid value surfaces the tracker's own error rather than a silent guess.
|
|
453
|
+
|
|
454
|
+
**A bad `--project`/`--type` gets a better error, automatically.** If create fails because the project or issue type doesn't exist, TicketLens fetches your tracker's real, current project list (and, for Jira, the real issue types for that project) and shows them alongside the failure — e.g. `Known creatable projects: CNV1, ECNT.` — rather than a bare tracker error. This is reactive only: it never runs on a successful create, never auto-retries the write, and is cached locally per profile for 24h so a burst of failed attempts doesn't re-fetch every time.
|
|
455
|
+
|
|
456
|
+
All six write actions (comment/transition/assign/link/update/create) have a short local debounce (10s) against an accidental double-fire (a flaky retry, hitting enter twice), and every successful write is appended to a local, append-only audit log (`~/.ticketlens/ticket-action-log.jsonl`). A write that times out is never retried automatically — unlike Recall notes, ticket writes aren't naturally idempotent, so a timed-out attempt is surfaced to you instead of silently repeated. `duplicates` has neither, since nothing is written.
|
|
445
457
|
|
|
446
458
|
---
|
|
447
459
|
|
|
@@ -722,7 +734,7 @@ ticketlens mcp # Start the MCP stdio server (reca
|
|
|
722
734
|
ticketlens mcp install # Register it into the current project's .mcp.json
|
|
723
735
|
ticketlens mcp install --dry-run # Preview the registration without writing
|
|
724
736
|
|
|
725
|
-
# ── Comment, Transition, Assign, Duplicates &
|
|
737
|
+
# ── Comment, Transition, Assign, Duplicates, Link, Update & Create ──────────────
|
|
726
738
|
ticketlens comment CNV1-2 --body="Looks good, merging." # Post a comment to the tracker [Pro]
|
|
727
739
|
ticketlens transition CNV1-2 # List valid transitions (read-only) [Pro]
|
|
728
740
|
ticketlens transition CNV1-2 --target="Done" --confirm # Execute the transition [Pro]
|
|
@@ -731,6 +743,10 @@ ticketlens duplicates CNV1-2 # Find likely duplic
|
|
|
731
743
|
ticketlens duplicates CNV1-2 --threshold=0.5 # Tighten the match threshold [Pro]
|
|
732
744
|
ticketlens link CNV1-2 CNV1-3 # List valid link types (read-only) [Pro]
|
|
733
745
|
ticketlens link CNV1-2 CNV1-3 --type="Duplicate" --confirm # Execute the link [Pro]
|
|
746
|
+
ticketlens update CNV1-2 --title="New title" # Update title/description/labels/priority [Pro]
|
|
747
|
+
ticketlens update CNV1-2 --add-labels=urgent --remove-labels=stale # Add/remove labels [Pro]
|
|
748
|
+
ticketlens create --project=CNV1 --type="Task" --summary="New ticket" # Create a new ticket [Pro]
|
|
749
|
+
ticketlens create --project=ENG --summary="New issue" --profile=linear-team # Create on a different profile [Pro]
|
|
734
750
|
|
|
735
751
|
# ── Stats ──────────────────────────────────────────────────────────────────────
|
|
736
752
|
ticketlens stats # Response-time metrics from local history
|
|
@@ -818,6 +834,8 @@ ticketlens transition CNV1-2 --target="Done" --confirm # Transition ticket stat
|
|
|
818
834
|
ticketlens assign CNV1-2 --to=me # Assign the ticket to yourself
|
|
819
835
|
ticketlens duplicates CNV1-2 # Find likely duplicates (read-only)
|
|
820
836
|
ticketlens link CNV1-2 CNV1-3 --type="Duplicate" --confirm # Link two tickets
|
|
837
|
+
ticketlens update CNV1-2 --title="..." # Update title/description/labels/priority
|
|
838
|
+
ticketlens create --project=CNV1 --type="Task" --summary="..." # Create a new ticket
|
|
821
839
|
ticketlens activate YOUR-LICENSE-KEY # Activate Pro license
|
|
822
840
|
```
|
|
823
841
|
|
package/bin/ticketlens.mjs
CHANGED
|
@@ -18,7 +18,7 @@ import { activateLicense, checkLicense, revalidateIfStale, isLicensed, showUpgra
|
|
|
18
18
|
import { deleteProfile, loadProfiles, saveCredentialKey } from '../skills/jtb/scripts/lib/profile-resolver.mjs';
|
|
19
19
|
import { run as runCache } from '../skills/jtb/scripts/lib/cache-manager.mjs';
|
|
20
20
|
import {
|
|
21
|
-
printHelp, printProfiles,
|
|
21
|
+
printHelp, printProfiles, printHistoryHelp,
|
|
22
22
|
printLoginHelp, printLogoutHelp, printSyncHelp,
|
|
23
23
|
printActivateHelp, printLicenseHelp, printDeleteHelp,
|
|
24
24
|
printProfilesHelp, printScheduleHelp,
|
|
@@ -28,7 +28,7 @@ import {
|
|
|
28
28
|
printCollisionsHelp, printStatsHelp,
|
|
29
29
|
printCloudKeysHelp,
|
|
30
30
|
printNoteHelp, printRecallHelp, printMcpHelp,
|
|
31
|
-
printCommentHelp, printTransitionHelp, printAssignHelp, printDuplicatesHelp, printLinkHelp,
|
|
31
|
+
printCommentHelp, printTransitionHelp, printAssignHelp, printDuplicatesHelp, printLinkHelp, printUpdateHelp, printCreateHelp,
|
|
32
32
|
} from '../skills/jtb/scripts/lib/help.mjs';
|
|
33
33
|
import { runStats } from '../skills/jtb/scripts/lib/run-stats.mjs';
|
|
34
34
|
import { createStyler } from '../skills/jtb/scripts/lib/ansi.mjs';
|
|
@@ -134,6 +134,7 @@ switch (command) {
|
|
|
134
134
|
}
|
|
135
135
|
|
|
136
136
|
case 'history': {
|
|
137
|
+
if (cmdArgs.includes('--help') || cmdArgs.includes('-h')) { printHistoryHelp(); break; }
|
|
137
138
|
if (!isLicensed('pro')) { showUpgradePrompt('pro', 'ticketlens history'); break; }
|
|
138
139
|
const ticketKey = cmdArgs[0];
|
|
139
140
|
if (!ticketKey || ticketKey.startsWith('-')) {
|
|
@@ -541,7 +542,7 @@ switch (command) {
|
|
|
541
542
|
case 'cloud-keys': {
|
|
542
543
|
if (cmdArgs.includes('--help') || cmdArgs.includes('-h')) { printCloudKeysHelp(); break; }
|
|
543
544
|
|
|
544
|
-
const { listCloudKeys, addCloudKey,
|
|
545
|
+
const { listCloudKeys, addCloudKey, runCloudKeysRemove, setPriority, setTimeout_, testCloudKey } =
|
|
545
546
|
await import('../skills/jtb/scripts/lib/cloud-keys.mjs');
|
|
546
547
|
|
|
547
548
|
const cliToken = readCliToken();
|
|
@@ -591,12 +592,14 @@ switch (command) {
|
|
|
591
592
|
if (subCmd === 'remove') {
|
|
592
593
|
const provider = cmdArgs[1];
|
|
593
594
|
if (!provider) {
|
|
594
|
-
process.stderr.write('Usage: ticketlens cloud-keys remove <provider
|
|
595
|
+
process.stderr.write('Usage: ticketlens cloud-keys remove <provider> [--yes|-y]\n');
|
|
595
596
|
process.exitCode = 1;
|
|
596
597
|
return;
|
|
597
598
|
}
|
|
598
|
-
|
|
599
|
-
|
|
599
|
+
const forceYes = cmdArgs.includes('--yes') || cmdArgs.includes('-y');
|
|
600
|
+
const { removed } = await runCloudKeysRemove(cfg, provider, { forceYes });
|
|
601
|
+
if (removed) process.stdout.write(`${s.brand('✓')} ${provider} key removed.\n`);
|
|
602
|
+
else process.exitCode = 1;
|
|
600
603
|
return;
|
|
601
604
|
}
|
|
602
605
|
|
|
@@ -806,6 +809,30 @@ switch (command) {
|
|
|
806
809
|
break;
|
|
807
810
|
}
|
|
808
811
|
|
|
812
|
+
case 'update': {
|
|
813
|
+
if (cmdArgs.includes('--help') || cmdArgs.includes('-h')) { printUpdateHelp(); break; }
|
|
814
|
+
const { runTicketUpdate } = await import('../skills/jtb/scripts/lib/ticket-command.mjs');
|
|
815
|
+
runTicketUpdate(cmdArgs).then(({ ok }) => {
|
|
816
|
+
if (!ok) process.exitCode = 1;
|
|
817
|
+
}).catch(err => {
|
|
818
|
+
process.stderr.write(`Error: ${err.message}\n`);
|
|
819
|
+
process.exitCode = 1;
|
|
820
|
+
});
|
|
821
|
+
break;
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
case 'create': {
|
|
825
|
+
if (cmdArgs.includes('--help') || cmdArgs.includes('-h')) { printCreateHelp(); break; }
|
|
826
|
+
const { runTicketCreate } = await import('../skills/jtb/scripts/lib/ticket-command.mjs');
|
|
827
|
+
runTicketCreate(cmdArgs).then(({ ok }) => {
|
|
828
|
+
if (!ok) process.exitCode = 1;
|
|
829
|
+
}).catch(err => {
|
|
830
|
+
process.stderr.write(`Error: ${err.message}\n`);
|
|
831
|
+
process.exitCode = 1;
|
|
832
|
+
});
|
|
833
|
+
break;
|
|
834
|
+
}
|
|
835
|
+
|
|
809
836
|
case 'help':
|
|
810
837
|
default: {
|
|
811
838
|
const isInteractive = args.length === 0 && process.stdin.isTTY && process.stdout.isTTY && !process.env.CI;
|
package/package.json
CHANGED
package/skills/jtb/SKILL.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
<!-- jtb-skill-version: 0.
|
|
1
|
+
<!-- jtb-skill-version: 0.27.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.
|
|
@@ -66,6 +66,8 @@ Fetches a Jira ticket and produces a structured brief with code references, then
|
|
|
66
66
|
/jtb assign PROD-1234 --to=me # assign the ticket to yourself (Pro)
|
|
67
67
|
```
|
|
68
68
|
|
|
69
|
+
**Destructive commands** (`note delete`, `cloud-keys remove`, `delete <profile>`) prompt for interactive y/N confirmation and refuse outright in a non-interactive shell unless `--yes` is passed — there is no way to silently skip this. Only pass `--yes` when the user has explicitly asked for that specific deletion in this conversation (their message *is* the confirmation); never add it to route around the prompt for a deletion you decided to make on your own.
|
|
70
|
+
|
|
69
71
|
## Prerequisites
|
|
70
72
|
|
|
71
73
|
TicketLens supports two connection methods — check in this order:
|
|
@@ -91,7 +93,7 @@ If the first argument is `triage`:
|
|
|
91
93
|
|
|
92
94
|
Run:
|
|
93
95
|
```bash
|
|
94
|
-
|
|
96
|
+
ticketlens triage $EXTRA_ARGS
|
|
95
97
|
```
|
|
96
98
|
|
|
97
99
|
Where `$EXTRA_ARGS` are any flags passed (e.g. `--stale=3 --status=QA --profile=acme`).
|
|
@@ -112,7 +114,7 @@ If the first argument is `collisions`:
|
|
|
112
114
|
|
|
113
115
|
Run:
|
|
114
116
|
```bash
|
|
115
|
-
|
|
117
|
+
ticketlens collisions $EXTRA_ARGS
|
|
116
118
|
```
|
|
117
119
|
|
|
118
120
|
Where `$EXTRA_ARGS` are any flags passed (e.g. `--json`, `--plain`).
|
|
@@ -136,7 +138,7 @@ Follow the Prerequisites section above:
|
|
|
136
138
|
|
|
137
139
|
Run:
|
|
138
140
|
```bash
|
|
139
|
-
|
|
141
|
+
ticketlens "$TICKET_KEY" $EXTRA_ARGS
|
|
140
142
|
```
|
|
141
143
|
|
|
142
144
|
Where `$TICKET_KEY` is the first argument (e.g. `PROD-1234`) and `$EXTRA_ARGS` are any flags passed (e.g. `--depth=0`).
|
|
@@ -234,9 +236,11 @@ echo "The body text of the note, one or more paragraphs." | \
|
|
|
234
236
|
ticketlens note add --title="Short title" --ticket=TICKET-KEY --tags=a,b
|
|
235
237
|
```
|
|
236
238
|
|
|
239
|
+
**Choosing tags:** derive them 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`. A tag like `jtb` or `ticketlens` tells a future search nothing that the ticket/project context doesn't already say; a tag like `retry-backoff` is what actually surfaces this note when someone else hits the same problem. Same rule whether you're constructing the bash command above or calling `recall_add` directly — see its tool description for the same guidance.
|
|
240
|
+
|
|
237
241
|
To search saved notes directly (outside of automatic brief injection): `ticketlens recall "<query>"`.
|
|
238
242
|
|
|
239
|
-
**Pick exactly one path per capture — never both.** If this harness has TicketLens's MCP server configured (
|
|
243
|
+
**Pick exactly one path per capture — never both.** If this harness has TicketLens's MCP server configured (tools named `recall_add`/`recall_search` — often shown as `mcp__ticketlens__recall_add` — visible in your tool list), **use those tools, not the bash commands above** — same license gate, same secret scan, same vault, same team sync, just no shell command to construct. Only fall back to the bash form when the MCP tools are genuinely absent from your tool list. If they're absent because this project has never registered the server, tell the user once: `ticketlens mcp install` writes (or merges into) this project's `.mcp.json` — don't run it yourself unprompted, since it changes what your harness auto-connects to on next launch, and the user should be the one deciding that. Calling both for the same insight creates two near-duplicate notes (no dedup exists between the two paths) and, with team sync on, two separate pushes for a manager to review.
|
|
240
244
|
|
|
241
245
|
### Quality loop (Pro, in-session only)
|
|
242
246
|
|
|
@@ -284,7 +288,7 @@ ticketlens duplicates PROD-1234 # find likely dupl
|
|
|
284
288
|
|
|
285
289
|
The three write actions (comment/transition/assign) have a short local debounce (10s) against an accidental double-fire, and every write is appended to a local audit log (`~/.ticketlens/ticket-action-log.jsonl`). A write that times out is never retried automatically — surface the failure to the user rather than silently re-attempting, since a ticket write isn't naturally idempotent the way a Recall note save is. `duplicates` has neither, since nothing is written.
|
|
286
290
|
|
|
287
|
-
**Pick exactly one path per action — never both.** If this harness has TicketLens's MCP server configured (
|
|
291
|
+
**Pick exactly one path per action — never both.** If this harness has TicketLens's MCP server configured (tools named `ticket_comment`/`ticket_transition`/`ticket_assign`/`ticket_duplicates`/`ticket_link`/`ticket_update`/`ticket_create` — often shown as `mcp__ticketlens__ticket_comment` etc. — visible in your tool list), **use those tools, not the bash commands above** — same license gate, same cooldown, same audit log. Only fall back to the bash form when the MCP tools are genuinely absent from your tool list; if that's because this project has never registered the server, see the `ticketlens mcp install` note above (Recall section) — same guidance applies here.
|
|
288
292
|
|
|
289
293
|
Requires a Pro license — on Free, all four no-op with an upgrade hint on stderr.
|
|
290
294
|
|
|
@@ -272,5 +272,103 @@ export function createGitHubAdapter(conn, { fetcher = globalThis.fetch } = {}) {
|
|
|
272
272
|
if (!res.ok) await throwGitHubWriteError(res, 'linking', sourceKey);
|
|
273
273
|
return { executed: true, closedAsDuplicateOf: targetKey };
|
|
274
274
|
},
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Unlike Jira/Linear's single atomic mutation, GitHub genuinely has no
|
|
278
|
+
* one-call way to do this: title/description share a PATCH, but labels
|
|
279
|
+
* use their own dedicated additive (POST) and per-label (DELETE)
|
|
280
|
+
* endpoints — never the full-replace PUT, which would force an unsafe
|
|
281
|
+
* read-then-write race. Each operation is independent and best-effort:
|
|
282
|
+
* one failing must never prevent the others from being attempted, and
|
|
283
|
+
* the caller needs to know exactly which fields actually landed.
|
|
284
|
+
*/
|
|
285
|
+
async updateFields(key, { title, description, priority, addLabels, removeLabels } = {}, opts = {}) {
|
|
286
|
+
if (priority !== undefined) {
|
|
287
|
+
throw new Error('GitHub Issues have no native priority field — cannot update priority.');
|
|
288
|
+
}
|
|
289
|
+
const number = parseInt(key.split('-').pop(), 10);
|
|
290
|
+
const signal = () => AbortSignal.timeout(opts.timeoutMs ?? 10_000);
|
|
291
|
+
const applied = {};
|
|
292
|
+
const errors = {};
|
|
293
|
+
|
|
294
|
+
if (title !== undefined || description !== undefined) {
|
|
295
|
+
try {
|
|
296
|
+
const body = {};
|
|
297
|
+
if (title !== undefined) body.title = title;
|
|
298
|
+
if (description !== undefined) body.body = description;
|
|
299
|
+
const res = await fetcher(`${GITHUB_API}/repos/${owner}/${repo}/issues/${number}`, {
|
|
300
|
+
method: 'PATCH',
|
|
301
|
+
headers: { ...headers, 'Content-Type': 'application/json' },
|
|
302
|
+
body: JSON.stringify(body),
|
|
303
|
+
signal: signal(),
|
|
304
|
+
});
|
|
305
|
+
if (!res.ok) await throwGitHubWriteError(res, 'updating', key);
|
|
306
|
+
if (title !== undefined) applied.title = true;
|
|
307
|
+
if (description !== undefined) applied.description = true;
|
|
308
|
+
} catch (err) {
|
|
309
|
+
if (title !== undefined) errors.title = err;
|
|
310
|
+
if (description !== undefined) errors.description = err;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
if (addLabels?.length) {
|
|
315
|
+
try {
|
|
316
|
+
const res = await fetcher(`${GITHUB_API}/repos/${owner}/${repo}/issues/${number}/labels`, {
|
|
317
|
+
method: 'POST',
|
|
318
|
+
headers: { ...headers, 'Content-Type': 'application/json' },
|
|
319
|
+
body: JSON.stringify({ labels: addLabels }),
|
|
320
|
+
signal: signal(),
|
|
321
|
+
});
|
|
322
|
+
if (!res.ok) await throwGitHubWriteError(res, 'adding labels to', key);
|
|
323
|
+
applied.addLabels = addLabels;
|
|
324
|
+
} catch (err) {
|
|
325
|
+
errors.addLabels = err;
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
if (removeLabels?.length) {
|
|
330
|
+
const removed = [];
|
|
331
|
+
const removeErrors = {};
|
|
332
|
+
for (const label of removeLabels) {
|
|
333
|
+
try {
|
|
334
|
+
const res = await fetcher(`${GITHUB_API}/repos/${owner}/${repo}/issues/${number}/labels/${encodeURIComponent(label)}`, {
|
|
335
|
+
method: 'DELETE',
|
|
336
|
+
headers,
|
|
337
|
+
signal: signal(),
|
|
338
|
+
});
|
|
339
|
+
// A 404 here means the label was already absent — the caller's
|
|
340
|
+
// goal ("this label is gone") is already true, so this is
|
|
341
|
+
// treated as success, not a failure to surface.
|
|
342
|
+
if (!res.ok && res.status !== 404) await throwGitHubWriteError(res, 'removing a label from', key);
|
|
343
|
+
removed.push(label);
|
|
344
|
+
} catch (err) {
|
|
345
|
+
removeErrors[label] = err;
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
if (removed.length) applied.removeLabels = removed;
|
|
349
|
+
if (Object.keys(removeErrors).length) errors.removeLabels = removeErrors;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
return { applied, errors };
|
|
353
|
+
},
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* `project`/`type` are ignored — GitHub has no such concepts here: the
|
|
357
|
+
* target repo is fixed by the profile's baseUrl, and issues have no
|
|
358
|
+
* type field in this MVP scope.
|
|
359
|
+
*/
|
|
360
|
+
async createTicket({ summary, description } = {}, opts = {}) {
|
|
361
|
+
const body = { title: summary };
|
|
362
|
+
if (description !== undefined) body.body = description;
|
|
363
|
+
const res = await fetcher(`${GITHUB_API}/repos/${owner}/${repo}/issues`, {
|
|
364
|
+
method: 'POST',
|
|
365
|
+
headers: { ...headers, 'Content-Type': 'application/json' },
|
|
366
|
+
body: JSON.stringify(body),
|
|
367
|
+
signal: AbortSignal.timeout(opts.timeoutMs ?? 10_000),
|
|
368
|
+
});
|
|
369
|
+
if (!res.ok) await throwGitHubWriteError(res, 'creating an issue in', `${owner}/${repo}`);
|
|
370
|
+
const raw = await res.json();
|
|
371
|
+
return { key: `${keyPrefix}-${raw.number}`, id: String(raw.id), url: raw.html_url ?? null };
|
|
372
|
+
},
|
|
275
373
|
};
|
|
276
374
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { fetchTicket, fetchCurrentUser, searchTickets, fetchStatuses, postComment, getTransitions, postTransition, assignIssue, escapeJql, getIssueLinkTypes, postIssueLink } from '../jira-client.mjs';
|
|
1
|
+
import { fetchTicket, fetchCurrentUser, searchTickets, fetchStatuses, fetchProjects, fetchIssueTypes, postComment, getTransitions, postTransition, assignIssue, escapeJql, getIssueLinkTypes, postIssueLink, updateIssue, createIssue } from '../jira-client.mjs';
|
|
2
2
|
import { buildJiraEnv } from '../config.mjs';
|
|
3
3
|
|
|
4
4
|
/**
|
|
@@ -110,5 +110,44 @@ export function createJiraAdapter(conn, { fetcher = globalThis.fetch } = {}) {
|
|
|
110
110
|
await postIssueLink(sourceKey, targetKey, match.name, { ...base, ...opts });
|
|
111
111
|
return { executed: true };
|
|
112
112
|
},
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Jira does this in a single atomic PUT (fields + update.labels in one
|
|
116
|
+
* request, confirmed via Jira's own docs) — unlike GitHub, there is no
|
|
117
|
+
* per-field partial-failure surface here: either the whole call
|
|
118
|
+
* succeeds and every requested field is applied, or it throws and
|
|
119
|
+
* ticket-command.mjs's existing formatWriteFailure handles it exactly
|
|
120
|
+
* like every other write. Priority-name validity is not pre-checked —
|
|
121
|
+
* an invalid name surfaces Jira's own 400 with details, same design
|
|
122
|
+
* choice already made for ticket_create's issuetype field.
|
|
123
|
+
*/
|
|
124
|
+
async updateFields(key, { title, description, priority, addLabels, removeLabels } = {}, opts = {}) {
|
|
125
|
+
await updateIssue(key, { summary: title, description, priority, addLabels, removeLabels }, { ...base, ...opts });
|
|
126
|
+
const applied = {};
|
|
127
|
+
if (title !== undefined) applied.title = true;
|
|
128
|
+
if (description !== undefined) applied.description = true;
|
|
129
|
+
if (priority !== undefined) applied.priority = priority;
|
|
130
|
+
if (addLabels?.length) applied.addLabels = addLabels;
|
|
131
|
+
if (removeLabels?.length) applied.removeLabels = removeLabels;
|
|
132
|
+
return { applied, errors: {} };
|
|
133
|
+
},
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* project/type are passed straight through — never pre-validated
|
|
137
|
+
* client-side, same design choice already made for updateFields'
|
|
138
|
+
* priority field. An invalid issuetype surfaces Jira's own 400.
|
|
139
|
+
*/
|
|
140
|
+
createTicket: ({ project, type, summary, description } = {}, opts = {}) =>
|
|
141
|
+
createIssue({ project, type, summary, description }, { ...base, ...opts }),
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Real, currently-creatable projects for this token — used to enrich a
|
|
145
|
+
* ticket_create failure message with actual options, never to
|
|
146
|
+
* pre-validate before writing.
|
|
147
|
+
*/
|
|
148
|
+
listCreatableProjects: (opts = {}) => fetchProjects({ ...base, ...opts }),
|
|
149
|
+
|
|
150
|
+
/** Real, currently-configured issue types for one project. */
|
|
151
|
+
listIssueTypes: (projectKey, opts = {}) => fetchIssueTypes(projectKey, { ...base, ...opts }),
|
|
113
152
|
};
|
|
114
153
|
}
|
|
@@ -90,6 +90,17 @@ async function fetchIssueStateInfo(key, { token, fetcher, signal }) {
|
|
|
90
90
|
return node;
|
|
91
91
|
}
|
|
92
92
|
|
|
93
|
+
/** Splits a list of label names into those found in `byName` (with their resolved id) and those not. */
|
|
94
|
+
function resolveLabelNames(names, byName) {
|
|
95
|
+
const resolved = [];
|
|
96
|
+
const missing = [];
|
|
97
|
+
for (const name of names) {
|
|
98
|
+
const id = byName.get(name.toLowerCase());
|
|
99
|
+
if (id) resolved.push({ id, name }); else missing.push(name);
|
|
100
|
+
}
|
|
101
|
+
return { resolved, missing };
|
|
102
|
+
}
|
|
103
|
+
|
|
93
104
|
async function fetchTeamWorkflowStates(teamId, { token, fetcher, signal }) {
|
|
94
105
|
const data = await gql(
|
|
95
106
|
`query ($teamId: ID!) {
|
|
@@ -336,5 +347,145 @@ export function createLinearAdapter(conn, { fetcher = globalThis.fetch } = {}) {
|
|
|
336
347
|
}
|
|
337
348
|
return { executed: true };
|
|
338
349
|
},
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* Unlike Jira's freeform label strings, Linear's addedLabelIds/
|
|
353
|
+
* removedLabelIds (IssueUpdateInput) take real label UUIDs and never
|
|
354
|
+
* auto-create a missing one — a name must be resolved against the
|
|
355
|
+
* issue's own team first (same shape as fetchTeamWorkflowStates).
|
|
356
|
+
* Resolution failures (unknown label name, unresolvable priority name)
|
|
357
|
+
* are collected into `errors` and simply excluded from the mutation
|
|
358
|
+
* input rather than blocking it — whatever DID resolve still lands in
|
|
359
|
+
* one atomic issueUpdate call. Empty input skips the mutation entirely
|
|
360
|
+
* (nothing valid to send). Priority is accepted as a display name for
|
|
361
|
+
* cross-tracker consistency and reverse-mapped via the same
|
|
362
|
+
* PRIORITY_LABELS table normalizeLinearIssue already uses for reads.
|
|
363
|
+
*/
|
|
364
|
+
async updateFields(key, { title, description, priority, addLabels, removeLabels } = {}, opts = {}) {
|
|
365
|
+
const signal = AbortSignal.timeout(opts.timeoutMs ?? 10_000);
|
|
366
|
+
const info = await fetchIssueStateInfo(key, { token, fetcher, signal });
|
|
367
|
+
|
|
368
|
+
const input = {};
|
|
369
|
+
const applied = {};
|
|
370
|
+
const errors = {};
|
|
371
|
+
|
|
372
|
+
if (title !== undefined) input.title = title;
|
|
373
|
+
if (description !== undefined) input.description = description;
|
|
374
|
+
|
|
375
|
+
let resolvedPriorityLabel;
|
|
376
|
+
if (priority !== undefined) {
|
|
377
|
+
const normalized = priority.trim().toLowerCase();
|
|
378
|
+
if (normalized === 'none') {
|
|
379
|
+
input.priority = 0;
|
|
380
|
+
resolvedPriorityLabel = 'None';
|
|
381
|
+
} else {
|
|
382
|
+
const entry = Object.entries(PRIORITY_LABELS).find(([, label]) => label.toLowerCase() === normalized);
|
|
383
|
+
if (entry) {
|
|
384
|
+
input.priority = Number(entry[0]);
|
|
385
|
+
resolvedPriorityLabel = entry[1];
|
|
386
|
+
} else {
|
|
387
|
+
errors.priority = { reason: 'not-found', options: ['None', ...Object.values(PRIORITY_LABELS)] };
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
let addLabelsResolved, addLabelsMissing, removeLabelsResolved, removeLabelsMissing;
|
|
393
|
+
if (addLabels?.length || removeLabels?.length) {
|
|
394
|
+
const labelData = await gql(
|
|
395
|
+
`query ($teamId: ID!) { issueLabels(filter: { team: { id: { eq: $teamId } } }, first: 250) { nodes { id name } } }`,
|
|
396
|
+
{ teamId: info.team.id },
|
|
397
|
+
{ token, fetcher, signal },
|
|
398
|
+
);
|
|
399
|
+
const byName = new Map((labelData.issueLabels?.nodes ?? []).map(l => [l.name.toLowerCase(), l.id]));
|
|
400
|
+
|
|
401
|
+
if (addLabels?.length) {
|
|
402
|
+
({ resolved: addLabelsResolved, missing: addLabelsMissing } = resolveLabelNames(addLabels, byName));
|
|
403
|
+
if (addLabelsResolved.length) input.addedLabelIds = addLabelsResolved.map(l => l.id);
|
|
404
|
+
if (addLabelsMissing.length) errors.addLabels = { reason: 'not-found', missing: addLabelsMissing };
|
|
405
|
+
}
|
|
406
|
+
if (removeLabels?.length) {
|
|
407
|
+
({ resolved: removeLabelsResolved, missing: removeLabelsMissing } = resolveLabelNames(removeLabels, byName));
|
|
408
|
+
if (removeLabelsResolved.length) input.removedLabelIds = removeLabelsResolved.map(l => l.id);
|
|
409
|
+
if (removeLabelsMissing.length) errors.removeLabels = { reason: 'not-found', missing: removeLabelsMissing };
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
if (Object.keys(input).length === 0) {
|
|
414
|
+
return { applied, errors };
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
const data = await gql(
|
|
418
|
+
`mutation ($id: String!, $input: IssueUpdateInput!) { issueUpdate(id: $id, input: $input) { success } }`,
|
|
419
|
+
{ id: info.id, input },
|
|
420
|
+
{ token, fetcher, signal },
|
|
421
|
+
);
|
|
422
|
+
if (!data.issueUpdate?.success) {
|
|
423
|
+
throw new Error(`Linear issueUpdate reported success:false updating ${key}`);
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
if (title !== undefined) applied.title = true;
|
|
427
|
+
if (description !== undefined) applied.description = true;
|
|
428
|
+
if (input.priority !== undefined) applied.priority = resolvedPriorityLabel;
|
|
429
|
+
if (addLabelsResolved?.length) applied.addLabels = addLabelsResolved.map(l => l.name);
|
|
430
|
+
if (removeLabelsResolved?.length) applied.removeLabels = removeLabelsResolved.map(l => l.name);
|
|
431
|
+
|
|
432
|
+
return { applied, errors };
|
|
433
|
+
},
|
|
434
|
+
|
|
435
|
+
/**
|
|
436
|
+
* `project` is the team's short key (e.g. "ENG") — mutations need the
|
|
437
|
+
* UUID, never the key, so it's resolved via TeamFilter.key first
|
|
438
|
+
* (confirmed against Linear's own official generated GraphQL schema).
|
|
439
|
+
* No discovery call on an unresolvable key, same "surface a clear
|
|
440
|
+
* terminal error" choice already made for Jira's issuetype — there is
|
|
441
|
+
* no partial-creation concept here, unlike updateFields' best-effort
|
|
442
|
+
* shape. `type` has no Linear equivalent and is intentionally ignored.
|
|
443
|
+
*/
|
|
444
|
+
async createTicket({ project, summary, description } = {}, opts = {}) {
|
|
445
|
+
const signal = AbortSignal.timeout(opts.timeoutMs ?? 10_000);
|
|
446
|
+
const teamData = await gql(
|
|
447
|
+
`query ($key: String!) { teams(filter: { key: { eq: $key } }, first: 1) { nodes { id } } }`,
|
|
448
|
+
{ key: project },
|
|
449
|
+
{ token, fetcher, signal },
|
|
450
|
+
);
|
|
451
|
+
const team = teamData.teams?.nodes?.[0];
|
|
452
|
+
if (!team) {
|
|
453
|
+
const err = new Error(`Linear team not found for project "${project}".`);
|
|
454
|
+
// Marked, not message-sniffed — ticket-command.mjs's cache-refresh
|
|
455
|
+
// enrichment needs a clean way to detect "the project/team didn't
|
|
456
|
+
// resolve" without parsing this string.
|
|
457
|
+
err.code = 'PROJECT_NOT_FOUND';
|
|
458
|
+
throw err;
|
|
459
|
+
}
|
|
460
|
+
const input = { teamId: team.id, title: summary };
|
|
461
|
+
if (description !== undefined) input.description = description;
|
|
462
|
+
const data = await gql(
|
|
463
|
+
`mutation ($input: IssueCreateInput!) { issueCreate(input: $input) { success issue { identifier id url } } }`,
|
|
464
|
+
{ input },
|
|
465
|
+
{ token, fetcher, signal },
|
|
466
|
+
);
|
|
467
|
+
if (!data.issueCreate?.success) {
|
|
468
|
+
throw new Error(`Linear issueCreate reported success:false creating an issue in team "${project}".`);
|
|
469
|
+
}
|
|
470
|
+
const issue = data.issueCreate.issue;
|
|
471
|
+
return { key: issue.identifier, id: issue.id, url: issue.url ?? null };
|
|
472
|
+
},
|
|
473
|
+
|
|
474
|
+
/**
|
|
475
|
+
* Real, currently-accessible teams for this token — unfiltered, used
|
|
476
|
+
* only to enrich a ticket_create failure message with actual options
|
|
477
|
+
* (Jira calls the equivalent concept "projects"; --project already
|
|
478
|
+
* conflates the two at the CLI level, so the shape matches for a
|
|
479
|
+
* uniform cache).
|
|
480
|
+
*/
|
|
481
|
+
async listCreatableProjects(opts = {}) {
|
|
482
|
+
const signal = AbortSignal.timeout(opts.timeoutMs ?? 10_000);
|
|
483
|
+
const data = await gql(
|
|
484
|
+
`query { teams(first: 250) { nodes { key name } } }`,
|
|
485
|
+
{},
|
|
486
|
+
{ token, fetcher, signal },
|
|
487
|
+
);
|
|
488
|
+
return (data.teams?.nodes ?? []).map(t => ({ key: t.key, name: t.name }));
|
|
489
|
+
},
|
|
339
490
|
};
|
|
340
491
|
}
|
|
@@ -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,
|
|
@@ -130,6 +130,10 @@ export function parseCommand(args) {
|
|
|
130
130
|
return { command: 'mcp', args: args.slice(1) };
|
|
131
131
|
}
|
|
132
132
|
|
|
133
|
+
if (first === 'cloud-keys') {
|
|
134
|
+
return { command: 'cloud-keys', args: args.slice(1) };
|
|
135
|
+
}
|
|
136
|
+
|
|
133
137
|
if (first === 'comment') {
|
|
134
138
|
return { command: 'comment', args: args.slice(1) };
|
|
135
139
|
}
|
|
@@ -150,6 +154,14 @@ export function parseCommand(args) {
|
|
|
150
154
|
return { command: 'link', args: args.slice(1) };
|
|
151
155
|
}
|
|
152
156
|
|
|
157
|
+
if (first === 'update') {
|
|
158
|
+
return { command: 'update', args: args.slice(1) };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (first === 'create') {
|
|
162
|
+
return { command: 'create', args: args.slice(1) };
|
|
163
|
+
}
|
|
164
|
+
|
|
153
165
|
// Anything that looks like a ticket key or any non-flag arg → fetch
|
|
154
166
|
return { command: 'fetch', args };
|
|
155
167
|
}
|
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
* All operations require a CLI token (set via `ticketlens login`).
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
+
import { confirmDestructive } from './confirm.mjs';
|
|
8
|
+
|
|
7
9
|
const SUPPORTED_PROVIDERS = ['groq', 'anthropic', 'openai'];
|
|
8
10
|
|
|
9
11
|
function apiBase(config) {
|
|
@@ -84,6 +86,29 @@ export async function removeCloudKey(config, provider) {
|
|
|
84
86
|
await fetchApi(config, `/v1/ai-providers/${target.id}`, 'DELETE');
|
|
85
87
|
}
|
|
86
88
|
|
|
89
|
+
/**
|
|
90
|
+
* CLI-facing wrapper around removeCloudKey — gates the irreversible remote
|
|
91
|
+
* deletion behind a y/N confirmation (or --yes) before calling it.
|
|
92
|
+
*/
|
|
93
|
+
export async function runCloudKeysRemove(config, provider, opts = {}) {
|
|
94
|
+
const {
|
|
95
|
+
stream = process.stderr,
|
|
96
|
+
stdin = process.stdin,
|
|
97
|
+
forceYes = false,
|
|
98
|
+
confirmFn = confirmDestructive,
|
|
99
|
+
removeCloudKeyFn = removeCloudKey,
|
|
100
|
+
} = opts;
|
|
101
|
+
|
|
102
|
+
const confirmed = await confirmFn(`Remove the ${provider} key`, { stdin, stream, forceYes });
|
|
103
|
+
if (!confirmed) {
|
|
104
|
+
stream.write(' Aborted — key was not removed.\n');
|
|
105
|
+
return { removed: false };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
await removeCloudKeyFn(config, provider);
|
|
109
|
+
return { removed: true };
|
|
110
|
+
}
|
|
111
|
+
|
|
87
112
|
export async function setPriority(config, provider, priority) {
|
|
88
113
|
const target = await findProvider(config, provider);
|
|
89
114
|
await fetchApi(config, `/v1/ai-providers/${target.id}`, 'PUT', { priority: Number(priority) });
|