@aholbreich/agent-skills 1.0.0 → 1.1.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/CHANGELOG.md CHANGED
@@ -1,10 +1,39 @@
1
1
  # Changelog
2
2
 
3
- ## Unreleased
3
+ ## 1.1.0 - 2026-05-11
4
4
 
5
- (empty)
5
+ Added:
6
+
7
+ - `jira-browser-fetch --skip-existing` skips issues that already have a valid `raw/<KEY>/issue.json`, reading the saved `connected-keys.json` so `--connected --scan-text` traversal still resumes correctly.
8
+ - `jira-browser-fetch` prints aggregate progress (`[N/total] pct%`) and a trailing ETA line during multi-issue runs.
9
+ - `confluence-update` and `confluence-browser-fetch` emit a clearer error when Confluence probes return 404. After all wikiBase probes 404, the verifier does one sanity probe against the Jira API at the same site root; if that succeeds, the error specifically says "cookies are valid for ${site} but Confluence at ${wikiBase} returned 404", pointing to either a wrong `--site` or a tenant without Confluence enabled.
10
+ - README gains a "Project status" section flagging the opinionated browser-only auth approach, Linux-Fedora-only end-to-end testing, and a feedback request.
11
+ - Every SKILL.md's "Shared Atlassian SSO Session" section now teaches calling agents three things: (i) the script-Chrome is a separate window from the user's regular browser, cookies from the user's Chrome are not read; (ii) reuse signal — `Reusing Chrome DevTools on port 9223` / `Found existing tab for <host>` means do not re-prompt SSO; (iii) `CHROME=/path/to/launcher` env var for Flatpak/Snap/non-PATH installs. The Typical Workflow's SSO step is clarified to be first-run-or-expired-only.
12
+
13
+ Changed:
14
+
15
+ - The "Reuse one Atlassian browser login" section in README now reflects the unified-defaults behavior — no env vars required for the default sharing.
16
+ - **Unified Chrome profile and DevTools port across all five Atlassian skills.** Defaults are now `~/.local/share/atlassian-browser-chrome` and port `9223` for `jira-browser-fetch`, `jira-update`, `confluence-browser-fetch`, `confluence-update`, and `bitbucket-browser-fetch`. One SSO login persists across all skills — no env vars required. Skill-specific `*_CHROME_PROFILE` / `*_CHROME_DEBUG_PORT` env vars still override for isolation. Each SKILL.md gains a "Shared Atlassian SSO Session" section near the top.
17
+ - `confluence-update` and `confluence-browser-fetch` now strip a trailing `/wiki` from `--site` (or `CONFLUENCE_SITE`) with a stderr note, instead of building the unreachable `…/wiki/wiki` URL.
18
+ - Replaced the 19-step `npm run check` chain with a `bin/check.js` script that auto-discovers `bin/`, `lib/`, and every `skills/*/scripts/` JS file. Aggregates failures (no longer stops at the first error) and prints a summary line. New skills/scripts are picked up automatically with no `package.json` edit.
19
+
20
+ Migration:
21
+
22
+ - Users who previously logged in via the old per-skill profiles (`~/.local/share/jira-browser-fetch-chrome`, `confluence-browser-fetch-chrome`, `bitbucket-browser-fetch-chrome`) will hit a fresh login on first run after upgrade. To preserve the existing session, move whichever profile you used: `mv ~/.local/share/jira-browser-fetch-chrome ~/.local/share/atlassian-browser-chrome` (only one — pick the one with your live cookies). Or just re-SSO once; the new shared profile then serves all five skills.
23
+
24
+ ## 1.0.1 - 2026-05-09
25
+
26
+ Added:
27
+
28
+ - `jira-update <command> --help` and `confluence-update <command> --help` now print command-specific options instead of falling back to top-level usage.
29
+ - `jira-update transition --help` documents the `--field key=value` heuristics (`priority`/`resolution`/`status` wrap as `{name: VALUE}`; `labels`/`components`/`fixVersions` split on commas; everything else passes through as a string).
30
+ - `jira-update` validates issue keys client-side (`PROJ-123` form). `comment`, `transition`, `update-fields`, `link <FROM>`, and `link --to <TO>` fail fast with exit 2 instead of round-tripping a bad key through SSO and the Jira REST API.
31
+
32
+ Changed:
33
+
34
+ - `jira-update` validation errors (missing manifest fields, unresolved transitions/link types, bad representation) now exit 2 with a clean `error: ...` message instead of dumping a Node stack trace and exiting 1. Implemented via a new `UsageError` class in `skills/jira-update/scripts/lib.js`.
6
35
 
7
- ## 0.10.0 - 2026-05-08
36
+ ## 1.0.0 - 2026-05-08
8
37
 
9
38
  Added:
10
39
 
package/README.md CHANGED
@@ -1,8 +1,24 @@
1
1
  # Agent Skills
2
2
 
