@tplog/zendcli 0.2.2 → 1.0.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.
Files changed (3) hide show
  1. package/README.md +7 -0
  2. package/dist/cli.js +205 -100
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -39,11 +39,18 @@ Environment variables take precedence over the config file.
39
39
  ## Usage
40
40
 
41
41
  ```bash
42
+ zend --help
43
+ zend tickets --help
44
+ zend email --help
45
+ zend follower --help
46
+ zend comments --help
42
47
  zend tickets --limit 10
43
48
  zend tickets --status open --limit 20
44
49
  zend email user@example.com
45
50
  zend email user@example.com --status unresolved
46
51
  zend email user@example.com --status open,pending
52
+ zend follower
53
+ zend follower tp@dify.ai --limit 3
47
54
  zend comments 12345
48
55
  zend comments 12345 --type public
49
56
  zend comments 12345 --json
package/dist/cli.js CHANGED
@@ -3512,30 +3512,101 @@ function getConfig() {
3512
3512
  }
3513
3513
 
3514
3514
  // src/api.ts
3515
- async function apiGet(path, params) {
3516
- const { subdomain, email, api_token } = getConfig();
3517
- const url = new URL(`https://${subdomain}.zendesk.com${path}`);
3518
- if (params) {
3519
- for (const [key, value] of Object.entries(params)) {
3520
- url.searchParams.set(key, String(value));
3521
- }
3515
+ var ApiError = class extends Error {
3516
+ status;
3517
+ body;
3518
+ constructor(message, status, body) {
3519
+ super(message);
3520
+ this.name = "ApiError";
3521
+ this.status = status;
3522
+ this.body = body;
3522
3523
  }
3524
+ };
3525
+ function authHeader() {
3526
+ const { email, api_token } = getConfig();
3523
3527
  const credentials = btoa(`${email}/token:${api_token}`);
3524
- const resp = await fetch(url.toString(), {
3525
- headers: { Authorization: `Basic ${credentials}` }
3526
- });
3528
+ return `Basic ${credentials}`;
3529
+ }
3530
+ async function fetchJson(url) {
3531
+ let resp;
3532
+ try {
3533
+ resp = await fetch(url, {
3534
+ headers: { Authorization: authHeader() }
3535
+ });
3536
+ } catch (error) {
3537
+ const message = error instanceof Error ? error.message : "Network request failed";
3538
+ throw new ApiError(message);
3539
+ }
3527
3540
  if (!resp.ok) {
3528
3541
  const body = await resp.text();
3529
- console.error(`Error ${resp.status}: ${body}`);
3530
- process.exit(1);
3542
+ throw new ApiError(`HTTP ${resp.status}`, resp.status, body);
3531
3543
  }
3532
3544
  return resp.json();
3533
3545
  }
3546
+ async function apiGet(path, params) {
3547
+ const { subdomain } = getConfig();
3548
+ const url = new URL(`https://${subdomain}.zendesk.com${path}`);
3549
+ for (const [key, value] of Object.entries(params || {})) {
3550
+ url.searchParams.set(key, String(value));
3551
+ }
3552
+ return fetchJson(url.toString());
3553
+ }
3554
+ async function apiGetUrl(url) {
3555
+ return fetchJson(url);
3556
+ }
3534
3557
 
3535
3558
  // src/cli.ts
3536
3559
  var program2 = new Command();
3537
3560
  var VALID_TICKET_STATUSES = ["new", "open", "pending", "hold", "solved", "closed"];
3538
- program2.name("zend").description("Zendesk tickets & comments CLI").version("0.1.0");
3561
+ var KNOWN_COMMANDS = /* @__PURE__ */ new Set(["configure", "follower", "comments", "help", "email", "ticket"]);
3562
+ var EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
3563
+ var DIGITS_RE = /^\d+$/;
3564
+ var CliError = class extends Error {
3565
+ code;
3566
+ details;
3567
+ exitCode;
3568
+ constructor(code, message, details = {}, exitCode = 1) {
3569
+ super(message);
3570
+ this.name = "CliError";
3571
+ this.code = code;
3572
+ this.details = details;
3573
+ this.exitCode = exitCode;
3574
+ }
3575
+ };
3576
+ program2.name("zend").description("Zendesk tickets CLI").version("2.0.0");
3577
+ function printJson(value) {
3578
+ process.stdout.write(`${JSON.stringify(value, null, 2)}
3579
+ `);
3580
+ }
3581
+ function fail(code, message, details = {}, exitCode = 1) {
3582
+ printJson({ error: code, message, ...details });
3583
+ process.exit(exitCode);
3584
+ }
3585
+ function handleError(error) {
3586
+ if (error instanceof CliError) {
3587
+ fail(error.code, error.message, error.details, error.exitCode);
3588
+ }
3589
+ if (error instanceof ApiError) {
3590
+ if (error.status === 401) {
3591
+ fail("auth_failed", "401 Unauthorized", { status: 401 });
3592
+ }
3593
+ if (error.status === 404) {
3594
+ fail("not_found", "Resource not found", { status: 404 });
3595
+ }
3596
+ fail("api_error", error.body || error.message, error.status ? { status: error.status } : {});
3597
+ }
3598
+ const message = error instanceof Error ? error.message : "Unknown error";
3599
+ fail("unknown_error", message);
3600
+ }
3601
+ function run(fn) {
3602
+ return async (...args) => {
3603
+ try {
3604
+ await fn(...args);
3605
+ } catch (error) {
3606
+ handleError(error);
3607
+ }
3608
+ };
3609
+ }
3539
3610
  function prompt(question, defaultValue = "") {
3540
3611
  return new Promise((resolve) => {
3541
3612
  const rl = readline.createInterface({ input: process.stdin, output: process.stderr });
@@ -3552,139 +3623,173 @@ function promptHidden(question, hasDefault = false) {
3552
3623
  const stdout = process.stderr;
3553
3624
  let value = "";
3554
3625
  readline.emitKeypressEvents(stdin);
3555
- if (stdin.isTTY) {
3556
- stdin.setRawMode(true);
3557
- }
3558
- const suffix = hasDefault ? " [****]" : "";
3559
- stdout.write(`${question}${suffix}: `);
3626
+ if (stdin.isTTY) stdin.setRawMode(true);
3627
+ stdout.write(`${question}${hasDefault ? " [****]" : ""}: `);
3560
3628
  const onKeypress = (char, key) => {
3561
3629
  if (key.name === "return" || key.name === "enter") {
3562
3630
  stdout.write("\n");
3563
3631
  stdin.off("keypress", onKeypress);
3564
- if (stdin.isTTY) {
3565
- stdin.setRawMode(false);
3566
- }
3632
+ if (stdin.isTTY) stdin.setRawMode(false);
3567
3633
  resolve(value);
3568
3634
  return;
3569
3635
  }
3570
3636
  if (key.ctrl && key.name === "c") {
3571
3637
  stdout.write("\n");
3572
3638
  stdin.off("keypress", onKeypress);
3573
- if (stdin.isTTY) {
3574
- stdin.setRawMode(false);
3575
- }
3639
+ if (stdin.isTTY) stdin.setRawMode(false);
3576
3640
  process.exit(130);
3577
3641
  }
3578
3642
  if (key.name === "backspace") {
3579
3643
  value = value.slice(0, -1);
3580
3644
  return;
3581
3645
  }
3582
- if (char) {
3583
- value += char;
3584
- }
3646
+ if (char) value += char;
3585
3647
  };
3586
3648
  stdin.on("keypress", onKeypress);
3587
3649
  });
3588
3650
  }
3589
- function parseStatusFilter(input) {
3590
- if (!input) return [];
3651
+ function parseStatusFilter(input = "unresolved") {
3591
3652
  const statuses = input.split(",").map((value) => value.trim().toLowerCase()).filter(Boolean);
3592
3653
  const invalid = statuses.filter((status) => status !== "unresolved" && status !== "all" && !VALID_TICKET_STATUSES.includes(status));
3593
3654
  if (invalid.length > 0) {
3594
- console.error(`Invalid status value(s): ${invalid.join(", ")}`);
3595
- console.error(`Allowed values: unresolved, all, ${VALID_TICKET_STATUSES.join(", ")}`);
3596
- process.exit(1);
3655
+ throw new CliError("invalid_args", `Invalid status value(s): ${invalid.join(", ")}`, { input });
3597
3656
  }
3598
3657
  if (statuses.includes("all")) return [];
3599
3658
  if (statuses.includes("unresolved")) return ["new", "open", "pending", "hold"];
3600
3659
  return statuses;
3601
3660
  }
3602
- program2.command("configure").description("Set up Zendesk credentials interactively").action(async () => {
3661
+ function parseLimit(input = "20") {
3662
+ const limit = Number.parseInt(input, 10);
3663
+ if (!Number.isFinite(limit) || Number.isNaN(limit)) {
3664
+ throw new CliError("invalid_args", "limit must be an integer", { limit: input });
3665
+ }
3666
+ if (limit < 1 || limit > 100) {
3667
+ throw new CliError("invalid_args", "limit must be between 1 and 100", { limit });
3668
+ }
3669
+ return limit;
3670
+ }
3671
+ function parseSort(input = "desc") {
3672
+ if (input !== "asc" && input !== "desc") {
3673
+ throw new CliError("invalid_args", "sort must be asc or desc", { sort: input });
3674
+ }
3675
+ return input;
3676
+ }
3677
+ function buildSearchQuery(base, rawStatus, statusFilter) {
3678
+ if (rawStatus === "unresolved") return `${base} status<solved`;
3679
+ if (statusFilter.length === 1) return `${base} status:${statusFilter[0]}`;
3680
+ return base;
3681
+ }
3682
+ function filterStatuses(tickets, rawStatus, statusFilter) {
3683
+ if (rawStatus === "unresolved" || statusFilter.length <= 1) return tickets;
3684
+ return tickets.filter((ticket) => statusFilter.includes(String(ticket.status || "").toLowerCase()));
3685
+ }
3686
+ async function findUserByEmail(email) {
3687
+ const data = await apiGet("/api/v2/users/search.json", { query: email });
3688
+ const users = data.users || [];
3689
+ const exact = users.find((user) => user.email?.toLowerCase() === email.toLowerCase());
3690
+ if (exact) return exact;
3691
+ if (users[0]) return users[0];
3692
+ throw new CliError("user_not_found", `No Zendesk user found for email: ${email}`, { email });
3693
+ }
3694
+ async function fetchFollowerTickets(email, rawStatus, limit, sort) {
3695
+ const user = await findUserByEmail(email);
3696
+ const statusFilter = parseStatusFilter(rawStatus);
3697
+ const baseUrl = new URL(`${apiBaseUrl()}/api/v2/search.json`);
3698
+ baseUrl.searchParams.set("query", buildSearchQuery("type:ticket", rawStatus, statusFilter));
3699
+ baseUrl.searchParams.set("sort_by", "updated_at");
3700
+ baseUrl.searchParams.set("sort_order", sort);
3701
+ baseUrl.searchParams.set("per_page", "100");
3702
+ const matches = [];
3703
+ let nextUrl = baseUrl.toString();
3704
+ while (nextUrl && matches.length < limit) {
3705
+ const data = await apiGetUrl(nextUrl);
3706
+ const tickets = filterStatuses(data.results || [], rawStatus, statusFilter);
3707
+ for (const ticket of tickets) {
3708
+ if ((ticket.follower_ids || []).includes(user.id) && ticket.assignee_id !== user.id) {
3709
+ matches.push(ticket);
3710
+ }
3711
+ if (matches.length >= limit) break;
3712
+ }
3713
+ nextUrl = data.next_page || null;
3714
+ }
3715
+ return matches;
3716
+ }
3717
+ function apiBaseUrl() {
3718
+ const { subdomain } = getConfig();
3719
+ return `https://${subdomain}.zendesk.com`;
3720
+ }
3721
+ program2.command("configure").description("Set up Zendesk credentials interactively").action(run(async () => {
3603
3722
  const existing = loadConfig();
3604
- console.error("Zendesk CLI Configuration");
3605
- console.error("\u2500".repeat(30));
3723
+ process.stderr.write("Zendesk CLI Configuration\n");
3724
+ process.stderr.write(`${"\u2500".repeat(30)}
3725
+ `);
3606
3726
  const subdomain = await prompt("Subdomain (xxx.zendesk.com)", existing.subdomain);
3607
3727
  const email = await prompt("Email", existing.email);
3608
3728
  const tokenInput = await promptHidden("API Token", Boolean(existing.api_token));
3609
3729
  const api_token = tokenInput || existing.api_token || "";
3610
3730
  saveConfig({ subdomain, email, api_token });
3611
- console.error("\nSaved to ~/.zendcli/config.json with restricted permissions (0600)");
3612
- console.error("Environment variables also work: ZENDESK_SUBDOMAIN, ZENDESK_EMAIL, ZENDESK_API_TOKEN");
3613
- });
3614
- program2.command("tickets").description("List tickets from Zendesk, sorted by updated_at").option("--status <status>", "Filter by status (new|open|pending|hold|solved|closed)").option("--limit <n>", "Max tickets to return", "20").option("--sort <order>", "Sort order (asc|desc)", "desc").action(async (opts) => {
3615
- const limit = parseInt(opts.limit, 10);
3616
- const data = await apiGet("/api/v2/tickets.json", {
3617
- per_page: Math.min(limit, 100),
3618
- sort_order: opts.sort,
3619
- sort_by: "updated_at"
3620
- });
3621
- let tickets = data.tickets || [];
3622
- if (opts.status) {
3623
- tickets = tickets.filter((t) => t.status === opts.status);
3731
+ printJson({ ok: true });
3732
+ }));
3733
+ program2.command("ticket <id>").description("Get a single ticket").action(run(async (id) => {
3734
+ if (!DIGITS_RE.test(id)) {
3735
+ throw new CliError("invalid_args", "ticket id must be numeric", { id });
3624
3736
  }
3625
- for (const t of tickets.slice(0, limit)) {
3626
- const status = (t.status || "").padEnd(8);
3627
- const subject = t.subject || "(no subject)";
3628
- console.log(`[${t.id}] ${status} ${subject}`);
3629
- console.log(` priority=${t.priority ?? "-"} type=${t.type ?? "-"} created=${t.created_at}`);
3630
- console.log();
3737
+ try {
3738
+ const data = await apiGet(`/api/v2/tickets/${id}.json`);
3739
+ if (!data.ticket) {
3740
+ throw new CliError("not_found", `Ticket ${id} not found`, { id: Number(id) });
3741
+ }
3742
+ printJson(data.ticket);
3743
+ } catch (error) {
3744
+ if (error instanceof ApiError && error.status === 404) {
3745
+ fail("not_found", `Ticket ${id} not found`, { id: Number(id) });
3746
+ }
3747
+ throw error;
3631
3748
  }
3632
- });
3633
- program2.command("email <email>").description("Find tickets for a requester email").option("--status <status>", "Filter status: unresolved|all|new|open|pending|hold|solved|closed or comma-separated list", "unresolved").option("--limit <n>", "Max tickets to return", "20").option("--sort <order>", "Sort order (asc|desc)", "desc").option("--json", "Output raw JSON").action(async (email, opts) => {
3634
- const limit = parseInt(opts.limit, 10);
3749
+ }));
3750
+ program2.command("email <email>").description("Find tickets for an assignee email").option("--status <status>", "unresolved|all|new|open|pending|hold|solved|closed or comma-separated list", "unresolved").option("--limit <n>", "Max tickets to return", "20").option("--sort <order>", "Sort order (asc|desc)", "desc").action(run(async (email, opts) => {
3751
+ const limit = parseLimit(opts.limit);
3752
+ const sort = parseSort(opts.sort);
3635
3753
  const statusFilter = parseStatusFilter(opts.status);
3636
- const canUseSearchStatus = opts.status === "unresolved" || statusFilter.length === 1;
3637
- let query = `type:ticket requester:${email}`;
3638
- if (opts.status === "unresolved") {
3639
- query += " status<solved";
3640
- } else if (canUseSearchStatus && statusFilter.length === 1) {
3641
- query += ` status:${statusFilter[0]}`;
3642
- }
3754
+ const query = buildSearchQuery(`type:ticket assignee:${email}`, opts.status, statusFilter);
3643
3755
  const data = await apiGet("/api/v2/search.json", {
3644
3756
  query,
3645
3757
  sort_by: "updated_at",
3646
- sort_order: opts.sort,
3758
+ sort_order: sort,
3647
3759
  per_page: 100
3648
3760
  });
3649
- let tickets = data.results || [];
3650
- if (statusFilter.length > 0 && opts.status !== "unresolved" && !canUseSearchStatus) {
3651
- tickets = tickets.filter((ticket) => statusFilter.includes((ticket.status || "").toLowerCase()));
3761
+ const tickets = filterStatuses(data.results || [], opts.status, statusFilter).slice(0, limit);
3762
+ printJson(tickets);
3763
+ }));
3764
+ program2.command("follower <email>").description("Find tickets where the user is a follower but not the assignee").option("--status <status>", "unresolved|all|new|open|pending|hold|solved|closed or comma-separated list", "unresolved").option("--limit <n>", "Max tickets to return", "20").option("--sort <order>", "Sort order (asc|desc)", "desc").action(run(async (email, opts) => {
3765
+ const limit = parseLimit(opts.limit);
3766
+ const sort = parseSort(opts.sort);
3767
+ const tickets = await fetchFollowerTickets(email, opts.status, limit, sort);
3768
+ printJson(tickets);
3769
+ }));
3770
+ program2.command("comments <ticketId>").description("List comments for a ticket").option("--type <type>", "all|public|internal", "all").option("--sort <order>", "Sort order (asc|desc)", "asc").action(run(async (ticketId, opts) => {
3771
+ if (!DIGITS_RE.test(ticketId)) {
3772
+ throw new CliError("invalid_args", "ticket id must be numeric", { ticketId });
3652
3773
  }
3653
- tickets = tickets.slice(0, limit);
3654
- if (opts.json) {
3655
- console.log(JSON.stringify(tickets, null, 2));
3656
- return;
3774
+ const sort = parseSort(opts.sort);
3775
+ if (!["all", "public", "internal"].includes(opts.type)) {
3776
+ throw new CliError("invalid_args", "type must be all, public, or internal", { type: opts.type });
3657
3777
  }
3658
- for (const t of tickets) {
3659
- const status = (t.status || "").padEnd(8);
3660
- const subject = t.subject || "(no subject)";
3661
- console.log(`[${t.id}] ${status} ${subject}`);
3662
- console.log(` priority=${t.priority ?? "-"} type=${t.type ?? "-"} requester=${email} updated=${t.updated_at}`);
3663
- console.log();
3664
- }
3665
- });
3666
- program2.command("comments <ticketId>").description("List comments/thread for a ticket (public=customer-facing, internal=agents only)").option("--type <type>", "Filter: all|public|internal", "all").option("--sort <order>", "Sort order (asc|desc)", "asc").option("--json", "Output raw JSON").action(async (ticketId, opts) => {
3667
3778
  const data = await apiGet(
3668
3779
  `/api/v2/tickets/${ticketId}/comments.json`,
3669
- { sort_order: opts.sort, per_page: 100 }
3780
+ { sort_order: sort, per_page: 100 }
3670
3781
  );
3671
3782
  let comments = data.comments || [];
3672
- if (opts.type === "public") {
3673
- comments = comments.filter((c) => c.public === true);
3674
- } else if (opts.type === "internal") {
3675
- comments = comments.filter((c) => c.public === false);
3676
- }
3677
- if (opts.json) {
3678
- console.log(JSON.stringify(comments, null, 2));
3679
- return;
3680
- }
3681
- for (const c of comments) {
3682
- const label = c.public ? "PUBLIC" : "INTERNAL";
3683
- const via = c.via?.channel ?? "?";
3684
- console.log(`--- [${label}] id=${c.id} author=${c.author_id} via=${via} ${c.created_at} ---`);
3685
- const body = (c.plain_body || c.body || "").trim();
3686
- console.log(body);
3687
- console.log();
3688
- }
3689
- });
3783
+ if (opts.type === "public") comments = comments.filter((comment) => comment.public === true);
3784
+ if (opts.type === "internal") comments = comments.filter((comment) => comment.public === false);
3785
+ printJson(comments);
3786
+ }));
3787
+ function preprocessArgv(argv) {
3788
+ const firstArg = argv[2];
3789
+ if (!firstArg || firstArg.startsWith("-") || KNOWN_COMMANDS.has(firstArg)) return argv;
3790
+ if (EMAIL_RE.test(firstArg)) return [...argv.slice(0, 2), "email", ...argv.slice(2)];
3791
+ if (DIGITS_RE.test(firstArg)) return [...argv.slice(0, 2), "ticket", ...argv.slice(2)];
3792
+ return argv;
3793
+ }
3794
+ process.argv = preprocessArgv(process.argv);
3690
3795
  program2.parse();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tplog/zendcli",
3
- "version": "0.2.2",
3
+ "version": "1.0.0",
4
4
  "description": "Minimal Zendesk CLI for tickets and comments",
5
5
  "repository": {
6
6
  "type": "git",