@thammarongg/jira-mcp 0.1.0 → 0.2.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
@@ -6,10 +6,42 @@ Works with both **Jira Cloud** (REST API v3) and **Jira Data Center** (REST API
6
6
 
7
7
  ## Install (one-click)
8
8
 
9
- Once published to npm, any MCP client can run it via `npx` — no local build needed:
9
+ Once published to npm, any MCP client can run it via `npx` — no local build needed.
10
+
11
+ ### Interactive installer (all agents at once)
12
+
13
+ ```bash
14
+ npx -y @thammarongg/jira-mcp install
15
+ ```
16
+
17
+ Shows a menu — Select All or pick agents (Claude Code, OpenCode, Codex, Cursor,
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.
22
+
23
+ Non-interactive (CI / scripting):
24
+
25
+ ```bash
26
+ # Jira Cloud
27
+ npx -y @thammarongg/jira-mcp install --agents all \
28
+ --base-url https://your-org.atlassian.net \
29
+ --email you@example.com --token xxx --yes
30
+
31
+ # Jira Data Center (subset of agents)
32
+ npx -y @thammarongg/jira-mcp install --agents claude-code,codex \
33
+ --base-url https://jira.yourcompany.com \
34
+ --username you --token xxx --yes
35
+ ```
36
+
37
+ Flags: `--agents all` or comma-separated ids/numbers (`claude-code`,
38
+ `opencode`, `codex`, `cursor`, `claude-desktop`, `gemini-cli`), `--base-url`,
39
+ `--email` (Cloud) / `--username` (Data Center), `--token` (API token or PAT),
40
+ `--password` (DC app password), `--yes` (skip the confirm prompt).
41
+
42
+ ### Claude Code (manual one-liner)
10
43
 
11
44
  ```bash
12
- # Claude Code (one-liner)
13
45
  claude mcp add jira --env JIRA_BASE_URL=https://your-org.atlassian.net \
14
46
  --env JIRA_EMAIL=you@example.com --env JIRA_API_TOKEN=xxx \
15
47
  -- npx -y @thammarongg/jira-mcp
package/dist/index.js CHANGED
@@ -33,6 +33,8 @@ async function main() {
33
33
  console.error(`jira-mcp ready: ${config.baseUrl} (API v${config.apiVersion}, ${config.isCloud ? "Cloud" : "Data Center"})`);
34
34
  }
35
35
  main().catch((err) => {
36
- console.error(`jira-mcp failed to start: ${err instanceof Error ? err.message : String(err)}`);
36
+ const msg = err instanceof Error ? err.message : String(err);
37
+ const prefix = process.argv[2] === "install" ? "jira-mcp install failed" : "jira-mcp failed to start";
38
+ console.error(`${prefix}: ${msg}`);
37
39
  process.exit(1);
38
40
  });
package/dist/install.js CHANGED
@@ -1,8 +1,19 @@
1
- import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
1
+ import { copyFileSync, existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs";
2
2
  import { homedir, platform } from "node:os";
3
3
  import { dirname, join } from "node:path";
4
4
  import * as readline from "node:readline";
5
- const PKG = "@thammarongg/jira-mcp";
5
+ import { fileURLToPath } from "node:url";
6
+ function readPackageName() {
7
+ try {
8
+ const pkgPath = join(dirname(fileURLToPath(import.meta.url)), "..", "package.json");
9
+ const parsed = JSON.parse(readFileSync(pkgPath, "utf8"));
10
+ if (typeof parsed.name === "string" && parsed.name.length > 0)
11
+ return parsed.name;
12
+ }
13
+ catch { }
14
+ return "@thammarongg/jira-mcp";
15
+ }
16
+ const PKG = readPackageName();
6
17
  const claudeDesktopPath = platform() === "darwin"
7
18
  ? join(homedir(), "Library", "Application Support", "Claude", "claude_desktop_config.json")
8
19
  : join(homedir(), ".config", "Claude", "claude_desktop_config.json");
@@ -48,6 +59,37 @@ function parseFlags(argv) {
48
59
  }
49
60
  return flags;
50
61
  }
62
+ function validateCredentialFlags(flags) {
63
+ const anyPresent = [flags.baseUrl, flags.email, flags.username, flags.token, flags.password].some((v) => v !== undefined);
64
+ if (!anyPresent)
65
+ return;
66
+ if (flags.email !== undefined && flags.username !== undefined)
67
+ throw new Error("--email and --username are mutually exclusive (Cloud uses --email, Data Center uses --username)");
68
+ if (flags.email !== undefined) {
69
+ const missing = [];
70
+ if (flags.baseUrl === undefined)
71
+ missing.push("--base-url");
72
+ if (flags.token === undefined)
73
+ missing.push("--token");
74
+ if (missing.length > 0)
75
+ throw new Error(`--email requires ${missing.join(" and ")}`);
76
+ if (flags.password !== undefined)
77
+ throw new Error("--password is a Data Center option and cannot be combined with --email");
78
+ return;
79
+ }
80
+ if (flags.username !== undefined) {
81
+ if (flags.baseUrl === undefined)
82
+ throw new Error("--username requires --base-url");
83
+ if (flags.token === undefined && flags.password === undefined)
84
+ throw new Error("--username requires --token or --password");
85
+ return;
86
+ }
87
+ if (flags.token !== undefined)
88
+ throw new Error("--token requires --email (Cloud) or --username (Data Center)");
89
+ if (flags.password !== undefined)
90
+ throw new Error("--password requires --username (Data Center)");
91
+ throw new Error("--base-url requires --email or --username (plus --token / --password)");
92
+ }
51
93
  function printUsage() {
52
94
  console.log(`jira-mcp install — configure the jira MCP server in your agent(s)
53
95
 
@@ -66,40 +108,181 @@ Options:
66
108
  -h, --help Show this help
67
109
  `);
68
110
  }
111
+ function makePrompter() {
112
+ const queue = [];
113
+ const waiters = [];
114
+ let closed = false;
115
+ let suspended = false;
116
+ let rl = null;
117
+ const attach = (r) => {
118
+ r.on("line", (line) => {
119
+ const waiter = waiters.shift();
120
+ if (waiter)
121
+ waiter(line);
122
+ else
123
+ queue.push(line);
124
+ });
125
+ r.on("close", () => {
126
+ if (suspended)
127
+ return;
128
+ closed = true;
129
+ while (waiters.length > 0)
130
+ waiters.shift()("");
131
+ });
132
+ };
133
+ const start = () => {
134
+ rl = readline.createInterface({
135
+ input: process.stdin,
136
+ output: process.stdout,
137
+ terminal: process.stdin.isTTY === true,
138
+ });
139
+ attach(rl);
140
+ };
141
+ start();
142
+ const prompter = {
143
+ get closed() {
144
+ return closed;
145
+ },
146
+ ask(question) {
147
+ process.stdout.write(question);
148
+ const queued = queue.shift();
149
+ if (queued !== undefined)
150
+ return Promise.resolve(queued);
151
+ if (closed)
152
+ return Promise.resolve("");
153
+ return new Promise((resolve) => waiters.push(resolve));
154
+ },
155
+ close() {
156
+ suspended = false;
157
+ closed = true;
158
+ while (waiters.length > 0)
159
+ waiters.shift()("");
160
+ rl?.close();
161
+ rl = null;
162
+ },
163
+ suspend() {
164
+ suspended = true;
165
+ rl?.close();
166
+ rl = null;
167
+ },
168
+ resume() {
169
+ suspended = false;
170
+ if (rl === null)
171
+ start();
172
+ },
173
+ tryQueued() {
174
+ return queue.shift();
175
+ },
176
+ };
177
+ return prompter;
178
+ }
179
+ function askPassword(p, promptText) {
180
+ const stdin = process.stdin;
181
+ if (typeof stdin.setRawMode !== "function") {
182
+ return p.ask(`${promptText} (no echo available) `).then((v) => v.trim());
183
+ }
184
+ const queued = p.tryQueued();
185
+ if (queued !== undefined) {
186
+ process.stdout.write(promptText);
187
+ return Promise.resolve(queued.trim());
188
+ }
189
+ return new Promise((resolve, reject) => {
190
+ let value = "";
191
+ let finished = false;
192
+ const cleanup = () => {
193
+ if (finished)
194
+ return;
195
+ finished = true;
196
+ stdin.removeListener("data", onData);
197
+ stdin.removeListener("end", onEnd);
198
+ stdin.removeListener("close", onClose);
199
+ stdin.setRawMode(false);
200
+ };
201
+ const finish = (v) => {
202
+ cleanup();
203
+ p.resume();
204
+ if (v === null)
205
+ reject(new Error("input closed while reading password"));
206
+ else {
207
+ process.stdout.write("\n");
208
+ resolve(v.trim());
209
+ }
210
+ };
211
+ const onData = (buf) => {
212
+ for (const ch of buf.toString("utf8")) {
213
+ if (ch === "\r" || ch === "\n") {
214
+ finish(value);
215
+ return;
216
+ }
217
+ if (ch === "\x03") {
218
+ cleanup();
219
+ p.resume();
220
+ process.exit(130);
221
+ }
222
+ if (ch === "\x04") {
223
+ finish("");
224
+ return;
225
+ }
226
+ if (ch === "\u007f" || ch === "\b")
227
+ value = value.slice(0, -1);
228
+ else if (ch >= " ")
229
+ value += ch;
230
+ }
231
+ };
232
+ const onEnd = () => finish(null);
233
+ const onClose = () => finish(null);
234
+ p.suspend();
235
+ process.stdout.write(promptText);
236
+ stdin.setRawMode(true);
237
+ stdin.resume();
238
+ stdin.on("data", onData);
239
+ stdin.on("end", onEnd);
240
+ stdin.on("close", onClose);
241
+ });
242
+ }
69
243
  export async function runInstaller(argv) {
70
244
  const flags = parseFlags(argv);
71
- const selected = flags.agents ? resolveAgents(flags.agents) : await promptSelection();
72
- const env = flags.baseUrl && (flags.email || flags.username)
73
- ? buildEnvFromFlags(flags)
74
- : await promptCredentials();
75
- console.log("\nWill configure the jira MCP server in:");
76
- for (const agent of selected)
77
- console.log(` - ${agent.label.padEnd(15)} ${agent.path}`);
78
- console.log(`Env vars: ${Object.keys(env).join(", ")} (values not shown)`);
79
- if (!flags.yes) {
80
- const rl = makeRl();
81
- const answer = (await ask(rl, "\nProceed? [Y/n] ")).trim().toLowerCase();
82
- rl.close();
83
- if (answer !== "" && answer !== "y" && answer !== "yes") {
84
- console.log("Aborted.");
85
- return;
86
- }
87
- }
88
- let failed = false;
89
- for (const agent of selected) {
90
- try {
91
- const result = writeAgentConfig(agent, env);
92
- console.log(` ok ${agent.label.padEnd(15)} ${agent.path} (${result})`);
245
+ validateCredentialFlags(flags);
246
+ const hasCredentials = (flags.baseUrl && flags.email && flags.token) ||
247
+ (flags.baseUrl && flags.username && (flags.token || flags.password));
248
+ const needsPrompts = !flags.agents || !hasCredentials || !flags.yes;
249
+ const p = needsPrompts ? makePrompter() : undefined;
250
+ try {
251
+ const selected = flags.agents ? resolveAgents(flags.agents) : await promptSelection(p);
252
+ const env = hasCredentials ? buildEnvFromFlags(flags) : await promptCredentials(p);
253
+ console.log("\nWill configure the jira MCP server in:");
254
+ for (const agent of selected)
255
+ console.log(` - ${agent.label.padEnd(15)} ${agent.path}`);
256
+ console.log(`Env vars: ${Object.keys(env).join(", ")} (values not shown)`);
257
+ if (!flags.yes) {
258
+ const answer = (await p.ask("\nProceed? [Y/n] ")).trim().toLowerCase();
259
+ if ((p.closed && answer === "") || (answer !== "" && answer !== "y" && answer !== "yes")) {
260
+ console.log("Aborted.");
261
+ return;
262
+ }
93
263
  }
94
- catch (err) {
95
- failed = true;
96
- console.error(` ERR ${agent.label.padEnd(15)} ${agent.path}: ${err instanceof Error ? err.message : String(err)}`);
264
+ let failed = false;
265
+ for (const agent of selected) {
266
+ try {
267
+ const result = writeAgentConfig(agent, env);
268
+ if (result === "skipped")
269
+ console.log(` skip ${agent.label.padEnd(15)} ${agent.path}`);
270
+ else
271
+ console.log(` ok ${agent.label.padEnd(15)} ${agent.path} (${result})`);
272
+ }
273
+ catch (err) {
274
+ failed = true;
275
+ console.error(` ERR ${agent.label.padEnd(15)} ${agent.path}: ${err instanceof Error ? err.message : String(err)}`);
276
+ }
97
277
  }
278
+ console.log("\nDone. Restart each agent to pick up the new MCP server.");
279
+ console.log("Verify by asking your agent to call the get_current_user tool.");
280
+ if (failed)
281
+ process.exitCode = 1;
282
+ }
283
+ finally {
284
+ p?.close();
98
285
  }
99
- console.log("\nDone. Restart each agent to pick up the new MCP server.");
100
- console.log("Verify by asking your agent to call the get_current_user tool.");
101
- if (failed)
102
- process.exitCode = 1;
103
286
  }
104
287
  function resolveAgents(spec) {
105
288
  const norm = spec.trim().toLowerCase();
@@ -107,9 +290,9 @@ function resolveAgents(spec) {
107
290
  return [...AGENTS];
108
291
  const ids = norm.split(/[,\s]+/).filter(Boolean);
109
292
  const selected = ids.map((id) => {
110
- const agent = AGENTS.find((a) => a.id === id);
293
+ const agent = /^\d+$/.test(id) ? AGENTS[Number(id) - 1] : AGENTS.find((a) => a.id === id);
111
294
  if (!agent)
112
- throw new Error(`Unknown agent '${id}'. Valid: ${AGENTS.map((a) => a.id).join(", ")} or all`);
295
+ throw new Error(`Unknown agent '${id}'. Valid: ${AGENTS.map((a, i) => `${i + 1}/${a.id}`).join(", ")} or all`);
113
296
  return agent;
114
297
  });
115
298
  if (selected.length === 0)
@@ -119,14 +302,10 @@ function resolveAgents(spec) {
119
302
  function buildEnvFromFlags(flags) {
120
303
  const env = { JIRA_BASE_URL: flags.baseUrl };
121
304
  if (flags.email) {
122
- if (!flags.token)
123
- throw new Error("--email requires --token");
124
305
  env.JIRA_EMAIL = flags.email;
125
306
  env.JIRA_API_TOKEN = flags.token;
126
307
  }
127
308
  else {
128
- if (!flags.token && !flags.password)
129
- throw new Error("--username requires --token or --password");
130
309
  env.JIRA_USERNAME = flags.username;
131
310
  if (flags.token)
132
311
  env.JIRA_API_TOKEN = flags.token;
@@ -135,84 +314,37 @@ function buildEnvFromFlags(flags) {
135
314
  }
136
315
  return env;
137
316
  }
138
- function makeRl() {
139
- return readline.createInterface({ input: process.stdin, output: process.stdout });
140
- }
141
- function ask(rl, question) {
142
- return new Promise((resolve) => rl.question(question, resolve));
143
- }
144
- function askPassword(promptText) {
145
- const stdin = process.stdin;
146
- if (typeof stdin.setRawMode !== "function") {
147
- return new Promise((resolve) => {
148
- process.stdout.write(`${promptText} (non-interactive: value visible)\n`);
149
- const rl = makeRl();
150
- rl.question("", (v) => {
151
- rl.close();
152
- resolve(v.trim());
153
- });
154
- });
155
- }
156
- return new Promise((resolve) => {
157
- let value = "";
158
- const done = () => {
159
- stdin.removeListener("data", onData);
160
- stdin.setRawMode(false);
161
- process.stdout.write("\n");
162
- resolve(value);
163
- };
164
- const onData = (buf) => {
165
- for (const ch of buf.toString("utf8")) {
166
- if (ch === "\r" || ch === "\n") {
167
- done();
168
- return;
169
- }
170
- if (ch === "\u007f" || ch === "\b")
171
- value = value.slice(0, -1);
172
- else if (ch >= " ")
173
- value += ch;
174
- }
175
- };
176
- process.stdout.write(promptText);
177
- stdin.setRawMode(true);
178
- stdin.on("data", onData);
179
- });
180
- }
181
- async function promptSelection() {
317
+ async function promptSelection(p) {
182
318
  console.log(`\njira-mcp installer — select agents to configure:\n`);
183
319
  console.log(" a) Select All");
184
320
  AGENTS.forEach((agent, i) => {
185
321
  console.log(` ${i + 1}) ${agent.label.padEnd(15)} ${agent.path}`);
186
322
  });
187
- const rl = makeRl();
188
323
  for (;;) {
189
- const answer = (await ask(rl, "\nChoice (e.g. 1,3 or a): ")).trim().toLowerCase();
190
- rl.close();
324
+ const answer = (await p.ask("\nChoice (e.g. 1,3 or a): ")).trim().toLowerCase();
325
+ if (answer === "" && p.closed)
326
+ throw new Error("No input received (stdin closed)");
191
327
  try {
192
328
  return answer === "a" || answer === "all" ? [...AGENTS] : resolveAgents(answer);
193
329
  }
194
330
  catch (err) {
331
+ if (p.closed)
332
+ throw err;
195
333
  console.log(` ${err instanceof Error ? err.message : String(err)}`);
196
334
  }
197
335
  }
198
336
  }
199
- async function promptCredentials() {
200
- const rl = makeRl();
201
- const baseUrl = (await ask(rl, "Jira base URL (e.g. https://your-org.atlassian.net): ")).trim();
337
+ async function promptCredentials(p) {
338
+ const baseUrl = (await p.ask("Jira base URL (e.g. https://your-org.atlassian.net): ")).trim();
202
339
  if (!/^https?:\/\/.+/i.test(baseUrl)) {
203
- rl.close();
204
340
  throw new Error("Jira base URL must start with http(s)://");
205
341
  }
206
- const mode = (await ask(rl, "Deployment: [1] Jira Cloud [2] Jira Data Center (default 1): ")).trim();
342
+ const mode = (await p.ask("Deployment: [1] Jira Cloud [2] Jira Data Center (default 1): ")).trim();
207
343
  const isCloud = mode === "" || mode === "1";
208
- const user = (await ask(rl, isCloud ? "Atlassian email: " : "Data Center username: ")).trim();
209
- if (!user) {
210
- rl.close();
344
+ const user = (await p.ask(isCloud ? "Atlassian email: " : "Data Center username: ")).trim();
345
+ if (!user)
211
346
  throw new Error(isCloud ? "Email is required" : "Username is required");
212
- }
213
- const secret = await askPassword(isCloud ? "API token (hidden, from id.atlassian.com): " : "API token / app password (hidden): ");
214
- rl.resume();
215
- rl.close();
347
+ const secret = await askPassword(p, isCloud ? "API token (hidden, from id.atlassian.com): " : "API token / app password (hidden): ");
216
348
  if (!secret)
217
349
  throw new Error("API token is required");
218
350
  const env = { JIRA_BASE_URL: baseUrl };
@@ -230,8 +362,14 @@ function writeAgentConfig(agent, env) {
230
362
  if (agent.kind === "codex")
231
363
  return upsertCodex(agent.path, env);
232
364
  if (agent.kind === "opencode") {
365
+ const jsonc = agent.path.replace(/\.json$/, ".jsonc");
366
+ if (existsSync(jsonc)) {
367
+ console.log(` ! ${jsonc} exists — skipping automatic opencode write (JSONC may contain comments); merge the jira entry manually`);
368
+ return "skipped";
369
+ }
233
370
  return mergeJsonFile(agent.path, (obj) => {
234
371
  const mcp = (obj.mcp ??= {});
372
+ reportEnvChanges(envOf(mcp.jira), env, agent.path);
235
373
  mcp.jira = {
236
374
  type: "local",
237
375
  command: ["npx", "-y", PKG],
@@ -242,31 +380,122 @@ function writeAgentConfig(agent, env) {
242
380
  }
243
381
  return mergeJsonFile(agent.path, (obj) => {
244
382
  const mcpServers = (obj.mcpServers ??= {});
383
+ reportEnvChanges(envOf(mcpServers.jira), env, agent.path);
245
384
  mcpServers.jira = { command: "npx", args: ["-y", PKG], env };
246
385
  });
247
386
  }
387
+ function envOf(entry) {
388
+ if (!entry || typeof entry !== "object" || Array.isArray(entry))
389
+ return undefined;
390
+ const e = entry.env ?? entry.environment;
391
+ if (!e || typeof e !== "object" || Array.isArray(e))
392
+ return undefined;
393
+ return e;
394
+ }
395
+ function reportEnvChanges(oldEnv, newEnv, path) {
396
+ if (!oldEnv)
397
+ return;
398
+ const replaced = Object.keys(newEnv).filter((k) => oldEnv[k] !== newEnv[k]);
399
+ const removed = Object.keys(oldEnv).filter((k) => !(k in newEnv));
400
+ if (replaced.length > 0)
401
+ console.log(` ! ${path}: existing jira entry will be replaced — env vars changed: ${replaced.join(", ")}`);
402
+ if (removed.length > 0)
403
+ console.log(` ! ${path}: existing jira entry will be replaced — env vars removed: ${removed.join(", ")}`);
404
+ }
405
+ function fileStamp(path) {
406
+ const s = statSync(path);
407
+ return { mtimeMs: s.mtimeMs, size: s.size };
408
+ }
409
+ function assertUnchanged(path, before) {
410
+ const after = statSync(path);
411
+ if (after.mtimeMs !== before.mtimeMs || after.size !== before.size) {
412
+ throw new Error("changed while installing — close running agents and retry");
413
+ }
414
+ }
415
+ function atomicWrite(path, content) {
416
+ const tmp = `${path}.tmp-${process.pid}`;
417
+ writeFileSync(tmp, content);
418
+ renameSync(tmp, path);
419
+ }
248
420
  function mergeJsonFile(path, mutate) {
249
421
  const existed = existsSync(path);
422
+ let stamp;
250
423
  let obj = {};
251
424
  if (existed) {
425
+ stamp = fileStamp(path);
252
426
  const raw = readFileSync(path, "utf8");
427
+ let parsed;
253
428
  try {
254
- const parsed = raw.trim() ? JSON.parse(raw) : {};
255
- if (parsed && typeof parsed === "object" && !Array.isArray(parsed))
256
- obj = parsed;
429
+ parsed = raw.trim() ? JSON.parse(raw) : {};
257
430
  }
258
431
  catch {
259
- copyFileSync(path, `${path}.bak`);
260
- console.log(` ! ${path} was not valid JSON — backed up to ${path}.bak, starting fresh`);
432
+ parsed = undefined;
433
+ }
434
+ if (parsed === undefined || typeof parsed !== "object" || Array.isArray(parsed)) {
435
+ console.log(` ! ${path} is not a JSON object — backing up to ${path}.bak and starting fresh`);
436
+ }
437
+ else {
438
+ obj = parsed;
261
439
  }
262
440
  }
263
441
  mutate(obj);
264
442
  mkdirSync(dirname(path), { recursive: true });
265
- writeFileSync(path, JSON.stringify(obj, null, 2) + "\n");
443
+ if (existed) {
444
+ assertUnchanged(path, stamp);
445
+ copyFileSync(path, `${path}.bak`);
446
+ }
447
+ atomicWrite(path, JSON.stringify(obj, null, 2) + "\n");
266
448
  return existed ? "updated" : "created";
267
449
  }
268
450
  function tomlStr(v) {
269
- return `"${v.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
451
+ let out = "";
452
+ for (const ch of v) {
453
+ const code = ch.codePointAt(0);
454
+ if (ch === "\\")
455
+ out += "\\\\";
456
+ else if (ch === '"')
457
+ out += '\\"';
458
+ else if (ch === "\n")
459
+ out += "\\n";
460
+ else if (ch === "\t")
461
+ out += "\\t";
462
+ else if (ch === "\r")
463
+ out += "\\r";
464
+ else if (code < 0x20)
465
+ out += `\\u${code.toString(16).toUpperCase().padStart(4, "0")}`;
466
+ else
467
+ out += ch;
468
+ }
469
+ return `"${out}"`;
470
+ }
471
+ const JIRA_HEADER = /^\s*\[mcp_servers\.jira\](\s+#.*)?$/;
472
+ function parseTomlEnv(lines, start) {
473
+ let end = lines.length;
474
+ for (let i = start + 1; i < lines.length; i++) {
475
+ if (/^\s*\[/.test(lines[i])) {
476
+ end = i;
477
+ break;
478
+ }
479
+ }
480
+ for (let i = start + 1; i < end; i++) {
481
+ const m = lines[i].match(/^\s*env\s*=\s*\{(.*)\}\s*$/);
482
+ if (!m)
483
+ continue;
484
+ const out = {};
485
+ for (const part of m[1].split(",")) {
486
+ const eq = part.indexOf("=");
487
+ if (eq < 0)
488
+ continue;
489
+ const k = part.slice(0, eq).trim();
490
+ let v = part.slice(eq + 1).trim();
491
+ if (v.length >= 2 && v.startsWith('"') && v.endsWith('"'))
492
+ v = v.slice(1, -1);
493
+ if (k)
494
+ out[k] = v;
495
+ }
496
+ return out;
497
+ }
498
+ return undefined;
270
499
  }
271
500
  function upsertCodex(path, env) {
272
501
  const block = [
@@ -279,24 +508,51 @@ function upsertCodex(path, env) {
279
508
  ];
280
509
  if (!existsSync(path)) {
281
510
  mkdirSync(dirname(path), { recursive: true });
282
- writeFileSync(path, block.join("\n") + "\n");
511
+ atomicWrite(path, block.join("\n") + "\n");
283
512
  return "created";
284
513
  }
514
+ const stamp = fileStamp(path);
285
515
  const raw = readFileSync(path, "utf8");
286
- const lines = raw.split("\n");
287
- const start = lines.findIndex((l) => /^\s*\[mcp_servers\.jira\]\s*$/.test(l));
288
- if (start >= 0) {
289
- let end = lines.length;
290
- for (let i = start + 1; i < lines.length; i++) {
291
- if (/^\s*\[/.test(lines[i])) {
292
- end = i;
293
- break;
516
+ const eol = raw.includes("\r\n") ? "\r\n" : "\n";
517
+ const lines = raw.split("\n").map((l) => (l.endsWith("\r") ? l.slice(0, -1) : l));
518
+ const starts = [];
519
+ for (let i = 0; i < lines.length; i++) {
520
+ if (JIRA_HEADER.test(lines[i]))
521
+ starts.push(i);
522
+ }
523
+ let content;
524
+ if (starts.length === 0) {
525
+ content = raw.replace(/\s*$/, "") + eol + eol + block.join(eol) + eol;
526
+ }
527
+ else {
528
+ const remove = new Set();
529
+ for (const start of starts) {
530
+ let end = lines.length;
531
+ for (let i = start + 1; i < lines.length; i++) {
532
+ if (/^\s*\[/.test(lines[i])) {
533
+ end = i;
534
+ break;
535
+ }
294
536
  }
537
+ for (let i = start; i < end; i++)
538
+ remove.add(i);
295
539
  }
296
- lines.splice(start, end - start, ...block);
297
- writeFileSync(path, lines.join("\n"));
298
- return "updated";
540
+ const kept = lines.filter((_, i) => !remove.has(i));
541
+ let insertIdx = 0;
542
+ for (let i = 0; i < starts[0]; i++)
543
+ if (!remove.has(i))
544
+ insertIdx++;
545
+ kept.splice(insertIdx, 0, ...block);
546
+ const after = insertIdx + block.length;
547
+ if (after < kept.length && kept[after].trim() !== "")
548
+ kept.splice(after, 0, "");
549
+ content = kept.join(eol);
550
+ if (!content.endsWith(eol))
551
+ content += eol;
299
552
  }
300
- writeFileSync(path, raw.replace(/\s*$/, "") + "\n\n" + block.join("\n") + "\n");
553
+ reportEnvChanges(starts.length > 0 ? parseTomlEnv(lines, starts[0]) : undefined, env, path);
554
+ assertUnchanged(path, stamp);
555
+ copyFileSync(path, `${path}.bak`);
556
+ atomicWrite(path, content);
301
557
  return "updated";
302
558
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@thammarongg/jira-mcp",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "MCP server exposing the Jira REST API (boards, sprints, issues, JQL, and a generic passthrough) for Jira Cloud and Data Center",
5
5
  "type": "module",
6
6
  "bin": {
package/skill/SKILL.md CHANGED
@@ -13,8 +13,18 @@ server-side.
13
13
 
14
14
  ## If the jira MCP tools are not available
15
15
 
16
- Tell the user the server isn't configured and offer to set it up. Required
17
- env vars:
16
+ Tell the user the server isn't configured and offer to set it up. Easiest is
17
+ the interactive installer (writes config for the selected agents — Claude
18
+ Code, OpenCode, Codex, Cursor, Claude Desktop, Gemini CLI; backs up existing
19
+ files to `.bak`):
20
+
21
+ ```bash
22
+ npx -y @thammarongg/jira-mcp install
23
+ ```
24
+
25
+ Or non-interactive: `npx -y @thammarongg/jira-mcp install --agents all
26
+ --base-url https://your-org.atlassian.net --email you@example.com --token xxx
27
+ --yes`. Required env vars:
18
28
 
19
29
  - `JIRA_BASE_URL` — `https://your-org.atlassian.net` (Cloud) or
20
30
  `https://jira.yourcompany.com` (DC)