azdo-cli 0.10.0-develop.350 → 0.10.0-develop.373

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 (3) hide show
  1. package/README.md +6 -0
  2. package/dist/index.js +201 -12
  3. package/package.json +2 -1
package/README.md CHANGED
@@ -13,6 +13,7 @@ Azure DevOps CLI focused on work item read/write workflows.
13
13
  - Create or update work items from markdown documents (`upsert`)
14
14
  - Read and post work item comments (`comments`)
15
15
  - Read/write rich-text fields as markdown (`get-md-field`, `set-md-field`)
16
+ - Download images embedded in rich-text fields, optionally resized for LLM use (`get-item`/`get-md-field` `--download-images`, `--resize-images`)
16
17
  - Check branch pull request status, open PRs to `develop`, list PR comment threads for any PR (`--pr-number`), and resolve/reopen threads from the CLI (`pr`)
17
18
  - Persist org/project/default fields in local config (`config`)
18
19
  - List all fields of a work item (`list-fields`)
@@ -37,6 +38,11 @@ azdo config set project myproject
37
38
  # Read a work item
38
39
  azdo get-item 12345
39
40
 
41
+ # Download images embedded in a work item's rich-text fields (opt-in)
42
+ azdo get-item 12345 --download-images # saved to the system temp dir
43
+ azdo get-item 12345 --resize-images 1024 --images-path ./img # cap width at 1024px, save as PNG
44
+ azdo get-md-field 12345 System.Description --download-images # same flags on get-md-field
45
+
40
46
  # Update state
41
47
  azdo set-state 12345 "Active"
42
48
 
package/dist/index.js CHANGED
@@ -1053,6 +1053,173 @@ function handleCommandError(err, id, context, scope = "write", exit = true) {
1053
1053
  }
1054
1054
  }
1055
1055
 
