From cb65718507268d3dad420c30cce48245eec507aa Mon Sep 17 00:00:00 2001 From: "Marcus A." Date: Tue, 7 Apr 2026 17:36:14 +0000 Subject: [PATCH] Initial commit: BMS skill with template support --- SKILL.md | 176 +++++++++++++++ references/key-schemas.md | 107 +++++++++ scripts/bms-auth.sh | 134 +++++++++++ scripts/bms-lookup.sh | 99 +++++++++ scripts/bms-templates.sh | 185 ++++++++++++++++ scripts/bms-tickets.sh | 454 ++++++++++++++++++++++++++++++++++++++ scripts/bms.sh | 85 +++++++ 7 files changed, 1240 insertions(+) create mode 100644 SKILL.md create mode 100644 references/key-schemas.md create mode 100755 scripts/bms-auth.sh create mode 100755 scripts/bms-lookup.sh create mode 100644 scripts/bms-templates.sh create mode 100644 scripts/bms-tickets.sh create mode 100644 scripts/bms.sh diff --git a/SKILL.md b/SKILL.md new file mode 100644 index 0000000..53093b3 --- /dev/null +++ b/SKILL.md @@ -0,0 +1,176 @@ +# BMS Skill — Kaseya BMS Ticket Management + +Manage service desk tickets in Kaseya BMS (Business Management Solution) via the BMS API v2. + +## Configuration + +Required environment variables (store in shell profile or a secrets manager): + +```bash +export BMS_TENANT="your-tenant-name" # Your BMS tenant/subdomain +export BMS_USERNAME="user@example.com" # BMS login username +export BMS_PASSWORD="yourpassword" # BMS login password +# Or use client credentials (OAuth2): +export BMS_CLIENT_ID="your-client-id" +export BMS_CLIENT_SECRET="your-client-secret" +``` + +Tokens are cached automatically at `~/.bms_token.json`. + +## Commands + +All commands route through `scripts/bms.sh`. Run without arguments for usage. + +### Authentication + +```bash +bms auth # Authenticate and cache token +bms auth --status # Show token status / expiry +``` + +### Listing Tickets + +```bash +bms tickets list # All open tickets (paginated) +bms tickets list --status "Open" # Filter by status name +bms tickets list --assignee "John Smith" # Filter by assignee name +bms tickets list --from 2024-01-01 --to 2024-01-31 # Filter by created date range +bms tickets list --priority "High" # Filter by priority +bms tickets list --queue "Support" # Filter by queue +bms tickets list --account "Acme Corp" # Filter by account +bms tickets list --page 2 --page-size 50 # Pagination +bms tickets list --format json # Raw JSON output +``` + +### Getting a Ticket + +```bash +bms tickets get # Get full ticket details +bms tickets get --json # Raw JSON +``` + +### Creating Tickets + +```bash +bms tickets create \ + --title "Server is down" \ + --details "The main server stopped responding at 2pm" \ + --account-id 123 \ + --location-id 456 \ + --status-id 1 \ + --priority-id 2 \ + --type-id 1 \ + --source-id 1 \ + --assignee-id 789 +``` + +Create from a **template** (pre-fills fields; CLI overrides take precedence): + +```bash +bms tickets create --template-id 7 --account-id 123 --location-id 456 +# Fields from template 7 are used; only account/location are overridden. +# Any required field still missing triggers an interactive prompt. +``` + +Or use fully interactive mode (prompts for all required fields): + +```bash +bms tickets create --interactive +``` + +### Updating Tickets + +```bash +bms tickets update --status-id 3 # Change status +bms tickets update --assignee-id 789 # Reassign +bms tickets update --priority-id 1 # Change priority +bms tickets update --title "New title" # Update title +``` + +### Adding Notes + +```bash +bms tickets note --message "Called client, investigating" +bms tickets note --message "Internal update" --internal +bms tickets note --message "Resolved via restart" --status-id 5 +``` + +### Assigning Tickets + +```bash +bms tickets assign --assignee-id 789 --note "Routing to tier 2" +bms tickets assign --queue-id 3 +``` + +### Resolving Tickets + +```bash +bms tickets resolve --comment "Replaced failed drive, server is back online" +bms tickets resolve --comment "Fixed" --status-id 6 --publish-kb +``` + +### Deleting Tickets + +```bash +bms tickets delete # Delete single ticket +bms tickets delete 123 456 789 # Delete multiple tickets +``` + +### Lookup Tables (for getting valid IDs) + +```bash +bms lookup statuses # List all ticket statuses with IDs +bms lookup priorities # List all priorities with IDs +bms lookup queues # List all queues with IDs +bms lookup issue-types # List all issue types +bms lookup assignees # List all assignees/technicians +bms lookup ticket-types # List ticket types +bms lookup sources # List ticket sources +``` + +### Templates + +Browse pre-defined ticket, note, and timelog templates configured in BMS. + +```bash +bms templates tickets list # List all ticket templates (Id, Name, QueueId, PriorityId, etc.) +bms templates tickets get # Inspect a specific ticket template (raw JSON) +bms templates notes list # List all note templates +bms templates timelogs list # List all timelog templates + +# Add --format json to any list command for raw JSON output +bms templates tickets list --format json +``` + +## Template-Based Ticket Creation + +`bms tickets create --template-id ` does the following: + +1. Fetches `GET /v2/servicedesk/templates/tickets/{templateId}` to retrieve template defaults. +2. Merges them with any CLI overrides you provide (`--title`, `--description`, `--account-id`, `--location-id`, `--status-id`, `--priority-id`, `--type-id`, `--source-id`, `--assignee-id`, `--queue-id`, `--due-date`, `--contact-id`). CLI values always win. +3. Prompts interactively (via stdin) for any required field still missing after the merge. +4. Posts the final payload to `POST /v2/servicedesk/tickets`. + +Use `bms templates tickets list` to see available template IDs before creating. + +## Notes / Quirks + +- **Auth**: BMS uses JWT Bearer tokens obtained via `POST /v2/security/authenticate` with `GrantType=password`. Tokens expire; the skill auto-refreshes using the refresh token endpoint. +- **Required fields for ticket creation**: Title, Details, AccountId, LocationId, StatusId, PriorityId, TypeId, SourceId, OpenDate — all are required by the API schema. +- **IDs not names**: The API uses integer IDs for status, priority, type, etc. Use `bms lookup` commands to find the right IDs for your tenant. +- **Search vs GET list**: For filtered searches, `POST /v2/servicedesk/tickets/search` (with body) is more flexible than `GET /v2/servicedesk/tickets` (with query params); this skill uses the POST search by default. +- **Pagination**: Default page size is 25. Use `--page-size` (max appears to be 100) and `--page` for large result sets. +- **Date format**: Dates should be ISO 8601 strings, e.g. `2024-01-15T00:00:00`. +- **Note TypeId**: Required when posting notes. Use `bms lookup note-types` to find valid IDs (typically: 1=Comment, 2=Resolution, etc. — varies by tenant). +- **Rate limits**: Not documented in the Swagger spec. Treat as standard REST API — avoid tight loops; add a small sleep between bulk operations. + +## References + +- [BMS API Swagger UI](https://api.bms.kaseya.com/swagger/index.html) +- [BMS API Swagger JSON](https://api.bms.kaseya.com/swagger/v2/swagger.json) +- `references/key-schemas.md` — TicketInputDto, filter, and note schemas +- `scripts/bms.sh` — Main CLI entrypoint +- `scripts/bms-auth.sh` — Auth and token management +- `scripts/bms-tickets.sh` — Ticket CRUD operations (includes `--template-id` support) +- `scripts/bms-lookup.sh` — Lookup table helpers +- `scripts/bms-templates.sh` — Template listing commands (tickets, notes, timelogs) diff --git a/references/key-schemas.md b/references/key-schemas.md new file mode 100644 index 0000000..c0ff512 --- /dev/null +++ b/references/key-schemas.md @@ -0,0 +1,107 @@ +# Key BMS API Schemas + +Source: `GET https://api.bms.kaseya.com/swagger/v2/swagger.json` + +## TicketInputDto (POST/PUT /v2/servicedesk/tickets) + +| Field | Type | Required | Notes | +|---|---|---|---| +| Title | string | ✅ | Ticket subject | +| Details | string | ✅ | Body/description | +| AccountId | integer | ✅ | Client account ID | +| LocationId | integer | ✅ | Account location ID | +| StatusId | integer | ✅ | Use `bms lookup statuses` | +| PriorityId | integer | ✅ | Use `bms lookup priorities` | +| TypeId | integer | ✅ | Use `bms lookup ticket-types` | +| SourceId | integer | ✅ | Use `bms lookup sources` | +| OpenDate | string (ISO 8601) | ✅ | e.g. `2024-01-15T00:00:00` | +| ContactId | integer | | Contact person at the account | +| AssigneeId | integer | | Technician assigned | +| QueueId | integer | | Queue to place ticket in | +| IssueTypeId | integer | | | +| SubIssueTypeId | integer | | | +| DueDate | string | | ISO 8601 | +| SLAId | integer | | | +| WorkTypeId | integer | | | + +## TicketFilterDto (inside GetTicketsInputDto.Filter) + +Useful filter fields for `POST /v2/servicedesk/tickets/search`: + +| Field | Type | Notes | +|---|---|---| +| StatusNames | string | Comma-separated or single status name | +| AssigneeName | string | Partial match supported | +| PriorityNames | string | Priority name(s) | +| QueueNames | string | Queue name(s) | +| Account | string | Account name search | +| Title | string | Ticket title search | +| CreatedOnFrom | string (ISO 8601) | Date range start | +| CreatedOnTo | string (ISO 8601) | Date range end | +| OpenDateFrom / OpenDateTo | string | Open date range | +| DueDateFrom / DueDateTo | string | Due date range | +| ExcludeCompleted | integer | 1 = exclude completed | +| AccountIds | string | Comma-separated account IDs | + +## TicketNotePostInputDto (POST /v2/servicedesk/tickets/{id}/notes) + +| Field | Type | Required | Notes | +|---|---|---|---| +| Details | string | ✅ | Note body | +| IsInternal | boolean | ✅ | true = internal (hidden from client portal) | +| TypeId | integer | ✅ | Note type ID — use `bms lookup note-types` | +| NoteDate | string (ISO 8601) | ✅ | When the note was created | +| StatusId | integer | | Change ticket status simultaneously | + +## AssignTicketInputDto (POST /v2/servicedesk/tickets/{id}/assignticket) + +| Field | Type | Required | Notes | +|---|---|---|---| +| Details | string | ✅ | Assignment note text | +| IsInternal | boolean | ✅ | | +| TypeId | integer | ✅ | Note type | +| NoteDate | string | ✅ | | +| StatusId | integer | ✅ | Status after assignment | +| AssigneeId | integer | | New assignee | +| QueueId | integer | | Queue to move to | + +## ResolveTicketDto (POST /v2/servicedesk/tickets/{id}/resolve) + +| Field | Type | Required | Notes | +|---|---|---|---| +| Comment | string | ✅ | Resolution description | +| StatusId | integer | | Override resolved status | +| IsPublishToKnowledgeBase | boolean | | Add to KB | +| IsInternal | boolean | | | + +## Pagination (GetTicketsInputDto) + +| Field | Notes | +|---|---| +| PageNumber | 1-indexed | +| PageSize | Max ~100 | +| Sort | e.g. `"CreatedOn desc"` | + +## Auth: POST /v2/security/authenticate + +Multipart form-data: + +| Field | Notes | +|---|---| +| GrantType | `"password"` or `"client_credentials"` | +| UserName | For password grant | +| Password | For password grant | +| Tenant | Your tenant name (required) | +| ClientId | For client_credentials grant | +| ClientSecret | For client_credentials grant | + +Response contains: `AccessToken`, `RefreshToken`, `ExpiresIn` + +## Auth: POST /v2/security/refreshtoken + +JSON body: + +| Field | Required | +|---|---| +| AccessToken | ✅ | +| RefreshToken | ✅ | diff --git a/scripts/bms-auth.sh b/scripts/bms-auth.sh new file mode 100755 index 0000000..d01d8ad --- /dev/null +++ b/scripts/bms-auth.sh @@ -0,0 +1,134 @@ +#!/usr/bin/env bash +# bms-auth.sh — Kaseya BMS authentication helper +# Obtains and caches JWT tokens. Called by bms.sh. + +set -euo pipefail + +BMS_API_BASE="${BMS_API_BASE:-https://api.bms.kaseya.com}" +BMS_TOKEN_FILE="${BMS_TOKEN_FILE:-$HOME/.bms_token.json}" + +# ─── Helpers ──────────────────────────────────────────────────────────────── + +die() { echo "ERROR: $*" >&2; exit 1; } + +require_env() { + local var="$1" + [[ -n "${!var:-}" ]] || die "Environment variable $var is required. See SKILL.md for setup." +} + +token_is_valid() { + # Returns 0 (true) if cached token exists and has not expired (with 60s buffer) + [[ -f "$BMS_TOKEN_FILE" ]] || return 1 + local exp + exp=$(jq -r '.expires_at // 0' "$BMS_TOKEN_FILE" 2>/dev/null) || return 1 + local now + now=$(date +%s) + [[ $((exp - 60)) -gt $now ]] +} + +save_token() { + local response="$1" + local access_token refresh_token expires_in expires_at + access_token=$(echo "$response" | jq -r '.AccessToken // .access_token // empty') + refresh_token=$(echo "$response" | jq -r '.RefreshToken // .refresh_token // empty') + expires_in=$(echo "$response" | jq -r '.ExpiresIn // .expires_in // 3600') + expires_at=$(( $(date +%s) + expires_in )) + + [[ -n "$access_token" ]] || die "No access token in auth response: $response" + + jq -n \ + --arg at "$access_token" \ + --arg rt "${refresh_token:-}" \ + --argjson ea "$expires_at" \ + '{access_token: $at, refresh_token: $rt, expires_at: $ea}' \ + > "$BMS_TOKEN_FILE" + chmod 600 "$BMS_TOKEN_FILE" +} + +# ─── Auth Actions ──────────────────────────────────────────────────────────── + +cmd_auth_login() { + require_env BMS_TENANT + + local response + + if [[ -n "${BMS_CLIENT_ID:-}" && -n "${BMS_CLIENT_SECRET:-}" ]]; then + # OAuth2 client credentials flow + echo "Authenticating with client credentials..." >&2 + response=$(curl -sf -X POST "${BMS_API_BASE}/v2/security/authenticate" \ + -F "GrantType=client_credentials" \ + -F "ClientId=${BMS_CLIENT_ID}" \ + -F "ClientSecret=${BMS_CLIENT_SECRET}" \ + -F "Tenant=${BMS_TENANT}") || die "Authentication request failed" + else + # Password flow + require_env BMS_USERNAME + require_env BMS_PASSWORD + echo "Authenticating with username/password..." >&2 + response=$(curl -sf -X POST "${BMS_API_BASE}/v2/security/authenticate" \ + -F "GrantType=password" \ + -F "UserName=${BMS_USERNAME}" \ + -F "Password=${BMS_PASSWORD}" \ + -F "Tenant=${BMS_TENANT}") || die "Authentication request failed" + fi + + save_token "$response" + echo "Authenticated successfully. Token cached at $BMS_TOKEN_FILE" >&2 +} + +cmd_auth_refresh() { + [[ -f "$BMS_TOKEN_FILE" ]] || die "No cached token. Run: bms auth" + local access_token refresh_token + access_token=$(jq -r '.access_token' "$BMS_TOKEN_FILE") + refresh_token=$(jq -r '.refresh_token // empty' "$BMS_TOKEN_FILE") + + [[ -n "$refresh_token" ]] || { cmd_auth_login; return; } + + local response + response=$(curl -sf -X POST "${BMS_API_BASE}/v2/security/refreshtoken" \ + -H "Content-Type: application/json" \ + -d "{\"AccessToken\":\"${access_token}\",\"RefreshToken\":\"${refresh_token}\"}") \ + || { echo "Refresh failed, re-authenticating..." >&2; cmd_auth_login; return; } + + save_token "$response" + echo "Token refreshed." >&2 +} + +cmd_auth_status() { + if [[ ! -f "$BMS_TOKEN_FILE" ]]; then + echo "No token cached." + return + fi + local expires_at now remaining + expires_at=$(jq -r '.expires_at // 0' "$BMS_TOKEN_FILE") + now=$(date +%s) + remaining=$((expires_at - now)) + if [[ $remaining -gt 0 ]]; then + echo "Token valid. Expires in ${remaining}s (at $(date -d "@${expires_at}" 2>/dev/null || date -r "${expires_at}" 2>/dev/null || echo "unknown"))" + else + echo "Token expired ${remaining#-}s ago." + fi +} + +# ─── Public: get_token ─────────────────────────────────────────────────────── +# Prints the current access token, refreshing/authenticating as needed. +get_token() { + if ! token_is_valid; then + if [[ -f "$BMS_TOKEN_FILE" ]]; then + cmd_auth_refresh + else + cmd_auth_login + fi + fi + jq -r '.access_token' "$BMS_TOKEN_FILE" +} + +# ─── Dispatch ──────────────────────────────────────────────────────────────── + +case "${1:-}" in + login|"") cmd_auth_login ;; + refresh) cmd_auth_refresh ;; + status) cmd_auth_status ;; + get-token) get_token ;; + *) echo "Usage: bms-auth.sh [login|refresh|status|get-token]" >&2; exit 1 ;; +esac diff --git a/scripts/bms-lookup.sh b/scripts/bms-lookup.sh new file mode 100755 index 0000000..52d4cce --- /dev/null +++ b/scripts/bms-lookup.sh @@ -0,0 +1,99 @@ +#!/usr/bin/env bash +# bms-lookup.sh — Fetch lookup tables (statuses, priorities, queues, etc.) + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +BMS_API_BASE="${BMS_API_BASE:-https://api.bms.kaseya.com}" + +die() { echo "ERROR: $*" >&2; exit 1; } + +get_token() { + bash "${SCRIPT_DIR}/bms-auth.sh" get-token +} + +bms_curl() { + local path="$1"; shift + local token + token=$(get_token) + curl -sf -X GET \ + "${BMS_API_BASE}${path}" \ + -H "Authorization: Bearer ${token}" \ + -H "Accept: application/json" \ + "$@" +} + +format_lookup() { + jq -r ' + (.Data // .Items // .) | + if type == "array" then .[] + else . + end | + "\(.Id // .id)\t\(.Name // .name // .Text // .text // "(unnamed)")" + ' | sort -n | column -t -s $'\t' +} + +cmd_lookup() { + local table="$1" + case "$table" in + statuses|status) + echo "=== Ticket Statuses ===" >&2 + bms_curl "/v2/system/statuses/lookup" | format_lookup + ;; + priorities|priority) + echo "=== Priorities ===" >&2 + bms_curl "/v2/system/priorities/lookup" | format_lookup + ;; + queues|queue) + echo "=== Queues ===" >&2 + bms_curl "/v2/system/queues/lookup" | format_lookup + ;; + issue-types|issuetypes) + echo "=== Issue Types ===" >&2 + bms_curl "/v2/system/issuetypes/lookup" | format_lookup + ;; + sources|source) + echo "=== Ticket Sources ===" >&2 + bms_curl "/v2/system/lookup/TicketSource" | format_lookup + ;; + ticket-types|tickettypes) + echo "=== Ticket Types ===" >&2 + bms_curl "/v2/system/lookup/TicketType" | format_lookup + ;; + assignees|assignee|technicians) + echo "=== Assignees / Technicians ===" >&2 + bms_curl "/v2/hr/assignees/lookup" | format_lookup + ;; + slas|sla) + echo "=== SLAs ===" >&2 + bms_curl "/v2/system/slas/lookup" | format_lookup + ;; + work-types|worktypes) + echo "=== Work Types ===" >&2 + bms_curl "/v2/system/worktypes/lookup" | format_lookup + ;; + note-types|notetypes) + echo "=== Note Types (via tenantlookup) ===" >&2 + bms_curl "/v2/system/tenantlookup/NoteType" | format_lookup + ;; + all) + cmd_lookup statuses + echo + cmd_lookup priorities + echo + cmd_lookup queues + echo + cmd_lookup issue-types + echo + cmd_lookup assignees + ;; + *) + die "Unknown lookup table: $table +Available: statuses, priorities, queues, issue-types, sources, ticket-types, assignees, slas, work-types, note-types, all" + ;; + esac +} + +table="${1:-}" +[[ -n "$table" ]] || die "Usage: bms lookup " +cmd_lookup "$table" diff --git a/scripts/bms-templates.sh b/scripts/bms-templates.sh new file mode 100644 index 0000000..7693839 --- /dev/null +++ b/scripts/bms-templates.sh @@ -0,0 +1,185 @@ +#!/usr/bin/env bash +# bms-templates.sh — Kaseya BMS template lookups (tickets, notes, timelogs) + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +BMS_API_BASE="${BMS_API_BASE:-https://api.bms.kaseya.com}" + +# ─── Helpers ───────────────────────────────────────────────────────────────── + +die() { echo "ERROR: $*" >&2; exit 1; } + +get_token() { + bash "${SCRIPT_DIR}/bms-auth.sh" get-token +} + +bms_curl() { + local method="$1"; shift + local path="$1"; shift + local token + token=$(get_token) + curl -sf -X "$method" \ + "${BMS_API_BASE}${path}" \ + -H "Authorization: Bearer ${token}" \ + -H "Accept: application/json" \ + -H "Content-Type: application/json" \ + "$@" +} + +# ─── Formatters ────────────────────────────────────────────────────────────── + +format_template_list() { + jq -r ' + (.Data // .Items // .) | + if type == "array" then .[] else . end | + [ + (.Id // "-"), + (.Name // "(unnamed)"), + ("Q:" + ((.QueueId // "-") | tostring)), + ("Pri:" + ((.PriorityId // "-") | tostring)), + ("Type:" + ((.IssueTypeId // .TypeId // "-") | tostring)) + ] | @tsv + ' | sort -n | column -t -s $'\t' +} + +format_simple_lookup() { + # Generic: Id + Name for note/timelog templates + jq -r ' + (.Data // .Items // .) | + if type == "array" then .[] else . end | + "\(.Id // "-")\t\(.Name // .Text // "(unnamed)")" + ' | sort -n | column -t -s $'\t' +} + +# ─── Templates: Tickets ─────────────────────────────────────────────────────── + +cmd_templates_tickets_list() { + local format="table" + while [[ $# -gt 0 ]]; do + case "$1" in + --format|-f) format="$2"; shift 2 ;; + *) die "Unknown option: $1" ;; + esac + done + + echo "=== Ticket Templates ===" >&2 + local response + response=$(bms_curl GET "/v2/servicedesk/templates/tickets/lookup") + + if [[ "$format" == "json" ]]; then + echo "$response" | jq . + else + echo "$response" | format_template_list + fi +} + +cmd_templates_tickets_get() { + local template_id="${1:-}" + [[ -n "$template_id" ]] || die "Usage: bms templates tickets get " + + bms_curl GET "/v2/servicedesk/templates/tickets/${template_id}" | jq . +} + +# ─── Templates: Notes ──────────────────────────────────────────────────────── + +cmd_templates_notes_list() { + local format="table" + while [[ $# -gt 0 ]]; do + case "$1" in + --format|-f) format="$2"; shift 2 ;; + *) die "Unknown option: $1" ;; + esac + done + + echo "=== Note Templates ===" >&2 + local response + response=$(bms_curl GET "/v2/servicedesk/templates/notes/lookup") + + if [[ "$format" == "json" ]]; then + echo "$response" | jq . + else + echo "$response" | format_simple_lookup + fi +} + +# ─── Templates: Timelogs ───────────────────────────────────────────────────── + +cmd_templates_timelogs_list() { + local format="table" + while [[ $# -gt 0 ]]; do + case "$1" in + --format|-f) format="$2"; shift 2 ;; + *) die "Unknown option: $1" ;; + esac + done + + echo "=== Timelog Templates ===" >&2 + local response + response=$(bms_curl GET "/v2/servicedesk/templates/timelogs/lookup") + + if [[ "$format" == "json" ]]; then + echo "$response" | jq . + else + echo "$response" | format_simple_lookup + fi +} + +# ─── Dispatch ──────────────────────────────────────────────────────────────── + +usage_templates() { + cat >&2 <<'EOF' +Usage: bms templates [options] + +Resources and subcommands: + tickets list List all ticket templates + tickets get Get full details for a ticket template + notes list List all note templates + timelogs list List all timelog templates + +Options: + --format json Output raw JSON instead of table + +Examples: + bms templates tickets list + bms templates tickets get 42 + bms templates notes list + bms templates timelogs list +EOF + exit 1 +} + +resource="${1:-}" +[[ -n "$resource" ]] || usage_templates +shift + +case "$resource" in + tickets|ticket) + subcmd="${1:-list}" + [[ $# -gt 0 ]] && shift + case "$subcmd" in + list) cmd_templates_tickets_list "$@" ;; + get) cmd_templates_tickets_get "$@" ;; + *) die "Unknown tickets template subcommand: $subcmd (available: list, get)" ;; + esac + ;; + notes|note) + subcmd="${1:-list}" + [[ $# -gt 0 ]] && shift + case "$subcmd" in + list) cmd_templates_notes_list "$@" ;; + *) die "Unknown notes template subcommand: $subcmd (available: list)" ;; + esac + ;; + timelogs|timelog) + subcmd="${1:-list}" + [[ $# -gt 0 ]] && shift + case "$subcmd" in + list) cmd_templates_timelogs_list "$@" ;; + *) die "Unknown timelogs template subcommand: $subcmd (available: list)" ;; + esac + ;; + *) + die "Unknown template resource: $resource (available: tickets, notes, timelogs)" + ;; +esac diff --git a/scripts/bms-tickets.sh b/scripts/bms-tickets.sh new file mode 100644 index 0000000..8a3d657 --- /dev/null +++ b/scripts/bms-tickets.sh @@ -0,0 +1,454 @@ +#!/usr/bin/env bash +# bms-tickets.sh — Kaseya BMS ticket CRUD operations + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +BMS_API_BASE="${BMS_API_BASE:-https://api.bms.kaseya.com}" + +# ─── Helpers ───────────────────────────────────────────────────────────────── + +die() { echo "ERROR: $*" >&2; exit 1; } + +get_token() { + bash "${SCRIPT_DIR}/bms-auth.sh" get-token +} + +bms_curl() { + local method="$1"; shift + local path="$1"; shift + local token + token=$(get_token) + + curl -sf -X "$method" \ + "${BMS_API_BASE}${path}" \ + -H "Authorization: Bearer ${token}" \ + -H "Content-Type: application/json" \ + "$@" +} + +# Pretty-print a ticket list +format_ticket_list() { + jq -r ' + (.Data // .Items // .) | + if type == "array" then .[] else . end | + "\(.TicketNumber // .Id)\t[\(.StatusName // "?")] \(.Title)\t| \(.AccountName // "?")\t| Assignee: \(.AssigneeName // "unassigned")\t| Priority: \(.PriorityName // "?")" + ' | column -t -s $'\t' +} + +format_ticket_detail() { + jq -r ' + .Data // . | + "Ticket: \(.TicketNumber) (ID: \(.Id)) +Title: \(.Title) +Status: \(.StatusName) (ID: \(.StatusId)) +Priority: \(.PriorityName) (ID: \(.PriorityId)) +Account: \(.AccountName) (ID: \(.AccountId)) +Location: \(.LocationName // "N/A") +Contact: \(.ContactName // "N/A") +Queue: \(.QueueName // "N/A") +Assignee: \(.AssigneeName // "unassigned") +Type: \(.TypeName // "N/A") +Created: \(.CreatedOn) +Modified: \(.ModifiedOn) +Due: \(.DueDate // "none") +--- +\(.Details // "(no details)")" + ' +} + +# ─── Commands ──────────────────────────────────────────────────────────────── + +cmd_list() { + local status="" assignee="" from="" to="" priority="" queue="" account="" + local page=1 page_size=25 format="table" + + while [[ $# -gt 0 ]]; do + case "$1" in + --status) status="$2"; shift 2 ;; + --assignee) assignee="$2"; shift 2 ;; + --from) from="$2"; shift 2 ;; + --to) to="$2"; shift 2 ;; + --priority) priority="$2"; shift 2 ;; + --queue) queue="$2"; shift 2 ;; + --account) account="$2"; shift 2 ;; + --page) page="$2"; shift 2 ;; + --page-size) page_size="$2"; shift 2 ;; + --format) format="$2"; shift 2 ;; + *) die "Unknown option: $1" ;; + esac + done + + # Build filter JSON + local filter="{" + local sep="" + [[ -n "$status" ]] && { filter+="${sep}\"StatusNames\":\"${status}\""; sep=","; } + [[ -n "$assignee" ]] && { filter+="${sep}\"AssigneeName\":\"${assignee}\""; sep=","; } + [[ -n "$from" ]] && { filter+="${sep}\"CreatedOnFrom\":\"${from}T00:00:00\""; sep=","; } + [[ -n "$to" ]] && { filter+="${sep}\"CreatedOnTo\":\"${to}T23:59:59\""; sep=","; } + [[ -n "$priority" ]] && { filter+="${sep}\"PriorityNames\":\"${priority}\""; sep=","; } + [[ -n "$queue" ]] && { filter+="${sep}\"QueueNames\":\"${queue}\""; sep=","; } + [[ -n "$account" ]] && { filter+="${sep}\"Account\":\"${account}\""; sep=","; } + filter+="}" + + local body + body=$(jq -n \ + --argjson filter "$filter" \ + --argjson page "$page" \ + --argjson page_size "$page_size" \ + '{Filter: $filter, PageNumber: $page, PageSize: $page_size}') + + local response + response=$(bms_curl POST "/v2/servicedesk/tickets/search" -d "$body") + + if [[ "$format" == "json" ]]; then + echo "$response" | jq . + else + local total + total=$(echo "$response" | jq -r '.TotalCount // .Total // "?"') + echo "Tickets (page ${page}, ${page_size} per page, total: ${total}):" >&2 + echo "$response" | format_ticket_list + fi +} + +cmd_get() { + local ticket_id="${1:-}" + local format="${2:-table}" + [[ -n "$ticket_id" ]] || die "Usage: bms tickets get " + + local response + response=$(bms_curl GET "/v2/servicedesk/tickets/${ticket_id}") + + if [[ "$format" == "json" ]] || [[ "${2:-}" == "--json" ]]; then + echo "$response" | jq . + else + echo "$response" | format_ticket_detail + fi +} + +# Prompt for a value if empty; first arg is field label, second is current value (by nameref) +# Usage: prompt_if_empty "Label" varname +prompt_if_empty() { + local label="$1" + local -n _ref="$2" + if [[ -z "${_ref:-}" ]]; then + read -r -p "${label}: " _ref + fi +} + +cmd_create() { + local title="" details="" account_id="" location_id="" contact_id="" + local status_id="" priority_id="" type_id="" source_id="" + local assignee_id="" queue_id="" due_date="" open_date="" + local template_id="" + local interactive=false + + while [[ $# -gt 0 ]]; do + case "$1" in + --template-id) template_id="$2"; shift 2 ;; + --title) title="$2"; shift 2 ;; + --details|--description) details="$2"; shift 2 ;; + --account-id) account_id="$2"; shift 2 ;; + --location-id) location_id="$2"; shift 2 ;; + --contact-id) contact_id="$2"; shift 2 ;; + --status-id) status_id="$2"; shift 2 ;; + --priority-id) priority_id="$2"; shift 2 ;; + --type-id) type_id="$2"; shift 2 ;; + --source-id) source_id="$2"; shift 2 ;; + --assignee-id) assignee_id="$2"; shift 2 ;; + --queue-id) queue_id="$2"; shift 2 ;; + --due-date) due_date="$2"; shift 2 ;; + --interactive) interactive=true; shift ;; + *) die "Unknown option: $1" ;; + esac + done + + # ── Template pre-fill ──────────────────────────────────────────────────── + if [[ -n "$template_id" ]]; then + echo "Fetching template ${template_id}..." >&2 + local tmpl + tmpl=$(bms_curl GET "/v2/servicedesk/templates/tickets/${template_id}" | jq '.Data // .') + + # Fill only fields not already set by CLI flags + [[ -z "$title" ]] && title=$(echo "$tmpl" | jq -r '.Title // empty') + [[ -z "$details" ]] && details=$(echo "$tmpl" | jq -r '.Details // empty') + [[ -z "$status_id" ]] && status_id=$(echo "$tmpl" | jq -r '.StatusId // empty') + [[ -z "$priority_id" ]] && priority_id=$(echo "$tmpl" | jq -r '.PriorityId // empty') + [[ -z "$type_id" ]] && type_id=$(echo "$tmpl" | jq -r '(.TypeId // .IssueTypeId) // empty') + [[ -z "$source_id" ]] && source_id=$(echo "$tmpl" | jq -r '.SourceId // empty') + [[ -z "$queue_id" ]] && queue_id=$(echo "$tmpl" | jq -r '.QueueId // empty') + [[ -z "$assignee_id" ]] && assignee_id=$(echo "$tmpl" | jq -r '.AssigneeId // empty') + [[ -z "$account_id" ]] && account_id=$(echo "$tmpl" | jq -r '.AccountId // empty') + [[ -z "$location_id" ]] && location_id=$(echo "$tmpl" | jq -r '.LocationId // empty') + [[ -z "$contact_id" ]] && contact_id=$(echo "$tmpl" | jq -r '.ContactId // empty') + fi + + # ── Interactive prompts ─────────────────────────────────────────────────── + if $interactive; then + prompt_if_empty "Title" title + prompt_if_empty "Details" details + prompt_if_empty "Account ID" account_id + prompt_if_empty "Location ID" location_id + prompt_if_empty "Status ID" status_id + prompt_if_empty "Priority ID" priority_id + prompt_if_empty "Type ID" type_id + prompt_if_empty "Source ID" source_id + prompt_if_empty "Assignee ID (optional, Enter to skip)" assignee_id + else + # When using a template, prompt only for fields still missing that are required + if [[ -n "$template_id" ]]; then + [[ -n "$title" ]] || { read -r -p "Title: " title; } + [[ -n "$details" ]] || { read -r -p "Details: " details; } + [[ -n "$account_id" ]] || { read -r -p "Account ID: " account_id; } + [[ -n "$location_id" ]] || { read -r -p "Location ID: " location_id; } + [[ -n "$status_id" ]] || { read -r -p "Status ID: " status_id; } + [[ -n "$priority_id" ]] || { read -r -p "Priority ID: " priority_id; } + [[ -n "$type_id" ]] || { read -r -p "Type ID: " type_id; } + [[ -n "$source_id" ]] || { read -r -p "Source ID: " source_id; } + fi + fi + + [[ -n "$title" ]] || die "--title is required" + [[ -n "$details" ]] || die "--details is required" + [[ -n "$account_id" ]] || die "--account-id is required" + [[ -n "$location_id" ]] || die "--location-id is required" + [[ -n "$status_id" ]] || die "--status-id is required" + [[ -n "$priority_id" ]] || die "--priority-id is required" + [[ -n "$type_id" ]] || die "--type-id is required" + [[ -n "$source_id" ]] || die "--source-id is required" + + open_date="${open_date:-$(date -u +%Y-%m-%dT%H:%M:%S)}" + + local body + body=$(jq -n \ + --arg title "$title" \ + --arg details "$details" \ + --argjson account_id "$account_id" \ + --argjson location_id "$location_id" \ + --argjson status_id "$status_id" \ + --argjson priority_id "$priority_id" \ + --argjson type_id "$type_id" \ + --argjson source_id "$source_id" \ + --arg open_date "$open_date" \ + '{ + Title: $title, + Details: $details, + AccountId: $account_id, + LocationId: $location_id, + StatusId: $status_id, + PriorityId: $priority_id, + TypeId: $type_id, + SourceId: $source_id, + OpenDate: $open_date + }') + + # Optionally add non-required fields + [[ -n "$contact_id" ]] && body=$(echo "$body" | jq --argjson v "$contact_id" '. + {ContactId: $v}') + [[ -n "$assignee_id" ]] && body=$(echo "$body" | jq --argjson v "$assignee_id" '. + {AssigneeId: $v}') + [[ -n "$queue_id" ]] && body=$(echo "$body" | jq --argjson v "$queue_id" '. + {QueueId: $v}') + [[ -n "$due_date" ]] && body=$(echo "$body" | jq --arg v "$due_date" '. + {DueDate: $v}') + + local response + response=$(bms_curl POST "/v2/servicedesk/tickets" -d "$body") + echo "$response" | jq -r '"Created ticket ID: \(.Data.Id // .Id) — \(.Data.TicketNumber // .TicketNumber // "N/A")"' +} + +cmd_update() { + local ticket_id="${1:-}" + [[ -n "$ticket_id" ]] || die "Usage: bms tickets update [options]" + shift + + # Fetch current ticket first so we can do a full PUT with changes merged + local current + current=$(bms_curl GET "/v2/servicedesk/tickets/${ticket_id}" | jq '.Data // .') + + local patch="{}" + while [[ $# -gt 0 ]]; do + case "$1" in + --title) patch=$(echo "$patch" | jq --arg v "$2" '. + {Title: $v}'); shift 2 ;; + --details) patch=$(echo "$patch" | jq --arg v "$2" '. + {Details: $v}'); shift 2 ;; + --status-id) patch=$(echo "$patch" | jq --argjson v "$2" '. + {StatusId: $v}'); shift 2 ;; + --priority-id) patch=$(echo "$patch" | jq --argjson v "$2" '. + {PriorityId: $v}'); shift 2 ;; + --assignee-id) patch=$(echo "$patch" | jq --argjson v "$2" '. + {AssigneeId: $v}'); shift 2 ;; + --queue-id) patch=$(echo "$patch" | jq --argjson v "$2" '. + {QueueId: $v}'); shift 2 ;; + --due-date) patch=$(echo "$patch" | jq --arg v "$2" '. + {DueDate: $v}'); shift 2 ;; + *) die "Unknown option: $1" ;; + esac + done + + # Merge patch onto current (keep required fields from current ticket) + local body + body=$(echo "$current" | jq \ + --argjson patch "$patch" \ + '{ + Title: .Title, + Details: .Details, + AccountId: .AccountId, + LocationId: .LocationId, + StatusId: .StatusId, + PriorityId: .PriorityId, + TypeId: .TypeId, + SourceId: .SourceId, + OpenDate: .OpenDate, + AssigneeId: .AssigneeId, + QueueId: .QueueId, + ContactId: .ContactId + } * $patch') + + local response + response=$(bms_curl PUT "/v2/servicedesk/tickets/${ticket_id}" -d "$body") + echo "$response" | jq -r '"Updated ticket \(.Data.Id // .Id // "'"$ticket_id"'")"' +} + +cmd_note() { + local ticket_id="${1:-}" + [[ -n "$ticket_id" ]] || die "Usage: bms tickets note --message [options]" + shift + + local message="" is_internal=false type_id=1 status_id="" note_date="" + + while [[ $# -gt 0 ]]; do + case "$1" in + --message|-m) message="$2"; shift 2 ;; + --internal) is_internal=true; shift ;; + --type-id) type_id="$2"; shift 2 ;; + --status-id) status_id="$2"; shift 2 ;; + *) die "Unknown option: $1" ;; + esac + done + + [[ -n "$message" ]] || die "--message is required" + note_date=$(date -u +%Y-%m-%dT%H:%M:%S) + + local body + body=$(jq -n \ + --arg details "$message" \ + --argjson is_internal "$is_internal" \ + --argjson type_id "$type_id" \ + --arg note_date "$note_date" \ + '{ + Details: $details, + IsInternal: $is_internal, + TypeId: $type_id, + NoteDate: $note_date + }') + + [[ -n "$status_id" ]] && body=$(echo "$body" | jq --argjson v "$status_id" '. + {StatusId: $v}') + + local response + response=$(bms_curl POST "/v2/servicedesk/tickets/${ticket_id}/notes" -d "$body") + echo "$response" | jq -r '"Note added (ID: \(.Data.Id // .Id // "ok"))"' +} + +cmd_assign() { + local ticket_id="${1:-}" + [[ -n "$ticket_id" ]] || die "Usage: bms tickets assign [options]" + shift + + local assignee_id="" queue_id="" note="" is_internal=false type_id=1 status_id="" + + while [[ $# -gt 0 ]]; do + case "$1" in + --assignee-id) assignee_id="$2"; shift 2 ;; + --queue-id) queue_id="$2"; shift 2 ;; + --note|-n) note="$2"; shift 2 ;; + --internal) is_internal=true; shift ;; + --type-id) type_id="$2"; shift 2 ;; + --status-id) status_id="$2"; shift 2 ;; + *) die "Unknown option: $1" ;; + esac + done + + [[ -n "$note" ]] || note="Ticket reassigned." + local note_date + note_date=$(date -u +%Y-%m-%dT%H:%M:%S) + + local body + body=$(jq -n \ + --arg details "$note" \ + --argjson is_internal "$is_internal" \ + --argjson type_id "$type_id" \ + --arg note_date "$note_date" \ + '{ + Details: $details, + IsInternal: $is_internal, + TypeId: $type_id, + NoteDate: $note_date + }') + + [[ -n "$status_id" ]] && body=$(echo "$body" | jq --argjson v "$status_id" '. + {StatusId: $v}') + [[ -n "$assignee_id" ]] && body=$(echo "$body" | jq --argjson v "$assignee_id" '. + {AssigneeId: $v}') + [[ -n "$queue_id" ]] && body=$(echo "$body" | jq --argjson v "$queue_id" '. + {QueueId: $v}') + + local response + response=$(bms_curl POST "/v2/servicedesk/tickets/${ticket_id}/assignticket" -d "$body") + echo "$response" | jq -r '"Ticket \("'"$ticket_id"'") assigned."' +} + +cmd_resolve() { + local ticket_id="${1:-}" + [[ -n "$ticket_id" ]] || die "Usage: bms tickets resolve --comment [options]" + shift + + local comment="" status_id="" publish_kb=false is_internal=false + + while [[ $# -gt 0 ]]; do + case "$1" in + --comment|-c) comment="$2"; shift 2 ;; + --status-id) status_id="$2"; shift 2 ;; + --publish-kb) publish_kb=true; shift ;; + --internal) is_internal=true; shift ;; + *) die "Unknown option: $1" ;; + esac + done + + [[ -n "$comment" ]] || die "--comment is required" + + local body + body=$(jq -n \ + --arg comment "$comment" \ + --argjson publish_kb "$publish_kb" \ + --argjson is_internal "$is_internal" \ + '{Comment: $comment, IsPublishToKnowledgeBase: $publish_kb, IsInternal: $is_internal}') + + [[ -n "$status_id" ]] && body=$(echo "$body" | jq --argjson v "$status_id" '. + {StatusId: $v}') + + local response + response=$(bms_curl POST "/v2/servicedesk/tickets/${ticket_id}/resolve" -d "$body") + echo "$response" | jq -r '"Ticket \("'"$ticket_id"'") resolved."' +} + +cmd_delete() { + local ids=("$@") + [[ ${#ids[@]} -gt 0 ]] || die "Usage: bms tickets delete [id2 ...]" + + if [[ ${#ids[@]} -eq 1 ]]; then + bms_curl DELETE "/v2/servicedesk/tickets/${ids[0]}" >/dev/null + echo "Deleted ticket ${ids[0]}" + else + local body + body=$(printf '%s\n' "${ids[@]}" | jq -Rs 'split("\n") | map(select(. != "")) | map(tonumber) | {Ids: .}') + bms_curl DELETE "/v2/servicedesk/tickets" -d "$body" >/dev/null + echo "Deleted tickets: ${ids[*]}" + fi +} + +# ─── Dispatch ──────────────────────────────────────────────────────────────── + +subcmd="${1:-list}" +[[ $# -gt 0 ]] && shift + +case "$subcmd" in + list) cmd_list "$@" ;; + get) cmd_get "$@" ;; + create) cmd_create "$@" ;; + update) cmd_update "$@" ;; + note) cmd_note "$@" ;; + assign) cmd_assign "$@" ;; + resolve) cmd_resolve "$@" ;; + delete) cmd_delete "$@" ;; + *) + echo "Usage: bms tickets " >&2 + exit 1 + ;; +esac diff --git a/scripts/bms.sh b/scripts/bms.sh new file mode 100644 index 0000000..0378786 --- /dev/null +++ b/scripts/bms.sh @@ -0,0 +1,85 @@ +#!/usr/bin/env bash +# bms.sh — Kaseya BMS CLI entrypoint +# Usage: bms [subcommand] [options] + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +usage() { + cat >&2 <<'EOF' +bms — Kaseya BMS CLI + +Usage: + bms auth [login|refresh|status] Authenticate / manage tokens + bms tickets Manage tickets + bms templates Browse ticket/note/timelog templates + bms lookup Fetch lookup tables (IDs for statuses, etc.) + +Tickets subcommands: + list List/search tickets + get Get a single ticket + create Create a new ticket (supports --template-id) + update Update ticket fields + note Add a note to a ticket + assign Reassign a ticket + resolve Resolve a ticket + delete Delete ticket(s) + +Templates subcommands: + tickets list List all ticket templates + tickets get Show full details for a ticket template + notes list List all note templates + timelogs list List all timelog templates + +Lookup tables: + statuses, priorities, queues, issue-types, sources, ticket-types, + assignees, slas, work-types, note-types, all + +Examples: + bms auth + bms tickets list --status "Open" --assignee "Jane Doe" + bms tickets list --from 2024-01-01 --to 2024-01-31 + bms tickets get 12345 + bms tickets create --title "Server down" --details "..." --account-id 1 \ + --location-id 1 --status-id 1 --priority-id 2 --type-id 1 --source-id 1 + bms tickets create --template-id 7 --title "Override title" --account-id 99 --location-id 5 + bms tickets note 12345 --message "Called client" --internal + bms tickets resolve 12345 --comment "Fixed by replacing NIC" + bms templates tickets list + bms templates tickets get 7 + bms templates notes list + bms templates timelogs list + bms lookup statuses + bms lookup priorities + +Environment variables (required): + BMS_TENANT Your BMS tenant name + BMS_USERNAME BMS username (or use BMS_CLIENT_ID) + BMS_PASSWORD BMS password (or use BMS_CLIENT_SECRET) + +Optional: + BMS_CLIENT_ID OAuth2 client ID + BMS_CLIENT_SECRET OAuth2 client secret + BMS_API_BASE Override API base URL (default: https://api.bms.kaseya.com) + BMS_TOKEN_FILE Token cache path (default: ~/.bms_token.json) + +EOF + exit 1 +} + +cmd="${1:-}" +[[ -n "$cmd" ]] || usage +shift + +case "$cmd" in + auth) exec bash "${SCRIPT_DIR}/bms-auth.sh" "$@" ;; + tickets) exec bash "${SCRIPT_DIR}/bms-tickets.sh" "$@" ;; + templates) exec bash "${SCRIPT_DIR}/bms-templates.sh" "$@" ;; + lookup) exec bash "${SCRIPT_DIR}/bms-lookup.sh" "$@" ;; + help|-h|--help) usage ;; + *) + echo "Unknown command: $cmd" >&2 + usage + ;; +esac