@kolatts/pncli 1.11.1 → 1.13.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/dist/cli.js CHANGED
@@ -18,8 +18,9 @@ import {
18
18
  setGlobalOptions,
19
19
  setGlobalUser,
20
20
  success,
21
- warn
22
- } from "./chunk-VPUWMP2M.js";
21
+ warn,
22
+ writeRawOutput
23
+ } from "./chunk-BNT4SEZD.js";
23
24
  import {
24
25
  AdoCoreClient
25
26
  } from "./chunk-VDL32PSG.js";
@@ -276,29 +277,44 @@ function parseBranchReportOutput(raw, branchName, opts) {
276
277
  }
277
278
  }
278
279
  if (current) commits.push(current);
279
- const totalInsertions = commits.reduce((sum, c) => sum + c.insertions, 0);
280
- const totalDeletions = commits.reduce((sum, c) => sum + c.deletions, 0);
280
+ const isAbsoluteDate = (s) => /^\d{4}-\d{2}-\d{2}/.test(s);
281
+ const sinceDate = opts.since && isAbsoluteDate(opts.since) ? opts.since.slice(0, 10) : null;
282
+ const untilDate = opts.until && isAbsoluteDate(opts.until) ? opts.until.slice(0, 10) : null;
283
+ const filteredCommits = sinceDate || untilDate ? commits.filter((c) => {
284
+ const d = c.date.slice(0, 10);
285
+ if (sinceDate && d < sinceDate) return false;
286
+ if (untilDate && d > untilDate) return false;
287
+ return true;
288
+ }) : commits;
289
+ const totalInsertions = filteredCommits.reduce((sum, c) => sum + c.insertions, 0);
290
+ const totalDeletions = filteredCommits.reduce((sum, c) => sum + c.deletions, 0);
281
291
  return {
282
292
  branch: branchName,
283
293
  since: opts.since ?? null,
284
294
  until: opts.until ?? null,
285
- commits,
286
- weeks: buildWeeklyBreakdown(commits),
287
- byAuthor: buildByAuthor(commits),
295
+ commits: filteredCommits,
296
+ weeks: buildWeeklyBreakdown(filteredCommits),
297
+ byAuthor: buildByAuthor(filteredCommits),
288
298
  summary: {
289
- commitCount: commits.length,
299
+ commitCount: filteredCommits.length,
290
300
  totalInsertions,
291
301
  totalDeletions,
292
302
  totalNetLines: totalInsertions - totalDeletions,
293
- totalFilesChanged: commits.reduce((sum, c) => sum + c.filesChanged, 0)
303
+ totalFilesChanged: filteredCommits.reduce((sum, c) => sum + c.filesChanged, 0)
294
304
  }
295
305
  };
296
306
  }
307
+ function normalizeGitDate(dateStr) {
308
+ if (/^\d{4}-\d{2}-\d{2}$/.test(dateStr)) {
309
+ return `${dateStr}T00:00:00`;
310
+ }
311
+ return dateStr;
312
+ }
297
313
  function buildReportArgs(opts) {
298
314
  const fmt = `${COMMIT_PREFIX}%H %an %aI %s`;
299
315
  const args = ["log", `--format=${fmt}`, "--numstat"];
300
- if (opts.since) args.push(`--since=${opts.since}`);
301
- if (opts.until) args.push(`--until=${opts.until}`);
316
+ if (opts.since) args.push(`--since=${normalizeGitDate(opts.since)}`);
317
+ if (opts.until) args.push(`--until=${normalizeGitDate(opts.until)}`);
302
318
  if (opts.base && opts.branch) {
303
319
  args.push(`${opts.base}..${opts.branch}`);
304
320
  } else if (opts.base) {
@@ -405,38 +421,38 @@ function parseAdoRemote(remoteUrl, adoBaseUrl) {
405
421
  } catch {
406
422
  baseHostname = adoBaseUrl.replace(/\/$/, "").replace(/^https?:\/\//, "").split(/[:/]/)[0].toLowerCase();
407
423
  }
408
- let path6 = remoteUrl;
424
+ let path8 = remoteUrl;
409
425
  let remoteHostname = "";
410
- const sshProtoMatch = path6.match(/^ssh:\/\/[^@]*@?([^/:]+)(?::\d+)?(.*)/);
426
+ const sshProtoMatch = path8.match(/^ssh:\/\/[^@]*@?([^/:]+)(?::\d+)?(.*)/);
411
427
  if (sshProtoMatch) {
412
428
  remoteHostname = sshProtoMatch[1].toLowerCase();
413
- path6 = sshProtoMatch[2];
429
+ path8 = sshProtoMatch[2];
414
430
  } else {
415
- const gitAtMatch = path6.match(/^git@([^:]+)(?::\d+)?:(.*)/);
431
+ const gitAtMatch = path8.match(/^git@([^:]+)(?::\d+)?:(.*)/);
416
432
  if (gitAtMatch) {
417
433
  remoteHostname = gitAtMatch[1].toLowerCase();
418
- path6 = "/" + gitAtMatch[2];
434
+ path8 = "/" + gitAtMatch[2];
419
435
  } else {
420
- const httpsMatch = path6.match(/^https?:\/\/([^/:]+)(?::\d+)?(.*)/);
436
+ const httpsMatch = path8.match(/^https?:\/\/([^/:]+)(?::\d+)?(.*)/);
421
437
  if (httpsMatch) {
422
438
  remoteHostname = httpsMatch[1].toLowerCase();
423
- path6 = httpsMatch[2];
439
+ path8 = httpsMatch[2];
424
440
  }
425
441
  }
426
442
  }
427
443
  if (!remoteHostname || remoteHostname !== baseHostname) return null;
428
- path6 = path6.replace(/\.git$/, "");
429
- const gitIdx = path6.indexOf("/_git/");
430
- const sshIdx = path6.indexOf("/_ssh/");
444
+ path8 = path8.replace(/\.git$/, "");
445
+ const gitIdx = path8.indexOf("/_git/");
446
+ const sshIdx = path8.indexOf("/_ssh/");
431
447
  const markerIdx = gitIdx !== -1 ? gitIdx : sshIdx;
432
448
  if (markerIdx === -1) return null;
433
- const repo = path6.slice(markerIdx + 6);
449
+ const repo = decodeURIComponent(path8.slice(markerIdx + 6));
434
450
  if (!repo) return null;
435
- const before = path6.slice(1, markerIdx);
451
+ const before = path8.slice(1, markerIdx);
436
452
  const parts = before.split("/").filter(Boolean);
437
453
  if (parts.length < 2) return null;
438
- const project = parts[parts.length - 1];
439
- const collection = parts[parts.length - 2];
454
+ const project = decodeURIComponent(parts[parts.length - 1]);
455
+ const collection = decodeURIComponent(parts[parts.length - 2]);
440
456
  return { collection, project, repo };
441
457
  }
442
458
  function getRemoteUrls(repoRoot) {
@@ -736,7 +752,7 @@ function registerGitCommands(program2) {
736
752
  until: opts.until
737
753
  });
738
754
  if (opts.csv) {
739
- process.stdout.write(formatBranchReportCsv(report));
755
+ writeRawOutput(formatBranchReportCsv(report));
740
756
  } else {
741
757
  success(report, "git", "report", start);
742
758
  }
@@ -776,7 +792,13 @@ function registerGitCommands(program2) {
776
792
  });
777
793
  }
778
794
 
795
+ // src/services/jira/commands.ts
796
+ import { readFileSync as readFileSync2, existsSync, writeFileSync, mkdirSync } from "fs";
797
+ import path from "path";
798
+
779
799
  // src/services/jira/client.ts
800
+ import { readFileSync } from "fs";
801
+ import { basename, extname } from "path";
780
802
  var API2 = "/rest/api/2";
