@jackwener/opencli 1.8.0 → 1.8.1

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.
Files changed (153) hide show
  1. package/README.md +8 -49
  2. package/README.zh-CN.md +8 -52
  3. package/cli-manifest.json +1796 -191
  4. package/clis/_atlassian/shared.js +577 -0
  5. package/clis/_atlassian/shared.test.js +170 -0
  6. package/clis/bilibili/comment.js +125 -0
  7. package/clis/bilibili/comment.test.js +153 -0
  8. package/clis/bilibili/comments.js +116 -21
  9. package/clis/bilibili/comments.test.js +77 -18
  10. package/clis/bilibili/subtitle.js +76 -31
  11. package/clis/bilibili/subtitle.test.js +156 -9
  12. package/clis/bilibili/utils.js +63 -5
  13. package/clis/bilibili/utils.test.js +45 -1
  14. package/clis/chess/analyze.js +35 -0
  15. package/clis/chess/analyze.test.js +79 -0
  16. package/clis/chess/game.js +114 -0
  17. package/clis/chess/game.test.js +178 -0
  18. package/clis/chess/games.js +67 -0
  19. package/clis/chess/games.test.js +164 -0
  20. package/clis/chess/stats.js +32 -0
  21. package/clis/chess/stats.test.js +79 -0
  22. package/clis/chess/utils.js +170 -0
  23. package/clis/chess/utils.test.js +230 -0
  24. package/clis/confluence/commands.test.js +195 -0
  25. package/clis/confluence/create.js +39 -0
  26. package/clis/confluence/page.js +23 -0
  27. package/clis/confluence/search.js +34 -0
  28. package/clis/confluence/shared.js +173 -0
  29. package/clis/confluence/update.js +38 -0
  30. package/clis/douyin/hashtag.js +84 -23
  31. package/clis/douyin/hashtag.test.js +113 -0
  32. package/clis/geogebra/add-circle.js +46 -0
  33. package/clis/geogebra/add-line.js +35 -0
  34. package/clis/geogebra/add-point.js +27 -0
  35. package/clis/geogebra/add-polygon.js +25 -0
  36. package/clis/geogebra/eval.js +35 -0
  37. package/clis/geogebra/geogebra.test.js +175 -0
  38. package/clis/geogebra/hexagon.js +62 -0
  39. package/clis/geogebra/info.js +72 -0
  40. package/clis/geogebra/list.js +35 -0
  41. package/clis/geogebra/triangle.js +60 -0
  42. package/clis/geogebra/utils.js +271 -0
  43. package/clis/jira/attachments.js +28 -0
  44. package/clis/jira/commands.test.js +287 -0
  45. package/clis/jira/comments.js +28 -0
  46. package/clis/jira/issue.js +28 -0
  47. package/clis/jira/links.js +28 -0
  48. package/clis/jira/search.js +47 -0
  49. package/clis/jira/shared.js +256 -0
  50. package/clis/linkedin/job-detail.js +167 -0
  51. package/clis/linkedin/job-detail.test.js +38 -0
  52. package/clis/linkedin/jobs-preferences.js +113 -0
  53. package/clis/linkedin/jobs-preferences.test.js +43 -0
  54. package/clis/linkedin/post-analytics.js +74 -0
  55. package/clis/linkedin/post-analytics.test.js +40 -0
  56. package/clis/linkedin/posts-core.js +241 -0
  57. package/clis/linkedin/posts.js +22 -0
  58. package/clis/linkedin/posts.test.js +40 -0
  59. package/clis/linkedin/profile-analytics.js +104 -0
  60. package/clis/linkedin/profile-analytics.test.js +67 -0
  61. package/clis/linkedin/profile-experience.js +671 -0
  62. package/clis/linkedin/profile-experience.test.js +152 -0
  63. package/clis/linkedin/profile-projects.js +311 -0
  64. package/clis/linkedin/profile-projects.test.js +111 -0
  65. package/clis/linkedin/profile-read.js +148 -0
  66. package/clis/linkedin/profile-read.test.js +77 -0
  67. package/clis/linkedin/services-read.js +213 -0
  68. package/clis/linkedin/services-read.test.js +105 -0
  69. package/clis/linkedin/shared.js +124 -0
  70. package/clis/linkedin/timeline.js +14 -7
  71. package/clis/notebooklm/add-source.js +269 -0
  72. package/clis/notebooklm/add-source.test.js +97 -0
  73. package/clis/notebooklm/create.js +76 -0
  74. package/clis/notebooklm/create.test.js +58 -0
  75. package/clis/notebooklm/generate-audio.js +91 -0
  76. package/clis/notebooklm/generate-audio.test.js +63 -0
  77. package/clis/notebooklm/generate-slides.js +106 -0
  78. package/clis/notebooklm/generate-slides.test.js +75 -0
  79. package/clis/notebooklm/open.test.js +10 -10
  80. package/clis/notebooklm/rpc.js +20 -6
  81. package/clis/notebooklm/rpc.test.js +27 -1
  82. package/clis/notebooklm/utils.js +100 -24
  83. package/clis/notebooklm/utils.test.js +60 -1
  84. package/clis/notebooklm/write-note.js +103 -0
  85. package/clis/notebooklm/write-note.test.js +70 -0
  86. package/clis/pixiv/detail.js +41 -34
  87. package/clis/pixiv/detail.test.js +93 -0
  88. package/clis/pixiv/user.js +36 -31
  89. package/clis/pixiv/user.test.js +100 -0
  90. package/clis/pixiv/utils.js +56 -7
  91. package/clis/suno/generate.js +5 -0
  92. package/clis/suno/generate.test.js +9 -0
  93. package/clis/suno/status.js +3 -2
  94. package/clis/suno/utils.js +33 -24
  95. package/clis/suno/utils.test.js +106 -0
  96. package/clis/twitter/followers.js +6 -2
  97. package/clis/twitter/followers.test.js +19 -1
  98. package/clis/twitter/following.js +14 -5
  99. package/clis/twitter/following.test.js +29 -0
  100. package/clis/twitter/likes.js +12 -4
  101. package/clis/twitter/likes.test.js +26 -1
  102. package/clis/twitter/list-add.js +1 -1
  103. package/clis/twitter/list-remove.js +1 -1
  104. package/clis/twitter/notifications.js +4 -4
  105. package/clis/twitter/post.js +62 -4
  106. package/clis/twitter/post.test.js +35 -3
  107. package/clis/twitter/profile.js +81 -28
  108. package/clis/twitter/profile.test.js +113 -2
  109. package/clis/twitter/quote.js +9 -4
  110. package/clis/twitter/reply.js +13 -10
  111. package/clis/twitter/reply.test.js +41 -0
  112. package/clis/twitter/search.js +1 -1
  113. package/clis/twitter/search.test.js +35 -0
  114. package/clis/twitter/shared.js +11 -0
  115. package/clis/twitter/shared.test.js +37 -1
  116. package/clis/twitter/utils.js +53 -16
  117. package/clis/upwork/detail.js +132 -0
  118. package/clis/upwork/feed.js +109 -0
  119. package/clis/upwork/search.js +115 -0
  120. package/clis/upwork/upwork.test.js +566 -0
  121. package/clis/upwork/utils.js +323 -0
  122. package/clis/weread/book-search.js +438 -0
  123. package/clis/weread/book-search.test.js +242 -0
  124. package/clis/weread/search-regression.test.js +80 -0
  125. package/clis/weread/search.js +17 -2
  126. package/clis/xiaohongshu/creator-note-detail.js +165 -28
  127. package/clis/xiaohongshu/creator-note-detail.test.js +186 -37
  128. package/clis/xiaohongshu/creator-notes.js +251 -2
  129. package/clis/xiaohongshu/creator-notes.test.js +79 -2
  130. package/clis/xiaohongshu/download.js +97 -39
  131. package/clis/xiaohongshu/download.test.js +201 -0
  132. package/clis/zhihu/answer-comments.js +2 -21
  133. package/clis/zhihu/answer-detail.js +2 -31
  134. package/clis/zhihu/collection.js +2 -14
  135. package/clis/zhihu/collection.test.js +4 -3
  136. package/clis/zhihu/question.js +1 -9
  137. package/clis/zhihu/question.test.js +2 -2
  138. package/clis/zhihu/search.js +1 -12
  139. package/clis/zhihu/search.test.js +2 -2
  140. package/clis/zhihu/text.js +29 -0
  141. package/clis/zhihu/text.test.js +24 -0
  142. package/dist/src/browser/network-cache.js +13 -1
  143. package/dist/src/browser/network-cache.test.js +17 -0
  144. package/dist/src/download/index.js +13 -1
  145. package/dist/src/download/index.test.js +23 -1
  146. package/dist/src/download/media-download.test.js +3 -1
  147. package/dist/src/download/progress.js +2 -2
  148. package/dist/src/download/progress.test.js +12 -1
  149. package/dist/src/output.js +11 -1
  150. package/dist/src/output.test.js +6 -0
  151. package/dist/src/registry.js +1 -0
  152. package/dist/src/registry.test.js +11 -0
  153. package/package.json +1 -1
