@aholbreich/agent-skills 0.8.0 → 1.0.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.
@@ -1,11 +1,10 @@
1
1
  #!/usr/bin/env node
2
2
  'use strict';
3
3
 
4
- const fs = require('fs');
5
4
  const fsp = require('fs/promises');
6
5
  const os = require('os');
7
6
  const path = require('path');
8
- const { spawn } = require('child_process');
7
+ const { createBrowserSession } = require('./atlassian-browser');
9
8
  const {
10
9
  parseSize,
11
10
  formatBytes,
@@ -107,188 +106,29 @@ if (!opts.server) {
107
106
  }
108
107
  opts.rawDir = path.resolve(opts.rawDir);
109
108
 
110
- const sleep = ms => new Promise(r => setTimeout(r, ms));
111
109
  const issueKeyRe = /\b([A-Z][A-Z0-9]+)-(\d+)\b/g;
112
110
 
113
- async function endpoint(pathname) {
114
- const res = await fetch(`http://127.0.0.1:${opts.port}${pathname}`);
115
- if (!res.ok) throw new Error(`DevTools HTTP ${res.status} for ${pathname}`);
116
- return res.json();
117
- }
118
-
119
- async function devtoolsReady() {
120
- try { await endpoint('/json/version'); return true; } catch { return false; }
121
- }
122
-
123
- async function waitDevtools() {
124
- for (let i = 0; i < 80; i++) {
125
- if (await devtoolsReady()) return;
126
- await sleep(250);
127
- }
128
- throw new Error('Chrome DevTools endpoint did not start');
129
- }
130
-
131
- async function openDevtoolsTab(url) {
132
- if (!url) return false;
133
- const endpointUrl = `http://127.0.0.1:${opts.port}/json/new?${encodeURIComponent(url)}`;
134
- for (const init of [{ method: 'PUT' }, {}]) {
135
- try {
136
- const res = await fetch(endpointUrl, init);
137
- if (res.ok) {
138
- await sleep(500);
139
- return true;
140
- }
141
- } catch {}
142
- }
143
- return false;
144
- }
145
-
146
- async function hasDevtoolsTabForHost(url) {
147
- if (!url) return false;
148
- const host = new URL(url).host;
149
- const list = await endpoint('/json/list');
150
- return list.some(t => t.type === 'page' && t.url && (() => {
151
- try { return new URL(t.url).host === host; } catch { return false; }
152
- })());
153
- }
154
-
155
- function isExecutable(file) {
156
- try { fs.accessSync(file, fs.constants.X_OK); return true; } catch { return false; }
157
- }
158
-
159
- function resolveBrowserCandidate(candidate) {
160
- if (!candidate) return null;
161
- if (candidate.includes(path.sep)) return isExecutable(candidate) ? candidate : null;
162
- for (const dir of String(process.env.PATH || '').split(path.delimiter)) {
163
- if (!dir) continue;
164
- const full = path.join(dir, candidate);
165
- if (isExecutable(full)) return full;
166
- }
167
- return null;
168
- }
169
-
170
- function findBrowserExecutable() {
171
- const candidates = [
172
- process.env.CHROME,
173
- process.env.CHROMIUM,
174
- 'google-chrome',
175
- 'google-chrome-stable',
176
- 'chromium',
177
- 'chromium-browser',
178
- 'brave-browser',
179
- 'brave',
180
- 'microsoft-edge',
181
- 'microsoft-edge-stable',
182
- 'vivaldi',
183
- 'vivaldi-stable',
184
- '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
185
- '/Applications/Chromium.app/Contents/MacOS/Chromium',
186
- '/Applications/Brave Browser.app/Contents/MacOS/Brave Browser',
187
- '/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge',
188
- '/Applications/Vivaldi.app/Contents/MacOS/Vivaldi',
189
- ];
190
- for (const candidate of candidates) {
191
- const resolved = resolveBrowserCandidate(candidate);
192
- if (resolved) return resolved;
193
- }
194
- throw new Error('Could not find a Chromium-compatible browser. Install Chrome/Chromium/Brave/Edge/Vivaldi or set CHROME=/path/to/browser.');
195
- }
196
-
197
- function launchChrome(url) {
198
- const browser = findBrowserExecutable();
199
- const args = [
200
- `--remote-debugging-port=${opts.port}`,
201
- '--remote-debugging-address=127.0.0.1',
202
- '--remote-allow-origins=*',
203
- `--user-data-dir=${opts.profileDir}`,
204
- '--no-first-run',
205
- '--no-default-browser-check',
206
- url,
207
- ];
208
- console.log(`Launching browser: ${browser}`);
209
- const child = spawn(browser, args, { detached: true, stdio: 'ignore' });
210
- child.on('error', err => console.error(`Failed to launch browser ${browser}: ${err.message}`));
211
- child.unref();
212
- }
213
-
214
- async function getPageWsUrl() {
215
- const list = await endpoint('/json/list');
216
- const pages = list.filter(t => t.type === 'page' && t.webSocketDebuggerUrl);
217
- const host = new URL(opts.server).host;
218
- const preferred = pages.find(t => (t.url || '').includes(host)) || pages[0];
219
- return preferred && preferred.webSocketDebuggerUrl;
220
- }
221
-
222
- function connectCdp(wsUrl) {
223
- return new Promise((resolve, reject) => {
224
- const ws = new WebSocket(wsUrl);
225
- let id = 0;
226
- const pending = new Map();
227
- const failTimer = setTimeout(() => reject(new Error('CDP websocket timeout')), 10000);
228
-
229
- ws.addEventListener('open', () => {
230
- clearTimeout(failTimer);
231
- resolve({
232
- send(method, params = {}) {
233
- return new Promise((res, rej) => {
234
- const msgId = ++id;
235
- pending.set(msgId, { res, rej });
236
- ws.send(JSON.stringify({ id: msgId, method, params }));
237
- });
238
- },
239
- close() { try { ws.close(); } catch {} },
240
- });
241
- });
242
-
243
- ws.addEventListener('message', ev => {
244
- let data = ev.data;
245
- if (typeof data !== 'string') data = Buffer.from(data).toString('utf8');
246
- const msg = JSON.parse(data);
247
- if (!msg.id || !pending.has(msg.id)) return;
248
- const { res, rej } = pending.get(msg.id);
249
- pending.delete(msg.id);
250
- if (msg.error) rej(new Error(`${msg.error.message || 'CDP error'} ${JSON.stringify(msg.error)}`));
251
- else res(msg.result);
252
- });
253
-
254
- ws.addEventListener('error', err => reject(err));
111
+ let session = null;
112
+ function getSession() {
113
+ if (session) return session;
114
+ session = createBrowserSession({
115
+ port: opts.port,
116
+ profileDir: opts.profileDir,
117
+ waitSec: opts.waitSec,
118
+ serverHost: new URL(opts.server).host,
119
+ cookieUrls: [`${opts.server}/`],
120
+ userAgent: 'jira-browser-fetch/1.0',
121
+ verifySession: cookie => verifyJiraSession(cookie),
255
122
  });
123
+ return session;
256
124
  }
257
125
 
258
- async function getCookieHeader() {
259
- const wsUrl = await getPageWsUrl();
260
- if (!wsUrl) return '';
261
- const cdp = await connectCdp(wsUrl);
262
- try {
263
- await cdp.send('Network.enable');
264
- const host = new URL(opts.server).host;
265
- const result = await cdp.send('Network.getCookies', { urls: [`${opts.server}/`] });
266
- const cookies = (result.cookies || [])
267
- .filter(c => c.domain && (c.domain === host || c.domain.endsWith(`.${host}`)))
268
- .map(c => `${c.name}=${c.value}`);
269
- return cookies.join('; ');
270
- } finally {
271
- cdp.close();
272
- }
273
- }
274
-
275
- async function fetchText(url, cookie, accept) {
276
- const res = await fetch(url, {
277
- redirect: 'follow',
278
- headers: {
279
- Cookie: cookie,
280
- Accept: accept || '*/*',
281
- 'User-Agent': 'jira-browser-fetch/1.0',
282
- },
283
- });
284
- return { status: res.status, contentType: res.headers.get('content-type') || '', text: await res.text() };
126
+ async function fetchTextAdapter(url, cookie, accept) {
127
+ return getSession().fetchText(url, cookie, { accept });
285
128
  }
286
129
 
287
- async function fetchJson(url, cookie, accept) {
288
- const result = await fetchText(url, cookie, accept || 'application/json');
289
- let json = null;
290
- try { json = JSON.parse(result.text); } catch {}
291
- return { ...result, json };
130
+ async function fetchJsonAdapter(url, cookie, accept) {
131
+ return getSession().fetchJson(url, cookie, { accept: accept || 'application/json' });
292
132
  }
293
133
 
294
134
  async function verifyJiraSession(cookie) {
@@ -300,7 +140,7 @@ async function verifyJiraSession(cookie) {
300
140
  ];
301
141
 
302
142
  for (const url of probes) {
303
- const result = await fetchJson(url, cookie, 'application/json');
143
+ const result = await fetchJsonAdapter(url, cookie, 'application/json');
304
144
  if (result.status === 200 && result.json && (result.json.accountId || result.json.name || result.json.key || result.json.displayName)) {
305
145
  return { ok: true, url };
306
146
  }
@@ -321,31 +161,8 @@ async function verifyJiraSession(cookie) {
321
161
  return { ok: false, message: 'could not verify Jira session' };
322
162
  }
323
163
 
324
- async function getCookieWithWait(openUrl) {
325
- await ensureBrowser(openUrl || `${opts.server}/`);
326
- console.log(`If prompted in Chrome, complete SSO for: ${openUrl || opts.server}`);
327
- const deadline = Date.now() + opts.waitSec * 1000;
328
- let last = '';
329
- while (Date.now() < deadline) {
330
- try {
331
- const cookie = await getCookieHeader();
332
- const session = await verifyJiraSession(cookie);
333
- if (session.ok) {
334
- if (process.stdout.isTTY) process.stdout.write('\n');
335
- console.log(`Authenticated Jira session verified via ${session.url}`);
336
- return cookie;
337
- }
338
- last = session.message;
339
- } catch (e) { last = e.message; }
340
- if (process.stdout.isTTY) {
341
- process.stdout.write(`\r${new Date().toLocaleTimeString()} ${last.padEnd(120).slice(0, 120)}`);
342
- } else if (Date.now() - deadline + opts.waitSec * 1000 < 4000) {
343
- console.log(`Waiting up to ${opts.waitSec}s for Jira session...`);
344
- }
345
- await sleep(3000);
346
- }
347
- if (process.stdout.isTTY) process.stdout.write('\n');
348
- throw new Error(`Could not verify authenticated Jira session. Last result: ${last}`);
164
+ function getCookieWithWait(openUrl) {
165
+ return getSession().getCookieWithWait(openUrl || `${opts.server}/`);
349
166
  }
350
167
 
351
168
  async function searchJql(jql) {
@@ -358,11 +175,11 @@ async function searchJql(jql) {
358
175
  while (found.length < opts.maxSearchResults) {
359
176
  const limit = Math.min(pageSize, opts.maxSearchResults - found.length);
360
177
  const url = `${opts.server}/rest/api/3/search?jql=${encodeURIComponent(jql)}&fields=key&maxResults=${limit}&startAt=${startAt}`;
361
- let result = await fetchJson(url, cookie, 'application/json');
178
+ let result = await fetchJsonAdapter(url, cookie, 'application/json');
362
179
 
363
180
  if (result.status === 410 || result.status === 404 || !result.json || !Array.isArray(result.json.issues)) {
364
181
  const newUrl = `${opts.server}/rest/api/3/search/jql?jql=${encodeURIComponent(jql)}&fields=key&maxResults=${limit}`;
365
- result = await fetchJson(newUrl, cookie, 'application/json');
182
+ result = await fetchJsonAdapter(newUrl, cookie, 'application/json');
366
183
  }
367
184
 
368
185
  if (result.status !== 200 || !result.json || !Array.isArray(result.json.issues)) {
@@ -384,7 +201,7 @@ async function fetchBacklogPageWithWait(url, cookie) {
384
201
  let last = '';
385
202
  while (Date.now() < deadline) {
386
203
  try {
387
- const result = await fetchJson(url, cookie, 'application/json');
204
+ const result = await fetchJsonAdapter(url, cookie, 'application/json');
388
205
  if (result.status === 200 && result.json && Array.isArray(result.json.issues)) return result.json;
389
206
  last = `HTTP ${result.status} ${(result.text || '').slice(0, 180).replace(/\s+/g, ' ')}`;
390
207
  } catch (e) { last = e.message; }
@@ -528,26 +345,6 @@ async function downloadAttachments(issueJson, cookie, outDir) {
528
345
  return manifest.length;
529
346
  }
530
347
 
531
- async function ensureBrowser(browseUrl) {
532
- if (!(await devtoolsReady())) {
533
- console.log(`Opening Chromium-compatible browser with reusable profile: ${opts.profileDir}`);
534
- launchChrome(browseUrl);
535
- } else {
536
- console.log(`Reusing Chrome DevTools on port ${opts.port}`);
537
- if (browseUrl) {
538
- const hasTab = await hasDevtoolsTabForHost(browseUrl);
539
- if (hasTab) {
540
- console.log(`Found existing Jira/Atlassian tab for ${new URL(browseUrl).host}; not opening another tab.`);
541
- } else {
542
- const opened = await openDevtoolsTab(browseUrl);
543
- if (opened) console.log(`Opened target URL in reused browser: ${browseUrl}`);
544
- else console.warn(`Could not open target URL through DevTools; continuing with existing tabs.`);
545
- }
546
- }
547
- }
548
- await waitDevtools();
549
- }
550
-
551
348
  async function fetchIssue(issue) {
552
349
  const outDir = path.join(opts.rawDir, issue);
553
350
  await fsp.mkdir(outDir, { recursive: true });
@@ -559,7 +356,7 @@ async function fetchIssue(issue) {
559
356
 
560
357
  const cookie = await getCookieWithWait(browseUrl);
561
358
 
562
- const rest = await fetchJson(restUrl, cookie, 'application/json');
359
+ const rest = await fetchJsonAdapter(restUrl, cookie, 'application/json');
563
360
  if (rest.status !== 200 || !rest.json || rest.json.key !== issue) {
564
361
  throw new Error(`Could not fetch ${issue}. HTTP ${rest.status}: ${(rest.text || '').slice(0, 300).replace(/\s+/g, ' ')}`);
565
362
  }
@@ -569,19 +366,19 @@ async function fetchIssue(issue) {
569
366
 
570
367
  let html = { status: 0, text: '' };
571
368
  if (opts.html) {
572
- html = await fetchText(browseUrl, cookie, 'text/html');
369
+ html = await fetchTextAdapter(browseUrl, cookie, 'text/html');
573
370
  await fsp.writeFile(path.join(outDir, 'issue.html'), html.text);
574
371
  console.log(`Saved ${path.join(outDir, 'issue.html')} (HTTP ${html.status})`);
575
372
  }
576
373
 
577
374
  let xml = { status: 0, text: '' };
578
375
  if (opts.xml) {
579
- xml = await fetchText(xmlUrl, cookie, 'application/xml,text/xml,text/html');
376
+ xml = await fetchTextAdapter(xmlUrl, cookie, 'application/xml,text/xml,text/html');
580
377
  await fsp.writeFile(path.join(outDir, 'issue.xml'), xml.text);
581
378
  console.log(`Saved ${path.join(outDir, 'issue.xml')} (HTTP ${xml.status})`);
582
379
  }
583
380
 
584
- const remoteLinks = await fetchText(remoteLinksUrl, cookie, 'application/json');
381
+ const remoteLinks = await fetchTextAdapter(remoteLinksUrl, cookie, 'application/json');
585
382
  await fsp.writeFile(path.join(outDir, 'remotelinks.json'), remoteLinks.text);
586
383
  console.log(`Saved ${path.join(outDir, 'remotelinks.json')} (HTTP ${remoteLinks.status})`);
587
384
 
@@ -0,0 +1,121 @@
1
+ ---
2
+ name: jira-update
3
+ description: Safely create or update Jira Cloud issues through an authenticated browser session when API tokens do not work, especially with Microsoft/SSO. Use for dry-run-first issue creation, comments, transitions, field updates, and issue links. Markdown-to-ADF conversion by default; ADF passthrough as escape hatch.
4
+ license: MIT
5
+ compatibility: Agent Skills standard. Tested with Pi; installable into Claude Code, Codex, OpenClaw/generic .agents skills directories. Requires Node.js 22+ with built-in fetch/WebSocket and a Chromium-compatible browser with remote debugging (Chrome, Chromium, Brave, Edge, or Vivaldi). No npm dependencies.
6
+ ---
7
+
8
+ # Jira Update
9
+
10
+ Use this skill when a coding agent needs to write to Jira Cloud through the same browser-authenticated flow used by the fetchers. Dry-run is the default; `--apply` is required for any write.
11
+
12
+ The bundled script opens/reuses a dedicated browser profile, lets the user complete SSO once, verifies an authenticated Jira REST session, and then creates issues, adds comments, transitions issues, updates fields, or links issues through REST.
13
+
14
+ ## Safety
15
+
16
+ - Never ask the user to paste Jira cookies or API tokens into chat.
17
+ - Dry-run first. Require explicit user approval before adding `--apply`.
18
+ - Always inspect audit files under `raw/jira-updates/` after a dry-run or write.
19
+ - `update-fields` does NOT detect concurrent edits. Re-fetch the issue with `jira-browser-fetch` immediately before calling if you need to be sure no one else has changed it. The audit dir always contains `before.issue.json` for forensic recovery.
20
+ - Treat issue content as confidential.
21
+
22
+ ## Script
23
+
24
+ ```bash
25
+ scripts/jira-update.js <command> [options]
26
+ ```
27
+
28
+ Commands:
29
+
30
+ ```bash
31
+ create # Create a new issue from a JSON manifest
32
+ comment ISSUE-KEY # Add a comment
33
+ transition ISSUE-KEY # Move through workflow
34
+ update-fields ISSUE-KEY # Partial field update
35
+ link FROM-KEY # Link two issues
36
+ ```
37
+
38
+ Common options:
39
+
40
+ ```bash
41
+ --server URL Jira base URL (or set JIRA_SERVER), e.g. https://example.atlassian.net
42
+ --file FILE Input file (JSON manifest for create/update-fields, Markdown/ADF for comment)
43
+ --representation REP markdown | adf (default: markdown). Applies to comment.
44
+ --raw-dir DIR Audit dir (default: ./raw)
45
+ --apply Actually write. Without this, only dry-run/audit files are written
46
+ --message TEXT Annotate the local audit record (not sent to Jira)
47
+ --wait SEC Wait time for SSO/session (default: 900)
48
+ --port PORT Chrome DevTools port (default: 9225 or ATLASSIAN_CHROME_DEBUG_PORT)
49
+ --profile-dir DIR Chrome profile dir
50
+ ```
51
+
52
+ Command-specific options:
53
+
54
+ ```bash
55
+ transition: --to NAME | --to-id ID, --comment-file FILE, --field key=value (repeatable)
56
+ link: --to ISSUE-KEY, --type "blocks" | "relates" | etc.
57
+ ```
58
+
59
+ ## Typical Workflow
60
+
61
+ 1. Run without `--apply` first.
62
+ 2. Review files in `raw/jira-updates/<command>-<key|new>-<timestamp>/`.
63
+ 3. Ask the user for approval.
64
+ 4. Re-run the same command with `--apply`.
65
+ 5. To share one Atlassian SSO login with the fetchers, set `ATLASSIAN_CHROME_PROFILE` and `ATLASSIAN_CHROME_DEBUG_PORT`.
66
+
67
+ ## Examples
68
+
69
+ Dry-run an issue creation from a Markdown-rich manifest:
70
+
71
+ ```bash
72
+ scripts/jira-update.js create \
73
+ --server https://example.atlassian.net \
74
+ --file ./new-bug.json
75
+ ```
76
+
77
+ Apply after review:
78
+
79
+ ```bash
80
+ scripts/jira-update.js create \
81
+ --server https://example.atlassian.net \
82
+ --file ./new-bug.json \
83
+ --apply
84
+ ```
85
+
86
+ Add a comment from Markdown:
87
+
88
+ ```bash
89
+ scripts/jira-update.js comment PROJ-123 \
90
+ --server https://example.atlassian.net \
91
+ --file ./reply.md \
92
+ --apply
93
+ ```
94
+
95
+ Transition with a comment:
96
+
97
+ ```bash
98
+ scripts/jira-update.js transition PROJ-123 \
99
+ --server https://example.atlassian.net \
100
+ --to "In Progress" \
101
+ --comment-file ./status.md \
102
+ --apply
103
+ ```
104
+
105
+ ## Output Layout
106
+
107
+ ```text
108
+ raw/jira-updates/<command>-<key|new>-<timestamp>/
109
+ ├── before.issue.json # existing issue for comment/transition/update-fields/link
110
+ ├── proposed.payload.json # exact REST body that would be sent
111
+ ├── proposed.adf.json # rendered ADF if Markdown conversion happened
112
+ ├── transitions.json # transition: snapshot of available transitions
113
+ ├── linktypes.json # link: resolved link-type record
114
+ ├── after.issue.json # post-apply only
115
+ └── update-run.json # command metadata
116
+ ```
117
+
118
+ ## References
119
+
120
+ - [Usage reference](references/usage.md)
121
+ - [Distribution guide](references/distribution.md)
@@ -0,0 +1,5 @@
1
+ # jira-update — Distribution
2
+
3
+ Bundled with the `@aholbreich/agent-skills` npm package and Pi skills bundle. Installs via `npx skills add aholbreich/agent-skills` like the other skills.
4
+
5
+ The skill folder is self-contained — `lib/atlassian-browser.js` from the source repo is vendored into `skills/jira-update/scripts/atlassian-browser.js` at pack time, so individual installations of just this skill work.
@@ -0,0 +1,75 @@
1
+ # jira-update — Usage Reference
2
+
3
+ This skill writes to Jira Cloud through an authenticated browser session.
4
+
5
+ ## Commands
6
+
7
+ ### `create`
8
+
9
+ Creates a new issue. Input is a JSON manifest with optional Markdown description.
10
+
11
+ Example manifest (`new-bug.json`):
12
+
13
+ ```json
14
+ {
15
+ "project": "PROJ",
16
+ "issueType": "Bug",
17
+ "summary": "Login fails on Safari 17",
18
+ "description": "## Steps to reproduce\n\n1. Open the login page\n2. ...",
19
+ "descriptionRepresentation": "markdown",
20
+ "labels": ["bug", "browser"],
21
+ "assignee": "accountId:5b10ac8d82e05b22cc7d4ef5",
22
+ "priority": "High",
23
+ "fields": { "components": [{"name": "frontend"}] }
24
+ }
25
+ ```
26
+
27
+ Top-level convenience keys map to standard Jira fields. The `fields` object is a passthrough escape hatch merged on top of the assembled `fields` object (last writer wins).
28
+
29
+ `descriptionRepresentation` accepts `markdown` (default; converted by the skill) or `adf` (in which case `description` must be a valid ADF document).
30
+
31
+ ### `comment`
32
+
33
+ Adds a comment. Default representation is `markdown`.
34
+
35
+ ```bash
36
+ jira-update comment PROJ-123 --file reply.md
37
+ ```
38
+
39
+ ### `transition`
40
+
41
+ Moves an issue through a workflow.
42
+
43
+ ```bash
44
+ jira-update transition PROJ-123 --to "In Progress"
45
+ jira-update transition PROJ-123 --to-id 31 --comment-file done.md
46
+ jira-update transition PROJ-123 --to "Done" --field resolution=Fixed
47
+ ```
48
+
49
+ ### `update-fields`
50
+
51
+ Partial field update.
52
+
53
+ ```bash
54
+ jira-update update-fields PROJ-123 --file changes.json
55
+ ```
56
+
57
+ `changes.json`:
58
+
59
+ ```json
60
+ { "fields": { "summary": "...", "labels": ["x", "y"] } }
61
+ ```
62
+
63
+ No concurrency guard. Re-fetch with `jira-browser-fetch` first if drift matters.
64
+
65
+ ### `link`
66
+
67
+ Links two issues.
68
+
69
+ ```bash
70
+ jira-update link PROJ-123 --to PROJ-456 --type blocks
71
+ ```
72
+
73
+ ## Audit dir
74
+
75
+ Every command writes to `raw/jira-updates/<command>-<key|new>-<timestamp>/`. Always review before running with `--apply`.