1056
+ // src/services/image-download.ts
1057
+ import { Jimp } from "jimp";
1058
+ import { writeFile } from "fs/promises";
1059
+ import { existsSync as existsSync2 } from "fs";
1060
+ import { tmpdir } from "os";
1061
+ import { join as join2 } from "path";
1062
+ var ATTACHMENT_GUID_RE = /_apis\/wit\/attachments\/([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})/;
1063
+ function isAzureDevOpsAttachmentHost(hostname) {
1064
+ const host = hostname.toLowerCase();
1065
+ return host === "dev.azure.com" || host.endsWith(".dev.azure.com") || host.endsWith(".visualstudio.com");
1066
+ }
1067
+ function decodeHtmlEntities(value) {
1068
+ return value.replaceAll("&quot;", '"').replaceAll("&#39;", "'").replaceAll("&lt;", "<").replaceAll("&gt;", ">").replaceAll("&amp;", "&");
1069
+ }
1070
+ function parseAttachmentReference(rawUrl, sourceField) {
1071
+ const url = decodeHtmlEntities(rawUrl.trim());
1072
+ let parsed;
1073
+ try {
1074
+ parsed = new URL(url);
1075
+ } catch {
1076
+ return null;
1077
+ }
1078
+ if (parsed.protocol !== "https:" || !isAzureDevOpsAttachmentHost(parsed.hostname)) {
1079
+ return null;
1080
+ }
1081
+ const match = ATTACHMENT_GUID_RE.exec(parsed.pathname);
1082
+ if (!match) return null;
1083
+ const guid = match[1].toLowerCase();
1084
+ let suggestedExtension = ".png";
1085
+ const fileName = parsed.searchParams.get("fileName");
1086
+ if (fileName?.includes(".")) {
1087
+ suggestedExtension = fileName.slice(fileName.lastIndexOf(".")).toLowerCase();
1088
+ }
1089
+ return { url, sourceField, guid, suggestedExtension };
1090
+ }
1091
+ function extractImageReferences(content, sourceField) {
1092
+ if (!content) return [];
1093
+ const references = [];
1094
+ const seen = /* @__PURE__ */ new Set();
1095
+ const add = (rawUrl) => {
1096
+ const reference = parseAttachmentReference(rawUrl, sourceField);
1097
+ if (reference && !seen.has(reference.guid)) {
1098
+ seen.add(reference.guid);
1099
+ references.push(reference);
1100
+ }
1101
+ };
1102
+ const imgRegex = /<img\b[^>]*?\ssrc\s*=\s*["']([^"']+)["']/gi;
1103
+ let match;
1104
+ while ((match = imgRegex.exec(content)) !== null) {
1105
+ add(match[1]);
1106
+ }
1107
+ const markdownRegex = /!\[[^\]]*\]\(\s*([^)\s]+)/g;
1108
+ while ((match = markdownRegex.exec(content)) !== null) {
1109
+ add(match[1]);
1110
+ }
1111
+ return references;
1112
+ }
1113
+ function addImageDownloadOptions(command) {
1114
+ return command.option("--download-images", "download images embedded in rich-text fields to local files").option("--resize-images <pixels>", "max image width in px; downloads and resizes embedded images to PNG (implies --download-images)").option("--images-path <dir>", "destination directory for downloaded images (default: system temp dir)");
1115
+ }
1116
+ function resolveImageDownloadOptionsOrExit(flags) {
1117
+ try {
1118
+ return resolveImageDownloadOptions(flags);
1119
+ } catch (err) {
1120
+ process.stderr.write(`Error: ${err instanceof Error ? err.message : String(err)}
1121
+ `);
1122
+ process.exit(1);
1123
+ }
1124
+ }
1125
+ function resolveImageDownloadOptions(flags) {
1126
+ const wantsResize = flags.resizeImages !== void 0;
1127
+ const enabled = Boolean(flags.downloadImages) || wantsResize;
1128
+ let maxWidth;
1129
+ if (wantsResize) {
1130
+ const parsed = Number(flags.resizeImages);
1131
+ if (!Number.isInteger(parsed) || parsed <= 0) {
1132
+ throw new Error(
1133
+ `Invalid --resize-images value "${flags.resizeImages}": must be a positive integer (max width in pixels).`
1134
+ );
1135
+ }
1136
+ maxWidth = parsed;
1137
+ }
1138
+ const outputDir = flags.imagesPath ?? tmpdir();
1139
+ if (flags.imagesPath !== void 0 && !existsSync2(outputDir)) {
1140
+ throw new Error(`Images path "${outputDir}" does not exist.`);
1141
+ }
1142
+ return { enabled, maxWidth, outputDir };
1143
+ }
1144
+ function buildImageFileName(workItemId, index, reference, resizing) {
1145
+ const ext = resizing ? ".png" : reference.suggestedExtension;
1146
+ return `wi-${workItemId}-${index}${ext}`;
1147
+ }
1148
+ async function processImageBytes(bytes, maxWidth) {
1149
+ if (maxWidth === void 0) {
1150
+ return { buffer: Buffer.from(bytes), resized: false, format: "original" };
1151
+ }
1152
+ const image = await Jimp.read(Buffer.from(bytes));
1153
+ let resized = false;
1154
+ if (image.bitmap.width > maxWidth) {
1155
+ image.resize({ w: maxWidth });
1156
+ resized = true;
1157
+ }
1158
+ const buffer = await image.getBuffer("image/png");
1159
+ return { buffer, resized, format: "png" };
1160
+ }
1161
+ async function downloadImagesFromFields(fields, args, credential) {
1162
+ const { workItemId, options } = args;
1163
+ const resizing = options.maxWidth !== void 0;
1164
+ const seen = /* @__PURE__ */ new Set();
1165
+ const references = [];
1166
+ for (const field of fields) {
1167
+ for (const reference of extractImageReferences(field.content, field.field)) {
1168
+ if (!seen.has(reference.guid)) {
1169
+ seen.add(reference.guid);
1170
+ references.push(reference);
1171
+ }
1172
+ }
1173
+ }
1174
+ const results = [];
1175
+ let index = 0;
1176
+ for (const reference of references) {
1177
+ index += 1;
1178
+ try {
1179
+ const bytes = await downloadAttachment(reference.url, credential);
1180
+ const processed = await processImageBytes(bytes, options.maxWidth);
1181
+ const fileName = buildImageFileName(workItemId, index, reference, resizing);
1182
+ const outputPath = join2(options.outputDir, fileName);
1183
+ await writeFile(outputPath, processed.buffer);
1184
+ results.push({
1185
+ reference,
1186
+ path: outputPath,
1187
+ resized: processed.resized,
1188
+ format: resizing ? "png" : reference.suggestedExtension.replace(/^\./, "")
1189
+ });
1190
+ } catch (err) {
1191
+ results.push({
1192
+ reference,
1193
+ resized: false,
1194
+ format: reference.suggestedExtension.replace(/^\./, ""),
1195
+ error: err instanceof Error ? err.message : String(err)
1196
+ });
1197
+ }
1198
+ }
1199
+ return results;
1200
+ }
1201
+ async function runImageDownload(fields, args, credential) {
1202
+ const results = await downloadImagesFromFields(fields, args, credential);
1203
+ process.stdout.write(formatImageSummary(results) + "\n");
1204
+ for (const result of results) {
1205
+ if (result.error) {
1206
+ process.stderr.write(`Failed to download image ${result.reference.url}: ${result.error}
1207
+ `);
1208
+ }
1209
+ }
1210
+ }
1211
+ function formatImageSummary(results) {
1212
+ if (results.length === 0) {
1213
+ return "Images: no images found in rich-text fields";
1214
+ }
1215
+ const saved = results.filter((r) => r.path);
1216
+ const lines = [`Images: ${saved.length} downloaded`];
1217
+ for (const result of saved) {
1218
+ lines.push(` ${result.path}`);
1219
+ }
1220
+ return lines.join("\n");
1221
+ }
1222
+
1056
1223
  // src/commands/get-item.ts