781
803
  var JiraClient = class {
782
804
  constructor(http) {
@@ -796,6 +818,7 @@ var JiraClient = class {
796
818
  ...opts.priority ? { priority: { name: opts.priority } } : {},
797
819
  ...opts.assignee ? { assignee: { name: opts.assignee } } : {},
798
820
  ...opts.labels?.length ? { labels: opts.labels } : {},
821
+ ...opts.parent ? { parent: { key: opts.parent } } : {},
799
822
  ...opts.customFieldValues ?? {}
800
823
  }
801
824
  };
@@ -864,7 +887,86 @@ var JiraClient = class {
864
887
  return { issues: allIssues, total: allIssues.length, startAt: 0, maxResults: allIssues.length };
865
888
  }
866
889
  async fetchFields() {
867
- return this.http.jira(`${API2}/field`);
890
+ const fields = await this.http.jira(`${API2}/field`);
891
+ return fields.map((f) => ({ ...f, pncliType: schemaToPncliType(f.schema) }));
892
+ }
893
+ async fetchFieldsWithAllowedValues(project, issueType) {
894
+ const fields = await this.fetchFields();
895
+ let metaFields = {};
896
+ try {
897
+ const meta = await this.http.jira(`${API2}/issue/createmeta`, {
898
+ params: { projectKeys: project, expand: "projects.issuetypes.fields" }
899
+ });
900
+ metaFields = collectMetaFields(meta.projects, issueType);
901
+ } catch {
902
+ try {
903
+ metaFields = await this.fetchCreateMetaViaIssuetypes(project, issueType);
904
+ } catch {
905
+ }
906
+ }
907
+ return fields.map((f) => {
908
+ const av = metaFields[f.id]?.allowedValues;
909
+ return av?.length ? { ...f, allowedValues: av } : f;
910
+ });
911
+ }
912
+ /**
913
+ * Fallback for instances where the classic createmeta endpoint returns 404.
914
+ * Uses the paginated endpoint available in Jira Datacenter 8.4+.
915
+ */
916
+ async fetchCreateMetaViaIssuetypes(project, issueType) {
917
+ const itList = await this.http.jira(
918
+ `${API2}/issue/createmeta/${encodeURIComponent(project)}/issuetypes`
919
+ );
920
+ const matchingTypes = issueType ? itList.values.filter((it) => it.name.toLowerCase() === issueType.toLowerCase()) : itList.values;
921
+ const result = {};
922
+ for (const it of matchingTypes) {
923
+ const fieldList = await this.http.jira(
924
+ `${API2}/issue/createmeta/${encodeURIComponent(project)}/issuetypes/${it.id}`
925
+ );
926
+ for (const field of fieldList.values) {
927
+ if (!field.allowedValues?.length) continue;
928
+ const existing = result[field.fieldId]?.allowedValues ?? [];
929
+ const seen = new Set(existing.map((v) => v.id));
930
+ for (const av of field.allowedValues) {
931
+ if (!seen.has(av.id)) {
932
+ existing.push(av);
933
+ seen.add(av.id);
934
+ }
935
+ }
936
+ result[field.fieldId] = { allowedValues: existing };
937
+ }
938
+ }
939
+ return result;
940
+ }
941
+ async addLabels(key, labels) {
942
+ await this.http.jira(`${API2}/issue/${key}`, {
943
+ method: "PUT",
944
+ body: { update: { labels: labels.map((l) => ({ add: l })) } }
945
+ });
946
+ }
947
+ async removeLabels(key, labels) {
948
+ await this.http.jira(`${API2}/issue/${key}`, {
949
+ method: "PUT",
950
+ body: { update: { labels: labels.map((l) => ({ remove: l })) } }
951
+ });
952
+ }
953
+ async uploadAttachment(key, filePath) {
954
+ const fileContent = readFileSync(filePath);
955
+ const fileName = basename(filePath);
956
+ const mimeType = guessMimeType(filePath);
957
+ const formData = new FormData();
958
+ formData.append("file", new Blob([fileContent], { type: mimeType }), fileName);
959
+ return this.http.jiraUpload(`${API2}/issue/${key}/attachments`, formData);
960
+ }
961
+ async listAttachments(key) {
962
+ const result = await this.http.jira(
963
+ `${API2}/issue/${key}`,
964
+ { params: { fields: "attachment" } }
965
+ );
966
+ return result.fields.attachment ?? [];
967
+ }
968
+ async downloadAttachment(contentUrl) {
969
+ return this.http.jiraBuffer(contentUrl);
868
970
  }
869
971
  async assignIssue(key, accountId) {
870
972
  await this.http.jira(`${API2}/issue/${key}/assignee`, {
@@ -894,6 +996,70 @@ var JiraClient = class {
894
996
  });
895
997
  }
896
998
  };
999
+ function guessMimeType(filePath) {
1000
+ const ext = extname(filePath).toLowerCase();
1001
+ const map = {
1002
+ ".jpg": "image/jpeg",
1003
+ ".jpeg": "image/jpeg",
1004
+ ".png": "image/png",
1005
+ ".gif": "image/gif",
1006
+ ".svg": "image/svg+xml",
1007
+ ".pdf": "application/pdf",
1008
+ ".txt": "text/plain",
1009
+ ".log": "text/plain",
1010
+ ".csv": "text/csv",
1011
+ ".json": "application/json",
1012
+ ".xml": "application/xml",
1013
+ ".zip": "application/zip",
1014
+ ".tar": "application/x-tar",
1015
+ ".gz": "application/gzip",
1016
+ ".md": "text/markdown",
1017
+ ".html": "text/html",
1018
+ ".htm": "text/html",
1019
+ ".doc": "application/msword",
1020
+ ".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
1021
+ ".xls": "application/vnd.ms-excel",
1022
+ ".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
1023
+ };
1024
+ return map[ext] ?? "application/octet-stream";
1025
+ }
1026
+ function schemaToPncliType(schema) {
1027
+ if (!schema) return void 0;
1028
+ const custom = schema.custom ?? "";
1029
+ if (custom.includes("cascadingselect")) return "cascading-select";
1030
+ if (custom.includes("multicheckboxes") || custom.includes("multiselect")) return "multi-select";
1031
+ if (custom.includes("select") || custom.includes("radiobuttons")) return "select";
1032
+ if (custom.includes("userpicker") || custom.includes("multiuserpicker")) return "user";
1033
+ if (custom.includes("labels")) return "labels";
1034
+ if (custom.includes("url")) return "url";
1035
+ if (custom.includes("textarea") || custom.includes("textfield")) return "textarea";
1036
+ if (schema.type === "number") return "number";
1037
+ if (schema.type === "date") return "date";
1038
+ if (schema.type === "datetime") return "datetime";
1039
+ if (schema.type === "string") return "string";
1040
+ return void 0;
1041
+ }
1042
+ function collectMetaFields(projects, issueType) {
1043
+ const result = {};
1044
+ for (const proj of projects) {
1045
+ for (const it of proj.issuetypes) {
1046
+ if (issueType && it.name.toLowerCase() !== issueType.toLowerCase()) continue;
1047
+ for (const [fieldId, fieldMeta] of Object.entries(it.fields)) {
1048
+ if (!fieldMeta.allowedValues?.length) continue;
1049
+ const existing = result[fieldId]?.allowedValues ?? [];
1050
+ const seen = new Set(existing.map((v) => v.id));
1051
+ for (const av of fieldMeta.allowedValues) {
1052
+ if (!seen.has(av.id)) {
1053
+ existing.push(av);
1054
+ seen.add(av.id);
1055
+ }
1056
+ }
1057
+ result[fieldId] = { allowedValues: existing };
1058
+ }
1059
+ }
1060
+ }
1061
+ return result;
1062
+ }
897
1063
 
898
1064
  // src/services/jira/custom-fields.ts
899
1065
  function buildFieldMap(fields) {
@@ -937,6 +1103,15 @@ function formatFieldValue(value, type) {
937
1103
  return { value };
938
1104
  case "multi-select":
939
1105
  return value.split(",").map((v) => ({ value: v.trim() }));
1106
+ case "option-id":
1107
+ return { id: value };
1108
+ case "cascading-select": {
1109
+ const colon = value.indexOf(":");
1110
+ if (colon !== -1) {
1111
+ return { id: value.slice(0, colon).trim(), child: { id: value.slice(colon + 1).trim() } };
1112
+ }
1113
+ return { id: value.trim() };
1114
+ }
940
1115
  case "labels":
941
1116
  return value.split(",").map((v) => v.trim());
942
1117
  case "user":
@@ -986,41 +1161,41 @@ function registerJiraCommands(program2) {
986
1161
  fail(err, "jira", "get-issue", start);
987
1162
  }
988
1163
  });
989
- jira.command("create-issue").description("Create a Jira issue").option("--project <key>", "Project key").option("--type <type>", "Issue type (Bug, Story, Task, ...)").requiredOption("--summary <text>", "Issue summary").option("--description <text>", "Issue description").option("--priority <name>", "Priority name").option("--assignee <accountId>", "Assignee account ID").option("--labels <labels>", "Comma-separated labels").option("--parent <key>", 'Parent issue key \u2014 creates an "is child of" link after creation').option("--field <Name=value>", "Custom field value (repeatable)", (val, acc) => [...acc, val], []).action(async (opts) => {
1164
+ jira.command("create-issue").description("Create a Jira issue").option("--project <key>", "Project key").option("--type <type>", "Issue type (Bug, Story, Task, ...)").requiredOption("--summary <text>", "Issue summary").option("--description <text>", "Issue description").option("--priority <name>", "Priority name").option("--assignee <accountId>", "Assignee account ID").option("--labels <labels>", "Comma-separated labels").option("--parent <key>", "Parent issue key \u2014 sets fields.parent in the create payload").option("--field <Name=value>", "Custom field value; use Name=@file.json to read value from file (repeatable)", (val, acc) => [...acc, val], []).option("--fields-file <path>", "Path to a JSON file mapping field names/IDs to their Jira API values").action(async (opts) => {
990
1165
  const start = Date.now();
1166
+ let fieldMap = buildFieldMap([]);
991
1167
  try {
992
- const { client, fieldMap } = getClientAndFields(program2);
1168
+ const resolved = getClientAndFields(program2);
1169
+ const client = resolved.client;
1170
+ fieldMap = resolved.fieldMap;
993
1171
  const defaults = getDefaults(program2);
994
1172
  const project = opts.project ?? defaults.project;
995
1173
  const issueType = opts.type ?? defaults.issueType ?? "Task";
996
1174
  const priority = opts.priority ?? defaults.priority;
997
1175
  if (!project) throw new PncliError("--project required (or set defaults.jira.project in config)", 1);
998
1176
  const labels = opts.labels ? opts.labels.split(",").map((s) => s.trim()) : void 0;
999
- const customFieldValues = parseFieldArgs(opts.field, fieldMap);
1000
- const data = await client.createIssue({ project, issueType, summary: opts.summary, description: opts.description, priority, assignee: opts.assignee, labels, customFieldValues });
1177
+ const fileFields = opts.fieldsFile ? parseFieldsFile(opts.fieldsFile, fieldMap) : {};
1178
+ const customFieldValues = { ...fileFields, ...parseFieldArgs(opts.field, fieldMap) };
1179
+ const data = await client.createIssue({ project, issueType, summary: opts.summary, description: opts.description, priority, assignee: opts.assignee, labels, parent: opts.parent, customFieldValues });
1001
1180
  success(data, "jira", "create-issue", start);
1002
- if (opts.parent) {
1003
- try {
1004
- await client.linkIssue({ key: data.key, linkType: "is child of", target: opts.parent });
1005
- } catch (linkErr) {
1006
- warn(`Created ${data.key} but failed to link to parent ${opts.parent}: ${linkErr instanceof Error ? linkErr.message : linkErr}`);
1007
- }
1008
- }
1009
- return;
1010
1181
  } catch (err) {
1011
- fail(err, "jira", "create-issue", start);
1182
+ fail(translateFieldErrors(err, fieldMap), "jira", "create-issue", start);
1012
1183
  }
1013
1184
  });
1014
- jira.command("update-issue").description("Update a Jira issue").requiredOption("--key <issue-key>", "Issue key").option("--summary <text>", "New summary").option("--description <text>", "New description").option("--priority <name>", "New priority").option("--assignee <accountId>", "New assignee account ID").option("--labels <labels>", "Comma-separated labels").option("--field <Name=value>", "Custom field value (repeatable)", (val, acc) => [...acc, val], []).action(async (opts) => {
1185
+ jira.command("update-issue").description("Update a Jira issue").requiredOption("--key <issue-key>", "Issue key").option("--summary <text>", "New summary").option("--description <text>", "New description").option("--priority <name>", "New priority").option("--assignee <accountId>", "New assignee account ID").option("--labels <labels>", "Comma-separated labels").option("--field <Name=value>", "Custom field value; use Name=@file.json to read value from file (repeatable)", (val, acc) => [...acc, val], []).option("--fields-file <path>", "Path to a JSON file mapping field names/IDs to their Jira API values").action(async (opts) => {
1015
1186
  const start = Date.now();
1187
+ let fieldMap = buildFieldMap([]);
1016
1188
  try {
1017
- const { client, fieldMap } = getClientAndFields(program2);
1189
+ const resolved = getClientAndFields(program2);
1190
+ const client = resolved.client;
1191
+ fieldMap = resolved.fieldMap;
1018
1192
  const labels = opts.labels ? opts.labels.split(",").map((s) => s.trim()) : void 0;
1019
- const customFieldValues = parseFieldArgs(opts.field, fieldMap);
1193
+ const fileFields = opts.fieldsFile ? parseFieldsFile(opts.fieldsFile, fieldMap) : {};
1194
+ const customFieldValues = { ...fileFields, ...parseFieldArgs(opts.field, fieldMap) };
1020
1195
  await client.updateIssue(opts.key, { summary: opts.summary, description: opts.description, priority: opts.priority, assignee: opts.assignee, labels, customFieldValues });
1021
1196
  success({ updated: opts.key }, "jira", "update-issue", start);
1022
1197
  } catch (err) {
1023
- fail(err, "jira", "update-issue", start);
1198
+ fail(translateFieldErrors(err, fieldMap), "jira", "update-issue", start);
1024
1199
  }
1025
1200
  });
1026
1201
  jira.command("transition-issue").description("Transition a Jira issue to a new status").requiredOption("--key <issue-key>", "Issue key").requiredOption("--transition <name-or-id>", "Transition name or ID").action(async (opts) => {
@@ -1086,6 +1261,28 @@ function registerJiraCommands(program2) {
1086
1261
  fail(err, "jira", "search", start);
1087
1262
  }
1088
1263
  });
1264
+ jira.command("add-label").description("Add one or more labels to a Jira issue (non-destructive)").requiredOption("--key <issue-key>", "Issue key").requiredOption("--labels <labels>", "Comma-separated labels to add").action(async (opts) => {
1265
+ const start = Date.now();
1266
+ try {
1267
+ const client = getClient(program2);
1268
+ const labels = opts.labels.split(",").map((s) => s.trim()).filter(Boolean);
1269
+ await client.addLabels(opts.key, labels);
1270
+ success({ updated: opts.key, labelsAdded: labels }, "jira", "add-label", start);
1271
+ } catch (err) {
1272
+ fail(err, "jira", "add-label", start);
1273
+ }
1274
+ });
1275
+ jira.command("remove-label").description("Remove one or more labels from a Jira issue (non-destructive)").requiredOption("--key <issue-key>", "Issue key").requiredOption("--labels <labels>", "Comma-separated labels to remove").action(async (opts) => {
1276
+ const start = Date.now();
1277
+ try {
1278
+ const client = getClient(program2);
1279
+ const labels = opts.labels.split(",").map((s) => s.trim()).filter(Boolean);
1280
+ await client.removeLabels(opts.key, labels);
1281
+ success({ updated: opts.key, labelsRemoved: labels }, "jira", "remove-label", start);
1282
+ } catch (err) {
1283
+ fail(err, "jira", "remove-label", start);
1284
+ }
1285
+ });
1089
1286
  jira.command("assign").description("Assign a Jira issue to a user").requiredOption("--key <issue-key>", "Issue key").requiredOption("--assignee <accountId>", "Assignee account ID").action(async (opts) => {
1090
1287
  const start = Date.now();
1091
1288
  try {
@@ -1106,12 +1303,50 @@ function registerJiraCommands(program2) {
1106
1303
  fail(err, "jira", "link-issue", start);
1107
1304
  }
1108
1305
  });
1109
- jira.command("fields").description("List custom fields (configured or discovered from Jira API) (consider --output-file for large results)").option("--discover", "Fetch field metadata from Jira API").option("--custom-only", "Show only custom fields (requires --discover)").action(async (opts) => {
1306
+ jira.command("add-attachment").description("Upload a file as an attachment to a Jira issue").requiredOption("--key <issue-key>", "Issue key (e.g. PROJ-123)").requiredOption("--file <path>", "Path to the file to upload").action(async (opts) => {
1307
+ const start = Date.now();
1308
+ try {
1309
+ if (!existsSync(opts.file)) throw new PncliError(`File not found: ${opts.file}`, 1);
1310
+ const client = getClient(program2);
1311
+ const data = await client.uploadAttachment(opts.key, opts.file);
1312
+ success(data, "jira", "add-attachment", start);
1313
+ } catch (err) {
1314
+ fail(err, "jira", "add-attachment", start);
1315
+ }
1316
+ });
1317
+ jira.command("list-attachments").description("List attachments on a Jira issue").requiredOption("--key <issue-key>", "Issue key (e.g. PROJ-123)").action(async (opts) => {
1318
+ const start = Date.now();
1319
+ try {
1320
+ const client = getClient(program2);
1321
+ const data = await client.listAttachments(opts.key);
1322
+ success(data, "jira", "list-attachments", start);
1323
+ } catch (err) {
1324
+ fail(err, "jira", "list-attachments", start);
1325
+ }
1326
+ });
1327
+ jira.command("download-attachment").description("Download a Jira issue attachment to .pncli/ (or --dir)").requiredOption("--key <issue-key>", "Issue key (e.g. PROJ-123)").requiredOption("--attachment-id <id>", "Attachment ID from list-attachments output").option("--dir <path>", "Output directory (default: .pncli relative to cwd)").action(async (opts) => {
1328
+ const start = Date.now();
1329
+ try {
1330
+ const client = getClient(program2);
1331
+ const attachments = await client.listAttachments(opts.key);
1332
+ const attachment = attachments.find((a) => a.id === opts.attachmentId);
1333
+ if (!attachment) throw new PncliError(`Attachment not found: ${opts.attachmentId}`, 1);
1334
+ const outDir = opts.dir ?? path.join(process.cwd(), ".pncli");
1335
+ mkdirSync(outDir, { recursive: true });
1336
+ const outPath = path.join(outDir, path.basename(attachment.filename));
1337
+ const buffer = await client.downloadAttachment(attachment.content);
1338
+ writeFileSync(outPath, buffer);
1339
+ success({ saved: outPath, filename: attachment.filename, size: buffer.length }, "jira", "download-attachment", start);
1340
+ } catch (err) {
1341
+ fail(err, "jira", "download-attachment", start);
1342
+ }
1343
+ });
1344
+ jira.command("fields").description("List custom fields (configured or discovered from Jira API) (consider --output-file for large results)").option("--discover", "Fetch field metadata from Jira API").option("--custom-only", "Show only custom fields (requires --discover)").option("--project <key>", "Project key \u2014 fetches allowedValues for select fields via createmeta (requires --discover)").option("--issue-type <type>", "Filter allowedValues to a specific issue type (requires --project)").action(async (opts) => {
1110
1345
  const start = Date.now();
1111
1346
  try {
1112
1347
  if (opts.discover) {
1113
1348
  const client = getClient(program2);
1114
- const fields = await client.fetchFields();
1349
+ const fields = opts.project ? await client.fetchFieldsWithAllowedValues(opts.project, opts.issueType) : await client.fetchFields();
1115
1350
  const result = opts.customOnly ? fields.filter((f) => f.custom) : fields;
1116
1351
  success(result, "jira", "fields", start);
1117
1352
  } else {
@@ -1130,22 +1365,76 @@ function parseFieldArgs(fieldArgs, fieldMap) {
1130
1365
  if (eq === -1) throw new PncliError(`Invalid --field format (expected Name=value): ${arg}`, 1);
1131
1366
  const name = arg.slice(0, eq).trim();
1132
1367
  const value = arg.slice(eq + 1);
1133
- const def = fieldMap.byName.get(name.toLowerCase());
1134
- if (!def) throw new PncliError(`Unknown custom field: "${name}". Run: pncli jira fields`, 1);
1135
- result[def.id] = formatFieldValue(value, def.type);
1368
+ const def = fieldMap.byName.get(name.toLowerCase()) ?? fieldMap.byId.get(name);
1369
+ if (!def) throw new PncliError(
1370
+ `Unknown custom field: "${name}". Fields must be registered in config by friendly name or ID. Run: pncli jira fields`,
1371
+ 1
1372
+ );
1373
+ if (value.startsWith("@")) {
1374
+ const filePath = value.slice(1);
1375
+ let raw;
1376
+ try {
1377
+ raw = readFileSync2(filePath, "utf8").trim();
1378
+ } catch (e) {
1379
+ throw new PncliError(`Cannot read file "${filePath}": ${e.message}`, 1);
1380
+ }
1381
+ try {
1382
+ result[def.id] = JSON.parse(raw);
1383
+ } catch {
1384
+ result[def.id] = raw;
1385
+ }
1386
+ } else {
1387
+ result[def.id] = formatFieldValue(value, def.type);
1388
+ }
1136
1389
  }
1137
1390
  return result;
1138
1391
  }
1392
+ function parseFieldsFile(filePath, fieldMap) {
1393
+ let raw;
1394
+ try {
1395
+ raw = readFileSync2(filePath, "utf8");
1396
+ } catch (e) {
1397
+ throw new PncliError(`Cannot read fields file "${filePath}": ${e.message}`, 1);
1398
+ }
1399
+ let data;
1400
+ try {
1401
+ data = JSON.parse(raw);
1402
+ } catch {
1403
+ throw new PncliError(`Fields file "${filePath}" must be a valid JSON object`, 1);
1404
+ }
1405
+ if (typeof data !== "object" || data === null || Array.isArray(data)) {
1406
+ throw new PncliError(`Fields file "${filePath}" must be a JSON object`, 1);
1407
+ }
1408
+ const result = {};
1409
+ for (const [key, val] of Object.entries(data)) {
1410
+ const def = fieldMap.byName.get(key.toLowerCase()) ?? fieldMap.byId.get(key);
1411
+ if (!def) throw new PncliError(
1412
+ `Unknown field in "${filePath}": "${key}". Fields must be registered in config by friendly name or ID. Run: pncli jira fields`,
1413
+ 1
1414
+ );
1415
+ result[def.id] = val;
1416
+ }
1417
+ return result;
1418
+ }
1419
+ function translateFieldErrors(err, fieldMap) {
1420
+ if (!(err instanceof PncliError) || fieldMap.byId.size === 0) return err;
1421
+ const translated = err.message.replace(/\bcustomfield_\d+\b/g, (id) => {
1422
+ const def = fieldMap.byId.get(id);
1423
+ return def ? `${def.name} (${id})` : id;
1424
+ });
1425
+ if (translated === err.message) return err;
1426
+ return new PncliError(translated, err.status, err.url);
1427
+ }
1139
1428
 
1140
1429
  // src/services/bitbucket/commands.ts
1141
- function getClient2(program2) {
1430
+ function getClient2(program2, overrides) {
1142
1431
  const opts = program2.optsWithGlobals();
1143
1432
  const config = loadConfig({ configPath: opts.config });
1144
1433
  const http = createHttpClient(config, Boolean(opts.dryRun));
1145
1434
  const client = new BitbucketClient(http);
1146
1435
  const ctx = getGitContext(config);
1147
- const project = opts.project ?? ctx?.project ?? config.defaults.bitbucket?.project ?? "";
1148
- const repo = opts.repo ?? ctx?.repo ?? config.defaults.bitbucket?.repo ?? "";
1436
+ const project = overrides?.project ?? opts.project ?? ctx?.project ?? config.defaults.bitbucket?.project ?? "";
1437
+ const repo = overrides?.repo ?? opts.repo ?? ctx?.repo ?? config.defaults.bitbucket?.repo ?? "";
1149
1438
  if (!project || !repo) {
1150
1439
  throw new PncliError(
1151
1440
  "Could not determine Bitbucket project/repo. Pass --project and --repo, or run pncli config init.",
@@ -1176,10 +1465,10 @@ function registerBitbucketCommands(program2) {
1176
1465
  fail(err, "bitbucket", "get-pr", start);
1177
1466
  }
1178
1467
  });
1179
- bb.command("create-pr").description("Create a pull request").requiredOption("--title <title>", "PR title").requiredOption("--source <branch>", "Source branch").option("--target <branch>", "Target branch (defaults to config)").option("--description <desc>", "PR description").option("--reviewers <users>", "Comma-separated reviewer usernames").action(async (opts) => {
1468
+ bb.command("create-pr").description("Create a pull request").requiredOption("--title <title>", "PR title").requiredOption("--source <branch>", "Source branch").option("--target <branch>", "Target branch (defaults to config)").option("--description <desc>", "PR description").option("--reviewers <users>", "Comma-separated reviewer usernames").option("--project <key>", "Bitbucket project key (overrides parent --project; use ~username for personal repos)").option("--repo <slug>", "Bitbucket repository slug (overrides parent --repo)").action(async (opts) => {
1180
1469
  const start = Date.now();
1181
1470
  try {
1182
- const { client, project, repo } = getClient2(bb);
1471
+ const { client, project, repo } = getClient2(bb, { project: opts.project, repo: opts.repo });
1183
1472
  const config = loadConfig({ configPath: bb.optsWithGlobals().config });
1184
1473
  const target = opts.target ?? config.defaults.bitbucket?.targetBranch ?? "main";
1185
1474
  const reviewers = opts.reviewers ? opts.reviewers.split(",").map((s) => s.trim()) : [];
@@ -1403,9 +1692,9 @@ var ConfluenceClient = class {
1403
1692
  const initialStart = opts.start ?? 0;
1404
1693
  return this.http.confluencePaginate(async (start, limit) => {
1405
1694
  return this.http.confluence(`${API3}/content`, {
1406
- params: { spaceKey, type: "page", expand: "version", start: initialStart + start, limit: opts.limit ?? limit }
1695
+ params: { spaceKey, type: "page", expand: "version", start: initialStart + start, limit }
1407
1696
  });
1408
- });
1697
+ }, opts.limit);
1409
1698
  }
1410
1699
  async getPageChildren(id) {
1411
1700
  return this.http.confluencePaginate(async (start, limit) => {
@@ -1508,11 +1797,11 @@ var ConfluenceClient = class {
1508
1797
  return this.http.confluence(`${API3}/space`, {
1509
1798
  params: {
1510
1799
  start: initialStart + start,
1511
- limit: opts.limit ?? limit,
1800
+ limit,
1512
1801
  ...opts.type ? { type: opts.type } : {}
1513
1802
  }
1514
1803
  });
1515
- });
1804
+ }, opts.limit);
1516
1805
  }
1517
1806
  async getSpace(key) {
1518
1807
  return this.http.confluence(`${API3}/space/${key}`, {
@@ -1559,7 +1848,7 @@ function registerConfluenceCommands(program2) {
1559
1848
  fail(err, "confluence", "get-page-by-title", start);
1560
1849
  }
1561
1850
  });
1562
- confluence.command("list-pages").description("List pages in a Confluence space (consider --output-file for large results)").requiredOption("--space <key>", "Space key").option("--limit <n>", "Max results per page (default: all)").option("--start <n>", "Offset for first result").action(async (opts) => {
1851
+ confluence.command("list-pages").description("List pages in a Confluence space (consider --output-file for large results)").requiredOption("--space <key>", "Space key").option("--limit <n>", "Max total pages to return (default: all)").option("--start <n>", "Offset for first result").action(async (opts) => {
1563
1852
  const start = Date.now();
1564
1853
  try {
1565
1854
  const client = getClient3(program2);
@@ -1691,7 +1980,7 @@ function registerConfluenceCommands(program2) {
1691
1980
  fail(err, "confluence", "remove-label", start);
1692
1981
  }
1693
1982
  });
1694
- confluence.command("list-spaces").description("List Confluence spaces").option("--type <type>", "Space type: global or personal").option("--limit <n>", "Max results (default: all)").action(async (opts) => {
1983
+ confluence.command("list-spaces").description("List Confluence spaces").option("--type <type>", "Space type: global or personal").option("--limit <n>", "Max total spaces to return (default: all)").action(async (opts) => {
1695
1984
  const start = Date.now();
1696
1985
  try {
1697
1986
  const client = getClient3(program2);
@@ -2267,23 +2556,23 @@ import { Option } from "commander";
2267
2556
 
2268
2557
  // src/services/deps/parsers/index.ts
2269
2558
  import fs from "fs";
2270
- import path4 from "path";
2559
+ import path5 from "path";
2271
2560
  import { execSync as execSync3, execFileSync as execFileSync2 } from "child_process";
2272
2561
 
2273
2562
  // src/services/deps/parsers/npm.ts
2274
- import path from "path";
2563
+ import path2 from "path";
2275
2564
  function findNpmManifests(files) {
2276
2565
  const lockFiles = new Set(
2277
2566
  files.filter((f) => {
2278
- const base = path.basename(f);
2567
+ const base = path2.basename(f);
2279
2568
  return base === "package-lock.json" || base === "yarn.lock" || base === "pnpm-lock.yaml";
2280
2569
  })
2281
2570
  );
2282
2571
  const manifests = [];
2283
2572
  for (const file of files) {
2284
- if (path.basename(file) !== "package.json") continue;
2573
+ if (path2.basename(file) !== "package.json") continue;
2285
2574
  if (file.split("/").includes("node_modules")) continue;
2286
- const dir = path.dirname(file);
2575
+ const dir = path2.dirname(file);
2287
2576
  let lockFile;
2288
2577
  for (const lf of ["package-lock.json", "yarn.lock", "pnpm-lock.yaml"]) {
2289
2578
  const candidate = dir === "." ? lf : `${dir}/${lf}`;
@@ -2298,7 +2587,7 @@ function findNpmManifests(files) {
2298
2587
  }
2299
2588
  function parseNpmPackages(content, manifest, opts, lockContent) {
2300
2589
  if (lockContent && manifest.lockFile) {
2301
- const base = path.basename(manifest.lockFile);
2590
+ const base = path2.basename(manifest.lockFile);
2302
2591
  if (base === "package-lock.json") {
2303
2592
  const pkgs = parsePackageLock(lockContent, manifest.lockFile, opts);
2304
2593
  if (pkgs.length > 0) return pkgs;
@@ -2446,26 +2735,26 @@ function parsePnpmLock(content, lockFilePath, opts, packageJsonContent) {
2446
2735
  }
2447
2736
 
2448
2737
  // src/services/deps/parsers/nuget.ts
2449
- import path2 from "path";
2738
+ import path3 from "path";
2450
2739
  var PROJ_EXTENSIONS = /* @__PURE__ */ new Set([".csproj", ".fsproj", ".vbproj"]);
2451
2740
  function findNugetManifests(files) {
2452
2741
  const lockFiles = /* @__PURE__ */ new Map();
2453
2742
  for (const f of files) {
2454
- if (path2.basename(f) === "packages.lock.json") {
2455
- lockFiles.set(path2.dirname(f), f);
2743
+ if (path3.basename(f) === "packages.lock.json") {
2744
+ lockFiles.set(path3.dirname(f), f);
2456
2745
  }
2457
2746
  }
2458
2747
  const manifests = [];
2459
2748
  for (const file of files) {
2460
- const base = path2.basename(file);
2461
- const ext = path2.extname(file);
2749
+ const base = path3.basename(file);
2750
+ const ext = path3.extname(file);
2462
2751
  if (PROJ_EXTENSIONS.has(ext) || base === "packages.config") {
2463
- const dir = path2.dirname(file);
2752
+ const dir = path3.dirname(file);
2464
2753
  const lockFile = lockFiles.get(dir);
2465
2754
  manifests.push({ file, ecosystem: "nuget", lockFile });
2466
2755
  }
2467
2756
  if (base === "Directory.Packages.props") {
2468
- const dir = path2.dirname(file);
2757
+ const dir = path3.dirname(file);
2469
2758
  manifests.push({ file, ecosystem: "nuget", lockFile: lockFiles.get(dir) });
2470
2759
  }
2471
2760
  }
@@ -2484,7 +2773,7 @@ function findNugetManifests(files) {
2484
2773
  return deduped;
2485
2774
  }
2486
2775
  function parseNugetPackages(content, manifest, opts, lockContent, propsContent) {
2487
- const base = path2.basename(manifest.file);
2776
+ const base = path3.basename(manifest.file);
2488
2777
  if (lockContent) {
2489
2778
  return parsePackagesLock(lockContent, manifest.lockFile ?? manifest.file, opts);
2490
2779
  }
@@ -2605,14 +2894,14 @@ function parsePackagesLock(content, filePath, opts) {
2605
2894
  }
2606
2895
 
2607
2896
  // src/services/deps/parsers/maven.ts
2608
- import path3 from "path";
2897
+ import path4 from "path";
2609
2898
  function findMavenManifests(files) {
2610
- const lockFiles = new Set(files.filter((f) => path3.basename(f) === "gradle.lockfile"));
2899
+ const lockFiles = new Set(files.filter((f) => path4.basename(f) === "gradle.lockfile"));
2611
2900
  const manifests = [];
2612
2901
  for (const file of files) {
2613
- const base = path3.basename(file);
2902
+ const base = path4.basename(file);
2614
2903
  if (base === "pom.xml" || base === "build.gradle" || base === "build.gradle.kts") {
2615
- const dir = path3.dirname(file);
2904
+ const dir = path4.dirname(file);
2616
2905
  const lockCandidate = dir === "." ? "gradle.lockfile" : `${dir}/gradle.lockfile`;
2617
2906
  const lockFile = lockFiles.has(lockCandidate) ? lockCandidate : void 0;
2618
2907
  manifests.push({ file, ecosystem: "maven", lockFile });
@@ -2621,7 +2910,7 @@ function findMavenManifests(files) {
2621
2910
  return manifests;
2622
2911
  }
2623
2912
  function parseMavenPackages(content, manifest, opts, lockContent) {
2624
- const base = path3.basename(manifest.file);
2913
+ const base = path4.basename(manifest.file);
2625
2914
  if (lockContent && manifest.lockFile) {
2626
2915
  return parseGradleLock(lockContent, manifest.lockFile, opts);
2627
2916
  }
@@ -2782,7 +3071,7 @@ function getRepoFiles(repoRoot) {
2782
3071
  }
2783
3072
  function readFile(repoRoot, relPath) {
2784
3073
  try {
2785
- return fs.readFileSync(path4.join(repoRoot, relPath), "utf8");
3074
+ return fs.readFileSync(path5.join(repoRoot, relPath), "utf8");
2786
3075
  } catch {
2787
3076
  return null;
2788
3077
  }
@@ -2800,13 +3089,13 @@ function readFileAtRef(repoRoot, ref, relPath) {
2800
3089
  }
2801
3090
  }
2802
3091
  function findPropsContent(manifestFile, readFn) {
2803
- let dir = path4.dirname(manifestFile);
3092
+ let dir = path5.dirname(manifestFile);
2804
3093
  for (let depth = 0; depth < 10; depth++) {
2805
3094
  const candidate = dir === "." ? "Directory.Packages.props" : `${dir}/Directory.Packages.props`;
2806
3095
  const content = readFn(candidate);
2807
3096
  if (content) return content;
2808
3097
  if (dir === ".") break;
2809
- dir = path4.dirname(dir);
3098
+ dir = path5.dirname(dir);
2810
3099
  }
2811
3100
  return null;
2812
3101
  }
@@ -4084,6 +4373,24 @@ var AdoWorkClient = class {
4084
4373
  { method: "POST", body: { text } }
4085
4374
  );
4086
4375
  }
4376
+ async addTags(collection, id, tags) {
4377
+ const item = await this.getWorkItem(collection, id);
4378
+ const existing = item.fields["System.Tags"] ?? "";
4379
+ const current = existing.split(";").map((t) => t.trim()).filter(Boolean);
4380
+ const lowerSet = new Set(current.map((t) => t.toLowerCase()));
4381
+ const toAdd = tags.filter((t) => !lowerSet.has(t.toLowerCase()));
4382
+ const merged = [...current, ...toAdd];
4383
+ const patch = [{ op: "add", path: "/fields/System.Tags", value: merged.join("; ") }];
4384
+ return this.updateWorkItem(collection, id, patch);
4385
+ }
4386
+ async removeTags(collection, id, tags) {
4387
+ const item = await this.getWorkItem(collection, id);
4388
+ const existing = item.fields["System.Tags"] ?? "";
4389
+ const lower = new Set(tags.map((t) => t.toLowerCase()));
4390
+ const remaining = existing.split(";").map((t) => t.trim()).filter((t) => t && !lower.has(t.toLowerCase()));
4391
+ const patch = [{ op: "add", path: "/fields/System.Tags", value: remaining.join("; ") }];
4392
+ return this.updateWorkItem(collection, id, patch);
4393
+ }
4087
4394
  async listWorkItemTypes(collection, project) {
4088
4395
  const result = await this.http.ado(
4089
4396
  `/${encodeURIComponent(collection)}/${encodeURIComponent(project)}/_apis/wit/workitemtypes?api-version=${API6}`
@@ -4109,6 +4416,24 @@ var AdoWorkClient = class {
4109
4416
  );
4110
4417
  return result.value ?? [];
4111
4418
  }
4419
+ async listAttachments(collection, workItemId) {
4420
+ const item = await this.getWorkItem(collection, workItemId);
4421
+ return (item.relations ?? []).filter((r) => r.rel === "AttachedFile").map((r) => {
4422
+ const attrs = r.attributes ?? {};
4423
+ return {
4424
+ id: new URL(r.url).pathname.split("/").pop() ?? "",
4425
+ name: String(attrs["name"] ?? ""),
4426
+ url: r.url,
4427
+ comment: attrs["comment"] !== void 0 ? String(attrs["comment"]) : void 0,
4428
+ resourceSize: typeof attrs["resourceSize"] === "number" ? attrs["resourceSize"] : void 0,
4429
+ resourceCreatedDate: attrs["resourceCreatedDate"] !== void 0 ? String(attrs["resourceCreatedDate"]) : void 0,
4430
+ resourceModifiedDate: attrs["resourceModifiedDate"] !== void 0 ? String(attrs["resourceModifiedDate"]) : void 0
4431
+ };
4432
+ });
4433
+ }
4434
+ async downloadAttachment(absoluteUrl) {
4435
+ return this.http.adoBuffer(absoluteUrl);
4436
+ }
4112
4437
  };
4113
4438
 
4114
4439
  // src/services/ado/discovery.ts
@@ -4282,8 +4607,8 @@ function registerConfigCommands(program2) {
4282
4607
  if (cfg.ado.baseUrl) {
4283
4608
  try {
4284
4609
  const collection = cfg.defaults.ado?.collection;
4285
- const path6 = collection ? `/${encodeURIComponent(collection)}/_apis/connectionData?api-version=7.1-preview.1` : "/_apis/projectCollections?api-version=7.1";
4286
- await http.ado(path6);
4610
+ const path8 = collection ? `/${encodeURIComponent(collection)}/_apis/connectionData?api-version=7.1-preview.1` : "/_apis/projectCollections?api-version=7.1";
4611
+ await http.ado(path8);
4287
4612
  results.ado = { ok: true, message: "connected" };
4288
4613
  } catch (err) {
4289
4614
  results.ado = { ok: false, message: err instanceof Error ? err.message : String(err) };
@@ -4455,8 +4780,8 @@ function registerConfigCommands(program2) {
4455
4780
  } else {
4456
4781
  try {
4457
4782
  const collection = cfg.defaults.ado?.collection;
4458
- const path6 = collection ? `/${encodeURIComponent(collection)}/_apis/connectionData?api-version=7.1-preview.1` : "/_apis/projectCollections?api-version=7.1";
4459
- await http.ado(path6, { timeoutMs: 1e4 });
4783
+ const path8 = collection ? `/${encodeURIComponent(collection)}/_apis/connectionData?api-version=7.1-preview.1` : "/_apis/projectCollections?api-version=7.1";
4784
+ await http.ado(path8, { timeoutMs: 1e4 });
4460
4785
  results.ado = { status: "valid", message: "ok" };
4461
4786
  } catch (err) {
4462
4787
  results.ado = categorize2(err);
@@ -5452,15 +5777,22 @@ var AdoBuildClient = class {
5452
5777
  );
5453
5778
  }
5454
5779
  async listBuilds(collection, project, opts = {}) {
5780
+ const baseParams = {
5781
+ "api-version": API8,
5782
+ ...opts.definitionIds?.length ? { definitions: opts.definitionIds.join(",") } : {},
5783
+ ...opts.branchName ? { branchName: opts.branchName } : {},
5784
+ ...opts.statusFilter ? { statusFilter: opts.statusFilter } : {},
5785
+ ...opts.top ? { "$top": opts.top } : {}
5786
+ };
5787
+ if (opts.top) {
5788
+ const { data } = await this.http.adoRaw(
5789
+ `/${encodeURIComponent(collection)}/${encodeURIComponent(project)}/_apis/build/builds`,
5790
+ { params: baseParams }
5791
+ );
5792
+ return (data.value ?? []).slice(0, opts.top);
5793
+ }
5455
5794
  return this.http.adoPaginate(async (token) => {
5456
- const params = {
5457
- "api-version": API8,
5458
- ...opts.definitionIds?.length ? { definitionIds: opts.definitionIds.join(",") } : {},
5459
- ...opts.branchName ? { branchName: opts.branchName } : {},
5460
- ...opts.statusFilter ? { statusFilter: opts.statusFilter } : {},
5461
- ...opts.top ? { "$top": opts.top } : {},
5462
- ...token ? { continuationToken: token } : {}
5463
- };
5795
+ const params = { ...baseParams, ...token ? { continuationToken: token } : {} };
5464
5796
  return this.http.adoRaw(
5465
5797
  `/${encodeURIComponent(collection)}/${encodeURIComponent(project)}/_apis/build/builds`,
5466
5798
  { params }
@@ -5611,6 +5943,8 @@ function registerAdoProjectCommands(ado) {
5611
5943
  }
5612
5944
 
5613
5945
  // src/services/ado/commands/work.ts
5946
+ import fs3 from "fs";
5947
+ import path6 from "path";
5614
5948
  function registerAdoWorkCommands(ado) {
5615
5949
  const work = ado.command("work").description("Azure DevOps work item operations");
5616
5950
  work.command("get").description("Get a work item by ID").requiredOption("--id <n>", "Work item ID").action(async (opts) => {
@@ -5751,6 +6085,55 @@ function registerAdoWorkCommands(ado) {
5751
6085
  fail(err, "ado", "work-add-comment", start);
5752
6086
  }
5753
6087
  });
6088
+ work.command("add-tag").description("Add one or more tags to a work item (non-destructive)").requiredOption("--id <n>", "Work item ID").requiredOption("--tags <tags>", "Semicolon- or comma-separated tags to add").action(async (opts) => {
6089
+ const start = Date.now();
6090
+ try {
6091
+ const { collection, workClient } = getAdoContext(ado);
6092
+ const tags = opts.tags.split(/[;,]/).map((t) => t.trim()).filter(Boolean);
6093
+ const data = await workClient.addTags(collection, parseInt(opts.id, 10), tags);
6094
+ success(data, "ado", "work-add-tag", start);
6095
+ } catch (err) {
6096
+ fail(err, "ado", "work-add-tag", start);
6097
+ }
6098
+ });
6099
+ work.command("remove-tag").description("Remove one or more tags from a work item (non-destructive)").requiredOption("--id <n>", "Work item ID").requiredOption("--tags <tags>", "Semicolon- or comma-separated tags to remove").action(async (opts) => {
6100
+ const start = Date.now();
6101
+ try {
6102
+ const { collection, workClient } = getAdoContext(ado);
6103
+ const tags = opts.tags.split(/[;,]/).map((t) => t.trim()).filter(Boolean);
6104
+ const data = await workClient.removeTags(collection, parseInt(opts.id, 10), tags);
6105
+ success(data, "ado", "work-remove-tag", start);
6106
+ } catch (err) {
6107
+ fail(err, "ado", "work-remove-tag", start);
6108
+ }
6109
+ });
6110
+ work.command("list-attachments").description("List attachments on a work item").requiredOption("--id <n>", "Work item ID").action(async (opts) => {
6111
+ const start = Date.now();
6112
+ try {
6113
+ const { collection, workClient } = getAdoContext(ado);
6114
+ const data = await workClient.listAttachments(collection, parseInt(opts.id, 10));
6115
+ success(data, "ado", "work-list-attachments", start);
6116
+ } catch (err) {
6117
+ fail(err, "ado", "work-list-attachments", start);
6118
+ }
6119
+ });
6120
+ work.command("download-attachment").description("Download a work item attachment to .pncli/ (or --dir)").requiredOption("--id <n>", "Work item ID").requiredOption("--attachment-id <guid>", "Attachment ID from work list-attachments output").option("--dir <path>", "Output directory (default: .pncli relative to cwd)").action(async (opts) => {
6121
+ const start = Date.now();
6122
+ try {
6123
+ const { collection, workClient } = getAdoContext(ado);
6124
+ const attachments = await workClient.listAttachments(collection, parseInt(opts.id, 10));
6125
+ const attachment = attachments.find((a) => a.id === opts.attachmentId);
6126
+ if (!attachment) throw new PncliError(`Attachment not found: ${opts.attachmentId}`, 1);
6127
+ const outDir = opts.dir ?? path6.join(process.cwd(), ".pncli");
6128
+ fs3.mkdirSync(outDir, { recursive: true });
6129
+ const outPath = path6.join(outDir, path6.basename(attachment.name));
6130
+ const buffer = await workClient.downloadAttachment(attachment.url);
6131
+ fs3.writeFileSync(outPath, buffer);
6132
+ success({ saved: outPath, name: attachment.name, size: buffer.length }, "ado", "work-download-attachment", start);
6133
+ } catch (err) {
6134
+ fail(err, "ado", "work-download-attachment", start);
6135
+ }
6136
+ });
5754
6137
  work.command("types").description("List work item types in the project").option("--discover", "Fetch from server (always true for this command)").option("--save", "Save discovered types to ~/.pncli/config.json").action(async (opts) => {
5755
6138
  const start = Date.now();
5756
6139
  try {
@@ -5759,10 +6142,10 @@ function registerAdoWorkCommands(ado) {
5759
6142
  const types = await discoverTypes(workClient, collection, project);
5760
6143
  if (opts.save) {
5761
6144
  const globalConfigPath = getGlobalConfigPath(globalOpts.config);
5762
- const fs4 = await import("fs");
5763
- const existing = JSON.parse(fs4.readFileSync(globalConfigPath, "utf8") || "{}");
6145
+ const fs5 = await import("fs");
6146
+ const existing = JSON.parse(fs5.readFileSync(globalConfigPath, "utf8") || "{}");
5764
6147
  existing.ado = { ...existing.ado ?? {}, discoveredTypes: types };
5765
- fs4.writeFileSync(globalConfigPath, JSON.stringify(existing, null, 2) + "\n", "utf8");
6148
+ fs5.writeFileSync(globalConfigPath, JSON.stringify(existing, null, 2) + "\n", "utf8");
5766
6149
  warn(`Saved ${types.length} types to ${globalConfigPath}`);
5767
6150
  }
5768
6151
  success(types, "ado", "work-types", start);
@@ -5791,11 +6174,11 @@ function registerAdoWorkCommands(ado) {
5791
6174
  ) : allFields;
5792
6175
  if (opts.save) {
5793
6176
  const globalConfigPath = getGlobalConfigPath(globalOpts.config);
5794
- const fs4 = await import("fs");
5795
- const existing = JSON.parse(fs4.readFileSync(globalConfigPath, "utf8") || "{}");
6177
+ const fs5 = await import("fs");
6178
+ const existing = JSON.parse(fs5.readFileSync(globalConfigPath, "utf8") || "{}");
5796
6179
  const aliases = buildDefaultAliases(allFields);
5797
6180
  existing.ado = { ...existing.ado ?? {}, discoveredFields: allFields, fieldAliases: aliases };
5798
- fs4.writeFileSync(globalConfigPath, JSON.stringify(existing, null, 2) + "\n", "utf8");
6181
+ fs5.writeFileSync(globalConfigPath, JSON.stringify(existing, null, 2) + "\n", "utf8");
5799
6182
  warn(`Saved ${allFields.length} fields and ${Object.keys(aliases).length} aliases to ${globalConfigPath}`);
5800
6183
  }
5801
6184
  success(fields, "ado", "work-fields", start);
@@ -6076,7 +6459,7 @@ function registerAdoRepoCommands(ado) {
6076
6459
  const config = lc({ configPath: globalOpts.config });
6077
6460
  const { collection, project, repo: repo2, gitClient } = getAdoContext(ado, true);
6078
6461
  const { AdoCoreClient: AdoCoreClient2 } = await import("./core-SWYVXOQ4.js");
6079
- const { createHttpClient: createHttpClient2 } = await import("./http-LNDRNPP7.js");
6462
+ const { createHttpClient: createHttpClient2 } = await import("./http-BNCZJQTD.js");
6080
6463
  const http = createHttpClient2(config, Boolean(globalOpts.dryRun));
6081
6464
  const coreClient = new AdoCoreClient2(http);
6082
6465
  const me = await coreClient.getConnectionData(collection);
@@ -6169,12 +6552,32 @@ function registerAdoPipelineCommands(ado) {
6169
6552
  fail(err, "ado", "pipeline-run", start);
6170
6553
  }
6171
6554
  });
6172
- pipeline.command("list-runs").description("List pipeline runs").option("--definition <id>", "Filter by definition ID").option("--branch <ref>", "Filter by branch name").option("--status <filter>", "Filter by status (inProgress|completed|cancelling|...)").option("--top <n>", "Maximum results", "50").action(async (opts) => {
6555
+ pipeline.command("list-runs").description("List pipeline runs").option("--definition <id>", "Filter by definition ID").option("--name <name>", "Filter by pipeline name (resolved to definition ID; use pipeline list to see names)").option("--branch <ref>", "Filter by branch name").option("--status <filter>", "Filter by status (inProgress|completed|cancelling|...)").option("--top <n>", "Maximum results", "50").action(async (opts) => {
6173
6556
  const start = Date.now();
6174
6557
  try {
6175
6558
  const { collection, project, buildClient } = getAdoContext(ado);
6559
+ let definitionId;
6560
+ if (opts.definition && opts.name) {
6561
+ throw new PncliError("Cannot specify both --definition and --name. Use one or the other.", 1);
6562
+ }
6563
+ if (opts.definition) {
6564
+ definitionId = parseInt(opts.definition, 10);
6565
+ if (isNaN(definitionId)) {
6566
+ throw new PncliError(`--definition must be a numeric ID. Got "${opts.definition}". Use --name to filter by pipeline name instead.`, 1);
6567
+ }
6568
+ } else if (opts.name) {
6569
+ const defs = await buildClient.listDefinitions(collection, project);
6570
+ const match = defs.find((d) => d.name.toLowerCase() === opts.name.toLowerCase());
6571
+ if (!match) {
6572
+ throw new PncliError(
6573
+ `Pipeline "${opts.name}" not found. Run 'pncli ado pipeline list' to see available pipelines.`,
6574
+ 1
6575
+ );
6576
+ }
6577
+ definitionId = match.id;
6578
+ }
6176
6579
  const data = await buildClient.listBuilds(collection, project, {
6177
- definitionIds: opts.definition ? [parseInt(opts.definition, 10)] : void 0,
6580
+ definitionIds: definitionId !== void 0 ? [definitionId] : void 0,
6178
6581
  branchName: opts.branch,
6179
6582
  statusFilter: opts.status,
6180
6583
  top: parseInt(opts.top, 10)
@@ -6204,11 +6607,13 @@ function registerAdoPipelineCommands(ado) {
6204
6607
  fail(err, "ado", "pipeline-cancel-run", start);
6205
6608
  }
6206
6609
  });
6207
- pipeline.command("logs").description("List or retrieve build logs (consider --output-file for large logs)").requiredOption("--id <n>", "Build ID").option("--log-id <n>", "Specific log ID (omit to list all logs)").action(async (opts) => {
6610
+ pipeline.command("logs").description("List or retrieve build logs (consider --output-file for large logs)").option("--build-id <n>", "Build (run) ID \u2014 from pipeline list-runs or get-run").option("--id <n>", "Build (run) ID \u2014 alias for --build-id (kept for backwards compatibility)").option("--log-id <n>", "Specific log ID (omit to list all logs)").action(async (opts) => {
6208
6611
  const start = Date.now();
6209
6612
  try {
6613
+ const rawId = opts.buildId ?? opts.id;
6614
+ if (!rawId) throw new PncliError("--build-id is required (build run ID from pipeline list-runs or get-run)", 1);
6210
6615
  const { collection, project, buildClient } = getAdoContext(ado);
6211
- const buildId = parseInt(opts.id, 10);
6616
+ const buildId = parseInt(rawId, 10);
6212
6617
  if (opts.logId) {
6213
6618
  const data = await buildClient.getLog(collection, project, buildId, parseInt(opts.logId, 10));
6214
6619
  success({ log: data }, "ado", "pipeline-logs", start);
@@ -6260,9 +6665,10 @@ var JenkinsClient = class {
6260
6665
  this.http = http;
6261
6666
  }
6262
6667
  http;
6263
- async listJobs() {
6668
+ async listJobs(folder) {
6669
+ const endpoint = folder ? `/job/${jobPath(folder)}/api/json` : "/api/json";
6264
6670
  const data = await this.http.jenkins(
6265
- "/api/json",
6671
+ endpoint,
6266
6672
  { params: { tree: "jobs[name,url,color,description,buildable,lastBuild[number,url,result]]" } }
6267
6673
  );
6268
6674
  return data.jobs;
@@ -6342,17 +6748,17 @@ async function pollBuildComplete(client, jobName, buildNumber, pollMs, timeoutMs
6342
6748
  function registerJenkinsCommands(program2) {
6343
6749
  const jenkins = program2.command("jenkins").description("Jenkins Data Center operations");
6344
6750
  const pipeline = jenkins.command("pipeline").description("Pipeline (job/build) operations");
6345
- pipeline.command("list").description("List all jobs on the Jenkins instance").action(async () => {
6751
+ pipeline.command("list").description("List all jobs on the Jenkins instance (or within a folder)").option("--folder <name>", "Folder name to enumerate (supports nested folders: parentFolder/childFolder)").action(async (opts) => {
6346
6752
  const start = Date.now();
6347
6753
  try {
6348
6754
  const client = getClient6(program2);
6349
- const data = await client.listJobs();
6755
+ const data = await client.listJobs(opts.folder);
6350
6756
  success(data, "jenkins", "pipeline-list", start);
6351
6757
  } catch (err) {
6352
6758
  fail(err, "jenkins", "pipeline-list", start);
6353
6759
  }
6354
6760
  });
6355
- pipeline.command("get").description("Get details for a specific job").requiredOption("--name <job>", "Job name").action(async (opts) => {
6761
+ pipeline.command("get").description("Get details for a specific job").requiredOption("--name <job>", "Job name (use folder/job for folder-scoped jobs)").action(async (opts) => {
6356
6762
  const start = Date.now();
6357
6763
  try {
6358
6764
  const client = getClient6(program2);
@@ -6362,7 +6768,7 @@ function registerJenkinsCommands(program2) {
6362
6768
  fail(err, "jenkins", "pipeline-get", start);
6363
6769
  }
6364
6770
  });
6365
- pipeline.command("run").description("Trigger a build").requiredOption("--name <job>", "Job name").option("--parameter <k=v>", "Build parameter (repeatable)", (v, acc) => {
6771
+ pipeline.command("run").description("Trigger a build").requiredOption("--name <job>", "Job name (use folder/job for folder-scoped jobs)").option("--parameter <k=v>", "Build parameter (repeatable)", (v, acc) => {
6366
6772
  acc.push(v);
6367
6773
  return acc;
6368
6774
  }, []).option("--wait", "Wait for the build to complete before returning").option("--timeout <s>", "Max wait time in seconds per phase (queue-wait and build-wait each get this budget; default 600)", "600").option("--poll <s>", "Poll interval in seconds (default 10)", "10").action(async (opts) => {
@@ -6398,7 +6804,7 @@ function registerJenkinsCommands(program2) {
6398
6804
  fail(err, "jenkins", "pipeline-run", start);
6399
6805
  }
6400
6806
  });
6401
- pipeline.command("list-runs").description("List recent builds for a job").requiredOption("--name <job>", "Job name").option("--top <n>", "Maximum number of builds to return (default 25)", "25").action(async (opts) => {
6807
+ pipeline.command("list-runs").description("List recent builds for a job").requiredOption("--name <job>", "Job name (use folder/job for folder-scoped jobs)").option("--top <n>", "Maximum number of builds to return (default 25)", "25").action(async (opts) => {
6402
6808
  const start = Date.now();
6403
6809
  try {
6404
6810
  const client = getClient6(program2);
@@ -6410,7 +6816,7 @@ function registerJenkinsCommands(program2) {
6410
6816
  fail(err, "jenkins", "pipeline-list-runs", start);
6411
6817
  }
6412
6818
  });
6413
- pipeline.command("get-run").description("Get details for a specific build").requiredOption("--name <job>", "Job name").requiredOption("--number <n>", "Build number").action(async (opts) => {
6819
+ pipeline.command("get-run").description("Get details for a specific build").requiredOption("--name <job>", "Job name (use folder/job for folder-scoped jobs)").requiredOption("--number <n>", "Build number").action(async (opts) => {
6414
6820
  const start = Date.now();
6415
6821
  try {
6416
6822
  const client = getClient6(program2);
@@ -6422,7 +6828,7 @@ function registerJenkinsCommands(program2) {
6422
6828
  fail(err, "jenkins", "pipeline-get-run", start);
6423
6829
  }
6424
6830
  });
6425
- pipeline.command("logs").description("Fetch console log for a build (consider --output-file for large logs)").requiredOption("--name <job>", "Job name").requiredOption("--number <n>", "Build number").action(async (opts) => {
6831
+ pipeline.command("logs").description("Fetch console log for a build (consider --output-file for large logs)").requiredOption("--name <job>", "Job name (use folder/job for folder-scoped jobs)").requiredOption("--number <n>", "Build number").action(async (opts) => {
6426
6832
  const start = Date.now();
6427
6833
  try {
6428
6834
  const client = getClient6(program2);
@@ -6454,18 +6860,18 @@ var ArtifactoryClient = class {
6454
6860
  }
6455
6861
  });
6456
6862
  }
6457
- async getStorageInfo(repo, path6) {
6458
- const cleanPath = path6.startsWith("/") ? path6.slice(1) : path6;
6863
+ async getStorageInfo(repo, path8) {
6864
+ const cleanPath = path8.startsWith("/") ? path8.slice(1) : path8;
6459
6865
  return this.http.artifactory(`${API9}/storage/${repo}/${cleanPath}`);
6460
6866
  }
6461
- async getProperties(repo, path6) {
6462
- const cleanPath = path6.startsWith("/") ? path6.slice(1) : path6;
6867
+ async getProperties(repo, path8) {
6868
+ const cleanPath = path8.startsWith("/") ? path8.slice(1) : path8;
6463
6869
  return this.http.artifactory(`${API9}/storage/${repo}/${cleanPath}`, {
6464
6870
  params: { properties: "" }
6465
6871
  });
6466
6872
  }
6467
- async setProperties(repo, path6, properties, opts = {}) {
6468
- const cleanPath = path6.startsWith("/") ? path6.slice(1) : path6;
6873
+ async setProperties(repo, path8, properties, opts = {}) {
6874
+ const cleanPath = path8.startsWith("/") ? path8.slice(1) : path8;
6469
6875
  const propString = Object.entries(properties).map(([k, v]) => `${k}=${v}`).join(";");
6470
6876
  await this.http.artifactory(`${API9}/storage/${repo}/${cleanPath}`, {
6471
6877
  method: "PUT",
@@ -6493,8 +6899,18 @@ var ArtifactoryClient = class {
6493
6899
  headers: { "Content-Type": "text/plain" }
6494
6900
  });
6495
6901
  }
6496
- async listBuilds() {
6497
- return this.http.artifactory(`${API9}/build`);
6902
+ async listBuilds(opts = {}) {
6903
+ const raw = await this.http.artifactory(`${API9}/build`, { timeoutMs: opts.timeoutMs });
6904
+ const builds = (raw.builds ?? []).map((b) => ({
6905
+ name: b.uri.replace(/^\/|\/$/g, ""),
6906
+ uri: b.uri,
6907
+ lastStarted: b.lastStarted
6908
+ }));
6909
+ const result = { builds, uri: raw.uri };
6910
+ if (builds.length === 0) {
6911
+ result.hint = 'No builds are registered in Artifactory Build Integration. Build names may differ from ADO pipeline or uDeploy component names. Use "artifactory repos" to list repositories, then "artifactory search --repo <repo>" to locate artifacts directly.';
6912
+ }
6913
+ return result;
6498
6914
  }
6499
6915
  async listBuildRuns(buildName) {
6500
6916
  return this.http.artifactory(`${API9}/build/${encodeURIComponent(buildName)}`);
@@ -6537,27 +6953,27 @@ function registerArtifactoryCommands(program2) {
6537
6953
  fail(err, "artifactory", "repos", start);
6538
6954
  }
6539
6955
  });
6540
- art.command("artifact-info <repo> <path>").description("Get metadata for an artifact or folder (checksums, size, timestamps)").action(async (repo, path6) => {
6956
+ art.command("artifact-info <repo> <path>").description("Get metadata for an artifact or folder (checksums, size, timestamps)").action(async (repo, path8) => {
6541
6957
  const start = Date.now();
6542
6958
  try {
6543
6959
  const client = getClient7(program2);
6544
- const data = await client.getStorageInfo(repo, path6);
6960
+ const data = await client.getStorageInfo(repo, path8);
6545
6961
  success(data, "artifactory", "artifact-info", start);
6546
6962
  } catch (err) {
6547
6963
  fail(err, "artifactory", "artifact-info", start);
6548
6964
  }
6549
6965
  });
6550
- art.command("properties-get <repo> <path>").description("Read custom properties on an artifact").action(async (repo, path6) => {
6966
+ art.command("properties-get <repo> <path>").description("Read custom properties on an artifact").action(async (repo, path8) => {
6551
6967
  const start = Date.now();
6552
6968
  try {
6553
6969
  const client = getClient7(program2);
6554
- const data = await client.getProperties(repo, path6);
6970
+ const data = await client.getProperties(repo, path8);
6555
6971
  success(data, "artifactory", "properties-get", start);
6556
6972
  } catch (err) {
6557
6973
  fail(err, "artifactory", "properties-get", start);
6558
6974
  }
6559
6975
  });
6560
- art.command("properties-set <repo> <path> [keyvals...]").description("Set custom properties on an artifact (format: key=value key=value...)").option("--recursive", "Apply recursively to folder contents", false).action(async (repo, path6, keyvals, opts) => {
6976
+ art.command("properties-set <repo> <path> [keyvals...]").description("Set custom properties on an artifact (format: key=value key=value...)").option("--recursive", "Apply recursively to folder contents", false).action(async (repo, path8, keyvals, opts) => {
6561
6977
  const start = Date.now();
6562
6978
  try {
6563
6979
  if (!keyvals.length) throw new PncliError("Provide at least one key=value pair");
@@ -6568,8 +6984,8 @@ function registerArtifactoryCommands(program2) {
6568
6984
  properties[kv.slice(0, eq)] = kv.slice(eq + 1);
6569
6985
  }
6570
6986
  const client = getClient7(program2);
6571
- await client.setProperties(repo, path6, properties, { recursive: opts.recursive });
6572
- success({ repo, path: path6, properties }, "artifactory", "properties-set", start);
6987
+ await client.setProperties(repo, path8, properties, { recursive: opts.recursive });
6988
+ success({ repo, path: path8, properties }, "artifactory", "properties-set", start);
6573
6989
  } catch (err) {
6574
6990
  fail(err, "artifactory", "properties-set", start);
6575
6991
  }
@@ -6595,11 +7011,14 @@ function registerArtifactoryCommands(program2) {
6595
7011
  fail(err, "artifactory", "search", start);
6596
7012
  }
6597
7013
  });
6598
- art.command("builds").description("List all build names in Artifactory").action(async () => {
7014
+ art.command("builds").description('List builds registered in Artifactory Build Integration (names may differ from ADO pipeline or uDeploy component names; use "search --repo" when empty)').option("--timeout <s>", "Request timeout in seconds (default 60; increase for large Artifactory instances)", "60").action(async (opts) => {
6599
7015
  const start = Date.now();
6600
7016
  try {
6601
7017
  const client = getClient7(program2);
6602
- const data = await client.listBuilds();
7018
+ const timeoutSecs = parseInt(opts.timeout, 10);
7019
+ if (isNaN(timeoutSecs) || timeoutSecs <= 0) throw new PncliError("--timeout must be a positive integer (seconds)", 1);
7020
+ const timeoutMs = timeoutSecs * 1e3;
7021
+ const data = await client.listBuilds({ timeoutMs });
6603
7022
  success(data, "artifactory", "builds", start);
6604
7023
  } catch (err) {
6605
7024
  fail(err, "artifactory", "builds", start);
@@ -7170,8 +7589,8 @@ function registerSonatypeIqCommands(program2) {
7170
7589
  const startTime = Date.now();
7171
7590
  try {
7172
7591
  const http = getHttpClient(program2);
7173
- const path6 = opts.organizationId ? `/api/v2/policies/organization/${encodeURIComponent(opts.organizationId)}` : "/api/v2/policies";
7174
- const data = await http.sonatypeiq(path6);
7592
+ const path8 = opts.organizationId ? `/api/v2/policies/organization/${encodeURIComponent(opts.organizationId)}` : "/api/v2/policies";
7593
+ const data = await http.sonatypeiq(path8);
7175
7594
  success(data, "sonatypeiq", "policies list", startTime);
7176
7595
  } catch (err) {
7177
7596
  fail(err, "sonatypeiq", "policies list", startTime);
@@ -7180,8 +7599,8 @@ function registerSonatypeIqCommands(program2) {
7180
7599
  }
7181
7600
 
7182
7601
  // src/services/skills/commands.ts
7183
- import fs3 from "fs";
7184
- import path5 from "path";
7602
+ import fs4 from "fs";
7603
+ import path7 from "path";
7185
7604
  import os from "os";
7186
7605
  import { fileURLToPath } from "url";
7187
7606
  import { execFileSync as execFileSync3 } from "child_process";
@@ -7193,18 +7612,30 @@ function injectTokenIntoUrl(url, token) {
7193
7612
  } catch {
7194
7613
  throw new Error(`--token requires an HTTPS clone URL; got: ${url}`);
7195
7614
  }
7196
- parsed.username = "x-token-auth";
7615
+ parsed.username = parsed.hostname === "github.com" ? "x-access-token" : "x-token-auth";
7197
7616
  parsed.password = token;
7198
7617
  return parsed.toString();
7199
7618
  }
7619
+ function repoNameFromUrl(url) {
7620
+ try {
7621
+ const parsed = new URL(url);
7622
+ const segment = parsed.pathname.split("/").filter(Boolean).pop() ?? "marketplace";
7623
+ return segment.replace(/\.git$/i, "") || "marketplace";
7624
+ } catch {
7625
+ return "marketplace";
7626
+ }
7627
+ }
7628
+ function defaultMarketplacePath(url) {
7629
+ return path7.join(os.homedir(), ".agents", "marketplaces", repoNameFromUrl(url));
7630
+ }
7200
7631
  var AGENT_PATHS = {
7201
- "github-copilot": { project: ".agents/skills", user: path5.join(os.homedir(), ".copilot/skills") },
7202
- "claude-code": { project: ".claude/skills", user: path5.join(os.homedir(), ".claude/skills") }
7632
+ "github-copilot": { project: ".agents/skills", user: path7.join(os.homedir(), ".copilot/skills") },
7633
+ "claude-code": { project: ".claude/skills", user: path7.join(os.homedir(), ".claude/skills") }
7203
7634
  };
7204
7635
  function getBundledSkillsDir() {
7205
7636
  try {
7206
7637
  const thisFile = fileURLToPath(import.meta.url);
7207
- return path5.resolve(path5.dirname(thisFile), "..", "skills");
7638
+ return path7.resolve(path7.dirname(thisFile), "..", "skills");
7208
7639
  } catch {
7209
7640
  return "";
7210
7641
  }
@@ -7216,22 +7647,22 @@ function registerSkillsCommands(program2) {
7216
7647
  try {
7217
7648
  let targetDir;
7218
7649
  if (opts.target) {
7219
- targetDir = path5.resolve(opts.target);
7650
+ targetDir = path7.resolve(opts.target);
7220
7651
  } else {
7221
7652
  const agentConfig = AGENT_PATHS[opts.agent];
7222
7653
  if (!agentConfig) {
7223
7654
  throw new Error(`Unknown agent: "${opts.agent}". Use: ${Object.keys(AGENT_PATHS).join(" | ")}`);
7224
7655
  }
7225
7656
  const scopePath = opts.scope === "user" ? agentConfig.user : agentConfig.project;
7226
- targetDir = path5.resolve(scopePath);
7657
+ targetDir = path7.resolve(scopePath);
7227
7658
  }
7228
- const resolvedTarget = path5.resolve(targetDir);
7659
+ const resolvedTarget = path7.resolve(targetDir);
7229
7660
  const bundledDir = getBundledSkillsDir();
7230
7661
  let skillDirs = [];
7231
7662
  if (bundledDir !== "") {
7232
7663
  try {
7233
- skillDirs = fs3.readdirSync(bundledDir).filter(
7234
- (name) => fs3.existsSync(path5.join(bundledDir, name, "SKILL.md"))
7664
+ skillDirs = fs4.readdirSync(bundledDir).filter(
7665
+ (name) => fs4.existsSync(path7.join(bundledDir, name, "SKILL.md"))
7235
7666
  );
7236
7667
  } catch {
7237
7668
  }
@@ -7242,25 +7673,25 @@ function registerSkillsCommands(program2) {
7242
7673
  const installed = [];
7243
7674
  const failed = [];
7244
7675
  for (const skillName of skillDirs) {
7245
- const existingDir = path5.resolve(targetDir, skillName);
7246
- if (!existingDir.startsWith(resolvedTarget + path5.sep)) continue;
7247
- if (fs3.existsSync(existingDir)) {
7248
- fs3.rmSync(existingDir, { recursive: true, force: true });
7676
+ const existingDir = path7.resolve(targetDir, skillName);
7677
+ if (!existingDir.startsWith(resolvedTarget + path7.sep)) continue;
7678
+ if (fs4.existsSync(existingDir)) {
7679
+ fs4.rmSync(existingDir, { recursive: true, force: true });
7249
7680
  }
7250
7681
  }
7251
7682
  warn(`Installing ${skillDirs.length} bundled skill(s) to ${targetDir}...`);
7252
7683
  for (const skillName of skillDirs) {
7253
- const skillDir = path5.resolve(targetDir, skillName);
7254
- if (!skillDir.startsWith(resolvedTarget + path5.sep)) {
7684
+ const skillDir = path7.resolve(targetDir, skillName);
7685
+ if (!skillDir.startsWith(resolvedTarget + path7.sep)) {
7255
7686
  failed.push(skillName);
7256
7687
  continue;
7257
7688
  }
7258
7689
  try {
7259
- const mdFiles = fs3.readdirSync(path5.join(bundledDir, skillName)).filter((f) => f.endsWith(".md"));
7260
- fs3.mkdirSync(skillDir, { recursive: true });
7690
+ const mdFiles = fs4.readdirSync(path7.join(bundledDir, skillName)).filter((f) => f.endsWith(".md"));
7691
+ fs4.mkdirSync(skillDir, { recursive: true });
7261
7692
  for (const mdFile of mdFiles) {
7262
- const content = fs3.readFileSync(path5.join(bundledDir, skillName, mdFile), "utf8");
7263
- fs3.writeFileSync(path5.join(skillDir, mdFile), content, "utf8");
7693
+ const content = fs4.readFileSync(path7.join(bundledDir, skillName, mdFile), "utf8");
7694
+ fs4.writeFileSync(path7.join(skillDir, mdFile), content, "utf8");
7264
7695
  }
7265
7696
  installed.push(skillName);
7266
7697
  } catch {
@@ -7289,25 +7720,25 @@ function registerSkillsCommands(program2) {
7289
7720
  try {
7290
7721
  let targetDir;
7291
7722
  if (opts.target) {
7292
- targetDir = path5.resolve(opts.target);
7723
+ targetDir = path7.resolve(opts.target);
7293
7724
  } else {
7294
7725
  const agentConfig = AGENT_PATHS[opts.agent];
7295
7726
  if (!agentConfig) {
7296
7727
  throw new Error(`Unknown agent: "${opts.agent}". Use: ${Object.keys(AGENT_PATHS).join(" | ")}`);
7297
7728
  }
7298
7729
  const scopePath = opts.scope === "user" ? agentConfig.user : agentConfig.project;
7299
- targetDir = path5.resolve(scopePath);
7730
+ targetDir = path7.resolve(scopePath);
7300
7731
  }
7301
- if (!fs3.existsSync(targetDir)) {
7732
+ if (!fs4.existsSync(targetDir)) {
7302
7733
  success({ skills: [], message: `No skills directory found at ${targetDir}. Run: pncli skills install` }, "skills", "list", start);
7303
7734
  return;
7304
7735
  }
7305
- const skillDirs = fs3.readdirSync(targetDir).filter((name) => {
7306
- const skillPath = path5.join(targetDir, name, "SKILL.md");
7307
- return fs3.existsSync(skillPath);
7736
+ const skillDirs = fs4.readdirSync(targetDir).filter((name) => {
7737
+ const skillPath = path7.join(targetDir, name, "SKILL.md");
7738
+ return fs4.existsSync(skillPath);
7308
7739
  });
7309
7740
  const skillsList = skillDirs.map((name) => {
7310
- const content = fs3.readFileSync(path5.join(targetDir, name, "SKILL.md"), "utf8");
7741
+ const content = fs4.readFileSync(path7.join(targetDir, name, "SKILL.md"), "utf8");
7311
7742
  const frontmatter = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
7312
7743
  const data = {};
7313
7744
  const metadata = {};
@@ -7346,42 +7777,46 @@ function registerSkillsCommands(program2) {
7346
7777
  }
7347
7778
  });
7348
7779
  const marketplace = skills.command("marketplace").description("Manage a git-hosted skills marketplace");
7349
- marketplace.command("setup").description("Clone a skills marketplace repo and register it in global config").argument("<url>", "Git clone URL of the marketplace repository").argument("<localPath>", "Local directory to clone the marketplace into").option("--branch <branch>", "Branch to clone", "master").option("--token <token>", "Bitbucket HTTP access token for authenticated clone and pull").action(async (url, localPath, opts) => {
7780
+ marketplace.command("setup").description("Clone a skills marketplace repo and register it in global config").argument("<url>", "Git clone URL of the marketplace repository").argument("[localPath]", "Local directory to clone into (default: ~/.agents/marketplaces/<repo-name>)").option("--branch <branch>", "Branch to clone (default: remote HEAD)").option("--token <token>", "HTTP access token for authenticated clone and pull (GitHub PAT or Bitbucket token)").action(async (url, localPath, opts) => {
7350
7781
  const start = Date.now();
7351
7782
  try {
7352
- const resolvedPath = path5.resolve(localPath);
7353
- const hasGit = fs3.existsSync(path5.join(resolvedPath, ".git"));
7354
- if (fs3.existsSync(resolvedPath) && !hasGit && fs3.readdirSync(resolvedPath).length > 0) {
7783
+ const resolvedPath = path7.resolve(localPath ?? defaultMarketplacePath(url));
7784
+ const hasGit = fs4.existsSync(path7.join(resolvedPath, ".git"));
7785
+ if (fs4.existsSync(resolvedPath) && !hasGit && fs4.readdirSync(resolvedPath).length > 0) {
7355
7786
  throw new Error(`Directory already exists and is not a git repo: ${resolvedPath}`);
7356
7787
  }
7357
7788
  if (hasGit) {
7358
7789
  warn(`Directory already contains a git repo at ${resolvedPath} \u2014 skipping clone, updating config only.`);
7359
7790
  } else {
7360
- warn(`Cloning ${url} (branch: ${opts.branch}) \u2192 ${resolvedPath}...`);
7791
+ const branchLabel = opts.branch ?? "remote default";
7792
+ warn(`Cloning ${url} (branch: ${branchLabel}) \u2192 ${resolvedPath}...`);
7361
7793
  const cloneUrl = opts.token ? injectTokenIntoUrl(url, opts.token) : url;
7794
+ const cloneArgs = ["clone"];
7795
+ if (opts.branch) cloneArgs.push("--branch", opts.branch);
7796
+ cloneArgs.push(cloneUrl, resolvedPath);
7362
7797
  try {
7363
- execFileSync3("git", ["clone", "--branch", opts.branch, cloneUrl, resolvedPath], { stdio: ["inherit", "inherit", "pipe"] });
7798
+ execFileSync3("git", cloneArgs, { stdio: ["inherit", "inherit", "pipe"] });
7364
7799
  } catch (e) {
7365
7800
  const msg = e instanceof Error ? e.message : String(e);
7366
- throw new Error(msg.replace(/x-token-auth:[^@]+@/g, "x-token-auth:***@"));
7801
+ throw new Error(msg.replace(/x-(?:token-auth|access-token):[^@]+@/g, "x-token-auth:***@"));
7367
7802
  }
7368
7803
  }
7369
7804
  const configPath = getGlobalConfigPath();
7370
7805
  const existing = loadJsonFile(configPath) ?? {};
7371
7806
  existing.marketplace = { repoUrl: url, localPath: resolvedPath, ...opts.token ? { token: opts.token } : {} };
7372
7807
  writeGlobalConfig(existing, configPath);
7373
- success({ repoUrl: url, localPath: resolvedPath, branch: opts.branch, tokenConfigured: !!opts.token }, "skills", "marketplace-setup", start);
7808
+ success({ repoUrl: url, localPath: resolvedPath, branch: opts.branch ?? null, tokenConfigured: !!opts.token }, "skills", "marketplace-setup", start);
7374
7809
  } catch (err) {
7375
7810
  fail(err, "skills", "marketplace-setup", start);
7376
7811
  }
7377
7812
  });
7378
- marketplace.command("sync").description("Pull latest marketplace content and install a plugin's skills").argument("[plugin]", "Plugin name to install (skips interactive selection)").option("--claude", "Install to ~/.claude/skills instead of ~/.agents/skills").action(async (plugin, opts) => {
7813
+ marketplace.command("sync").description("Pull latest marketplace content and install a plugin's skills").argument("[plugin]", 'Plugin name to install, or "all" to install every plugin (skips interactive selection)').option("--claude", "Install to ~/.claude/skills instead of ~/.agents/skills").option("--force", "Force reinstall even if the marketplace repo has no new changes").action(async (plugin, opts) => {
7379
7814
  const start = Date.now();
7380
7815
  try {
7381
7816
  const configPath = getGlobalConfigPath();
7382
7817
  const globalConfig = loadJsonFile(configPath) ?? {};
7383
7818
  const marketplacePath = globalConfig.marketplace?.localPath;
7384
- if (!marketplacePath || !fs3.existsSync(marketplacePath)) {
7819
+ if (!marketplacePath || !fs4.existsSync(marketplacePath)) {
7385
7820
  throw new Error("Marketplace not configured. Run: pncli skills marketplace setup <url> <localPath>");
7386
7821
  }
7387
7822
  warn("Pulling latest marketplace content...");
@@ -7392,16 +7827,61 @@ function registerSkillsCommands(program2) {
7392
7827
  gitArgs.push("-c", `remote.origin.url=${injectTokenIntoUrl(repoUrl, token)}`);
7393
7828
  }
7394
7829
  gitArgs.push("pull");
7830
+ let pullOutput;
7395
7831
  try {
7396
- execFileSync3("git", gitArgs, { stdio: ["inherit", "inherit", "pipe"] });
7832
+ pullOutput = execFileSync3("git", gitArgs, { encoding: "utf8", stdio: ["inherit", "pipe", "pipe"], env: { ...process.env, LANG: "C", LC_ALL: "C" } });
7397
7833
  } catch (e) {
7398
7834
  const msg = e instanceof Error ? e.message : String(e);
7399
- throw new Error(msg.replace(/x-token-auth:[^@]+@/g, "x-token-auth:***@"));
7835
+ throw new Error(msg.replace(/x-(?:token-auth|access-token):[^@]+@/g, "x-token-auth:***@"));
7836
+ }
7837
+ const marketplaceUpdated = !pullOutput.includes("Already up to date");
7838
+ if (pullOutput.trim() && marketplaceUpdated) {
7839
+ warn(pullOutput.trim());
7840
+ }
7841
+ warn(marketplaceUpdated ? "Marketplace updated." : "Marketplace already up to date \u2014 no changes to sync.");
7842
+ if (!marketplaceUpdated && !plugin && !opts.force) {
7843
+ success({
7844
+ marketplace: marketplacePath,
7845
+ marketplaceUpdated: false,
7846
+ updated: false,
7847
+ skipped: true,
7848
+ message: "No marketplace changes detected \u2014 skipping plugin selection and install."
7849
+ }, "skills", "marketplace-sync", start);
7850
+ return;
7400
7851
  }
7401
7852
  const pluginChoices = resolvePluginChoices(marketplacePath);
7402
7853
  if (pluginChoices.length === 0) {
7403
7854
  throw new Error("No plugins found in marketplace. Check the marketplace repository structure.");
7404
7855
  }
7856
+ const targetDir = opts.claude ? path7.join(os.homedir(), ".claude", "skills") : path7.join(os.homedir(), ".agents", "skills");
7857
+ if (plugin === "all") {
7858
+ const results = {};
7859
+ let totalInstalled = 0;
7860
+ for (const pluginChoice of pluginChoices) {
7861
+ const skillsSrc2 = resolveSkillsSrc(marketplacePath, pluginChoice.name);
7862
+ if (!fs4.existsSync(skillsSrc2)) {
7863
+ results[pluginChoice.name] = { installed: [], failed: [] };
7864
+ warn(`No skills directory found for plugin "${pluginChoice.name}" \u2014 skipping.`);
7865
+ continue;
7866
+ }
7867
+ const { installed: installed2, failed: failed2 } = copyPluginSkills(skillsSrc2, targetDir);
7868
+ results[pluginChoice.name] = { installed: installed2, failed: failed2 };
7869
+ totalInstalled += installed2.length;
7870
+ for (const skill of installed2) {
7871
+ warn(` ${skill}: ${path7.join(skillsSrc2, skill)} \u2192 ${path7.join(targetDir, skill)}`);
7872
+ }
7873
+ if (failed2.length > 0) {
7874
+ warn(`Skipped ${failed2.length} skill(s) with invalid names in "${pluginChoice.name}": ${failed2.join(", ")}`);
7875
+ }
7876
+ }
7877
+ success({
7878
+ plugins: results,
7879
+ total: totalInstalled,
7880
+ target: targetDir,
7881
+ marketplaceUpdated
7882
+ }, "skills", "marketplace-sync", start);
7883
+ return;
7884
+ }
7405
7885
  let selectedPlugin;
7406
7886
  if (plugin) {
7407
7887
  if (!pluginChoices.some((p) => p.name === plugin)) {
@@ -7418,11 +7898,13 @@ function registerSkillsCommands(program2) {
7418
7898
  });
7419
7899
  }
7420
7900
  const skillsSrc = resolveSkillsSrc(marketplacePath, selectedPlugin);
7421
- if (!fs3.existsSync(skillsSrc)) {
7901
+ if (!fs4.existsSync(skillsSrc)) {
7422
7902
  throw new Error(`No skills directory found at ${skillsSrc}`);
7423
7903
  }
7424
- const targetDir = opts.claude ? path5.join(os.homedir(), ".claude", "skills") : path5.join(os.homedir(), ".agents", "skills");
7425
7904
  const { installed, failed } = copyPluginSkills(skillsSrc, targetDir);
7905
+ for (const skill of installed) {
7906
+ warn(` ${skill}: ${path7.join(skillsSrc, skill)} \u2192 ${path7.join(targetDir, skill)}`);
7907
+ }
7426
7908
  if (failed.length > 0) {
7427
7909
  warn(`Skipped ${failed.length} skill(s) with invalid names: ${failed.join(", ")}`);
7428
7910
  }
@@ -7431,7 +7913,8 @@ function registerSkillsCommands(program2) {
7431
7913
  installed,
7432
7914
  failed,
7433
7915
  total: installed.length,
7434
- target: targetDir
7916
+ target: targetDir,
7917
+ marketplaceUpdated
7435
7918
  }, "skills", "marketplace-sync", start);
7436
7919
  } catch (err) {
7437
7920
  fail(err, "skills", "marketplace-sync", start);
@@ -7439,46 +7922,46 @@ function registerSkillsCommands(program2) {
7439
7922
  });
7440
7923
  }
7441
7924
  function resolvePluginChoices(marketplacePath) {
7442
- const marketplaceJsonPath = path5.join(marketplacePath, ".claude-plugin", "marketplace.json");
7443
- if (fs3.existsSync(marketplaceJsonPath)) {
7925
+ const marketplaceJsonPath = path7.join(marketplacePath, ".claude-plugin", "marketplace.json");
7926
+ if (fs4.existsSync(marketplaceJsonPath)) {
7444
7927
  try {
7445
- const meta = JSON.parse(fs3.readFileSync(marketplaceJsonPath, "utf8"));
7928
+ const meta = JSON.parse(fs4.readFileSync(marketplaceJsonPath, "utf8"));
7446
7929
  if (Array.isArray(meta.plugins) && meta.plugins.length > 0) {
7447
7930
  return meta.plugins.map((p) => ({ name: p.name, description: p.description ?? "" }));
7448
7931
  }
7449
7932
  } catch {
7450
7933
  }
7451
7934
  }
7452
- const pluginsDir = path5.join(marketplacePath, "plugins");
7453
- if (fs3.existsSync(pluginsDir)) {
7454
- return fs3.readdirSync(pluginsDir).filter((name) => fs3.statSync(path5.join(pluginsDir, name)).isDirectory()).map((name) => ({ name, description: "" }));
7935
+ const pluginsDir = path7.join(marketplacePath, "plugins");
7936
+ if (fs4.existsSync(pluginsDir)) {
7937
+ return fs4.readdirSync(pluginsDir).filter((name) => fs4.statSync(path7.join(pluginsDir, name)).isDirectory()).map((name) => ({ name, description: "" }));
7455
7938
  }
7456
7939
  return [];
7457
7940
  }
7458
7941
  function resolveSkillsSrc(marketplacePath, selectedPlugin) {
7459
- const pluginsBase = path5.resolve(marketplacePath, "plugins");
7460
- const skillsSrc = path5.resolve(pluginsBase, selectedPlugin, "skills");
7461
- if (!skillsSrc.startsWith(pluginsBase + path5.sep)) {
7942
+ const pluginsBase = path7.resolve(marketplacePath, "plugins");
7943
+ const skillsSrc = path7.resolve(pluginsBase, selectedPlugin, "skills");
7944
+ if (!skillsSrc.startsWith(pluginsBase + path7.sep)) {
7462
7945
  throw new Error(`Invalid plugin name: "${selectedPlugin}"`);
7463
7946
  }
7464
7947
  return skillsSrc;
7465
7948
  }
7466
7949
  function copyPluginSkills(skillsSrc, targetDir) {
7467
- fs3.mkdirSync(targetDir, { recursive: true });
7468
- const resolvedTarget = path5.resolve(targetDir);
7469
- const skillNames = fs3.readdirSync(skillsSrc).filter(
7470
- (name) => fs3.statSync(path5.join(skillsSrc, name)).isDirectory()
7950
+ fs4.mkdirSync(targetDir, { recursive: true });
7951
+ const resolvedTarget = path7.resolve(targetDir);
7952
+ const skillNames = fs4.readdirSync(skillsSrc).filter(
7953
+ (name) => fs4.statSync(path7.join(skillsSrc, name)).isDirectory()
7471
7954
  );
7472
7955
  const installed = [];
7473
7956
  const failed = [];
7474
7957
  for (const skillName of skillNames) {
7475
- const dest = path5.resolve(targetDir, skillName);
7476
- if (!dest.startsWith(resolvedTarget + path5.sep)) {
7958
+ const dest = path7.resolve(targetDir, skillName);
7959
+ if (!dest.startsWith(resolvedTarget + path7.sep)) {
7477
7960
  failed.push(skillName);
7478
7961
  continue;
7479
7962
  }
7480
- fs3.rmSync(dest, { recursive: true, force: true });
7481
- fs3.cpSync(path5.join(skillsSrc, skillName), dest, { recursive: true });
7963
+ fs4.rmSync(dest, { recursive: true, force: true });
7964
+ fs4.cpSync(path7.join(skillsSrc, skillName), dest, { recursive: true });
7482
7965
  installed.push(skillName);
7483
7966
  }
7484
7967
  return { installed, failed };