@@ -0,0 +1,271 @@
1
+ import { ArgumentError, CommandExecutionError } from '@jackwener/opencli/errors';
2
+
3
+ /**
4
+ * Shared utilities for GeoGebra adapters.
5
+ *
6
+ * GeoGebra Geometry exposes a `ggbApplet` JavaScript API on the page after
7
+ * the GWT-compiled app initializes. All adapters share the same pattern:
8
+ * navigate → wait for applet → call API via page.evaluate().
9
+ */
10
+
11
+ const GEOGEBRA_URL = 'https://www.geogebra.org/geometry';
12
+ const APPLET_WAIT_MS = 15_000;
13
+
14
+ export function unwrapBridgeEnvelope(value) {
15
+ if (value && typeof value === 'object' && 'data' in value && 'session' in value) {
16
+ return value.data;
17
+ }
18
+ return value;
19
+ }
20
+
21
+ function isPlainObject(value) {
22
+ return value && typeof value === 'object' && !Array.isArray(value);
23
+ }
24
+
25
+ export function normalizeLabel(value, label = 'label') {
26
+ const normalized = String(value ?? '').trim();
27
+ if (!/^[A-Za-z][A-Za-z0-9_]*$/.test(normalized)) {
28
+ throw new ArgumentError(`${label} must be an ASCII GeoGebra label like A, B1, or poly_1`);
29
+ }
30
+ return normalized;
31
+ }
32
+
33
+ export function normalizeLabelList(value, label, min, max = Infinity) {
34
+ const parts = String(value ?? '').split(',').map(s => s.trim()).filter(Boolean);
35
+ if (parts.length < min || parts.length > max) {
36
+ throw new ArgumentError(`${label} must contain ${min === max ? min : `${min}-${max}`} comma-separated labels`);
37
+ }
38
+ return parts.map((part, idx) => normalizeLabel(part, `${label}[${idx + 1}]`));
39
+ }
40
+
41
+ export function normalizeNumber(value, label, { defaultValue, positive = false } = {}) {
42
+ const raw = value == null || value === '' ? defaultValue : value;
43
+ const number = Number(raw);
44
+ if (!Number.isFinite(number) || (positive && number <= 0)) {
45
+ throw new ArgumentError(`${label} must be a ${positive ? 'positive ' : ''}finite number`);
46
+ }
47
+ return number;
48
+ }
49
+
50
+ export function normalizeCoords(value) {
51
+ const parts = String(value ?? '').split(',').map(s => s.trim());
52
+ if (parts.length !== 2) {
53
+ throw new ArgumentError('coords must be in "x,y" format (e.g. "1,2")');
54
+ }
55
+ return parts.map((part, idx) => normalizeNumber(part, idx === 0 ? 'x' : 'y'));
56
+ }
57
+
58
+ export function requireGgbSuccess(result, message) {
59
+ if (!isPlainObject(result)) {
60
+ throw new CommandExecutionError(`${message}: malformed GeoGebra result`);
61
+ }
62
+ if (!result.ok) {
63
+ throw new CommandExecutionError(result.error || message);
64
+ }
65
+ return result;
66
+ }
67
+
68
+ /**
69
+ * Navigate to GeoGebra Geometry (if not already there) and wait for
70
+ * the ggbApplet API to become available.
71
+ */
72
+ export async function ensureApplet(page) {
73
+ let currentUrl = '';
74
+ try {
75
+ currentUrl = await page.getCurrentUrl();
76
+ } catch {
77
+ currentUrl = '';
78
+ }
79
+ // If already on the geometry page, check if applet is ready without re-navigating
80
+ if (currentUrl?.includes('geogebra.org/geometry')) {
81
+ try {
82
+ const ready = unwrapBridgeEnvelope(await page.evaluate(`typeof ggbApplet !== 'undefined' && typeof ggbApplet.evalCommand === 'function'`));
83
+ if (ready) return;
84
+ } catch (err) {
85
+ throw new CommandExecutionError(`Failed to detect GeoGebra applet: ${err?.message || err}`);
86
+ }
87
+ }
88
+ // Navigate to GeoGebra Geometry
89
+ try {
90
+ await page.goto(GEOGEBRA_URL);
91
+ } catch (err) {
92
+ throw new CommandExecutionError(`Failed to load GeoGebra Geometry: ${err?.message || err}`);
93
+ }
94
+
95
+ let ready;
96
+ try {
97
+ ready = unwrapBridgeEnvelope(await page.evaluate(`
98
+ (async () => {
99
+ const deadline = Date.now() + ${APPLET_WAIT_MS};
100
+ while (Date.now() < deadline) {
101
+ if (typeof ggbApplet !== 'undefined' && typeof ggbApplet.evalCommand === 'function') {
102
+ return true;
103
+ }
104
+ await new Promise(r => setTimeout(r, 500));
105
+ }
106
+ return false;
107
+ })()
108
+ `));
109
+ } catch (err) {
110
+ throw new CommandExecutionError(`Failed to detect GeoGebra applet: ${err?.message || err}`);
111
+ }
112
+ if (ready !== true) {
113
+ throw new CommandExecutionError('ggbApplet not available after waiting. Make sure the GeoGebra Geometry page is fully loaded.');
114
+ }
115
+ }
116
+
117
+ /**
118
+ * Execute a GeoGebra command string via ggbApplet.evalCommandGetLabels.
119
+ * evalCommandGetLabels both executes the command and returns the created
120
+ * object label(s). We use it instead of evalCommand to avoid double-execution.
121
+ * Returns { ok, label } where label is the resulting object label(s).
122
+ */
123
+ export async function ggbEval(page, cmd) {
124
+ let result;
125
+ try {
126
+ result = unwrapBridgeEnvelope(await page.evaluate(`
127
+ (cmd => {
128
+ if (typeof ggbApplet === 'undefined' || typeof ggbApplet.evalCommandGetLabels !== 'function') {
129
+ return { ok: false, label: '', beforeCount: 0, afterCount: 0, error: 'ggbApplet is not ready' };
130
+ }
131
+ const collectNames = () => {
132
+ let names = ggbApplet.getAllObjectNames();
133
+ if (typeof names === 'string') {
134
+ names = names.split(',').map(s => s.trim()).filter(Boolean);
135
+ }
136
+ return Array.isArray(names) ? names : [];
137
+ };
138
+ const beforeCount = collectNames().length;
139
+ const label = ggbApplet.evalCommandGetLabels(cmd);
140
+ const afterCount = collectNames().length;
141
+ const dialogText = [...document.querySelectorAll('[role="dialog"], .gwt-DialogBox')]
142
+ .map(node => node.textContent?.trim() || '')
143
+ .find(text => /error|unknown command|错误|未知的指令/i.test(text)) || '';
144
+ return {
145
+ ok: label !== '' || afterCount > beforeCount,
146
+ label,
147
+ beforeCount,
148
+ afterCount,
149
+ error: dialogText || null,
150
+ };
151
+ })(${JSON.stringify(cmd)})
152
+ `));
153
+ } catch (err) {
154
+ throw new CommandExecutionError(`Failed to execute GeoGebra command: ${err?.message || err}`);
155
+ }
156
+ if (!isPlainObject(result) || typeof result.ok !== 'boolean') {
157
+ throw new CommandExecutionError('GeoGebra command returned malformed result');
158
+ }
159
+ return result;
160
+ }
161
+
162
+ /**
163
+ * List all currently known GeoGebra objects, optionally filtered by type.
164
+ */
165
+ export async function ggbListObjects(page, filterType) {
166
+ const normalizedFilter = filterType ? String(filterType).toLowerCase() : '';
167
+ let objects;
168
+ try {
169
+ objects = unwrapBridgeEnvelope(await page.evaluate(`
170
+ (filterType => {
171
+ const api = ggbApplet;
172
+ let names = api.getAllObjectNames();
173
+ if (typeof names === 'string') {
174
+ names = names.split(',').map(s => s.trim()).filter(Boolean);
175
+ }
176
+ if (!Array.isArray(names)) return { error: 'Object names are not an array' };
177
+ const result = [];
178
+ for (const name of names) {
179
+ try {
180
+ const type = api.getObjectType(name);
181
+ if (!type) return { error: 'Object has no type', name };
182
+ if (filterType && type.toLowerCase() !== filterType) continue;
183
+ result.push({
184
+ name,
185
+ type,
186
+ value: api.getValueString(name) || '',
187
+ visible: api.getVisible(name),
188
+ });
189
+ } catch (err) {
190
+ return { error: err?.message || String(err), name };
191
+ }
192
+ }
193
+ return result;
194
+ })(${JSON.stringify(normalizedFilter)})
195
+ `));
196
+ } catch (err) {
197
+ throw new CommandExecutionError(`Failed to list GeoGebra objects: ${err?.message || err}`);
198
+ }
199
+ if (objects && typeof objects === 'object' && !Array.isArray(objects) && objects.error) {
200
+ const nameSuffix = objects.name ? ` for ${objects.name}` : '';
201
+ throw new CommandExecutionError(`Failed to list GeoGebra objects${nameSuffix}: ${objects.error}`);
202
+ }
203
+ if (!Array.isArray(objects)) {
204
+ throw new CommandExecutionError('GeoGebra object list returned malformed result');
205
+ }
206
+ return objects;
207
+ }
208
+
209
+ /**
210
+ * Poll until the object count reaches the requested minimum.
211
+ */
212
+ export async function ggbWaitForObjectCount(page, minCount, timeoutMs = 4_000) {
213
+ const normalizedMinCount = normalizeNumber(minCount, 'minCount', { positive: true });
214
+ const normalizedTimeoutMs = normalizeNumber(timeoutMs, 'timeoutMs', { positive: true });
215
+ let count;
216
+ try {
217
+ count = unwrapBridgeEnvelope(await page.evaluate(`
218
+ (async () => {
219
+ const deadline = Date.now() + ${normalizedTimeoutMs};
220
+ while (Date.now() < deadline) {
221
+ let names = ggbApplet.getAllObjectNames();
222
+ if (typeof names === 'string') {
223
+ names = names.split(',').map(s => s.trim()).filter(Boolean);
224
+ }
225
+ if (Array.isArray(names) && names.length >= ${normalizedMinCount}) {
226
+ return names.length;
227
+ }
228
+ await new Promise(resolve => setTimeout(resolve, 200));
229
+ }
230
+ let names = ggbApplet.getAllObjectNames();
231
+ if (typeof names === 'string') {
232
+ names = names.split(',').map(s => s.trim()).filter(Boolean);
233
+ }
234
+ return Array.isArray(names) ? names.length : 0;
235
+ })()
236
+ `));
237
+ } catch (err) {
238
+ throw new CommandExecutionError(`Failed waiting for GeoGebra object count: ${err?.message || err}`);
239
+ }
240
+ if (!Number.isFinite(Number(count))) {
241
+ throw new CommandExecutionError('GeoGebra object count returned malformed result');
242
+ }
243
+ return Number(count);
244
+ }
245
+
246
+ /**
247
+ * Read a property from a GeoGebra object.
248
+ */
249
+ export async function ggbGetProperty(page, objName, property) {
250
+ try {
251
+ return unwrapBridgeEnvelope(await page.evaluate(`
252
+ (objName, property) => {
253
+ const api = ggbApplet;
254
+ switch (property) {
255
+ case 'type': return api.getObjectType(objName);
256
+ case 'value': return api.getValueString(objName);
257
+ case 'color': return api.getColor(objName);
258
+ case 'visible': return api.getVisible(objName);
259
+ case 'caption': return api.getCaption(objName) || '';
260
+ case 'xcoord': return api.getXcoord(objName);
261
+ case 'ycoord': return api.getYcoord(objName);
262
+ case 'definition': return api.getDefinitionString(objName);
263
+ case 'command': return api.getCommandString(objName);
264
+ default: return null;
265
+ }
266
+ }
267
+ `, objName, property));
268
+ } catch (err) {
269
+ throw new CommandExecutionError(`Failed to read GeoGebra object property: ${err?.message || err}`);
270
+ }
271
+ }
@@ -0,0 +1,28 @@
1
+ import { cli, Strategy } from '@jackwener/opencli/registry';
2
+ import { fetchIssue, jiraConfig, jiraRowsOrEmpty, normalizeAttachment, requireIssueKey } from './shared.js';
3
+ import { requirePayloadArray } from '../_atlassian/shared.js';
4
+
5
+ cli({
6
+ site: 'jira',
7
+ name: 'attachments',
8
+ access: 'read',
9
+ description: 'Jira issue attachment metadata',
10
+ domain: 'atlassian.net',
11
+ strategy: Strategy.PUBLIC,
12
+ browser: false,
13
+ args: [
14
+ { name: 'key', positional: true, required: true, help: 'Jira issue key, e.g. PROJ-123' },
15
+ ],
16
+ columns: ['id', 'filename', 'mimeType', 'size', 'url'],
17
+ func: async (args) => {
18
+ const key = requireIssueKey(args.key);
19
+ const config = jiraConfig();
20
+ const issue = await fetchIssue(config, key, ['attachment']);
21
+ const attachments = requirePayloadArray(issue.fields?.attachment, `jira attachments ${key}`);
22
+ return jiraRowsOrEmpty(
23
+ attachments.map(normalizeAttachment),
24
+ `jira attachments ${key}`,
25
+ `Jira issue ${key} has no attachments.`,
26
+ );
27
+ },
28
+ });
@@ -0,0 +1,287 @@
1
+ import { describe, expect, it, afterEach, vi } from 'vitest';
2
+ import { getRegistry } from '@jackwener/opencli/registry';
3
+ import { CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
4
+ import { __test__ as jiraSharedTest } from './shared.js';
5
+ import './issue.js';
6
+ import './search.js';
7
+ import './comments.js';
8
+ import './attachments.js';
9
+ import './links.js';
10
+
11
+ const ENV_KEYS = [
12
+ 'ATLASSIAN_JIRA_BASE_URL',
13
+ 'ATLASSIAN_DEPLOYMENT',
14
+ 'ATLASSIAN_EMAIL',
15
+ 'ATLASSIAN_API_TOKEN',
16
+ 'ATLASSIAN_USERNAME',
17
+ 'ATLASSIAN_PASSWORD',
18
+ 'ATLASSIAN_PAT',
19
+ ];
20
+
21
+ function clearEnv() {
22
+ for (const key of ENV_KEYS) delete process.env[key];
23
+ }
24
+
25
+ function setCloudEnv() {
26
+ clearEnv();
27
+ process.env.ATLASSIAN_JIRA_BASE_URL = 'https://team.atlassian.net';
28
+ process.env.ATLASSIAN_DEPLOYMENT = 'cloud';
29
+ process.env.ATLASSIAN_EMAIL = 'bot@example.com';
30
+ process.env.ATLASSIAN_API_TOKEN = 'secret';
31
+ }
32
+
33
+ function jsonResponse(body) {
34
+ return new Response(JSON.stringify(body), { status: 200, headers: { 'content-type': 'application/json' } });
35
+ }
36
+
37
+ afterEach(() => {
38
+ clearEnv();
39
+ vi.unstubAllGlobals();
40
+ });
41
+
42
+ describe('jira commands', () => {
43
+ it('registers non-browser REST commands', () => {
44
+ for (const name of ['issue', 'search', 'comments', 'attachments', 'links']) {
45
+ const cmd = getRegistry().get(`jira/${name}`);
46
+ expect(cmd).toBeDefined();
47
+ expect(cmd.browser).toBe(false);
48
+ expect(cmd.strategy).toBe('public');
49
+ }
50
+ });
51
+
52
+ it('normalizes a Cloud issue into agent-friendly context', async () => {
53
+ setCloudEnv();
54
+ vi.stubGlobal('fetch', vi.fn(async (url) => {
55
+ expect(String(url)).toContain('/rest/api/3/issue/PROJ-1?');
56
+ return jsonResponse({
57
+ id: '10001',
58
+ key: 'PROJ-1',
59
+ fields: {
60
+ summary: 'Checkout fails',
61
+ issuetype: { name: 'Bug' },
62
+ status: { name: 'In Progress' },
63
+ priority: { name: 'High' },
64
+ labels: ['prod'],
65
+ description: {
66
+ type: 'doc',
67
+ content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Payment fails' }] }],
68
+ },
69
+ comment: {
70
+ total: 1,
71
+ comments: [{
72
+ id: 'c1',
73
+ author: { displayName: 'Alice' },
74
+ created: '2026-05-01T00:00:00.000+0000',
75
+ body: { type: 'doc', content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Needs RCA' }] }] },
76
+ }],
77
+ },
78
+ attachment: [{ id: 'a1', filename: 'log.txt', mimeType: 'text/plain', size: 12, content: 'https://team.atlassian.net/secure/attachment/a1/log.txt' }],
79
+ issuelinks: [{ type: { name: 'Blocks' }, outwardIssue: { key: 'PROJ-2' } }],
80
+ fixVersions: [{ name: '1.2.3' }],
81
+ created: '2026-05-01T00:00:00.000+0000',
82
+ updated: '2026-05-02T00:00:00.000+0000',
83
+ },
84
+ });
85
+ }));
86
+ const cmd = getRegistry().get('jira/issue');
87
+ const rows = await cmd.func({ key: 'proj-1' });
88
+ expect(rows[0]).toMatchObject({
89
+ key: 'PROJ-1',
90
+ summary: 'Checkout fails',
91
+ issueType: 'Bug',
92
+ status: 'In Progress',
93
+ labels: ['prod'],
94
+ url: 'https://team.atlassian.net/browse/PROJ-1',
95
+ });
96
+ expect(rows[0].description.markdown).toBe('Payment fails');
97
+ expect(rows[0].comments[0].markdown).toBe('Needs RCA');
98
+ expect(rows[0].attachments[0].filename).toBe('log.txt');
99
+ expect(rows[0].linkedIssues[0]).toEqual({ key: 'PROJ-2', type: 'Blocks', direction: 'outward' });
100
+ });
101
+
102
+ it('fails typed when Jira issue payload is missing stable issue identity', async () => {
103
+ setCloudEnv();
104
+ vi.stubGlobal('fetch', vi.fn(async () => jsonResponse({ fields: { summary: 'No key' } })));
105
+ const cmd = getRegistry().get('jira/issue');
106
+ await expect(cmd.func({ key: 'PROJ-1' })).rejects.toBeInstanceOf(CommandExecutionError);
107
+ });
108
+
109
+ it('fails typed when Jira issue nested collections have malformed shapes', async () => {
110
+ setCloudEnv();
111
+ const cmd = getRegistry().get('jira/issue');
112
+ vi.stubGlobal('fetch', vi.fn(async () => jsonResponse({
113
+ id: '10001',
114
+ key: 'PROJ-1',
115
+ fields: {
116
+ summary: 'Checkout fails',
117
+ labels: [],
118
+ comment: { total: 1, comments: { id: 'c1' } },
119
+ attachment: [],
120
+ issuelinks: [],
121
+ },
122
+ })));
123
+ await expect(cmd.func({ key: 'PROJ-1' })).rejects.toBeInstanceOf(CommandExecutionError);
124
+
125
+ vi.stubGlobal('fetch', vi.fn(async () => jsonResponse({
126
+ id: '10001',
127
+ key: 'PROJ-1',
128
+ fields: {
129
+ summary: 'Checkout fails',
130
+ labels: [],
131
+ comment: { total: 0, comments: [] },
132
+ attachment: { id: 'a1' },
133
+ issuelinks: [],
134
+ },
135
+ })));
136
+ await expect(cmd.func({ key: 'PROJ-1' })).rejects.toBeInstanceOf(CommandExecutionError);
137
+
138
+ vi.stubGlobal('fetch', vi.fn(async () => jsonResponse({
139
+ id: '10001',
140
+ key: 'PROJ-1',
141
+ fields: {
142
+ summary: 'Checkout fails',
143
+ labels: [],
144
+ comment: null,
145
+ attachment: [],
146
+ issuelinks: [],
147
+ },
148
+ })));
149
+ await expect(cmd.func({ key: 'PROJ-1' })).rejects.toBeInstanceOf(CommandExecutionError);
150
+
151
+ vi.stubGlobal('fetch', vi.fn(async () => jsonResponse({
152
+ id: '10001',
153
+ key: 'PROJ-1',
154
+ fields: {
155
+ summary: 'Checkout fails',
156
+ labels: [],
157
+ comment: { total: 0, comments: [] },
158
+ attachment: null,
159
+ issuelinks: [],
160
+ },
161
+ })));
162
+ await expect(cmd.func({ key: 'PROJ-1' })).rejects.toBeInstanceOf(CommandExecutionError);
163
+ });
164
+
165
+ it('rejects invalid Jira issue keys before remote requests', async () => {
166
+ expect(() => jiraSharedTest.requireIssueKey('notakey')).toThrow(/Invalid Jira issue key/);
167
+ const fetchMock = vi.fn();
168
+ vi.stubGlobal('fetch', fetchMock);
169
+ const cmd = getRegistry().get('jira/issue');
170
+ await expect(cmd.func({ key: 'notakey' })).rejects.toMatchObject({ code: 'ARGUMENT' });
171
+ expect(fetchMock).not.toHaveBeenCalled();
172
+ });
173
+
174
+ it('fetches full comments when issue inline comments are truncated', async () => {
175
+ setCloudEnv();
176
+ const fetchMock = vi.fn(async (url) => {
177
+ const href = String(url);
178
+ if (href.includes('/comment?')) {
179
+ return jsonResponse({
180
+ comments: [
181
+ { id: 'c1', author: { displayName: 'Alice' }, created: '2026-05-01', body: 'one' },
182
+ { id: 'c2', author: { displayName: 'Bob' }, created: '2026-05-02', body: 'two' },
183
+ ],
184
+ });
185
+ }
186
+ return jsonResponse({
187
+ id: '10001',
188
+ key: 'PROJ-1',
189
+ fields: {
190
+ summary: 'Checkout fails',
191
+ labels: [],
192
+ comment: {
193
+ total: 2,
194
+ comments: [{ id: 'c1', author: { displayName: 'Alice' }, created: '2026-05-01', body: 'one' }],
195
+ },
196
+ attachment: [],
197
+ issuelinks: [],
198
+ },
199
+ });
200
+ });
201
+ vi.stubGlobal('fetch', fetchMock);
202
+ const cmd = getRegistry().get('jira/issue');
203
+ const rows = await cmd.func({ key: 'PROJ-1', 'comments-limit': 10 });
204
+ expect(rows[0].comments.map((comment) => comment.id)).toEqual(['c1', 'c2']);
205
+ expect(fetchMock).toHaveBeenCalledTimes(2);
206
+ });
207
+
208
+ it('uses Data Center search endpoint and payload', async () => {
209
+ clearEnv();
210
+ process.env.ATLASSIAN_JIRA_BASE_URL = 'https://jira.example.com';
211
+ process.env.ATLASSIAN_DEPLOYMENT = 'datacenter';
212
+ process.env.ATLASSIAN_USERNAME = 'bot';
213
+ process.env.ATLASSIAN_PASSWORD = 'secret';
214
+ const fetchMock = vi.fn(async (url, init) => {
215
+ expect(String(url)).toBe('https://jira.example.com/rest/api/2/search');
216
+ expect(init.method).toBe('POST');
217
+ expect(JSON.parse(init.body)).toMatchObject({
218
+ jql: 'project = PROJ',
219
+ startAt: 0,
220
+ maxResults: 5,
221
+ });
222
+ return jsonResponse({
223
+ issues: [{
224
+ id: '1',
225
+ key: 'PROJ-1',
226
+ fields: {
227
+ summary: 'Task',
228
+ status: { name: 'Done' },
229
+ updated: '2026-05-03T00:00:00.000+0000',
230
+ },
231
+ }],
232
+ });
233
+ });
234
+ vi.stubGlobal('fetch', fetchMock);
235
+ const cmd = getRegistry().get('jira/search');
236
+ const rows = await cmd.func({ jql: 'project = PROJ', limit: 5 });
237
+ expect(rows).toEqual([
238
+ expect.objectContaining({ key: 'PROJ-1', summary: 'Task', status: 'Done' }),
239
+ ]);
240
+ });
241
+
242
+ it('separates Jira search empty results from malformed search payloads', async () => {
243
+ setCloudEnv();
244
+ const cmd = getRegistry().get('jira/search');
245
+ vi.stubGlobal('fetch', vi.fn(async () => jsonResponse({ issues: [] })));
246
+ await expect(cmd.func({ jql: 'project = NONE' })).rejects.toBeInstanceOf(EmptyResultError);
247
+
248
+ vi.stubGlobal('fetch', vi.fn(async () => jsonResponse({ values: [] })));
249
+ await expect(cmd.func({ jql: 'project = PROJ' })).rejects.toBeInstanceOf(CommandExecutionError);
250
+ });
251
+
252
+ it('maps rendered comments to Markdown', async () => {
253
+ setCloudEnv();
254
+ vi.stubGlobal('fetch', vi.fn(async () => jsonResponse({
255
+ comments: [{
256
+ id: 'c1',
257
+ author: { displayName: 'Bob' },
258
+ created: '2026-05-01',
259
+ renderedBody: '<p>Fixed<br/>Ready for QA</p>',
260
+ }],
261
+ })));
262
+ const cmd = getRegistry().get('jira/comments');
263
+ const rows = await cmd.func({ key: 'PROJ-1', limit: 1 });
264
+ expect(rows[0]).toMatchObject({ id: 'c1', author: 'Bob' });
265
+ expect(rows[0].markdown).toContain('Fixed');
266
+ expect(rows[0].markdown).toContain('Ready for QA');
267
+ });
268
+
269
+ it('fails typed when Jira comment rows lack stable comment ids', async () => {
270
+ setCloudEnv();
271
+ vi.stubGlobal('fetch', vi.fn(async () => jsonResponse({
272
+ comments: [{ author: { displayName: 'Bob' }, created: '2026-05-01', body: 'body' }],
273
+ })));
274
+ const cmd = getRegistry().get('jira/comments');
275
+ await expect(cmd.func({ key: 'PROJ-1', limit: 1 })).rejects.toBeInstanceOf(CommandExecutionError);
276
+ });
277
+
278
+ it('separates no Jira attachments from malformed attachment payloads', async () => {
279
+ setCloudEnv();
280
+ const cmd = getRegistry().get('jira/attachments');
281
+ vi.stubGlobal('fetch', vi.fn(async () => jsonResponse({ id: '1', key: 'PROJ-1', fields: { attachment: [] } })));
282
+ await expect(cmd.func({ key: 'PROJ-1' })).rejects.toBeInstanceOf(EmptyResultError);
283
+
284
+ vi.stubGlobal('fetch', vi.fn(async () => jsonResponse({ id: '1', key: 'PROJ-1', fields: {} })));
285
+ await expect(cmd.func({ key: 'PROJ-1' })).rejects.toBeInstanceOf(CommandExecutionError);
286
+ });
287
+ });
@@ -0,0 +1,28 @@
1
+ import { cli, Strategy } from '@jackwener/opencli/registry';
2
+ import { fetchComments, jiraConfig, jiraRowsOrEmpty, normalizeComment, parseJiraLimit, requireIssueKey } from './shared.js';
3
+
4
+ cli({
5
+ site: 'jira',
6
+ name: 'comments',
7
+ access: 'read',
8
+ description: 'Jira issue comments as Markdown',
9
+ domain: 'atlassian.net',
10
+ strategy: Strategy.PUBLIC,
11
+ browser: false,
12
+ args: [
13
+ { name: 'key', positional: true, required: true, help: 'Jira issue key, e.g. PROJ-123' },
14
+ { name: 'limit', type: 'int', default: 50, help: 'Max comments to return (1-100)' },
15
+ ],
16
+ columns: ['id', 'author', 'created', 'updated', 'markdown'],
17
+ func: async (args) => {
18
+ const key = requireIssueKey(args.key);
19
+ const config = jiraConfig();
20
+ const limit = parseJiraLimit(args.limit, 50, 100);
21
+ const comments = await fetchComments(config, key, limit);
22
+ return jiraRowsOrEmpty(
23
+ comments.map(normalizeComment),
24
+ `jira comments ${key}`,
25
+ `Jira issue ${key} has no comments.`,
26
+ );
27
+ },
28
+ });
@@ -0,0 +1,28 @@
1
+ import { cli, Strategy } from '@jackwener/opencli/registry';
2
+ import { fetchComments, fetchIssue, jiraConfig, normalizeJiraIssue, requireIssueKey } from './shared.js';
3
+
4
+ cli({
5
+ site: 'jira',
6
+ name: 'issue',
7
+ access: 'read',
8
+ description: 'Jira issue detail normalized for agents (description, comments, attachments, links)',
9
+ domain: 'atlassian.net',
10
+ strategy: Strategy.PUBLIC,
11
+ browser: false,
12
+ args: [
13
+ { name: 'key', positional: true, required: true, help: 'Jira issue key, e.g. PROJ-123' },
14
+ { name: 'comments-limit', type: 'int', default: 100, help: 'Max comments to include (1-100)' },
15
+ ],
16
+ columns: ['key', 'summary', 'issueType', 'status', 'priority', 'assignee', 'updated', 'url'],
17
+ func: async (args) => {
18
+ const key = requireIssueKey(args.key);
19
+ const config = jiraConfig();
20
+ const issue = await fetchIssue(config, key);
21
+ const inlineComments = issue?.fields?.comment?.comments;
22
+ const total = Number(issue?.fields?.comment?.total ?? inlineComments?.length ?? 0);
23
+ const comments = total > (inlineComments?.length ?? 0)
24
+ ? await fetchComments(config, key, args['comments-limit'])
25
+ : inlineComments;
26
+ return [normalizeJiraIssue(issue, config, { comments })];
27
+ },
28
+ });
@@ -0,0 +1,28 @@
1
+ import { cli, Strategy } from '@jackwener/opencli/registry';
2
+ import { fetchIssue, jiraConfig, jiraRowsOrEmpty, normalizeIssueLink, requireIssueKey } from './shared.js';
3
+ import { requirePayloadArray } from '../_atlassian/shared.js';
4
+
5
+ cli({
6
+ site: 'jira',
7
+ name: 'links',
8
+ access: 'read',
9
+ description: 'Jira issue links',
10
+ domain: 'atlassian.net',
11
+ strategy: Strategy.PUBLIC,
12
+ browser: false,
13
+ args: [
14
+ { name: 'key', positional: true, required: true, help: 'Jira issue key, e.g. PROJ-123' },
15
+ ],
16
+ columns: ['key', 'type', 'direction'],
17
+ func: async (args) => {
18
+ const key = requireIssueKey(args.key);
19
+ const config = jiraConfig();
20
+ const issue = await fetchIssue(config, key, ['issuelinks']);
21
+ const links = requirePayloadArray(issue.fields?.issuelinks, `jira links ${key}`);
22
+ return jiraRowsOrEmpty(
23
+ links.map(normalizeIssueLink),
24
+ `jira links ${key}`,
25
+ `Jira issue ${key} has no linked issues.`,
26
+ );
27
+ },
28
+ });