Initial commit: BMS skill with template support

This commit is contained in:
2026-04-07 17:36:14 +00:00
commit cb65718507
7 changed files with 1240 additions and 0 deletions

176
SKILL.md Normal file
View File

@@ -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 <ticketId> # Get full ticket details
bms tickets get <ticketId> --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 <ticketId> --status-id 3 # Change status
bms tickets update <ticketId> --assignee-id 789 # Reassign
bms tickets update <ticketId> --priority-id 1 # Change priority
bms tickets update <ticketId> --title "New title" # Update title
```
### Adding Notes
```bash
bms tickets note <ticketId> --message "Called client, investigating"
bms tickets note <ticketId> --message "Internal update" --internal
bms tickets note <ticketId> --message "Resolved via restart" --status-id 5
```
### Assigning Tickets
```bash
bms tickets assign <ticketId> --assignee-id 789 --note "Routing to tier 2"
bms tickets assign <ticketId> --queue-id 3
```
### Resolving Tickets
```bash
bms tickets resolve <ticketId> --comment "Replaced failed drive, server is back online"
bms tickets resolve <ticketId> --comment "Fixed" --status-id 6 --publish-kb
```
### Deleting Tickets
```bash
bms tickets delete <ticketId> # 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 <id> # 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 <N>` 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)

107
references/key-schemas.md Normal file
View File

@@ -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 | ✅ |

134
scripts/bms-auth.sh Executable file
View File

@@ -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

99
scripts/bms-lookup.sh Executable file
View File

@@ -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 <statuses|priorities|queues|issue-types|sources|ticket-types|assignees|slas|work-types|note-types|all>"
cmd_lookup "$table"

185
scripts/bms-templates.sh Normal file
View File

@@ -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 <templateId>"
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 <resource> <subcommand> [options]
Resources and subcommands:
tickets list List all ticket templates
tickets get <id> 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

454
scripts/bms-tickets.sh Normal file
View File

@@ -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 <ticketId>"
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 <ticketId> [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 <ticketId> --message <text> [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 <ticketId> [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 <ticketId> --comment <text> [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 <id> [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 <list|get|create|update|note|assign|resolve|delete>" >&2
exit 1
;;
esac

85
scripts/bms.sh Normal file
View File

@@ -0,0 +1,85 @@
#!/usr/bin/env bash
# bms.sh — Kaseya BMS CLI entrypoint
# Usage: bms <command> [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 <subcommand> Manage tickets
bms templates <resource> <sub> Browse ticket/note/timelog templates
bms lookup <table> 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 <id> 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