@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 +32 -3
- package/README.md +35 -13
- package/bin/agent-skills.js +11 -5
- package/bin/check.js +80 -0
- package/package.json +2 -2
- package/skills/bitbucket-browser-fetch/SKILL.md +14 -0
- package/skills/bitbucket-browser-fetch/scripts/bitbucket-browser-fetch.js +4 -4
- package/skills/confluence-browser-fetch/SKILL.md +15 -4
- package/skills/confluence-browser-fetch/references/distribution.md +5 -1
- package/skills/confluence-browser-fetch/references/usage.md +2 -2
- package/skills/confluence-browser-fetch/scripts/confluence-browser-fetch.js +17 -5
- package/skills/confluence-update/SKILL.md +13 -2
- package/skills/confluence-update/references/usage.md +2 -2
- package/skills/confluence-update/scripts/confluence-update.js +113 -33
- package/skills/jira-browser-fetch/SKILL.md +15 -3
- package/skills/jira-browser-fetch/references/distribution.md +5 -1
- package/skills/jira-browser-fetch/scripts/jira-browser-fetch.js +32 -3
- package/skills/jira-browser-fetch/scripts/lib.js +26 -0
- package/skills/jira-update/SKILL.md +14 -3
- package/skills/jira-update/scripts/jira-update.js +112 -11
- package/skills/jira-update/scripts/lib.js +23 -15
package/CHANGELOG.md
CHANGED
|
@@ -1,10 +1,39 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
##
|
|
3
|
+
## 1.1.0 - 2026-05-11
|
|
4
4
|
|
|
5
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
192
|
-
export ATLASSIAN_CHROME_DEBUG_PORT=
|
|
213
|
+
export ATLASSIAN_CHROME_PROFILE="$HOME/some/other/path"
|
|
214
|
+
export ATLASSIAN_CHROME_DEBUG_PORT=9333
|
|
193
215
|
```
|
|
194
216
|
|
|
195
|
-
Skill-specific variables
|
|
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
|
-
##
|
|
400
|
+
## Example workflow: populating an LLM wiki
|
|
379
401
|
|
|
380
|
-
|
|
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,
|
package/bin/agent-skills.js
CHANGED
|
@@ -40,7 +40,10 @@ Commands:
|
|
|
40
40
|
help Show this help
|
|
41
41
|
|
|
42
42
|
Options for install:
|
|
43
|
-
--target NAME
|
|
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.
|
|
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
|
|
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
|
|
29
|
-
--profile-dir DIR Chrome profile dir (default: BITBUCKET_CHROME_PROFILE, ATLASSIAN_CHROME_PROFILE, or ~/.local/share/
|
|
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 ||
|
|
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/
|
|
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
|
|
52
|
-
5.
|
|
53
|
-
6.
|
|
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
|
-
│
|
|
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 `
|
|
110
|
-
| `ATLASSIAN_CHROME_DEBUG_PORT` | Shared Chrome DevTools port for
|
|
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
|
|
33
|
-
--profile-dir DIR Chrome profile dir (default: CONFLUENCE_CHROME_PROFILE, ATLASSIAN_CHROME_PROFILE, or ~/.local/share/
|
|
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 ||
|
|
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/
|
|
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
|
-
|
|
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 `
|
|
107
|
-
| `ATLASSIAN_CHROME_DEBUG_PORT` | Shared Chrome DevTools port for
|
|
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
|
-
|
|
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
|
|
44
|
-
--profile-dir DIR Chrome profile dir (default: CONFLUENCE_CHROME_PROFILE, ATLASSIAN_CHROME_PROFILE, or ~/.local/share/
|
|
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
|
-
|
|
47
|
-
|
|
33
|
+
function usage() {
|
|
34
|
+
console.log(`Usage: confluence-update <command> [options]
|
|
48
35
|
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
|
|
53
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 ||
|
|
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/
|
|
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
|
|
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
|
-
|
|
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
|
|
58
|
-
4.
|
|
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
|
-
│
|
|
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/
|
|
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/
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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 ||
|
|
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/
|
|
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(
|
|
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
|
|
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
|
|
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
|
|
182
|
-
if (!manifest.project) throw new
|
|
183
|
-
if (!manifest.issueType) throw new
|
|
184
|
-
if (!manifest.summary) throw new
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
264
|
-
if (!linkType || !linkType.name) throw new
|
|
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,
|