3
- Handcrafted [Agent Skills](https://agentskills.io/) for developer and LLM-wiki workflows. The package is intentionally a pure skills package with broad compatibility across Pi, Claude Code, Codex, OpenClaw/generic `.agents` setups, and other Agent Skills-compatible harnesses.
3
+ **Browser-authenticated Atlassian write surface for SSO-locked organizations.** Five [Agent Skills](https://agentskills.io/) covering Jira read+write, Confluence read+write, and Bitbucket read designed for orgs where Microsoft/SSO blocks API-token use.
4
4
 
5
- This repository is a pure skills package. It currently contains browser-authenticated Atlassian fetch and update tools (Jira read+write, Confluence read+write, Bitbucket read) that work well when Jira/Confluence/Bitbucket API-token authentication is unavailable because an organization uses Microsoft/SSO.
5
+ The package is a pure Agent Skills bundle, compatible with Pi, Claude Code, Codex, OpenClaw / generic `.agents` setups, and other Agent Skills-compatible harnesses.
6
+
7
+ ## Why this exists
8
+
9
+ Most Atlassian automation tools assume API tokens. In SSO-locked enterprises, API tokens are often disabled or restricted in ways that make scripted writes painful. These skills route through an authenticated **browser session** instead — you log in to Jira/Confluence/Bitbucket once in Chrome, and the skills replay your cookies via DevTools to make REST calls. No API token required.
10
+
11
+ Beyond the SSO bypass, the skills are built around three differentiators:
12
+
13
+ - **Dry-run-first writes.** Every write command (`jira-update create`, `confluence-update replace-block`, etc.) emits a full audit folder under `raw/` first. You only get a real write when you re-run with `--apply`. Each audit folder contains the proposed payload, the before-state, and a diff summary — review exactly what would happen before it does.
14
+ - **Markdown → ADF conversion.** `jira-update` converts Markdown to Atlassian Document Format (Jira Cloud's structured rich-text representation), so agents write descriptions, comments, and transitions in a familiar format without hand-rolling ADF JSON.
15
+ - **Shared browser session.** All Atlassian skills can reuse a single Chrome profile + DevTools port, so you log in once and every fetch/update skill rides the same SSO session.
16
+
17
+ ## Project status
18
+
19
+ Opinionated bundle: SSO browser-session auth only, no API tokens or app passwords. The whole stack is built around extracting Chrome cookies via the DevTools Protocol — if API tokens already work for your org, you do not need this. All five Atlassian skills share one Chrome profile (`~/.local/share/atlassian-browser-chrome`) and DevTools port (`9223`) by default; log in once and the others reuse the session.
20
+
21
+ **Tested on Linux (Fedora) at the moment.** macOS browser paths exist in the auto-detection logic but are not end-to-end verified; Windows is unsupported. Reports of what works on other distros or OSes are very welcome — open an issue or PR at [github.com/aholbreich/agent-skills/issues](https://github.com/aholbreich/agent-skills/issues). Feedback on SSO flavors, browser detection, profile/port collisions, and unusual Atlassian tenant shapes is especially useful.
6
22
 
7
23
  ## Skills
8
24
 
@@ -102,18 +118,22 @@ npx @aholbreich/agent-skills
102
118
  Install for a specific target:
103
119
 
104
120
  ```bash
105
- npx @aholbreich/agent-skills install --target agents
106
- npx @aholbreich/agent-skills install --target claude
107
- npx @aholbreich/agent-skills install --target codex
108
- npx @aholbreich/agent-skills install --target project
121
+ npx @aholbreich/agent-skills install --target agents # ~/.agents/skills (default)
122
+ npx @aholbreich/agent-skills install --target claude # ~/.claude/skills
123
+ npx @aholbreich/agent-skills install --target codex # ~/.codex/skills
124
+ npx @aholbreich/agent-skills install --target pi # ~/.pi/agent/skills
125
+ npx @aholbreich/agent-skills install --target project-agents # ./.agents/skills
126
+ npx @aholbreich/agent-skills install --target project-pi # ./.pi/skills
109
127
  ```
110
128
 
129
+ The bare `--target project` is a deprecated alias for `--target project-pi`; use the explicit form. Run `npx @aholbreich/agent-skills paths` to see every target's full path.
130
+
111
131
  Install only selected skills:
112
132
 
113
133
  ```bash
114
134
  npx @aholbreich/agent-skills install --skill jira-browser-fetch
115
135
  npx @aholbreich/agent-skills install --skill confluence-browser-fetch
116
- npx @aholbreich/agent-skills install --skill jira-browser-fetch --target project
136
+ npx @aholbreich/agent-skills install --skill jira-browser-fetch --target project-agents
117
137
  ```
118
138
 
119
139
  Or use the dependency-free interactive picker:
@@ -185,14 +205,16 @@ bitbucket-browser-fetch
185
205
 
186
206
  ## Reuse one Atlassian browser login
187
207
 
188
- To avoid separate Jira and Confluence SSO prompts, use one shared automation profile and DevTools port for both fetchers:
208
+ All five skills share one Chrome profile (`~/.local/share/atlassian-browser-chrome`) and DevTools port (`9223`) by default. Log in once via any skill and the others ride the same SSO session — no env vars required.
209
+
210
+ To relocate the shared profile or run on a different port:
189
211
 
190
212
  ```bash
191
- export ATLASSIAN_CHROME_PROFILE="$HOME/.local/share/atlassian-browser-fetch-chrome"
192
- export ATLASSIAN_CHROME_DEBUG_PORT=9223
213
+ export ATLASSIAN_CHROME_PROFILE="$HOME/some/other/path"
214
+ export ATLASSIAN_CHROME_DEBUG_PORT=9333
193
215
  ```
194
216
 
195
- Skill-specific variables such as `JIRA_CHROME_PROFILE`, `CONFLUENCE_CHROME_PROFILE`, or `BITBUCKET_CHROME_PROFILE` still override the shared profile when needed.
217
+ Skill-specific variables (`JIRA_CHROME_PROFILE`, `CONFLUENCE_CHROME_PROFILE`, `BITBUCKET_CHROME_PROFILE`, and the matching `*_CHROME_DEBUG_PORT`) override the shared values when you intentionally want skill isolation.
196
218
 
197
219
  ## Bitbucket examples
198
220
 
@@ -375,9 +397,9 @@ Disable the limit:
375
397
  --max-attachment-size unlimited
376
398
  ```
377
399
 
378
- ## Output and LLM wiki use
400
+ ## Example workflow: populating an LLM wiki
379
401
 
380
- The tools are designed to populate a wiki `raw/` folder. They do not synthesize pages themselves. A typical LLM wiki workflow is:
402
+ One common use case for the fetch skills is feeding an LLM-curated knowledge base. The tools populate a `raw/` evidence folder; they do not synthesize pages themselves. A typical pipeline:
381
403
 
382
404
  1. fetch Jira/Confluence sources into `raw/`,
383
405
  2. treat `raw/` as immutable evidence,
@@ -40,7 +40,10 @@ Commands:
40
40
  help Show this help
41
41
 
42
42
  Options for install:
43
- --target NAME pi | agents | claude | codex | openclaw | project | project-agents | project-claude | project-codex (default: agents)
43
+ --target NAME Install destination (default: agents). Run "agent-skills paths" for full paths.
44
+ User-scoped: pi | agents | claude | codex | openclaw
45
+ Project-scoped: project-pi | project-agents | project-claude | project-codex
46
+ Deprecated: project (alias for project-pi)
44
47
  --dir PATH Custom skills directory, overrides --target
45
48
  --skill NAME Install only selected skill(s); repeatable, comma-separated, or '*' for all
46
49
  --pick Interactively choose which bundled skills to install
@@ -54,10 +57,10 @@ Examples:
54
57
  npx @aholbreich/agent-skills install --pick
55
58
  npx @aholbreich/agent-skills install --target agents --force
56
59
  npx @aholbreich/agent-skills install --target pi --force
57
- npx @aholbreich/agent-skills install --target project
60
+ npx @aholbreich/agent-skills install --target project-agents
61
+ npx @aholbreich/agent-skills install --target project-pi
58
62
  npx @aholbreich/agent-skills install --target claude
59
63
  npx @aholbreich/agent-skills install --target codex
60
- npx @aholbreich/agent-skills install --target agents
61
64
  npx @aholbreich/agent-skills install --dir ~/.pi/agent/skills
62
65
  npx @aholbreich/agent-skills list
63
66
 
@@ -160,6 +163,9 @@ async function install(args) {
160
163
  if (!customDir && !TARGETS[target]) {
161
164
  throw new Error(`Unknown target '${target}'. Valid targets: ${Object.keys(TARGETS).join(', ')}`);
162
165
  }
166
+ if (target === 'project' && !customDir) {
167
+ console.warn('warning: --target project is deprecated; it maps to ./.pi/skills. Use --target project-pi for the same path or --target project-agents for ./.agents/skills.');
168
+ }
163
169
  if (pick && skillFilters.length) {
164
170
  throw new Error('Use either --pick or --skill, not both');
165
171
  }
@@ -193,9 +199,9 @@ async function install(args) {
193
199
  }
194
200
 
195
201
  console.log(`Done. Installed: ${installed}. Skipped: ${skipped}.`);
196
- if (target.startsWith('project') && !customDir) {
202
+ if ((target === 'project' || target.startsWith('project-')) && !customDir) {
197
203
  console.log('Project-local skills are available when running an Agent Skills-compatible tool inside this project.');
198
- } else if (target === 'pi' && !customDir) {
204
+ } else if ((target === 'pi' || target === 'project-pi') && !customDir) {
199
205
  console.log('Restart Pi or start a new session to discover newly installed skills.');
200
206
  }
201
207
  }
package/bin/check.js ADDED
@@ -0,0 +1,80 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ const { spawnSync } = require('node:child_process');
5
+ const fsp = require('node:fs/promises');
6
+ const path = require('node:path');
7
+
8
+ const repoRoot = path.resolve(__dirname, '..');
9
+
10
+ async function listJsFiles(dir) {
11
+ let entries;
12
+ try {
13
+ entries = await fsp.readdir(dir, { withFileTypes: true });
14
+ } catch (err) {
15
+ if (err.code === 'ENOENT') return [];
16
+ throw err;
17
+ }
18
+ const out = [];
19
+ for (const entry of entries) {
20
+ const full = path.join(dir, entry.name);
21
+ if (entry.isDirectory()) out.push(...await listJsFiles(full));
22
+ else if (entry.isFile() && entry.name.endsWith('.js')) out.push(full);
23
+ }
24
+ return out;
25
+ }
26
+
27
+ async function collectTargets() {
28
+ const files = [];
29
+ for (const d of ['bin', 'lib']) files.push(...await listJsFiles(path.join(repoRoot, d)));
30
+ const skillsDir = path.join(repoRoot, 'skills');
31
+ for (const entry of await fsp.readdir(skillsDir, { withFileTypes: true })) {
32
+ if (!entry.isDirectory()) continue;
33
+ files.push(...await listJsFiles(path.join(skillsDir, entry.name, 'scripts')));
34
+ }
35
+ return files.sort();
36
+ }
37
+
38
+ async function main() {
39
+ const verbose = process.argv.includes('--verbose');
40
+ const skipVendor = process.argv.includes('--no-vendor');
41
+
42
+ if (!skipVendor) {
43
+ const vendor = spawnSync(process.execPath, [path.join(repoRoot, 'bin/vendor.js')], { stdio: 'inherit' });
44
+ if (vendor.status !== 0) process.exit(vendor.status || 1);
45
+ }
46
+
47
+ const targets = await collectTargets();
48
+ const failures = [];
49
+ const start = Date.now();
50
+ for (const file of targets) {
51
+ const rel = path.relative(repoRoot, file);
52
+ const result = spawnSync(process.execPath, ['--check', file], { encoding: 'utf8' });
53
+ if (result.status === 0) {
54
+ if (verbose) console.log(`ok ${rel}`);
55
+ } else {
56
+ console.error(`FAIL ${rel}`);
57
+ failures.push({ rel, stderr: result.stderr });
58
+ }
59
+ }
60
+ const ms = Date.now() - start;
61
+
62
+ if (failures.length) {
63
+ console.error(`\n${failures.length} file(s) failed syntax check:`);
64
+ for (const f of failures) {
65
+ console.error(`\n--- ${f.rel} ---`);
66
+ console.error(f.stderr.trim());
67
+ }
68
+ process.exit(1);
69
+ }
70
+ console.log(`Syntax-checked ${targets.length} file(s) in ${ms}ms.`);
71
+ }
72
+
73
+ module.exports = { collectTargets, listJsFiles };
74
+
75
+ if (require.main === module) {
76
+ main().catch(err => {
77
+ console.error(err.stack || err.message);
78
+ process.exit(1);
79
+ });
80
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aholbreich/agent-skills",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
4
  "description": "Handcrafted Agent Skills for browser-authenticated Jira and Confluence ingestion, LLM wiki workflows, and developer automation.",
5
5
  "license": "MIT",
6
6
  "type": "commonjs",
@@ -44,7 +44,7 @@
44
44
  },
45
45
  "scripts": {
46
46
  "vendor": "node bin/vendor.js",
47
- "check": "node --check bin/agent-skills.js && node --check bin/vendor.js && node --check lib/atlassian-browser.js && npm run vendor && node --check skills/jira-browser-fetch/scripts/jira-browser-fetch.js && node --check skills/jira-browser-fetch/scripts/lib.js && node --check skills/jira-browser-fetch/scripts/atlassian-browser.js && node --check skills/confluence-browser-fetch/scripts/confluence-browser-fetch.js && node --check skills/confluence-browser-fetch/scripts/lib.js && node --check skills/confluence-browser-fetch/scripts/atlassian-browser.js && node --check skills/confluence-update/scripts/confluence-update.js && node --check skills/confluence-update/scripts/lib.js && node --check skills/confluence-update/scripts/atlassian-browser.js && node --check skills/bitbucket-browser-fetch/scripts/bitbucket-browser-fetch.js && node --check skills/bitbucket-browser-fetch/scripts/lib.js && node --check skills/bitbucket-browser-fetch/scripts/atlassian-browser.js && node --check skills/jira-update/scripts/jira-update.js && node --check skills/jira-update/scripts/lib.js && node --check skills/jira-update/scripts/atlassian-browser.js",
47
+ "check": "node bin/check.js",
48
48
  "pretest": "npm run vendor",
49
49
  "test": "node --test",
50
50
  "ci": "npm run check && npm test && npm pack --dry-run",
@@ -36,6 +36,20 @@ Important options:
36
36
  --profile-dir DIR Chrome profile dir
37
37
  ```
38
38
 
39
+ ## Shared Atlassian SSO Session
40
+
41
+ All five Atlassian skills (`jira-browser-fetch`, `jira-update`, `confluence-browser-fetch`, `confluence-update`, `bitbucket-browser-fetch`) default to the same Chrome profile (`~/.local/share/atlassian-browser-chrome`) and DevTools port (`9223`). Log in once via any skill and the others reuse that session automatically — no env vars needed.
42
+
43
+ Bitbucket sits on `bitbucket.org` rather than `*.atlassian.net`, so its cookies are scoped separately, but sharing one Chrome profile/port still avoids spawning extra browser windows.
44
+
45
+ **This is a separate Chrome window from any browser the user already has open.** The script always launches its own profile with remote-debugging enabled; cookies from the user's regular Chrome are not read. The user logs in inside the window the script opens; that session is then reused by every Atlassian skill until it expires.
46
+
47
+ **Reuse signal.** When attaching to an existing session, the script prints `Reusing Chrome DevTools on port 9223` and (if the target tab is open) `Found existing tab for <host>`. When you see those lines, do not ask the user to re-login — the session is already valid.
48
+
49
+ If Chrome/Chromium is installed via Flatpak, Snap, or another non-PATH location, set `CHROME=/path/to/launcher` (or a wrapper script) so the script can find the binary.
50
+
51
+ Override with `ATLASSIAN_CHROME_PROFILE` and/or `ATLASSIAN_CHROME_DEBUG_PORT` to relocate the shared profile/port, or use skill-specific `*_CHROME_PROFILE` / `*_CHROME_DEBUG_PORT` env vars for isolation.
52
+
39
53
  ## Example
40
54
 
41
55
  ```bash
@@ -25,8 +25,8 @@ Options:
25
25
  --raw-dir DIR Output raw directory (default: BITBUCKET_RAW_DIR or ./raw)
26
26
  --pagelen N Internal API page size (default: 100)
27
27
  --wait SEC Wait time for login/session (default: 900)
28
- --port PORT Chrome DevTools port (default: BITBUCKET_CHROME_DEBUG_PORT, ATLASSIAN_CHROME_DEBUG_PORT, or 9224)
29
- --profile-dir DIR Chrome profile dir (default: BITBUCKET_CHROME_PROFILE, ATLASSIAN_CHROME_PROFILE, or ~/.local/share/bitbucket-browser-fetch-chrome)
28
+ --port PORT Chrome DevTools port (default: BITBUCKET_CHROME_DEBUG_PORT, ATLASSIAN_CHROME_DEBUG_PORT, or 9223)
29
+ --profile-dir DIR Chrome profile dir (default: BITBUCKET_CHROME_PROFILE, ATLASSIAN_CHROME_PROFILE, or ~/.local/share/atlassian-browser-chrome)
30
30
  --help Show this help
31
31
 
32
32
  Examples:
@@ -39,9 +39,9 @@ const opts = {
39
39
  workspace: '',
40
40
  projectKey: '',
41
41
  rawDir: process.env.BITBUCKET_RAW_DIR || path.resolve(process.cwd(), 'raw'),
42
- port: Number(process.env.BITBUCKET_CHROME_DEBUG_PORT || process.env.ATLASSIAN_CHROME_DEBUG_PORT || 9224),
42
+ port: Number(process.env.BITBUCKET_CHROME_DEBUG_PORT || process.env.ATLASSIAN_CHROME_DEBUG_PORT || 9223),
43
43
  waitSec: Number(process.env.BITBUCKET_FETCH_WAIT_SEC || 900),
44
- profileDir: process.env.BITBUCKET_CHROME_PROFILE || process.env.ATLASSIAN_CHROME_PROFILE || path.join(os.homedir(), '.local/share/bitbucket-browser-fetch-chrome'),
44
+ profileDir: process.env.BITBUCKET_CHROME_PROFILE || process.env.ATLASSIAN_CHROME_PROFILE || path.join(os.homedir(), '.local/share/atlassian-browser-chrome'),
45
45
  pagelen: Number(process.env.BITBUCKET_PAGELEN || 100),
46
46
  };
47
47
 
@@ -43,15 +43,26 @@ Important options:
43
43
  --no-browser-html skip rendered browser HTML
44
44
  ```
45
45
 
46
+ ## Shared Atlassian SSO Session
47
+
48
+ All five Atlassian skills (`jira-browser-fetch`, `jira-update`, `confluence-browser-fetch`, `confluence-update`, `bitbucket-browser-fetch`) default to the same Chrome profile (`~/.local/share/atlassian-browser-chrome`) and DevTools port (`9223`). Log in once via any skill and the others reuse that session automatically — no env vars needed.
49
+
50
+ **This is a separate Chrome window from any browser the user already has open.** The script always launches its own profile with remote-debugging enabled; cookies from the user's regular Chrome are not read. The user logs in inside the window the script opens; that session is then reused by every Atlassian skill until it expires.
51
+
52
+ **Reuse signal.** When attaching to an existing session, the script prints `Reusing Chrome DevTools on port 9223` and (if the target tab is open) `Found existing tab for <host>`. When you see those lines, do not ask the user to re-SSO — the session is already valid.
53
+
54
+ If Chrome/Chromium is installed via Flatpak, Snap, or another non-PATH location, set `CHROME=/path/to/launcher` (or a wrapper script) so the script can find the binary.
55
+
56
+ Override with `ATLASSIAN_CHROME_PROFILE` and/or `ATLASSIAN_CHROME_DEBUG_PORT` to relocate the shared profile/port, or use skill-specific `*_CHROME_PROFILE` / `*_CHROME_DEBUG_PORT` env vars for isolation.
57
+
46
58
  ## Typical Workflow
47
59
 
48
60
  1. If the user gives a Confluence URL, run the script directly with that URL.
49
61
  2. If the user gives a title, ask for the space key or use `--cql`.
50
62
  3. Show the command before running it.
51
- 4. If Chrome opens, ask the user to complete SSO in that browser window.
52
- 5. To share one Atlassian SSO login with `jira-browser-fetch`, use `ATLASSIAN_CHROME_PROFILE` plus `ATLASSIAN_CHROME_DEBUG_PORT` (or matching `--profile-dir` and `--port`) for both tools.
53
- 6. Verify saved files.
54
- 7. If this is an LLM wiki ingest, process the saved `raw/confluence/...` material into `wiki/` per the project `AGENTS.md`.
63
+ 4. If Chrome opens (first run or expired session), ask the user to complete SSO in that window. On subsequent invocations the script reuses the session silently — see the Reuse signal above.
64
+ 5. Verify saved files.
65
+ 6. If this is an LLM wiki ingest, process the saved `raw/confluence/...` material into `wiki/` per the project `AGENTS.md`.
55
66
 
56
67
  Example:
57
68
 
@@ -6,12 +6,16 @@ This skill follows Pi / Agent Skills layout:
6
6
  confluence-browser-fetch/
7
7
  ├── SKILL.md
8
8
  ├── scripts/
9
- └── confluence-browser-fetch.js
9
+ ├── atlassian-browser.js # vendored from lib/atlassian-browser.js
10
+ │ ├── confluence-browser-fetch.js
11
+ │ └── lib.js
10
12
  └── references/
11
13
  ├── usage.md
12
14
  └── distribution.md
13
15
  ```
14
16
 
17
+ `scripts/atlassian-browser.js` is vendored from `lib/atlassian-browser.js` at the repo root by `bin/vendor.js` (run via `npm run vendor`, and automatically before `npm test` and `npm pack`). The skill folder is self-contained, so copying just the `confluence-browser-fetch/` directory works once vendoring has happened. If you hand-copy from a fresh source checkout, run `npm run vendor` first or include all three files in `scripts/`.
18
+
15
19
  ## Install for Current User
16
20
 
17
21
  ```bash
@@ -106,8 +106,8 @@ By default, pages with matching local `metadata.json` Confluence `version.number
106
106
  |---|---|
107
107
  | `CONFLUENCE_SITE` | Default Atlassian site, e.g. `https://example.atlassian.net` |
108
108
  | `CONFLUENCE_RAW_DIR` | Default output raw directory |
109
- | `CONFLUENCE_CHROME_DEBUG_PORT` | Chrome DevTools port, default `9224`; overrides `ATLASSIAN_CHROME_DEBUG_PORT` |
110
- | `ATLASSIAN_CHROME_DEBUG_PORT` | Shared Chrome DevTools port for Jira and Confluence browser fetchers. If only `ATLASSIAN_CHROME_PROFILE` is set, Confluence defaults to shared port `9223`. |
109
+ | `CONFLUENCE_CHROME_DEBUG_PORT` | Chrome DevTools port, default `9223`; overrides `ATLASSIAN_CHROME_DEBUG_PORT` |
110
+ | `ATLASSIAN_CHROME_DEBUG_PORT` | Shared Chrome DevTools port for all Atlassian browser skills (Jira/Confluence/Bitbucket). Default `9223`. |
111
111
  | `CONFLUENCE_FETCH_WAIT_SEC` | Wait timeout, default `900` |
112
112
  | `CONFLUENCE_MAX_SEARCH_RESULTS` | Max CQL pages, default `200` |
113
113
  | `CONFLUENCE_MAX_ATTACHMENT_SIZE` / `CONFLUENCE_MAX_ATTACHMENT_BYTES` | Max attachment download size, default `5mb`; skipped files are listed in `attachments.json` |
@@ -29,8 +29,8 @@ Options:
29
29
  --retries N HTTP retry count for transient failures (default: 3)
30
30
  --request-timeout SEC Per-request timeout (default: 60)
31
31
  --wait SEC Wait time for SSO/session (default: 900)
32
- --port PORT Chrome DevTools port (default: CONFLUENCE_CHROME_DEBUG_PORT, ATLASSIAN_CHROME_DEBUG_PORT, or 9224)
33
- --profile-dir DIR Chrome profile dir (default: CONFLUENCE_CHROME_PROFILE, ATLASSIAN_CHROME_PROFILE, or ~/.local/share/confluence-browser-fetch-chrome)
32
+ --port PORT Chrome DevTools port (default: CONFLUENCE_CHROME_DEBUG_PORT, ATLASSIAN_CHROME_DEBUG_PORT, or 9223)
33
+ --profile-dir DIR Chrome profile dir (default: CONFLUENCE_CHROME_PROFILE, ATLASSIAN_CHROME_PROFILE, or ~/.local/share/atlassian-browser-chrome)
34
34
  --help Show this help
35
35
 
36
36
  Examples:
@@ -45,9 +45,9 @@ Examples:
45
45
  const opts = {
46
46
  site: process.env.CONFLUENCE_SITE || '',
47
47
  rawDir: process.env.CONFLUENCE_RAW_DIR || path.resolve(process.cwd(), 'raw'),
48
- port: Number(process.env.CONFLUENCE_CHROME_DEBUG_PORT || process.env.ATLASSIAN_CHROME_DEBUG_PORT || (process.env.ATLASSIAN_CHROME_PROFILE ? 9223 : 9224)),
48
+ port: Number(process.env.CONFLUENCE_CHROME_DEBUG_PORT || process.env.ATLASSIAN_CHROME_DEBUG_PORT || 9223),
49
49
  waitSec: Number(process.env.CONFLUENCE_FETCH_WAIT_SEC || 900),
50
- profileDir: process.env.CONFLUENCE_CHROME_PROFILE || process.env.ATLASSIAN_CHROME_PROFILE || path.join(os.homedir(), '.local/share/confluence-browser-fetch-chrome'),
50
+ profileDir: process.env.CONFLUENCE_CHROME_PROFILE || process.env.ATLASSIAN_CHROME_PROFILE || path.join(os.homedir(), '.local/share/atlassian-browser-chrome'),
51
51
  maxSearchResults: Number(process.env.CONFLUENCE_MAX_SEARCH_RESULTS || 200),
52
52
  retries: Number(process.env.CONFLUENCE_RETRIES || 3),
53
53
  requestTimeoutSec: Number(process.env.CONFLUENCE_REQUEST_TIMEOUT_SEC || 60),
@@ -89,6 +89,11 @@ for (let i = 2; i < process.argv.length; i++) {
89
89
 
90
90
  if (!inputs.length && !opts.titles.length && !opts.cqls.length) { usage(); process.exit(2); }
91
91
  opts.site = opts.site.replace(/\/$/, '');
92
+ if (/\/wiki$/i.test(opts.site)) {
93
+ const stripped = opts.site.replace(/\/wiki$/i, '');
94
+ console.error(`Note: stripping trailing /wiki from --site (${opts.site} -> ${stripped}). Pass the site root, e.g. https://example.atlassian.net.`);
95
+ opts.site = stripped;
96
+ }
92
97
  if (!opts.site) {
93
98
  console.error('Missing Atlassian site. Pass --site https://example.atlassian.net or set CONFLUENCE_SITE.');
94
99
  process.exit(2);
@@ -178,7 +183,14 @@ async function verifyConfluenceSession(cookie) {
178
183
  return { ok: false, message: `session probe HTTP ${result.status} from ${url}` };
179
184
  }
180
185
 
181
- return { ok: false, message: 'could not verify Confluence session' };
186
+ try {
187
+ const sanity = await fetchJson(`${opts.site}/rest/api/3/myself`, cookie);
188
+ if (sanity.status === 200 && sanity.json && (sanity.json.accountId || sanity.json.displayName)) {
189
+ return { ok: false, message: `cookies are valid for ${opts.site} (Jira API responded) but Confluence at ${wikiBase} returned 404. Verify --site is the Atlassian site root (e.g. https://example.atlassian.net, without /wiki) and that Confluence is enabled on this tenant.` };
190
+ }
191
+ } catch {}
192
+
193
+ return { ok: false, message: `could not verify Confluence session at ${wikiBase}. Verify --site is the Atlassian site root (e.g. https://example.atlassian.net, without /wiki).` };
182
194
  }
183
195
 
184
196
  function getCookieWithWait(openUrl) {
@@ -49,6 +49,18 @@ Important options:
49
49
  --parent-id ID parent page for create
50
50
  ```
51
51
 
52
+ ## Shared Atlassian SSO Session
53
+
54
+ All five Atlassian skills (`jira-browser-fetch`, `jira-update`, `confluence-browser-fetch`, `confluence-update`, `bitbucket-browser-fetch`) default to the same Chrome profile (`~/.local/share/atlassian-browser-chrome`) and DevTools port (`9223`). Log in once via any skill and the others reuse that session automatically — no env vars needed.
55
+
56
+ **This is a separate Chrome window from any browser the user already has open.** The script always launches its own profile with remote-debugging enabled; cookies from the user's regular Chrome are not read. The user logs in inside the window the script opens; that session is then reused by every Atlassian skill until it expires.
57
+
58
+ **Reuse signal.** When attaching to an existing session, the script prints `Reusing Chrome DevTools on port 9223` and (if the target tab is open) `Found existing tab for <host>`. When you see those lines, do not ask the user to re-SSO — the session is already valid.
59
+
60
+ If Chrome/Chromium is installed via Flatpak, Snap, or another non-PATH location, set `CHROME=/path/to/launcher` (or a wrapper script) so the script can find the binary.
61
+
62
+ Override with `ATLASSIAN_CHROME_PROFILE` and/or `ATLASSIAN_CHROME_DEBUG_PORT` to relocate the shared profile/port, or use skill-specific `*_CHROME_PROFILE` / `*_CHROME_DEBUG_PORT` env vars for isolation.
63
+
52
64
  ## Typical Workflow
53
65
 
54
66
  1. Prefer `replace-block` when editing an agent-owned region.
@@ -56,8 +68,7 @@ Important options:
56
68
  3. Review `proposed.storage.html`, `payload.json`, and `update-run.json` under `raw/confluence-updates/`.
57
69
  4. Ask the user for approval.
58
70
  5. Re-run the same command with `--apply`.
59
- 6. If Chrome opens, ask the user to complete SSO.
60
- 7. To share one Atlassian SSO login with Jira/Confluence fetchers, use `ATLASSIAN_CHROME_PROFILE` plus `ATLASSIAN_CHROME_DEBUG_PORT`.
71
+ 6. If Chrome opens (first run or expired session), ask the user to complete SSO. On subsequent invocations the script reuses the session silently — see the Reuse signal above.
61
72
 
62
73
  ## Agent-owned blocks
63
74
 
@@ -103,8 +103,8 @@ The Markdown converter is intentionally simple: headings, paragraphs, unordered/
103
103
  |---|---|
104
104
  | `CONFLUENCE_SITE` | Default Atlassian site, e.g. `https://example.atlassian.net` |
105
105
  | `CONFLUENCE_UPDATE_RAW_DIR` / `CONFLUENCE_RAW_DIR` | Audit/output raw directory |
106
- | `CONFLUENCE_CHROME_DEBUG_PORT` | Chrome DevTools port, default `9224`; overrides `ATLASSIAN_CHROME_DEBUG_PORT` |
107
- | `ATLASSIAN_CHROME_DEBUG_PORT` | Shared Chrome DevTools port for Jira/Confluence tools. If only `ATLASSIAN_CHROME_PROFILE` is set, Confluence update defaults to shared port `9223`. |
106
+ | `CONFLUENCE_CHROME_DEBUG_PORT` | Chrome DevTools port, default `9223`; overrides `ATLASSIAN_CHROME_DEBUG_PORT` |
107
+ | `ATLASSIAN_CHROME_DEBUG_PORT` | Shared Chrome DevTools port for all Atlassian browser skills (Jira/Confluence/Bitbucket). Default `9223`. |
108
108
  | `CONFLUENCE_UPDATE_WAIT_SEC` / `CONFLUENCE_FETCH_WAIT_SEC` | Wait timeout, default `900` |
109
109
  | `CONFLUENCE_CHROME_PROFILE` | Dedicated Chrome profile dir; overrides `ATLASSIAN_CHROME_PROFILE`. By default this uses the same profile as `confluence-browser-fetch`. |
110
110
  | `ATLASSIAN_CHROME_PROFILE` | Shared browser profile dir for Jira, Confluence fetch, and Confluence update tools |
@@ -14,20 +14,7 @@ const {
14
14
  replaceMarkedBlock,
15
15
  } = lib;
16
16
 
17
- function usage() {
18
- console.log(`Usage: confluence-update <command> [options]
19
-
20
- Safely update or create Confluence Cloud pages through an authenticated browser session.
21
- Dry-run is the default; pass --apply to write to Confluence.
22
-
23
- Commands:
24
- update PAGE_ID_OR_URL Replace an existing page body
25
- replace-block PAGE_ID_OR_URL Replace only a marked agent-owned block
26
- replace-text PAGE_ID_OR_URL Replace an exact matched string in the page
27
- replace-element PAGE_ID_OR_URL Replace an element by its local-id
28
- create Create a new page
29
-
30
- Common options:
17
+ const COMMON_OPTIONS = `Common options:
31
18
  --site URL Atlassian site base URL (or CONFLUENCE_SITE), e.g. https://example.atlassian.net
32
19
  --file FILE Input file containing storage XHTML or Markdown
33
20
  --representation REP storage | markdown (default: storage)
@@ -40,26 +27,25 @@ Common options:
40
27
  --expected-version N|auto Fail if current page version is not N. Use 'auto' to always overwrite (default: null)
41
28
  --apply Actually write. Without this, only dry-run/audit files are written
42
29
  --wait SEC Wait time for SSO/session (default: 900)
43
- --port PORT Chrome DevTools port (default: CONFLUENCE_CHROME_DEBUG_PORT, ATLASSIAN_CHROME_DEBUG_PORT, or 9224)
44
- --profile-dir DIR Chrome profile dir (default: CONFLUENCE_CHROME_PROFILE, ATLASSIAN_CHROME_PROFILE, or ~/.local/share/confluence-browser-fetch-chrome)
30
+ --port PORT Chrome DevTools port (default: CONFLUENCE_CHROME_DEBUG_PORT, ATLASSIAN_CHROME_DEBUG_PORT, or 9223)
31
+ --profile-dir DIR Chrome profile dir (default: CONFLUENCE_CHROME_PROFILE, ATLASSIAN_CHROME_PROFILE, or ~/.local/share/atlassian-browser-chrome)`;
45
32
 
46
- Update options:
47
- --title TITLE Override page title while updating
33
+ function usage() {
34
+ console.log(`Usage: confluence-update <command> [options]
48
35
 
49
- replace-block options:
50
- --marker NAME Required marker name, e.g. agent-summary for <!-- agent-block:agent-summary:start -->
36
+ Safely update or create Confluence Cloud pages through an authenticated browser session.
37
+ Dry-run is the default; pass --apply to write to Confluence.
51
38
 
52
- replace-text options:
53
- --match TEXT Required exact string to find and replace
39
+ Commands:
40
+ update PAGE_ID_OR_URL Replace an existing page body
41
+ replace-block PAGE_ID_OR_URL Replace only a marked agent-owned block
42
+ replace-text PAGE_ID_OR_URL Replace an exact matched string in the page
43
+ replace-element PAGE_ID_OR_URL Replace an element by its local-id
44
+ create Create a new page
54
45
 
55
- replace-element options:
56
- --local-id ID Required local-id attribute value of the element to replace
46
+ Run "confluence-update <command> --help" for command-specific options.
57
47
 
58
- Create options:
59
- --space KEY Required Confluence space key
60
- --title TITLE Required page title
61
- --parent-id ID Parent page id. Required unless --allow-root is passed
62
- --allow-root Allow creating a root page without parent-id
48
+ ${COMMON_OPTIONS}
63
49
 
64
50
  Examples:
65
51
  confluence-update update 123456 --site https://example.atlassian.net --file page.storage.html --apply
@@ -68,14 +54,93 @@ Examples:
68
54
  `);
69
55
  }
70
56
 
57
+ const COMMAND_HELP = {
58
+ update: `Usage: confluence-update update PAGE_ID_OR_URL [options]
59
+
60
+ Replace an existing page's body with new content.
61
+
62
+ Required:
63
+ --file FILE Storage XHTML or Markdown source.
64
+
65
+ Optional:
66
+ --representation REP storage (default) or markdown.
67
+ --title TITLE Override page title while updating.
68
+ --expected-version N|auto Fail if current page version is not N (default: no check).
69
+
70
+ ${COMMON_OPTIONS}
71
+ `,
72
+ 'replace-block': `Usage: confluence-update replace-block PAGE_ID_OR_URL [options]
73
+
74
+ Replace only an agent-owned marked block in an existing page. Block is delimited by
75
+ <!-- agent-block:NAME:start --> ... <!-- agent-block:NAME:end --> comments.
76
+
77
+ Required:
78
+ --file FILE Replacement content (Markdown or storage).
79
+ --marker NAME Marker name, e.g. agent-summary.
80
+
81
+ Optional:
82
+ --representation REP storage (default) or markdown.
83
+
84
+ ${COMMON_OPTIONS}
85
+ `,
86
+ 'replace-text': `Usage: confluence-update replace-text PAGE_ID_OR_URL [options]
87
+
88
+ Replace an exact unique string in the page's storage XHTML.
89
+
90
+ Required:
91
+ --file FILE Replacement content (Markdown or storage).
92
+ --match TEXT Exact string to find. Must be unique in the page; fails otherwise.
93
+
94
+ Optional:
95
+ --representation REP storage (default) or markdown.
96
+
97
+ ${COMMON_OPTIONS}
98
+ `,
99
+ 'replace-element': `Usage: confluence-update replace-element PAGE_ID_OR_URL [options]
100
+
101
+ Replace an element identified by its local-id attribute. Caller must supply the
102
+ new element's full XHTML (including the local-id) in --file.
103
+
104
+ Required:
105
+ --file FILE Full replacement element (storage XHTML).
106
+ --local-id ID local-id attribute value of the element to replace.
107
+
108
+ ${COMMON_OPTIONS}
109
+ `,
110
+ create: `Usage: confluence-update create [options]
111
+
112
+ Create a new Confluence page.
113
+
114
+ Required:
115
+ --file FILE Page body (Markdown or storage).
116
+ --space KEY Confluence space key.
117
+ --title TITLE Page title.
118
+ --parent-id ID Parent page id (or --allow-root for a root page).
119
+
120
+ Optional:
121
+ --representation REP storage (default) or markdown.
122
+ --allow-root Permit creating a root page without --parent-id.
123
+
124
+ ${COMMON_OPTIONS}
125
+ `,
126
+ };
127
+
128
+ function commandHelp(command) {
129
+ if (COMMAND_HELP[command]) {
130
+ console.log(COMMAND_HELP[command]);
131
+ } else {
132
+ usage();
133
+ }
134
+ }
135
+
71
136
  const opts = {
72
137
  command: '',
73
138
  pageInput: '',
74
139
  site: process.env.CONFLUENCE_SITE || '',
75
140
  rawDir: process.env.CONFLUENCE_UPDATE_RAW_DIR || process.env.CONFLUENCE_RAW_DIR || path.resolve(process.cwd(), 'raw'),
76
- port: Number(process.env.CONFLUENCE_CHROME_DEBUG_PORT || process.env.ATLASSIAN_CHROME_DEBUG_PORT || (process.env.ATLASSIAN_CHROME_PROFILE ? 9223 : 9224)),
141
+ port: Number(process.env.CONFLUENCE_CHROME_DEBUG_PORT || process.env.ATLASSIAN_CHROME_DEBUG_PORT || 9223),
77
142
  waitSec: Number(process.env.CONFLUENCE_UPDATE_WAIT_SEC || process.env.CONFLUENCE_FETCH_WAIT_SEC || 900),
78
- profileDir: process.env.CONFLUENCE_CHROME_PROFILE || process.env.ATLASSIAN_CHROME_PROFILE || path.join(os.homedir(), '.local/share/confluence-browser-fetch-chrome'),
143
+ profileDir: process.env.CONFLUENCE_CHROME_PROFILE || process.env.ATLASSIAN_CHROME_PROFILE || path.join(os.homedir(), '.local/share/atlassian-browser-chrome'),
79
144
  file: '',
80
145
  representation: 'storage',
81
146
  title: '',
@@ -94,13 +159,17 @@ const opts = {
94
159
  };
95
160
 
96
161
  const args = process.argv.slice(2);
97
- if (!args.length || args.includes('-h') || args.includes('--help')) { usage(); process.exit(0); }
162
+ if (!args.length || args[0] === '-h' || args[0] === '--help') { usage(); process.exit(0); }
98
163
  opts.command = args.shift();
99
164
  if (!['update', 'replace-block', 'replace-text', 'replace-element', 'create'].includes(opts.command)) {
100
165
  console.error(`Unknown command: ${opts.command}`);
101
166
  usage();
102
167
  process.exit(2);
103
168
  }
169
+ if (args.includes('--help') || args.includes('-h')) {
170
+ commandHelp(opts.command);
171
+ process.exit(0);
172
+ }
104
173
  if (opts.command !== 'create') opts.pageInput = args.shift() || '';
105
174
 
106
175
  for (let i = 0; i < args.length; i++) {
@@ -130,6 +199,11 @@ for (let i = 0; i < args.length; i++) {
130
199
  }
131
200
 
132
201
  opts.site = opts.site.replace(/\/$/, '');
202
+ if (/\/wiki$/i.test(opts.site)) {
203
+ const stripped = opts.site.replace(/\/wiki$/i, '');
204
+ console.error(`Note: stripping trailing /wiki from --site (${opts.site} -> ${stripped}). Pass the site root, e.g. https://example.atlassian.net.`);
205
+ opts.site = stripped;
206
+ }
133
207
  opts.rawDir = path.resolve(opts.rawDir);
134
208
  const wikiBase = opts.site ? `${opts.site}/wiki` : '';
135
209
 
@@ -193,7 +267,13 @@ async function verifyConfluenceSession(cookie) {
193
267
  if (result.status === 404) continue;
194
268
  return { ok: false, message: `session probe HTTP ${result.status} from ${url}` };
195
269
  }
196
- return { ok: false, message: 'could not verify Confluence session' };
270
+ try {
271
+ const sanity = await fetchJsonAdapter(`${opts.site}/rest/api/3/myself`, cookie);
272
+ if (sanity.status === 200 && sanity.json && (sanity.json.accountId || sanity.json.displayName)) {
273
+ return { ok: false, message: `cookies are valid for ${opts.site} (Jira API responded) but Confluence at ${wikiBase} returned 404. Verify --site is the Atlassian site root (e.g. https://example.atlassian.net, without /wiki) and that Confluence is enabled on this tenant.` };
274
+ }
275
+ } catch {}
276
+ return { ok: false, message: `could not verify Confluence session at ${wikiBase}. Verify --site is the Atlassian site root (e.g. https://example.atlassian.net, without /wiki).` };
197
277
  }
198
278
 
199
279
  function getCookieWithWait(openUrl) {
@@ -38,6 +38,7 @@ Important options:
38
38
  --max-attachment-size S skip attachment files larger than S (default 5mb; use unlimited to disable)
39
39
  --prefix A,B,C only follow keys with these project prefixes
40
40
  --wait SEC SSO/session wait timeout per issue
41
+ --skip-existing skip issues that already have a valid raw/<KEY>/issue.json
41
42
  ```
42
43
 
43
44
  ## Example User Requests
@@ -50,13 +51,24 @@ Use this skill for user requests like:
50
51
  - "Pull my assigned Jira issues without asking me for an API token."
51
52
  - "Fetch this ticket and all linked tickets, including attachments under the default size limit."
52
53
 
54
+ ## Shared Atlassian SSO Session
55
+
56
+ All five Atlassian skills (`jira-browser-fetch`, `jira-update`, `confluence-browser-fetch`, `confluence-update`, `bitbucket-browser-fetch`) default to the same Chrome profile (`~/.local/share/atlassian-browser-chrome`) and DevTools port (`9223`). Log in once via any skill and the others reuse that session automatically — no env vars needed.
57
+
58
+ **This is a separate Chrome window from any browser the user already has open.** The script always launches its own profile with remote-debugging enabled; cookies from the user's regular Chrome are not read. The user logs in inside the window the script opens; that session is then reused by every Atlassian skill until it expires.
59
+
60
+ **Reuse signal.** When attaching to an existing session, the script prints `Reusing Chrome DevTools on port 9223` and (if the target tab is open) `Found existing tab for <host>`. When you see those lines, do not ask the user to re-SSO — the session is already valid.
61
+
62
+ If Chrome/Chromium is installed via Flatpak, Snap, or another non-PATH location, set `CHROME=/path/to/launcher` (or a wrapper script) so the script can find the binary.
63
+
64
+ Override with `ATLASSIAN_CHROME_PROFILE` and/or `ATLASSIAN_CHROME_DEBUG_PORT` to relocate the shared profile/port, or use skill-specific `*_CHROME_PROFILE` / `*_CHROME_DEBUG_PORT` env vars for isolation.
65
+
53
66
  ## Typical Workflow
54
67
 
55
68
  1. Identify raw directory.
56
69
  2. Run the script and show the command first.
57
- 3. If Chrome opens, ask the user to complete SSO in that browser window.
58
- 4. To share one Atlassian SSO login with `confluence-browser-fetch`, use `ATLASSIAN_CHROME_PROFILE` plus `ATLASSIAN_CHROME_DEBUG_PORT` (or matching `--profile-dir` and `--port`) for both tools.
59
- 5. Verify saved files.
70
+ 3. If Chrome opens (first run or expired session), ask the user to complete SSO in that window. On subsequent invocations the script reuses the session silently — see the Reuse signal above.
71
+ 4. Verify saved files.
60
72
 
61
73
  Example:
62
74
 
@@ -6,12 +6,16 @@ This skill follows Pi / Agent Skills layout:
6
6
  jira-browser-fetch/
7
7
  ├── SKILL.md
8
8
  ├── scripts/
9
- └── jira-browser-fetch.js
9
+ ├── atlassian-browser.js # vendored from lib/atlassian-browser.js
10
+ │ ├── jira-browser-fetch.js
11
+ │ └── lib.js
10
12
  └── references/
11
13
  ├── usage.md
12
14
  └── distribution.md
13
15
  ```
14
16
 
17
+ `scripts/atlassian-browser.js` is vendored from `lib/atlassian-browser.js` at the repo root by `bin/vendor.js` (run via `npm run vendor`, and automatically before `npm test` and `npm pack`). The skill folder is self-contained, so copying just the `jira-browser-fetch/` directory works once vendoring has happened. If you hand-copy from a fresh source checkout, run `npm run vendor` first or include all three files in `scripts/`.
18
+
15
19
  ## Install for Current User
16
20
 
17
21
  Copy the whole directory to Pi's global skill location:
@@ -13,6 +13,8 @@ const {
13
13
  parseBacklogInput,
14
14
  backlogApiUrl,
15
15
  issueKeysFromAgilePage,
16
+ readExistingIssueJson,
17
+ formatEta,
16
18
  } = require('./lib');
17
19
 
18
20
  function usage() {
@@ -35,10 +37,12 @@ Options:
35
37
  --prefix A,B,C Only fetch referenced keys with these project prefixes
36
38
  --wait SEC Wait time for SSO/session per issue (default: 900)
37
39
  --port PORT Chrome DevTools port (default: JIRA_CHROME_DEBUG_PORT, ATLASSIAN_CHROME_DEBUG_PORT, or 9223)
38
- --profile-dir DIR Chrome profile dir (default: JIRA_CHROME_PROFILE, ATLASSIAN_CHROME_PROFILE, or ~/.local/share/jira-browser-fetch-chrome)
40
+ --profile-dir DIR Chrome profile dir (default: JIRA_CHROME_PROFILE, ATLASSIAN_CHROME_PROFILE, or ~/.local/share/atlassian-browser-chrome)
39
41
  --no-attachments Do not download Jira attachments
40
42
  --no-html Do not save browser HTML
41
43
  --no-xml Do not save Jira XML issue view
44
+ --skip-existing Skip issues that already have a valid raw/<KEY>/issue.json
45
+ (still extracts connected keys for depth traversal)
42
46
  --help Show this help
43
47
 
44
48
  Examples:
@@ -55,7 +59,7 @@ const opts = {
55
59
  rawDir: process.env.JIRA_RAW_DIR || path.resolve(process.cwd(), 'raw'),
56
60
  port: Number(process.env.JIRA_CHROME_DEBUG_PORT || process.env.ATLASSIAN_CHROME_DEBUG_PORT || 9223),
57
61
  waitSec: Number(process.env.JIRA_FETCH_WAIT_SEC || 900),
58
- profileDir: process.env.JIRA_CHROME_PROFILE || process.env.ATLASSIAN_CHROME_PROFILE || path.join(os.homedir(), '.local/share/jira-browser-fetch-chrome'),
62
+ profileDir: process.env.JIRA_CHROME_PROFILE || process.env.ATLASSIAN_CHROME_PROFILE || path.join(os.homedir(), '.local/share/atlassian-browser-chrome'),
59
63
  connected: false,
60
64
  depth: undefined,
61
65
  scanText: false,
@@ -68,6 +72,7 @@ const opts = {
68
72
  attachments: true,
69
73
  html: true,
70
74
  xml: true,
75
+ skipExisting: false,
71
76
  };
72
77
  const issues = [];
73
78
 
@@ -91,6 +96,7 @@ for (let i = 2; i < process.argv.length; i++) {
91
96
  else if (a === '--no-attachments') opts.attachments = false;
92
97
  else if (a === '--no-html') opts.html = false;
93
98
  else if (a === '--no-xml') opts.xml = false;
99
+ else if (a === '--skip-existing') opts.skipExisting = true;
94
100
  else if (!a.startsWith('-')) issues.push(a.toUpperCase());
95
101
  else { console.error(`Unknown argument: ${a}`); process.exit(2); }
96
102
  }
@@ -347,6 +353,19 @@ async function downloadAttachments(issueJson, cookie, outDir) {
347
353
 
348
354
  async function fetchIssue(issue) {
349
355
  const outDir = path.join(opts.rawDir, issue);
356
+
357
+ if (opts.skipExisting) {
358
+ const existing = await readExistingIssueJson(outDir, issue);
359
+ if (existing) {
360
+ console.log(`Skipping ${issue} (already fetched: ${outDir})`);
361
+ try {
362
+ const saved = JSON.parse(await fsp.readFile(path.join(outDir, 'connected-keys.json'), 'utf8'));
363
+ if (Array.isArray(saved)) return saved;
364
+ } catch {}
365
+ return extractConnectedKeys(existing, []);
366
+ }
367
+ }
368
+
350
369
  await fsp.mkdir(outDir, { recursive: true });
351
370
 
352
371
  const browseUrl = `${opts.server}/browse/${issue}`;
@@ -448,11 +467,14 @@ async function main() {
448
467
  }
449
468
  }
450
469
 
470
+ const fetchStart = Date.now();
471
+ let processed = 0;
451
472
  for (let idx = 0; idx < queue.length; idx++) {
452
473
  const item = queue[idx];
453
474
  if (seen.has(item.key)) continue;
454
475
  seen.add(item.key);
455
- console.log(`\n===== Fetching ${item.key}${item.from ? ` (referenced by ${item.from})` : ''} =====`);
476
+ const pct = ((idx + 1) / queue.length * 100).toFixed(0);
477
+ console.log(`\n===== [${idx + 1}/${queue.length}] ${pct}% Fetching ${item.key}${item.from ? ` (referenced by ${item.from})` : ''} =====`);
456
478
  try {
457
479
  const connected = await fetchIssue(item.key);
458
480
  if (opts.connected && item.depth < opts.depth) {
@@ -464,6 +486,13 @@ async function main() {
464
486
  failed.push({ key: item.key, error: e.message });
465
487
  console.error(`SKIPPED/FAILED ${item.key}: ${e.message}`);
466
488
  }
489
+ processed++;
490
+ const remaining = queue.length - (idx + 1);
491
+ if (queue.length > 1 && remaining > 0) {
492
+ const elapsed = (Date.now() - fetchStart) / 1000;
493
+ const eta = elapsed / processed * remaining;
494
+ console.log(`Progress: ${idx + 1}/${queue.length} done, ${remaining} remaining, elapsed ${formatEta(elapsed)}, ETA ${formatEta(eta)}`);
495
+ }
467
496
  }
468
497
 
469
498
  const runMeta = { fetchedAt: new Date().toISOString(), server: opts.server, rawDir: opts.rawDir, requested: issues, searches, backlogs, fetched: [...seen].filter(k => !failed.some(f => f.key === k)), failed };
@@ -1,5 +1,8 @@
1
1
  'use strict';
2
2
 
3
+ const fsp = require('node:fs/promises');
4
+ const path = require('node:path');
5
+
3
6
  const DEFAULT_MAX_ATTACHMENT_BYTES = 5 * 1024 * 1024;
4
7
 
5
8
  function parseSize(value) {
@@ -80,6 +83,27 @@ function issueKeysFromAgilePage(page) {
80
83
  return issues.map(issue => issue && issue.key).filter(Boolean);
81
84
  }
82
85
 
86
+ async function readExistingIssueJson(outDir, issueKey) {
87
+ try {
88
+ const text = await fsp.readFile(path.join(outDir, 'issue.json'), 'utf8');
89
+ const parsed = JSON.parse(text);
90
+ if (parsed && parsed.key === issueKey) return parsed;
91
+ } catch {}
92
+ return null;
93
+ }
94
+
95
+ function formatEta(seconds) {
96
+ if (!Number.isFinite(seconds) || seconds <= 0) return '0s';
97
+ const s = Math.round(seconds);
98
+ if (s < 60) return `${s}s`;
99
+ const m = Math.floor(s / 60);
100
+ const rem = s % 60;
101
+ if (m < 60) return rem ? `${m}m${rem}s` : `${m}m`;
102
+ const h = Math.floor(m / 60);
103
+ const remM = m % 60;
104
+ return remM ? `${h}h${remM}m` : `${h}h`;
105
+ }
106
+
83
107
  module.exports = {
84
108
  DEFAULT_MAX_ATTACHMENT_BYTES,
85
109
  parseSize,
@@ -90,4 +114,6 @@ module.exports = {
90
114
  parseBacklogInput,
91
115
  backlogApiUrl,
92
116
  issueKeysFromAgilePage,
117
+ readExistingIssueJson,
118
+ formatEta,
93
119
  };
@@ -45,7 +45,7 @@ Common options:
45
45
  --apply Actually write. Without this, only dry-run/audit files are written
46
46
  --message TEXT Annotate the local audit record (not sent to Jira)
47
47
  --wait SEC Wait time for SSO/session (default: 900)
48
- --port PORT Chrome DevTools port (default: 9225 or ATLASSIAN_CHROME_DEBUG_PORT)
48
+ --port PORT Chrome DevTools port (default: ATLASSIAN_CHROME_DEBUG_PORT, or 9223)
49
49
  --profile-dir DIR Chrome profile dir
50
50
  ```
51
51
 
@@ -56,13 +56,24 @@ transition: --to NAME | --to-id ID, --comment-file FILE, --field key=value (repe
56
56
  link: --to ISSUE-KEY, --type "blocks" | "relates" | etc.
57
57
  ```
58
58
 
59
+ ## Shared Atlassian SSO Session
60
+
61
+ All five Atlassian skills (`jira-browser-fetch`, `jira-update`, `confluence-browser-fetch`, `confluence-update`, `bitbucket-browser-fetch`) default to the same Chrome profile (`~/.local/share/atlassian-browser-chrome`) and DevTools port (`9223`). Log in once via any skill and the others reuse that session automatically — no env vars needed.
62
+
63
+ **This is a separate Chrome window from any browser the user already has open.** The script always launches its own profile with remote-debugging enabled; cookies from the user's regular Chrome are not read. The user logs in inside the window the script opens; that session is then reused by every Atlassian skill until it expires.
64
+
65
+ **Reuse signal.** When attaching to an existing session, the script prints `Reusing Chrome DevTools on port 9223` and (if the target tab is open) `Found existing tab for <host>`. When you see those lines, do not ask the user to re-SSO — the session is already valid.
66
+
67
+ If Chrome/Chromium is installed via Flatpak, Snap, or another non-PATH location, set `CHROME=/path/to/launcher` (or a wrapper script) so the script can find the binary.
68
+
69
+ Override with `ATLASSIAN_CHROME_PROFILE` and/or `ATLASSIAN_CHROME_DEBUG_PORT` to relocate the shared profile/port, or use skill-specific `*_CHROME_PROFILE` / `*_CHROME_DEBUG_PORT` env vars for isolation.
70
+
59
71
  ## Typical Workflow
60
72
 
61
73
  1. Run without `--apply` first.
62
74
  2. Review files in `raw/jira-updates/<command>-<key|new>-<timestamp>/`.
63
75
  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`.
76
+ 4. Re-run the same command with `--apply`. If Chrome opens (first run or expired session), ask the user to complete SSO; on subsequent runs the script reuses the session silently.
66
77
 
67
78
  ## Examples
68
79
 
@@ -7,6 +7,15 @@ const path = require('node:path');
7
7
  const { createBrowserSession } = require('./atlassian-browser');
8
8
  const lib = require('./lib');
9
9
 
10
+ const COMMON_OPTIONS = `Common options:
11
+ --server URL Jira base URL (or JIRA_SERVER), e.g. https://example.atlassian.net
12
+ --raw-dir DIR Audit directory (default: ./raw)
13
+ --apply Actually write to Jira (without it, dry-run only)
14
+ --message TEXT Annotate the local audit record
15
+ --wait SEC Wait time for SSO/session (default: 900)
16
+ --port PORT Chrome DevTools port (default: JIRA_CHROME_DEBUG_PORT, ATLASSIAN_CHROME_DEBUG_PORT, or 9223)
17
+ --profile-dir DIR Chrome profile dir (default: JIRA_CHROME_PROFILE, ATLASSIAN_CHROME_PROFILE, or ~/.local/share/atlassian-browser-chrome)`;
18
+
10
19
  function topUsage() {
11
20
  console.log(`Usage: jira-update <command> [options]
12
21
 
@@ -20,25 +29,99 @@ Commands:
20
29
  Run "jira-update <command> --help" for command-specific options.
21
30
  Dry-run is the default; --apply is required to write.
22
31
 
23
- Common options:
24
- --server URL Jira base URL (or JIRA_SERVER), e.g. https://example.atlassian.net
25
- --raw-dir DIR Audit directory (default: ./raw)
26
- --apply Actually write to Jira
27
- --message TEXT Annotate the local audit record
28
- --wait SEC Wait time for SSO/session (default: 900)
29
- --port PORT Chrome DevTools port (default: 9225 or ATLASSIAN_CHROME_DEBUG_PORT)
30
- --profile-dir DIR Chrome profile dir
32
+ ${COMMON_OPTIONS}
31
33
  `);
32
34
  }
33
35
 
36
+ const COMMAND_HELP = {
37
+ create: `Usage: jira-update create [options]
38
+
39
+ Create a new Jira issue from a JSON manifest.
40
+
41
+ Required:
42
+ --file FILE Manifest JSON. Must include: project, issueType, summary.
43
+ Optional: description, descriptionRepresentation (markdown|adf, default markdown),
44
+ labels, assignee ("accountId:..." or bare name), priority, parent, fields (escape hatch).
45
+
46
+ ${COMMON_OPTIONS}
47
+ `,
48
+ comment: `Usage: jira-update comment ISSUE-KEY [options]
49
+
50
+ Add a comment to an existing issue.
51
+
52
+ Required:
53
+ --file FILE Comment body source.
54
+ --representation REP markdown (default) or adf.
55
+
56
+ ${COMMON_OPTIONS}
57
+ `,
58
+ transition: `Usage: jira-update transition ISSUE-KEY [options]
59
+
60
+ Move an issue through its workflow.
61
+
62
+ Required (one of):
63
+ --to NAME Transition name, case-insensitive (e.g. "In Progress").
64
+ --to-id ID Transition id (skips name lookup).
65
+
66
+ Optional:
67
+ --comment-file FILE Comment to attach to the transition.
68
+ --representation REP markdown (default) or adf for the comment file.
69
+ --field key=value Set a field as part of the transition. Repeatable.
70
+
71
+ --field key=value heuristics:
72
+ priority, resolution, status wrapped as { name: VALUE }
73
+ labels, components, fixVersions split on commas; labels become a string array;
74
+ components/fixVersions become [{name},...]
75
+ any other key passed through as a plain string
76
+
77
+ ${COMMON_OPTIONS}
78
+ `,
79
+ 'update-fields': `Usage: jira-update update-fields ISSUE-KEY [options]
80
+
81
+ Partial field update (PUT /issue/{key}). Does NOT detect concurrent edits;
82
+ re-fetch with jira-browser-fetch first if drift matters.
83
+
84
+ Required:
85
+ --file FILE JSON manifest with a top-level "fields" object.
86
+ Values are sent verbatim — caller is responsible for shape
87
+ (e.g. labels: ["a","b"], priority: { name: "High" }).
88
+
89
+ ${COMMON_OPTIONS}
90
+ `,
91
+ link: `Usage: jira-update link FROM-KEY [options]
92
+
93
+ Create an issue link between two issues.
94
+
95
+ Required:
96
+ --to ISSUE-KEY Target issue key (validated as PROJ-123 form).
97
+ --type LINK-TYPE Link type by name, inward, or outward
98
+ (e.g. "blocks", "is blocked by", "relates to").
99
+
100
+ ${COMMON_OPTIONS}
101
+ `,
102
+ };
103
+
104
+ function commandHelp(command) {
105
+ if (COMMAND_HELP[command]) {
106
+ console.log(COMMAND_HELP[command]);
107
+ } else {
108
+ topUsage();
109
+ }
110
+ }
111
+
112
+ const ISSUE_KEY_RE = /^[A-Z][A-Z0-9]+-\d+$/;
113
+ function validIssueKey(s) {
114
+ return ISSUE_KEY_RE.test(String(s || ''));
115
+ }
116
+
34
117
  const opts = {
35
118
  command: '',
36
119
  issueKey: '',
37
120
  server: process.env.JIRA_SERVER || '',
38
121
  rawDir: process.env.JIRA_UPDATE_RAW_DIR || process.env.JIRA_RAW_DIR || path.resolve(process.cwd(), 'raw'),
39
- port: Number(process.env.JIRA_CHROME_DEBUG_PORT || process.env.ATLASSIAN_CHROME_DEBUG_PORT || 9225),
122
+ port: Number(process.env.JIRA_CHROME_DEBUG_PORT || process.env.ATLASSIAN_CHROME_DEBUG_PORT || 9223),
40
123
  waitSec: Number(process.env.JIRA_UPDATE_WAIT_SEC || 900),
41
- profileDir: process.env.JIRA_CHROME_PROFILE || process.env.ATLASSIAN_CHROME_PROFILE || path.join(os.homedir(), '.local/share/jira-browser-fetch-chrome'),
124
+ profileDir: process.env.JIRA_CHROME_PROFILE || process.env.ATLASSIAN_CHROME_PROFILE || path.join(os.homedir(), '.local/share/atlassian-browser-chrome'),
42
125
  file: '',
43
126
  representation: 'markdown',
44
127
  apply: false,
@@ -60,12 +143,21 @@ if (!['create', 'comment', 'transition', 'update-fields', 'link'].includes(opts.
60
143
  process.exit(2);
61
144
  }
62
145
 
146
+ if (args.includes('--help') || args.includes('-h')) {
147
+ commandHelp(opts.command);
148
+ process.exit(0);
149
+ }
150
+
63
151
  if (['comment', 'transition', 'update-fields', 'link'].includes(opts.command)) {
64
152
  if (!args.length || args[0].startsWith('-')) {
65
- console.error(`${opts.command} requires an issue key as the first argument.`);
153
+ console.error(`error: ${opts.command} requires an issue key as the first argument (e.g. PROJ-123).`);
66
154
  process.exit(2);
67
155
  }
68
156
  opts.issueKey = args.shift();
157
+ if (!validIssueKey(opts.issueKey)) {
158
+ console.error(`error: invalid issue key "${opts.issueKey}". Expected format like PROJ-123 (uppercase letters, then "-", then digits).`);
159
+ process.exit(2);
160
+ }
69
161
  }
70
162
 
71
163
  for (let i = 0; i < args.length; i++) {
@@ -100,6 +192,11 @@ if (!opts.server) {
100
192
  process.exit(2);
101
193
  }
102
194
 
195
+ if (opts.command === 'link' && opts.to && !validIssueKey(opts.to)) {
196
+ console.error(`error: invalid --to issue key "${opts.to}". Expected format like PROJ-123.`);
197
+ process.exit(2);
198
+ }
199
+
103
200
  let session = null;
104
201
  function getSession() {
105
202
  if (session) return session;
@@ -399,6 +496,10 @@ async function main() {
399
496
  }
400
497
 
401
498
  main().catch(err => {
499
+ if (err && err.name === 'UsageError') {
500
+ console.error(`error: ${err.message}`);
501
+ process.exit(2);
502
+ }
402
503
  console.error(`\nERROR: ${err.stack || err.message}`);
403
504
  process.exit(1);
404
505
  });
@@ -1,5 +1,12 @@
1
1
  'use strict';
2
2
 
3
+ class UsageError extends Error {
4
+ constructor(message) {
5
+ super(message);
6
+ this.name = 'UsageError';
7
+ }
8
+ }
9
+
3
10
  function adfDoc(content) {
4
11
  return { type: 'doc', version: 1, content: content || [] };
5
12
  }
@@ -162,11 +169,11 @@ function markdownToAdf(input) {
162
169
  function renderDescription(input, representation) {
163
170
  const rep = String(representation || 'markdown').toLowerCase();
164
171
  if (rep === 'adf') {
165
- if (!input || typeof input !== 'object') throw new Error('descriptionRepresentation: adf requires an ADF object');
172
+ if (!input || typeof input !== 'object') throw new UsageError('descriptionRepresentation: adf requires an ADF object');
166
173
  return input;
167
174
  }
168
175
  if (rep === 'markdown' || rep === 'md') return markdownToAdf(String(input ?? ''));
169
- throw new Error(`Unsupported representation: ${representation}`);
176
+ throw new UsageError(`Unsupported representation: ${representation}`);
170
177
  }
171
178
 
172
179
  function parseAssignee(value) {
@@ -178,10 +185,10 @@ function parseAssignee(value) {
178
185
  }
179
186
 
180
187
  function buildCreatePayload(manifest) {
181
- if (!manifest || typeof manifest !== 'object') throw new Error('create manifest must be an object');
182
- if (!manifest.project) throw new Error('create manifest requires project (key)');
183
- if (!manifest.issueType) throw new Error('create manifest requires issueType (name)');
184
- if (!manifest.summary) throw new Error('create manifest requires summary');
188
+ if (!manifest || typeof manifest !== 'object') throw new UsageError('create manifest must be an object');
189
+ if (!manifest.project) throw new UsageError('create manifest requires project (key)');
190
+ if (!manifest.issueType) throw new UsageError('create manifest requires issueType (name)');
191
+ if (!manifest.summary) throw new UsageError('create manifest requires summary');
185
192
 
186
193
  const fields = {
187
194
  project: { key: String(manifest.project) },
@@ -208,19 +215,19 @@ function buildCreatePayload(manifest) {
208
215
 
209
216
  function resolveTransition(transitionsResponse, query) {
210
217
  const list = (transitionsResponse && transitionsResponse.transitions) || [];
211
- if (!list.length) throw new Error('No transitions available');
218
+ if (!list.length) throw new UsageError('No transitions available');
212
219
  if (query.id) {
213
220
  const match = list.find(t => String(t.id) === String(query.id));
214
- if (!match) throw new Error(`Transition not found: id=${query.id}. Available: ${list.map(t => `${t.id}:${t.name}`).join(', ')}`);
221
+ if (!match) throw new UsageError(`Transition not found: id=${query.id}. Available: ${list.map(t => `${t.id}:${t.name}`).join(', ')}`);
215
222
  return match;
216
223
  }
217
224
  if (query.name) {
218
225
  const want = String(query.name).toLowerCase();
219
226
  const match = list.find(t => String(t.name).toLowerCase() === want);
220
- if (!match) throw new Error(`Transition not found: "${query.name}". Available: ${list.map(t => t.name).join(', ')}`);
227
+ if (!match) throw new UsageError(`Transition not found: "${query.name}". Available: ${list.map(t => t.name).join(', ')}`);
221
228
  return match;
222
229
  }
223
- throw new Error('resolveTransition requires {name} or {id}');
230
+ throw new UsageError('resolveTransition requires {name} or {id}');
224
231
  }
225
232
 
226
233
  function fieldValueFromCli(key, value) {
@@ -234,7 +241,7 @@ function fieldValueFromCli(key, value) {
234
241
  }
235
242
 
236
243
  function buildTransitionPayload({ transitionId, commentBody, fields }) {
237
- if (!transitionId) throw new Error('buildTransitionPayload requires transitionId');
244
+ if (!transitionId) throw new UsageError('buildTransitionPayload requires transitionId');
238
245
  const payload = { transition: { id: String(transitionId) } };
239
246
  if (commentBody) {
240
247
  payload.update = { comment: [{ add: { body: commentBody } }] };
@@ -248,20 +255,20 @@ function buildTransitionPayload({ transitionId, commentBody, fields }) {
248
255
 
249
256
  function resolveLinkType(typesResponse, query) {
250
257
  const list = (typesResponse && typesResponse.issueLinkTypes) || [];
251
- if (!list.length) throw new Error('No issue link types available');
258
+ if (!list.length) throw new UsageError('No issue link types available');
252
259
  const want = String(query || '').toLowerCase();
253
260
  const match = list.find(t =>
254
261
  String(t.name).toLowerCase() === want
255
262
  || String(t.inward).toLowerCase() === want
256
263
  || String(t.outward).toLowerCase() === want
257
264
  );
258
- if (!match) throw new Error(`Link type not found: "${query}". Available: ${list.map(t => t.name).join(', ')}`);
265
+ if (!match) throw new UsageError(`Link type not found: "${query}". Available: ${list.map(t => t.name).join(', ')}`);
259
266
  return match;
260
267
  }
261
268
 
262
269
  function buildLinkPayload({ from, to, linkType }) {
263
- if (!from || !to) throw new Error('buildLinkPayload requires from and to');
264
- if (!linkType || !linkType.name) throw new Error('buildLinkPayload requires linkType.name');
270
+ if (!from || !to) throw new UsageError('buildLinkPayload requires from and to');
271
+ if (!linkType || !linkType.name) throw new UsageError('buildLinkPayload requires linkType.name');
265
272
  return {
266
273
  type: { name: linkType.name },
267
274
  inwardIssue: { key: to },
@@ -270,6 +277,7 @@ function buildLinkPayload({ from, to, linkType }) {
270
277
  }
271
278
 
272
279
  module.exports = {
280
+ UsageError,
273
281
  adfDoc,
274
282
  markdownToAdf,
275
283
  renderDescription,