@thammarongg/jira-mcp 0.3.0 → 0.5.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 CHANGED
@@ -16,9 +16,14 @@ npx -y @thammarongg/jira-mcp install
16
16
 
17
17
  Shows a menu — Select All or pick agents (Claude Code, OpenCode, Codex, Cursor,
18
18
  Claude Desktop, Gemini CLI) — then asks for the Jira base URL and credentials
19
- (token entry is hidden on a TTY). Existing config files are backed up to
20
- `.bak` before any modification, and re-running updates the `jira` entry in
21
- place instead of duplicating it.
19
+ (token entry is masked on a TTY). If a selected agent already has a `jira`
20
+ entry, the installer offers to reuse its base URL and account without asking
21
+ for the credentials again. Existing config files are backed up to `.bak`
22
+ before any modification, and re-running updates the `jira` entry in place
23
+ instead of duplicating it.
24
+
25
+ With `--yes` and no credential flags, a selected existing entry's credentials
26
+ are reused without prompting.
22
27
 
23
28
  Non-interactive (CI / scripting):
24
29
 
@@ -39,6 +44,29 @@ Flags: `--agents all` or comma-separated ids/numbers (`claude-code`,
39
44
  `--email` (Cloud) / `--username` (Data Center), `--token` (API token or PAT),
40
45
  `--password` (DC app password), `--yes` (skip the confirm prompt).
41
46
 
47
+ ### CLI commands
48
+
49
+ ```bash
50
+ npx -y @thammarongg/jira-mcp update # refresh configured agents
51
+ npx -y @thammarongg/jira-mcp update --agents codex
52
+ npx -y @thammarongg/jira-mcp uninstall # choose configured agents interactively
53
+ npx -y @thammarongg/jira-mcp uninstall --agents all --yes
54
+ npx -y @thammarongg/jira-mcp --version
55
+ npx -y @thammarongg/jira-mcp --help
56
+ ```
57
+
58
+ `update` is non-interactive and repairs every existing `jira` entry from that
59
+ agent's own credentials; agents without an entry are skipped. `uninstall`
60
+ removes only the `jira` entry, preserving the rest of each agent configuration;
61
+ it accepts the same `--agents` selection grammar as `install` and asks for
62
+ confirmation unless `--yes` is supplied.
63
+
64
+ Running `npx -y @thammarongg/jira-mcp` bare in a terminal prints a status card
65
+ with the installed version, configured agents, and available commands. MCP
66
+ clients pipe stdio, so the same bare command starts the MCP server for them;
67
+ it expects `JIRA_*` environment variables, which is why running it bare
68
+ without those variables reports a missing `JIRA_BASE_URL`.
69
+
42
70
  ### Claude Code (manual one-liner)
43
71
 
44
72
  ```bash
@@ -101,7 +129,7 @@ npm run build
101
129
 
102
130
  | Variable | Required | Description |
103
131
  | --- | --- | --- |
104
- | `JIRA_BASE_URL` | yes | `https://your-org.atlassian.net` (Cloud) or `https://jira.yourcompany.com` (DC) |
132
+ | `JIRA_BASE_URL` | yes | `https://your-org.atlassian.net` (Cloud) or `https://jira.yourcompany.com` (DC); a DC context path such as `/jira` is preserved |
105
133
  | `JIRA_EMAIL` | Cloud | Your Atlassian account email |
106
134
  | `JIRA_API_TOKEN` | yes | API token (Cloud: id.atlassian.com → Security → API tokens; DC: personal access token) |
107
135
  | `JIRA_USERNAME` | DC | Username (alternative to `JIRA_EMAIL`) |
@@ -160,20 +188,20 @@ mkdir -p ~/.agents/skills/jira && cp skill/SKILL.md ~/.agents/skills/jira/
160
188
 
161
189
  ## Tools
162
190
 
163
- ### Boards & sprints (Agile API)
191
+ ### Boards & sprints (Agile + Software APIs)
164
192
 
165
193
  | Tool | Description |
166
194
  | --- | --- |
167
- | `list_boards` | List boards (filter by type/name/project) |
195
+ | `list_boards` | List boards (filter by type/name/project key or ID) |
168
196
  | `get_board` | Board details incl. projects |
169
197
  | `list_sprints` | Sprints on a board (state: active/closed/future) |
170
198
  | `get_sprint` | Sprint details |
171
199
  | `create_sprint` | New sprint on a board |
172
200
  | `update_sprint` | Rename, reschedule, change goal/state |
173
201
  | `close_sprint` | Close a sprint |
174
- | `get_sprint_issues` | Issues in a sprint |
202
+ | `get_sprint_issues` | Issues in a sprint (Cloud cursor or DC offset paging) |
175
203
  | `get_sprint_view` | Full UI-like sprint view (board + sprint + issues in one call) |
176
- | `get_backlog` | Board backlog (Agile `/board/{id}/backlog`, rank-ordered) |
204
+ | `get_backlog` | Board backlog (Cloud Software enhanced or DC Agile, rank-ordered) |
177
205
 
178
206
  ### Epics
179
207
 
@@ -194,10 +222,10 @@ mkdir -p ~/.agents/skills/jira && cp skill/SKILL.md ~/.agents/skills/jira/
194
222
  | `update_issue` | Set fields and/or relative `update` ops |
195
223
  | `delete_issue` | Delete |
196
224
  | `search_issues` | **JQL search** — enhanced search (`/search/jql`) on Cloud, legacy `/search` on Data Center |
197
- | `get_issue_create_meta` | Discover projects/types/required fields |
225
+ | `get_issue_create_meta` | Discover projects/types/required fields (current per-project metadata endpoints) |
198
226
  | `get_issue_transitions` / `transition_issue` | Workflow transitions |
199
227
  | `assign_issue` | Assign/unassign |
200
- | `add_comment` / `list_comments` / `delete_comment` | Comments |
228
+ | `add_comment` / `list_comments` / `delete_comment` | Comments (plain text in, auto-converted to ADF on Cloud/v3) |
201
229
  | `get_issue_worklogs` / `add_worklog` | Time tracking |
202
230
 
203
231
  ### Projects, users, meta
@@ -219,6 +247,17 @@ mkdir -p ~/.agents/skills/jira && cp skill/SKILL.md ~/.agents/skills/jira/
219
247
  5. `create_issue` / `transition_issue` / `add_comment` to act
220
248
  6. Anything else → `jira_api`
221
249
 
250
+ ## Rich text and ADF
251
+
252
+ Jira Cloud REST v3 stores descriptions, comment bodies and worklog comments as
253
+ [Atlassian Document Format](https://developer.atlassian.com/cloud/jira/platform/apis/document/structure/),
254
+ not plain strings. Pass plain text to `create_issue`, `create_epic`,
255
+ `update_issue`, `transition_issue`, `add_comment` and `add_worklog` — the server
256
+ wraps it in ADF when `JIRA_API_VERSION` is `3`, and leaves it alone on v2 (Data
257
+ Center), where wiki markup applies. Blank lines start a new paragraph and single
258
+ newlines become line breaks; **Markdown is not rendered** (`**bold**` shows
259
+ literally). Already-built ADF objects are passed through untouched.
260
+
222
261
  ## Development
223
262
 
224
263
  ```bash
@@ -231,11 +270,17 @@ node scripts/smoke.mjs # stdio handshake + tools/list smoke test
231
270
  ## Notes & limitations
232
271
 
233
272
  - Auth is HTTP Basic (email+token for Cloud, username+token/password for DC) — the standard for Jira REST.
273
+ - `JIRA_BASE_URL` may include a Data Center context path (for example `https://jira.example.com/jira`); it is retained when resolving `/rest/...` endpoints.
234
274
  - Pagination: most list tools return Jira's native `startAt`/`maxResults`/`total`; pass `startAt` to page.
275
+ - Platform routing: Cloud-only enhanced Software reads use `/rest/software/1.0` for backlog, sprint issues, and epic issues (cursor `nextPageToken`); Data Center uses the corresponding Agile 1.0 endpoints with `startAt`/`total`. `list_boards` passes its project filter as the Agile API's singular `projectKeyOrId` parameter.
276
+ - `create_sprint` uses the documented top-level Agile `POST /rest/agile/1.0/sprint` route and sends the selected board as `originBoardId`.
235
277
  - JQL search on **Jira Cloud** uses `/rest/api/3/search/jql`, since Atlassian removed `GET /rest/api/{2,3}/search` on 2025-05-01 ([CHANGE-2046](https://developer.atlassian.com/changelog/#CHANGE-2046) — the old endpoint now returns HTTP 410). Consequences for `search_issues` on Cloud: the JQL must be **bounded** (include a restriction such as `project`, `assignee`, or `key`), the response carries **no `total`**, and paging is by cursor — pass the returned `nextPageToken` back and stop when `isLast` is true. `startAt` is rejected there rather than silently ignored, and `includeApproximateTotal: true` adds an approximate match count via `/search/approximate-count`.
236
278
  - Jira **Data Center** keeps the legacy `/search` endpoint with `startAt`/`total`; if a Cloud site on a custom domain is misdetected as DC, a 410 from `/search` transparently retries against `/search/jql`.
237
- - `get_backlog` calls the Agile API's `/board/{id}/backlog` endpoint, so it keeps `startAt`/`total` paging on Cloud and DC alike; pass `jql` to narrow it further.
279
+ - `get_issue_create_meta` composes the current per-project and per-issue-type metadata endpoints (`/issue/createmeta/{project}/issuetypes` and `/issue/createmeta/{project}/issuetypes/{issueType}`), because the old `/issue/createmeta` query form is deprecated on Cloud and removed from Jira Data Center 9.0+. Its output keeps the familiar `projects[].issuetypes[].fields` shape. Pass `projectKeys` when fields are expanded (the default); omitting it is allowed only with `expand: "projects.issuetypes"` for bounded issue-type summaries. Project metadata is fetched with a fixed concurrency of four, and issue-type field metadata is likewise limited to four concurrent requests.
280
+ - `get_backlog`, `get_sprint_issues`, and `get_epic_issues` use Cloud's current Software enhanced endpoints with cursor paging; pass `nextPageToken` for the next page. Data Center keeps Agile 1.0 and `startAt`/`total`. The older Cloud Agile issue-list endpoints remain available but are deprecated.
238
281
  - Rapid view IDs are computed as `boardId * 10^13 + sprintId` (Jira's documented convention).
239
282
  - Comment bodies use the `body` field on both Cloud (v3) and Data Center (v2).
283
+ - User references use `accountId` on API v3/Cloud and `name` on API v2/Data Center. `get_user` calls `/user?accountId=...` on v3; the v3 path form `/user/{accountId}` is not a current endpoint. `create_issue`'s `dueDate` input is sent as Jira's `duedate` field; `add_worklog` accepts an optional `started` timestamp and Jira defaults it to now when omitted.
240
284
  - Epics: the Agile epic API (`/rest/agile/1.0/epic/...`) only understands company-managed epics and returns HTTP 400 on team-managed ("next-gen") projects. `get_epic`, `get_epic_issues`, and `move_issue_to_epic` detect that and fall back to the issue/search APIs, where an epic is an ordinary issue linked to its children by `parent`.
285
+ - Jira Data Center exposes platform REST API v2/latest, not `/rest/api/3`. Leave `JIRA_API_VERSION` at its default (`2`) for DC; forcing `3` against a DC host is unsupported and causes the server to request unavailable `/rest/api/3/...` routes. Cloud-vs-DC endpoint routing is based on the detected deployment (`*.atlassian.net` is Cloud), while field representations are based on the selected API version.
241
286
  - `jira_api` paths must resolve under `/rest/` — paths that would escape it (e.g. via `..` segments) are rejected, and `?`/`#` must be passed via `query`.
package/dist/adf.js ADDED
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Jira Cloud REST v3 takes rich-text fields (descriptions, comment bodies,
3
+ * worklog comments) as Atlassian Document Format, not plain strings. v2 (Data
4
+ * Center) still takes wiki-markup strings, so every conversion is gated on the
5
+ * API version the client is talking to.
6
+ */
7
+ /** Wrap plain text in a minimal ADF document. Markdown is not interpreted. */
8
+ export function toAdfDoc(text) {
9
+ const content = text
10
+ .replace(/\r\n/g, "\n")
11
+ .split(/\n{2,}/)
12
+ .map((block) => block.split("\n").filter((line) => line !== ""))
13
+ .filter((lines) => lines.length > 0)
14
+ .map((lines) => ({
15
+ type: "paragraph",
16
+ content: lines.flatMap((line, i) => i === 0 ? [{ type: "text", text: line }] : [{ type: "hardBreak" }, { type: "text", text: line }]),
17
+ }));
18
+ return { type: "doc", version: 1, content };
19
+ }
20
+ /**
21
+ * Convert a rich-text value for the given API version. Strings become ADF on
22
+ * v3; anything else (a caller-built ADF doc, undefined) passes through.
23
+ */
24
+ export function richText(value, apiVersion) {
25
+ if (apiVersion !== "3" || typeof value !== "string")
26
+ return value;
27
+ return toAdfDoc(value);
28
+ }
29
+ const RICH_TEXT_FIELDS = ["description", "environment"];
30
+ /** Convert the known rich-text keys of an issue `fields` payload in place-safe fashion. */
31
+ export function richTextFields(fields, apiVersion) {
32
+ if (!fields || apiVersion !== "3")
33
+ return fields;
34
+ let out;
35
+ for (const key of RICH_TEXT_FIELDS) {
36
+ if (typeof fields[key] === "string") {
37
+ out ??= { ...fields };
38
+ out[key] = toAdfDoc(fields[key]);
39
+ }
40
+ }
41
+ return out ?? fields;
42
+ }
43
+ /**
44
+ * Convert comment bodies inside an issue `update` payload, e.g.
45
+ * { comment: [{ add: { body: "text" } }] }.
46
+ */
47
+ export function richTextUpdate(update, apiVersion) {
48
+ if (!update || apiVersion !== "3")
49
+ return update;
50
+ const ops = update.comment;
51
+ if (!Array.isArray(ops))
52
+ return update;
53
+ let changed = false;
54
+ const comment = ops.map((op) => {
55
+ if (!op || typeof op !== "object")
56
+ return op;
57
+ const entry = op;
58
+ let next;
59
+ for (const verb of ["add", "edit"]) {
60
+ const payload = entry[verb];
61
+ if (payload && typeof payload === "object" && typeof payload.body === "string") {
62
+ next ??= { ...entry };
63
+ const inner = payload;
64
+ next[verb] = { ...inner, body: toAdfDoc(inner.body) };
65
+ changed = true;
66
+ }
67
+ }
68
+ return next ?? entry;
69
+ });
70
+ return changed ? { ...update, comment } : update;
71
+ }
package/dist/agents.js ADDED
@@ -0,0 +1,412 @@
1
+ import { copyFileSync, existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs";
2
+ import { homedir, platform } from "node:os";
3
+ import { dirname, join } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ function packageJsonPath() {
6
+ return join(dirname(fileURLToPath(import.meta.url)), "..", "package.json");
7
+ }
8
+ function readPackage() {
9
+ try {
10
+ return JSON.parse(readFileSync(packageJsonPath(), "utf8"));
11
+ }
12
+ catch {
13
+ return {};
14
+ }
15
+ }
16
+ export function readPackageName() {
17
+ const name = readPackage().name;
18
+ return typeof name === "string" && name.length > 0 ? name : "@thammarongg/jira-mcp";
19
+ }
20
+ export function readPackageVersion() {
21
+ const version = readPackage().version;
22
+ return typeof version === "string" && version.length > 0 ? version : "0.0.0";
23
+ }
24
+ export const PKG = readPackageName();
25
+ export const PACKAGE_VERSION = readPackageVersion();
26
+ export const claudeDesktopPath = platform() === "darwin"
27
+ ? join(homedir(), "Library", "Application Support", "Claude", "claude_desktop_config.json")
28
+ : join(homedir(), ".config", "Claude", "claude_desktop_config.json");
29
+ export const AGENTS = [
30
+ { id: "claude-code", label: "Claude Code", path: join(homedir(), ".claude.json"), kind: "mcpServers" },
31
+ { id: "opencode", label: "OpenCode", path: join(homedir(), ".config", "opencode", "opencode.json"), kind: "opencode" },
32
+ { id: "codex", label: "Codex", path: join(homedir(), ".codex", "config.toml"), kind: "codex" },
33
+ { id: "cursor", label: "Cursor", path: join(homedir(), ".cursor", "mcp.json"), kind: "mcpServers" },
34
+ { id: "claude-desktop", label: "Claude Desktop", path: claudeDesktopPath, kind: "mcpServers" },
35
+ { id: "gemini-cli", label: "Gemini CLI", path: join(homedir(), ".gemini", "settings.json"), kind: "mcpServers" },
36
+ ];
37
+ export function envOf(entry) {
38
+ if (!entry || typeof entry !== "object" || Array.isArray(entry))
39
+ return undefined;
40
+ const record = entry;
41
+ const env = record.env ?? record.environment;
42
+ if (!env || typeof env !== "object" || Array.isArray(env))
43
+ return undefined;
44
+ return env;
45
+ }
46
+ function readJsonObject(path) {
47
+ if (!existsSync(path))
48
+ return undefined;
49
+ try {
50
+ const parsed = JSON.parse(readFileSync(path, "utf8"));
51
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
52
+ return undefined;
53
+ return parsed;
54
+ }
55
+ catch {
56
+ return undefined;
57
+ }
58
+ }
59
+ function asRecord(value) {
60
+ return value && typeof value === "object" && !Array.isArray(value) ? value : undefined;
61
+ }
62
+ const JIRA_HEADER = /^\s*\[mcp_servers\.jira\](\s+#.*)?$/;
63
+ function tomlLines(raw) {
64
+ const eol = raw.includes("\r\n") ? "\r\n" : "\n";
65
+ const lines = raw.split("\n").map((line) => (line.endsWith("\r") ? line.slice(0, -1) : line));
66
+ return { lines, eol };
67
+ }
68
+ function sectionEnd(lines, start) {
69
+ for (let i = start + 1; i < lines.length; i++) {
70
+ if (/^\s*\[/.test(lines[i]))
71
+ return i;
72
+ }
73
+ return lines.length;
74
+ }
75
+ function parseTomlString(raw, start) {
76
+ if (raw[start] !== '"')
77
+ return undefined;
78
+ let value = "";
79
+ for (let i = start + 1; i < raw.length; i++) {
80
+ const ch = raw[i];
81
+ if (ch === '"')
82
+ return { value, next: i + 1 };
83
+ if (ch !== "\\") {
84
+ value += ch;
85
+ continue;
86
+ }
87
+ const escaped = raw[++i];
88
+ if (escaped === undefined)
89
+ return undefined;
90
+ if (escaped === "n")
91
+ value += "\n";
92
+ else if (escaped === "r")
93
+ value += "\r";
94
+ else if (escaped === "t")
95
+ value += "\t";
96
+ else if (escaped === "u") {
97
+ const code = raw.slice(i + 1, i + 5);
98
+ if (!/^[0-9a-f]{4}$/i.test(code))
99
+ return undefined;
100
+ value += String.fromCodePoint(Number.parseInt(code, 16));
101
+ i += 4;
102
+ }
103
+ else
104
+ value += escaped;
105
+ }
106
+ return undefined;
107
+ }
108
+ function parseTomlEnvValue(raw) {
109
+ const out = {};
110
+ let i = 0;
111
+ while (i < raw.length) {
112
+ while (i < raw.length && /[\s,]/.test(raw[i]))
113
+ i++;
114
+ if (i >= raw.length)
115
+ break;
116
+ const keyStart = i;
117
+ while (i < raw.length && raw[i] !== "=" && raw[i] !== ",")
118
+ i++;
119
+ const key = raw.slice(keyStart, i).trim();
120
+ if (raw[i] !== "=")
121
+ break;
122
+ i++;
123
+ while (i < raw.length && /\s/.test(raw[i]))
124
+ i++;
125
+ let value;
126
+ if (raw[i] === '"') {
127
+ const parsed = parseTomlString(raw, i);
128
+ if (!parsed)
129
+ break;
130
+ value = parsed.value;
131
+ i = parsed.next;
132
+ }
133
+ else {
134
+ const valueStart = i;
135
+ while (i < raw.length && raw[i] !== ",")
136
+ i++;
137
+ value = raw.slice(valueStart, i).trim();
138
+ }
139
+ if (key)
140
+ out[key] = value;
141
+ while (i < raw.length && raw[i] !== ",")
142
+ i++;
143
+ if (raw[i] === ",")
144
+ i++;
145
+ }
146
+ return out;
147
+ }
148
+ function parseTomlEnv(lines, start, end) {
149
+ for (let i = start + 1; i < end; i++) {
150
+ const match = lines[i].match(/^\s*env\s*=\s*\{(.*)\}\s*$/);
151
+ if (match)
152
+ return parseTomlEnvValue(match[1]);
153
+ }
154
+ return {};
155
+ }
156
+ function readCodexJira(path) {
157
+ if (!existsSync(path))
158
+ return undefined;
159
+ try {
160
+ const { lines } = tomlLines(readFileSync(path, "utf8"));
161
+ for (let i = 0; i < lines.length; i++) {
162
+ if (!JIRA_HEADER.test(lines[i]))
163
+ continue;
164
+ const end = sectionEnd(lines, i);
165
+ return { start: i, end, env: parseTomlEnv(lines, i, end) };
166
+ }
167
+ }
168
+ catch { }
169
+ return undefined;
170
+ }
171
+ export function readAgentEntry(agent) {
172
+ if (agent.kind === "codex") {
173
+ const section = readCodexJira(agent.path);
174
+ return section ? { env: section.env } : undefined;
175
+ }
176
+ const object = readJsonObject(agent.path);
177
+ if (!object)
178
+ return undefined;
179
+ const container = asRecord(object[agent.kind === "opencode" ? "mcp" : "mcpServers"]);
180
+ return container && Object.hasOwn(container, "jira") ? container.jira : undefined;
181
+ }
182
+ export function hasAgentEntry(agent) {
183
+ if (agent.kind === "codex")
184
+ return readCodexJira(agent.path) !== undefined;
185
+ const object = readJsonObject(agent.path);
186
+ if (!object)
187
+ return false;
188
+ const container = asRecord(object[agent.kind === "opencode" ? "mcp" : "mcpServers"]);
189
+ return container !== undefined && Object.hasOwn(container, "jira");
190
+ }
191
+ export function readAgentEnv(agent) {
192
+ const env = envOf(readAgentEntry(agent));
193
+ if (!env)
194
+ return undefined;
195
+ const result = {};
196
+ for (const [key, value] of Object.entries(env)) {
197
+ if (typeof value === "string")
198
+ result[key] = value;
199
+ }
200
+ return Object.keys(result).length > 0 ? result : undefined;
201
+ }
202
+ export function resolveAgents(spec) {
203
+ const norm = spec.trim().toLowerCase();
204
+ if (norm === "all" || norm === "a")
205
+ return [...AGENTS];
206
+ const ids = norm.split(/[,\s]+/).filter(Boolean);
207
+ const selected = [];
208
+ for (const id of ids) {
209
+ const agent = /^\d+$/.test(id) ? AGENTS[Number(id) - 1] : AGENTS.find((candidate) => candidate.id === id);
210
+ if (!agent)
211
+ throw new Error(`Unknown agent '${id}'. Valid: ${AGENTS.map((a, i) => `${i + 1}/${a.id}`).join(", ")} or all`);
212
+ if (!selected.includes(agent))
213
+ selected.push(agent);
214
+ }
215
+ if (selected.length === 0)
216
+ throw new Error("No agents selected");
217
+ return selected;
218
+ }
219
+ function reportEnvChanges(oldEnv, newEnv, path) {
220
+ if (!oldEnv)
221
+ return;
222
+ const replaced = Object.keys(newEnv).filter((key) => oldEnv[key] !== newEnv[key]);
223
+ const removed = Object.keys(oldEnv).filter((key) => !(key in newEnv));
224
+ if (replaced.length > 0)
225
+ console.log(` ! ${path}: existing jira entry will be replaced — env vars changed: ${replaced.join(", ")}`);
226
+ if (removed.length > 0)
227
+ console.log(` ! ${path}: existing jira entry will be replaced — env vars removed: ${removed.join(", ")}`);
228
+ }
229
+ function fileStamp(path) {
230
+ const stat = statSync(path);
231
+ return { mtimeMs: stat.mtimeMs, size: stat.size };
232
+ }
233
+ function assertUnchanged(path, before) {
234
+ const after = statSync(path);
235
+ if (after.mtimeMs !== before.mtimeMs || after.size !== before.size) {
236
+ throw new Error("changed while installing — close running agents and retry");
237
+ }
238
+ }
239
+ function atomicWrite(path, content) {
240
+ const tmp = `${path}.tmp-${process.pid}`;
241
+ writeFileSync(tmp, content);
242
+ renameSync(tmp, path);
243
+ }
244
+ function mergeJsonFile(path, mutate) {
245
+ const existed = existsSync(path);
246
+ let stamp;
247
+ let object = {};
248
+ if (existed) {
249
+ stamp = fileStamp(path);
250
+ const raw = readFileSync(path, "utf8");
251
+ let parsed;
252
+ try {
253
+ parsed = raw.trim() ? JSON.parse(raw) : {};
254
+ }
255
+ catch {
256
+ parsed = undefined;
257
+ }
258
+ if (parsed === undefined || typeof parsed !== "object" || Array.isArray(parsed)) {
259
+ console.log(` ! ${path} is not a JSON object — backing up to ${path}.bak and starting fresh`);
260
+ }
261
+ else {
262
+ object = parsed;
263
+ }
264
+ }
265
+ mutate(object);
266
+ mkdirSync(dirname(path), { recursive: true });
267
+ if (existed) {
268
+ assertUnchanged(path, stamp);
269
+ copyFileSync(path, `${path}.bak`);
270
+ }
271
+ atomicWrite(path, JSON.stringify(object, null, 2) + "\n");
272
+ return existed ? "updated" : "created";
273
+ }
274
+ function tomlStr(value) {
275
+ let output = "";
276
+ for (const ch of value) {
277
+ const code = ch.codePointAt(0);
278
+ if (ch === "\\")
279
+ output += "\\\\";
280
+ else if (ch === '"')
281
+ output += '\\"';
282
+ else if (ch === "\n")
283
+ output += "\\n";
284
+ else if (ch === "\t")
285
+ output += "\\t";
286
+ else if (ch === "\r")
287
+ output += "\\r";
288
+ else if (code < 0x20)
289
+ output += `\\u${code.toString(16).toUpperCase().padStart(4, "0")}`;
290
+ else
291
+ output += ch;
292
+ }
293
+ return `"${output}"`;
294
+ }
295
+ function upsertCodex(path, env) {
296
+ const block = [
297
+ "[mcp_servers.jira]",
298
+ 'command = "npx"',
299
+ `args = ["-y", ${tomlStr(PKG)}]`,
300
+ `env = { ${Object.entries(env)
301
+ .map(([key, value]) => `${key} = ${tomlStr(value)}`)
302
+ .join(", ")} }`,
303
+ ];
304
+ if (!existsSync(path)) {
305
+ mkdirSync(dirname(path), { recursive: true });
306
+ atomicWrite(path, block.join("\n") + "\n");
307
+ return "created";
308
+ }
309
+ const stamp = fileStamp(path);
310
+ const raw = readFileSync(path, "utf8");
311
+ const { lines, eol } = tomlLines(raw);
312
+ const starts = [];
313
+ for (let i = 0; i < lines.length; i++) {
314
+ if (JIRA_HEADER.test(lines[i]))
315
+ starts.push(i);
316
+ }
317
+ let content;
318
+ if (starts.length === 0) {
319
+ content = raw.replace(/\s*$/, "") + eol + eol + block.join(eol) + eol;
320
+ }
321
+ else {
322
+ const remove = new Set();
323
+ for (const start of starts) {
324
+ const end = sectionEnd(lines, start);
325
+ for (let i = start; i < end; i++)
326
+ remove.add(i);
327
+ }
328
+ const kept = lines.filter((_, index) => !remove.has(index));
329
+ let insertIndex = 0;
330
+ for (let i = 0; i < starts[0]; i++)
331
+ if (!remove.has(i))
332
+ insertIndex++;
333
+ kept.splice(insertIndex, 0, ...block);
334
+ const after = insertIndex + block.length;
335
+ if (after < kept.length && kept[after].trim() !== "")
336
+ kept.splice(after, 0, "");
337
+ content = kept.join(eol);
338
+ if (!content.endsWith(eol))
339
+ content += eol;
340
+ }
341
+ reportEnvChanges(starts.length > 0 ? parseTomlEnv(lines, starts[0], sectionEnd(lines, starts[0])) : undefined, env, path);
342
+ assertUnchanged(path, stamp);
343
+ copyFileSync(path, `${path}.bak`);
344
+ atomicWrite(path, content);
345
+ return "updated";
346
+ }
347
+ export function writeAgentConfig(agent, env) {
348
+ if (agent.kind === "codex")
349
+ return upsertCodex(agent.path, env);
350
+ if (agent.kind === "opencode") {
351
+ const jsonc = agent.path.replace(/\.json$/, ".jsonc");
352
+ if (existsSync(jsonc)) {
353
+ console.log(` ! ${jsonc} exists — skipping automatic opencode write (JSONC may contain comments); merge the jira entry manually`);
354
+ return "skipped";
355
+ }
356
+ return mergeJsonFile(agent.path, (object) => {
357
+ const mcp = (object.mcp ??= {});
358
+ reportEnvChanges(envOf(mcp.jira), env, agent.path);
359
+ mcp.jira = {
360
+ type: "local",
361
+ command: ["npx", "-y", PKG],
362
+ environment: env,
363
+ enabled: true,
364
+ };
365
+ });
366
+ }
367
+ return mergeJsonFile(agent.path, (object) => {
368
+ const mcpServers = (object.mcpServers ??= {});
369
+ reportEnvChanges(envOf(mcpServers.jira), env, agent.path);
370
+ mcpServers.jira = { command: "npx", args: ["-y", PKG], env };
371
+ });
372
+ }
373
+ function removeJsonEntry(agent) {
374
+ if (!hasAgentEntry(agent))
375
+ return "skipped";
376
+ mergeJsonFile(agent.path, (object) => {
377
+ const key = agent.kind === "opencode" ? "mcp" : "mcpServers";
378
+ const container = asRecord(object[key]);
379
+ if (container)
380
+ delete container.jira;
381
+ });
382
+ return "removed";
383
+ }
384
+ function removeCodex(path) {
385
+ if (!existsSync(path))
386
+ return "skipped";
387
+ const stamp = fileStamp(path);
388
+ const raw = readFileSync(path, "utf8");
389
+ const { lines, eol } = tomlLines(raw);
390
+ const starts = [];
391
+ for (let i = 0; i < lines.length; i++)
392
+ if (JIRA_HEADER.test(lines[i]))
393
+ starts.push(i);
394
+ if (starts.length === 0)
395
+ return "skipped";
396
+ const remove = new Set();
397
+ for (const start of starts) {
398
+ const end = sectionEnd(lines, start);
399
+ for (let i = start; i < end; i++)
400
+ remove.add(i);
401
+ }
402
+ const content = lines.filter((_, index) => !remove.has(index)).join(eol);
403
+ assertUnchanged(path, stamp);
404
+ copyFileSync(path, `${path}.bak`);
405
+ atomicWrite(path, content);
406
+ return "removed";
407
+ }
408
+ export function removeAgentConfig(agent) {
409
+ if (agent.kind === "codex")
410
+ return removeCodex(agent.path);
411
+ return removeJsonEntry(agent);
412
+ }
package/dist/client.js CHANGED
@@ -21,7 +21,9 @@ export class JiraClient {
21
21
  }
22
22
  async request(method, path, opts = {}) {
23
23
  const url = new URL(`${this.cfg.baseUrl}${path.startsWith("/") ? path : `/${path}`}`);
24
- if (!url.pathname.startsWith("/rest/")) {
24
+ const basePath = new URL(this.cfg.baseUrl).pathname.replace(/\/+$/, "");
25
+ const restPrefix = `${basePath}/rest/`;
26
+ if (!url.pathname.startsWith(restPrefix)) {
25
27
  throw new Error(`Jira API path must start with /rest/ (resolved to ${url.pathname})`);
26
28
  }
27
29
  if (opts.query) {
@@ -81,6 +83,9 @@ export class JiraClient {
81
83
  agilePut(path, body) {
82
84
  return this.request("PUT", `/rest/agile/1.0${path}`, { body });
83
85
  }
86
+ softwareGet(path, query) {
87
+ return this.request("GET", `/rest/software/1.0${path}`, { query });
88
+ }
84
89
  }
85
90
  function extractErrorMessage(body) {
86
91
  if (typeof body === "string" && body.trim()) {
package/dist/config.js CHANGED
@@ -8,7 +8,7 @@ export function loadConfig(env = process.env) {
8
8
  const url = new URL(rawBase);
9
9
  if (!/^https?:$/.test(url.protocol))
10
10
  throw new Error("bad protocol");
11
- baseUrl = `${url.protocol}//${url.host}`;
11
+ baseUrl = `${url.protocol}//${url.host}${url.pathname.replace(/\/+$/, "")}`;
12
12
  }
13
13
  catch {
14
14
  throw new Error(`JIRA_BASE_URL is not a valid URL: ${rawBase}`);