1057
1224
  function parseRequestedFields(raw) {
1058
1225
  if (raw === void 0) return void 0;
@@ -1176,10 +1343,13 @@ function formatWorkItem(workItem, short, markdown = false) {
1176
1343
  }
1177
1344
  function createGetItemCommand() {
1178
1345
  const command = new Command("get-item");
1179
- command.description("Retrieve an Azure DevOps work item by ID").argument("<id>", "work item ID").option("--org <org>", "Azure DevOps organization").option("--project <project>", "Azure DevOps project").option("--short", "show abbreviated output").option("--fields <fields>", "comma-separated additional field reference names").option("--markdown", "convert rich text fields to markdown").action(
1346
+ command.description("Retrieve an Azure DevOps work item by ID").argument("<id>", "work item ID").option("--org <org>", "Azure DevOps organization").option("--project <project>", "Azure DevOps project").option("--short", "show abbreviated output").option("--fields <fields>", "comma-separated additional field reference names").option("--markdown", "convert rich text fields to markdown");
1347
+ addImageDownloadOptions(command);
1348
+ command.action(
1180
1349
  async (idStr, options) => {
1181
1350
  const id = parseWorkItemId(idStr);
1182
1351
  validateOrgProjectPair(options);
1352
+ const imageOptions = resolveImageDownloadOptionsOrExit(options);
1183
1353
  let context;
1184
1354
  try {
1185
1355
  context = resolveContext(options);
@@ -1189,6 +1359,15 @@ function createGetItemCommand() {
1189
1359
  const markdownEnabled = options.markdown ?? loadConfig().markdown ?? false;
1190
1360
  const output = formatWorkItem(workItem, options.short ?? false, markdownEnabled);
1191
1361
  process.stdout.write(output + "\n");
1362
+ if (imageOptions.enabled) {
1363
+ const fields = [{ content: workItem.description ?? "", field: "Description" }];
1364
+ if (workItem.extraFields) {
1365
+ for (const [name, value] of Object.entries(workItem.extraFields)) {
1366
+ fields.push({ content: value, field: name });
1367
+ }
1368
+ }
1369
+ await runImageDownload(fields, { workItemId: id, options: imageOptions }, credential);
1370
+ }
1192
1371
  } catch (err) {
1193
1372
  handleCommandError(err, id, context, "read", false);
1194
1373
  }
@@ -1943,10 +2122,13 @@ function createSetFieldCommand() {
1943
2122
  import { Command as Command8 } from "commander";
1944
2123
  function createGetMdFieldCommand() {
1945
2124
  const command = new Command8("get-md-field");
1946
- command.description("Get a work item field value, converting HTML to markdown").argument("<id>", "work item ID").argument("<field>", "field reference name (e.g., System.Description)").option("--org <org>", "Azure DevOps organization").option("--project <project>", "Azure DevOps project").action(
2125
+ command.description("Get a work item field value, converting HTML to markdown").argument("<id>", "work item ID").argument("<field>", "field reference name (e.g., System.Description)").option("--org <org>", "Azure DevOps organization").option("--project <project>", "Azure DevOps project");
2126
+ addImageDownloadOptions(command);
2127
+ command.action(
1947
2128
  async (idStr, field, options) => {
1948
2129
  const id = parseWorkItemId(idStr);
1949
2130
  validateOrgProjectPair(options);
2131
+ const imageOptions = resolveImageDownloadOptionsOrExit(options);
1950
2132
  let context;
1951
2133
  try {
1952
2134
  context = resolveContext(options);
@@ -1957,6 +2139,13 @@ function createGetMdFieldCommand() {
1957
2139
  } else {
1958
2140
  process.stdout.write(toMarkdown(value) + "\n");
1959
2141
  }
2142
+ if (imageOptions.enabled) {
2143
+ await runImageDownload(
2144
+ [{ content: value ?? "", field }],
2145
+ { workItemId: id, options: imageOptions },
2146
+ credential
2147
+ );
2148
+ }
1960
2149
  } catch (err) {
1961
2150
  handleCommandError(err, id, context, "read");
1962
2151
  }
@@ -1966,7 +2155,7 @@ function createGetMdFieldCommand() {
1966
2155
  }
1967
2156
 
1968
2157
  // src/commands/set-md-field.ts
1969
- import { existsSync as existsSync2, readFileSync as readFileSync3 } from "fs";
2158
+ import { existsSync as existsSync3, readFileSync as readFileSync3 } from "fs";
1970
2159
  import { Command as Command9 } from "commander";
1971
2160
  function fail(message) {
1972
2161
  process.stderr.write(`Error: ${message}
@@ -1986,7 +2175,7 @@ function resolveContent(inlineContent, options) {
1986
2175
  return null;
1987
2176
  }
1988
2177
  function readFileContent(filePath) {
1989
- if (!existsSync2(filePath)) {
2178
+ if (!existsSync3(filePath)) {
1990
2179
  fail(`File not found: ${filePath}`);
1991
2180
  }
1992
2181
  try {
@@ -2054,7 +2243,7 @@ function createSetMdFieldCommand() {
2054
2243
  }
2055
2244
 
2056
2245
  // src/commands/upsert.ts
2057
- import { existsSync as existsSync3, readFileSync as readFileSync4, unlinkSync } from "fs";
2246
+ import { existsSync as existsSync4, readFileSync as readFileSync4, unlinkSync } from "fs";
2058
2247
  import { Command as Command10 } from "commander";
2059
2248
 
2060
2249
  // src/services/task-document.ts
@@ -2218,7 +2407,7 @@ function loadSourceContent(options) {
2218
2407
  return { content: options.content };
2219
2408
  }
2220
2409
  const filePath = options.file;
2221
- if (!existsSync3(filePath)) {
2410
+ if (!existsSync4(filePath)) {
2222
2411
  fail2(`File not found: ${filePath}`);
2223
2412
  }
2224
2413
  try {
@@ -3117,9 +3306,9 @@ function createCommentsCommand() {
3117
3306
 
3118
3307
  // src/commands/download-attachment.ts
3119
3308
  import { Command as Command14 } from "commander";
3120
- import { writeFile } from "fs/promises";
3121
- import { existsSync as existsSync4 } from "fs";
3122
- import { join as join2 } from "path";
3309
+ import { writeFile as writeFile2 } from "fs/promises";
3310
+ import { existsSync as existsSync5 } from "fs";
3311
+ import { join as join3 } from "path";
3123
3312
  function createDownloadAttachmentCommand() {
3124
3313
  const command = new Command14("download-attachment");
3125
3314
  command.description("Download an attachment from an Azure DevOps work item").argument("<id>", "work item ID").argument("<filename>", "name of the attachment to download").option("--org <org>", "Azure DevOps organization").option("--project <project>", "Azure DevOps project").option("--output <dir>", "target directory for the downloaded file").action(
@@ -3131,7 +3320,7 @@ function createDownloadAttachmentCommand() {
3131
3320
  context = resolveContext(options);
3132
3321
  const credential = await requireAuthCredential(context.org);
3133
3322
  const outputDir = options.output ?? ".";
3134
- if (!existsSync4(outputDir)) {
3323
+ if (!existsSync5(outputDir)) {
3135
3324
  process.stderr.write(`Error: Output directory "${outputDir}" does not exist.
3136
3325
  `);
3137
3326
  process.exit(1);
@@ -3148,8 +3337,8 @@ function createDownloadAttachmentCommand() {
3148
3337
  process.exit(1);
3149
3338
  }
3150
3339
  const data = await downloadAttachment(attachment.url, credential);
3151
- const outputPath = join2(outputDir, filename);
3152
- await writeFile(outputPath, Buffer.from(data));
3340
+ const outputPath = join3(outputDir, filename);
3341
+ await writeFile2(outputPath, Buffer.from(data));
3153
3342
  process.stdout.write(
3154
3343
  `Downloaded "${filename}" (${formatFileSize(attachment.size)}) to ${outputPath}
3155
3344
  `
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "azdo-cli",
3
- "version": "0.10.0-develop.350",
3
+ "version": "0.10.0-develop.373",
4
4
  "description": "Azure DevOps CLI tool",
5
5
  "type": "module",
6
6
  "bin": {
@@ -27,6 +27,7 @@
27
27
  "dependencies": {
28
28
  "@napi-rs/keyring": "^1.2.0",
29
29
  "commander": "^14.0.3",
30
+ "jimp": "^1.6.1",
30
31
  "node-html-markdown": "^2.0.0"
31
32
  },
32
33
  "devDependencies": {