diff --git a/plugins/appsignal-cli/README.md b/plugins/appsignal-cli/README.md new file mode 100644 index 000000000..16b6587dd --- /dev/null +++ b/plugins/appsignal-cli/README.md @@ -0,0 +1,122 @@ +# AppSignal CLI plugin + +This plugin adds aliases, guard rails and completion for the +[AppSignal CLI](https://docs.appsignal.com/cli). + +To use it, add `appsignal-cli` to the plugins array in your zshrc file: + +```zsh +plugins=(... appsignal-cli) +``` + +## Requirements + +[AppSignal CLI](https://docs.appsignal.com/cli) 2.1 or newer, installed and on +your `PATH`. The plugin does nothing if it is not: + +```zsh +brew install appsignal/appsignal-cli/appsignal-cli +# or +curl -sSL https://github.com/appsignal/appsignal-cli/releases/latest/download/install.sh | sudo sh +``` + +Most commands need `appsignal-cli auth login` first. + +## Aliases + +| Alias | Command | Description | +| :------ | :----------------------------- | :--------------------------------- | +| `asig` | `appsignal-cli` | The AppSignal CLI itself | +| `aslog` | `appsignal-cli logs tail` | Stream log lines as they arrive | +| `asinc` | `appsignal-cli incidents list` | List incidents for an application | + +`aslog` and `asinc` complete like the commands they stand in for, so +`aslog --` offers the flags of `appsignal-cli logs tail`. + +## Functions + +| Function | Description | +| :-------------- | :----------------------------------------------------------------- | +| `appsignal-cli` | Wraps the CLI to confirm before irreversible commands. See Settings | + +## Completion + +Every command completes with a description of what it does, at every level, and +so do their flags and allowed values: incident states and severities, sort +orders, log severities, trigger fields and comparison operators, output formats +and skill targets. `samples` and `sample` complete like `traces`, as they do in +the CLI itself. + +`--org` completes offline from the `org` key of your project's +`.appsignal.toml` and of the global config. + +The AppSignal CLI ships no completion generator of its own, so `_appsignal-cli` +is written from its command definitions and kept in this repository. It targets +AppSignal CLI 2.1.x. + +## Settings + +Set these with `zstyle` in your zshrc, before Oh My Zsh is sourced. + +### Confirming destructive commands + +```zsh +zstyle ':omz:plugins:appsignal-cli' confirm-destructive no +``` + +By default the plugin asks for confirmation before four operations that the CLI +itself performs without a prompt: + +- `appsignal-cli logs metrics delete` +- `appsignal-cli logs triggers delete` +- `appsignal-cli triggers archive`, which also closes the trigger's alerts and + incidents +- `appsignal-cli incidents update --state CLOSED` when it names more than one + incident, since that flag accepts a comma-separated list + +Nothing else is intercepted, and the prompt is skipped when stdin is not a +terminal, so scripts, pipelines and CI are unaffected. Set the style to `no` to +turn the prompt off entirely, or run `command appsignal-cli` for a single +invocation. + +### Completing applications + +```zsh +zstyle ':omz:plugins:appsignal-cli' dynamic-app-completion yes +``` + +Off by default. When enabled, `--app`, `--app-id` and `--environment` are +completed from `appsignal-cli apps list --output json`, showing each +application's name and environment next to its ID instead of making you copy +IDs by hand. It is opt-in because it runs the CLI and makes a network call when +you press Tab, and because it needs you to be authenticated. Results +are cached for five minutes. + +### Disabling the aliases + +This works for every Oh My Zsh plugin: + +```zsh +zstyle ':omz:plugins:appsignal-cli' aliases no +``` + +## Caveats + +- `appsignal-cli` and the AppSignal Ruby gem's `appsignal` command are different + tools. The gem's command installs and diagnoses the agent inside your app; + this one queries your data. The plugin deliberately leaves `appsignal` alone, + so it keeps working in projects that use the gem. +- `appsignal-cli` is a shell function here. Run `command appsignal-cli` to reach + the binary directly. +- Most commands need exactly one of `--app-id` or `--app`. `--environment` only + narrows `--app`; it does nothing next to `--app-id`. +- `appsignal-cli project init` writes `.appsignal.toml`, and `auth login` then + stores OAuth tokens in it. Do not commit that file. +- `incidents delete-note`, `auth logout` and the `--clear-*` flags of + `logs metrics update` and `logs triggers update` are destructive too, but are + not prompted for: each affects a single note, login or field. +- `triggers create` and `triggers update` have their own `--format`, for the + format of the metric value. Everywhere else `--format` is a synonym for + `--output`. +- The CLI sends usage telemetry by default. `APPSIGNAL_CLI_TELEMETRY=0` turns it + off, and `APPSIGNAL_CLI_DEBUG=1` shows internal errors. diff --git a/plugins/appsignal-cli/_appsignal-cli b/plugins/appsignal-cli/_appsignal-cli new file mode 100644 index 000000000..138570d8c --- /dev/null +++ b/plugins/appsignal-cli/_appsignal-cli @@ -0,0 +1,1080 @@ +#compdef appsignal-cli + +# Completion for the AppSignal CLI. https://docs.appsignal.com/cli +# +# Targets AppSignal CLI 2.1.x. The CLI has no `completions` command, no +# clap_complete and no machine-readable command dump, so this is written from +# its command definitions and kept here. Nothing runs the CLI at completion +# time unless app completion is enabled below. + +# Organization slugs from the TOML config. Offline, always enabled. +# The CLI reads .appsignal.toml from the current directory upwards, stopping at +# the git root, and falls back to the global config. +_appsignal_cli_orgs() { + setopt localoptions extendedglob + local -a orgs files + local dir file line + # (#b) backreferences set $match, which would otherwise be left global. + local MATCH MBEGIN MEND + local -a match mbegin mend + + dir="$PWD" + while [[ -n "$dir" && "$dir" != "/" ]]; do + files+=("$dir/.appsignal.toml") + [[ -e "$dir/.git" ]] && break + dir="${dir:h}" + done + files+=("${XDG_CONFIG_HOME:-$HOME/.config}/appsignal/config.toml") + files+=("$HOME/Library/Application Support/appsignal/config.toml") + + for file in $files; do + [[ -r "$file" ]] || continue + for line in ${(f)"$(< $file)"}; do + [[ $line == (#b)[[:space:]]#org[[:space:]]#=[[:space:]]#\"([^\"]##)\"* ]] && orgs+=("$match[1]") + done + done + + orgs=(${(u)orgs}) + (( $#orgs )) || return 1 + _describe -t orgs 'organization' orgs +} + +# One tab-separated record per app, in $apps. Off by default: needs +# `appsignal-cli auth login` and makes a network call on TAB. Enable with: +# zstyle ':omz:plugins:appsignal-cli' dynamic-app-completion yes +_appsignal_cli_app_cache() { + zstyle -t ':omz:plugins:appsignal-cli' dynamic-app-completion || return 1 + + local cache_file="${ZSH_CACHE_DIR:-$HOME/.cache}/appsignal-cli-apps" + local -a fresh + local json split entry id name environment + # $match and friends are set by =~ and would otherwise be left global. + local MATCH MBEGIN MEND + local -a match mbegin mend + local nl=$'\n' tab=$'\t' + + # Reuse the cached list for five minutes. + fresh=(${cache_file}(Nms-300)) + if (( $#fresh )); then + apps=("${(@f)$(< "$cache_file")}") + (( $#apps )) + return + fi + + json="$(command appsignal-cli apps list --output json 2>/dev/null)" || return 1 + # The CLI pretty-prints its JSON, so flatten it first, then take one chunk + # per object. Done in zsh so there is no dependency on jq. + json="${json//$nl/ }" + split="${json//\{/$nl}" + for entry in ${(f)split}; do + id="" name="" environment="" + [[ $entry =~ '"id":[[:space:]]*"([^"]*)"' ]] && id=$match[1] + [[ $entry =~ '"name":[[:space:]]*"([^"]*)"' ]] && name=$match[1] + [[ $entry =~ '"environment":[[:space:]]*"([^"]*)"' ]] && environment=$match[1] + [[ -n $id && -n $name ]] || continue + apps+=("${id}${tab}${name}${tab}${environment}") + done + + (( $#apps )) || return 1 + print -rl -- $apps > "$cache_file" 2>/dev/null +} + +# Application names. +_appsignal_cli_apps() { + local -a apps names + local entry name + _appsignal_cli_app_cache || return 1 + for entry in $apps; do + name="${${(@ps:\t:)entry}[2]}" + names+=("${name//:/\\:}") + done + names=(${(u)names}) + _describe -t apps 'application' names +} + +# Application IDs, described by name and environment. +_appsignal_cli_app_ids() { + local -a apps ids + local entry id name environment + _appsignal_cli_app_cache || return 1 + for entry in $apps; do + id="${${(@ps:\t:)entry}[1]}" + name="${${(@ps:\t:)entry}[2]}" + environment="${${(@ps:\t:)entry}[3]}" + ids+=("${id}:${name//:/ }${environment:+ (${environment})}") + done + _describe -t app-ids 'application' ids +} + +# Environments seen across the account's apps. +_appsignal_cli_environments() { + local -a apps environments + local entry environment + _appsignal_cli_app_cache || return 1 + for entry in $apps; do + environment="${${(@ps:\t:)entry}[3]}" + [[ -n $environment ]] && environments+=("${environment//:/\\:}") + done + (( $#environments )) || return 1 + environments=(${(u)environments}) + _describe -t environments 'environment' environments +} + +# Log severity levels, as a comma-separated list. +_appsignal_cli_severities() { + _values -s , 'severity' UNKNOWN TRACE DEBUG INFO NOTICE WARN ERROR CRITICAL ALERT FATAL +} + +# The global flags, and the app reference group carried by most data commands. +# Filled into caller-local arrays so the specs are written once, not forty times. +_appsignal_cli_base_args() { + base_args=( + '(- *)'{-h,--help}'[Print help]' + '(-o --output --format)'{-o,--output,--format}'=[Output format for command results]:format:(human json)' + ) +} + +_appsignal_cli_app_args() { + app_args=( + '--app-id=[Application ID, an alternative to --app plus --environment]:app id:_appsignal_cli_app_ids' + '--app=[Application name, used with optional --environment to find the app]:app:_appsignal_cli_apps' + '--environment=[Environment filter, for example production]:environment:_appsignal_cli_environments' + '--org=[Organization slug, uses the saved default if omitted]:org:_appsignal_cli_orgs' + ) +} + +_appsignal_cli__about() { + local -a base_args + _appsignal_cli_base_args + _arguments -s -S $base_args +} + +_appsignal_cli__auth__login() { + local -a base_args + _appsignal_cli_base_args + _arguments -s -S $base_args \ + '--endpoint=[Override the AppSignal base URL]:url:_urls' \ + '--rest-endpoint=[Override the AppSignal REST API base URL]:url:_urls' \ + '--oauth-client-id=[Override the OAuth client ID used during login]:client id:' \ + '--org=[Set the default organization slug during login]:org:_appsignal_cli_orgs' +} + +_appsignal_cli__auth__logout() { + local -a base_args + _appsignal_cli_base_args + _arguments -s -S $base_args +} + +_appsignal_cli__auth__status() { + local -a base_args + _appsignal_cli_base_args + _arguments -s -S $base_args +} + +_appsignal_cli__auth() { + local curcontext="$curcontext" state line ret=1 + local -a commands + commands=( + 'login:Authenticate with AppSignal via OAuth' + 'logout:Remove stored credentials' + 'status:Show the current authentication status' + ) + + _arguments -C '1: :->cmd' '*:: :->args' && ret=0 + + case $state in + cmd) + _describe -t commands 'auth command' commands && ret=0 + ;; + args) + case $words[1] in + login) _appsignal_cli__auth__login && ret=0 ;; + logout) _appsignal_cli__auth__logout && ret=0 ;; + status) _appsignal_cli__auth__status && ret=0 ;; + esac + ;; + esac + + return ret +} + +_appsignal_cli__apps__list() { + local -a base_args + _appsignal_cli_base_args + _arguments -s -S $base_args +} + +_appsignal_cli__apps__info() { + local -a base_args + _appsignal_cli_base_args + _arguments -s -S $base_args \ + '--app-id=[The application ID]:app id:_appsignal_cli_app_ids' +} + +_appsignal_cli__apps__find() { + local -a base_args + _appsignal_cli_base_args + _arguments -s -S $base_args \ + '--name=[Application name, case-insensitive]:name:_appsignal_cli_apps' \ + '--environment=[Environment filter, for example production]:environment:_appsignal_cli_environments' \ + '--org=[Organization slug, uses the saved default if omitted]:org:_appsignal_cli_orgs' +} + +_appsignal_cli__apps__set_org() { + local -a base_args + _appsignal_cli_base_args + _arguments -s -S $base_args \ + '--org=[Organization slug]:org:_appsignal_cli_orgs' +} + +_appsignal_cli__apps__show_org() { + local -a base_args + _appsignal_cli_base_args + _arguments -s -S $base_args +} + +_appsignal_cli__apps__resources__any() { + local -a base_args app_args + _appsignal_cli_base_args + _appsignal_cli_app_args + _arguments -s -S $base_args $app_args +} + +_appsignal_cli__apps__resources() { + local curcontext="$curcontext" state line ret=1 + local -a commands + commands=( + 'all:Show all supported app resources' + 'users:Show app users' + 'notifiers:Show app notifiers' + 'namespaces:Show app namespaces' + 'dashboards:Show app dashboards' + 'deploy-markers:Show recent deploy markers' + ) + + _arguments -C '1: :->cmd' '*:: :->args' && ret=0 + + case $state in + cmd) + _describe -t commands 'apps resources command' commands && ret=0 + ;; + args) + case $words[1] in + all|users|notifiers|namespaces|dashboards|deploy-markers) + _appsignal_cli__apps__resources__any && ret=0 + ;; + esac + ;; + esac + + return ret +} + +_appsignal_cli__apps() { + local curcontext="$curcontext" state line ret=1 + local -a commands + commands=( + 'list:List all applications for the organization attached to the current OAuth token' + 'info:Show details for a specific application by ID' + 'find:Find an application by name and optional environment' + 'set-org:Set the default organization slug' + 'show-org:Show the current default organization' + 'resources:Show resources for an app' + ) + + _arguments -C '1: :->cmd' '*:: :->args' && ret=0 + + case $state in + cmd) + _describe -t commands 'apps command' commands && ret=0 + ;; + args) + case $words[1] in + list) _appsignal_cli__apps__list && ret=0 ;; + info) _appsignal_cli__apps__info && ret=0 ;; + find) _appsignal_cli__apps__find && ret=0 ;; + set-org) _appsignal_cli__apps__set_org && ret=0 ;; + show-org) _appsignal_cli__apps__show_org && ret=0 ;; + resources) _appsignal_cli__apps__resources && ret=0 ;; + esac + ;; + esac + + return ret +} + +_appsignal_cli__project__init() { + local -a base_args + _appsignal_cli_base_args + _arguments -s -S $base_args \ + '--endpoint=[Override the AppSignal base URL]:url:_urls' \ + '--rest-endpoint=[Override the AppSignal REST API base URL]:url:_urls' \ + '--oauth-client-id=[Override the OAuth client ID for this project]:client id:' \ + '--org=[Set the default organization slug for this project]:org:_appsignal_cli_orgs' +} + +_appsignal_cli__project() { + local curcontext="$curcontext" state line ret=1 + local -a commands + commands=( + 'init:Create or update the project-local .appsignal.toml' + ) + + _arguments -C '1: :->cmd' '*:: :->args' && ret=0 + + case $state in + cmd) + _describe -t commands 'project command' commands && ret=0 + ;; + args) + case $words[1] in + init) _appsignal_cli__project__init && ret=0 ;; + esac + ;; + esac + + return ret +} + +# Shared by every `incidents list*` command. +_appsignal_cli_incident_list_args() { + incident_list_args=( + '--limit=[Maximum number of incidents to return]:limit:' + '--offset=[Offset for pagination]:offset:' + '--state=[Filter by state]:state:(OPEN CLOSED WIP)' + '--order=[Sort order, LAST for most recent activity or ID for creation order]:order:(LAST ID)' + ) +} + +_appsignal_cli__incidents__list() { + local -a base_args app_args incident_list_args + _appsignal_cli_base_args + _appsignal_cli_app_args + _appsignal_cli_incident_list_args + _arguments -s -S $base_args $app_args $incident_list_args \ + '--namespaces=[Filter by namespaces, comma-separated, for example web,background]:namespaces:' \ + '--action=[Filter by action name, for example UsersController#show]:action:' +} + +_appsignal_cli__incidents__list_exceptions() { + local -a base_args app_args incident_list_args + _appsignal_cli_base_args + _appsignal_cli_app_args + _appsignal_cli_incident_list_args + _arguments -s -S $base_args $app_args $incident_list_args \ + '--namespaces=[Filter by namespaces, comma-separated, for example web,background]:namespaces:' \ + '--action=[Filter by action name, for example UsersController#show]:action:' \ + '--query=[Search query to filter exception incidents by name or message]:query:' +} + +_appsignal_cli__incidents__list_performance() { + local -a base_args app_args incident_list_args + _appsignal_cli_base_args + _appsignal_cli_app_args + _appsignal_cli_incident_list_args + _arguments -s -S $base_args $app_args $incident_list_args \ + '--namespaces=[Filter by namespaces, comma-separated, for example web,background]:namespaces:' \ + '--action=[Filter by action name, for example UsersController#show]:action:' \ + '--query=[Search query to filter performance incidents by action name]:query:' +} + +_appsignal_cli__incidents__list_anomalies() { + local -a base_args app_args incident_list_args + _appsignal_cli_base_args + _appsignal_cli_app_args + _appsignal_cli_incident_list_args + _arguments -s -S $base_args $app_args $incident_list_args +} + +_appsignal_cli__incidents__show() { + local -a base_args app_args + _appsignal_cli_base_args + _appsignal_cli_app_args + _arguments -s -S $base_args $app_args \ + '--number=[Incident number]:number:' +} + +_appsignal_cli__incidents__update() { + local -a base_args app_args + _appsignal_cli_base_args + _appsignal_cli_app_args + _arguments -s -S $base_args $app_args \ + '*--number=[Incident number, repeat or pass a comma-separated list for bulk state changes]:number:' \ + '--state=[New state]:state:(OPEN CLOSED WIP)' \ + '--severity=[New severity]:severity:(UNTRIAGED CRITICAL HIGH LOW NONE INFORMATIONAL)' \ + '--notification-frequency=[How often to notify about this incident]:frequency:(ALWAYS NEVER FIRST_IN_DEPLOY FIRST_AFTER_CLOSE NTH_IN_HOUR NTH_IN_DAY)' \ + '--notification-threshold=[Threshold for the NTH_IN_HOUR and NTH_IN_DAY frequencies]:threshold:' \ + '--assign=[Comma-separated user names or IDs to add as assignees]:assignees:' \ + '--assign-me[Assign the incident to the authenticated CLI user]' \ + '--unassign=[Comma-separated user names or IDs to remove from assignees]:assignees:' \ + '--description=[New description]:description:' +} + +_appsignal_cli__incidents__add_note() { + local -a base_args app_args + _appsignal_cli_base_args + _appsignal_cli_app_args + _arguments -s -S $base_args $app_args \ + '--number=[Incident number]:number:' \ + '--content=[Note content, markdown supported]:content:' +} + +_appsignal_cli__incidents__list_notes() { + local -a base_args app_args + _appsignal_cli_base_args + _appsignal_cli_app_args + _arguments -s -S $base_args $app_args \ + '--number=[Incident number]:number:' +} + +_appsignal_cli__incidents__update_note() { + local -a base_args app_args + _appsignal_cli_base_args + _appsignal_cli_app_args + _arguments -s -S $base_args $app_args \ + '--number=[Incident number]:number:' \ + '--id=[ID of the note to update]:note id:' \ + '--content=[Note content, markdown supported]:content:' +} + +_appsignal_cli__incidents__delete_note() { + local -a base_args app_args + _appsignal_cli_base_args + _appsignal_cli_app_args + _arguments -s -S $base_args $app_args \ + '--number=[Incident number]:number:' \ + '--id=[ID of the note to delete]:note id:' +} + +_appsignal_cli__incidents() { + local curcontext="$curcontext" state line ret=1 + local -a commands + commands=( + 'list:List incidents for an application (all types)' + 'list-exceptions:List exception incidents (with text search support)' + 'list-performance:List performance incidents (with text search support)' + 'list-anomalies:List anomaly detection incidents' + 'show:Show details for a specific incident by number' + 'update:Update an incident (state, severity, notification frequency, assignees)' + 'add-note:Add a note to an incident' + 'list-notes:List notes on an incident, including their IDs' + 'update-note:Update one of your notes on an incident' + 'delete-note:Delete one of your notes from an incident' + ) + + _arguments -C '1: :->cmd' '*:: :->args' && ret=0 + + case $state in + cmd) + _describe -t commands 'incidents command' commands && ret=0 + ;; + args) + case $words[1] in + list) _appsignal_cli__incidents__list && ret=0 ;; + list-exceptions) _appsignal_cli__incidents__list_exceptions && ret=0 ;; + list-performance) _appsignal_cli__incidents__list_performance && ret=0 ;; + list-anomalies) _appsignal_cli__incidents__list_anomalies && ret=0 ;; + show) _appsignal_cli__incidents__show && ret=0 ;; + update) _appsignal_cli__incidents__update && ret=0 ;; + add-note) _appsignal_cli__incidents__add_note && ret=0 ;; + list-notes) _appsignal_cli__incidents__list_notes && ret=0 ;; + update-note) _appsignal_cli__incidents__update_note && ret=0 ;; + delete-note) _appsignal_cli__incidents__delete_note && ret=0 ;; + esac + ;; + esac + + return ret +} + +# Shared by `logs tail` and `logs search`. +_appsignal_cli_log_filter_args() { + log_filter_args=( + '--query=[Log query filter, supports field filters and free text]:query:' + '--severities=[Comma-separated severity levels, for example ERROR,CRITICAL]:severities:_appsignal_cli_severities' + '--source-ids=[Comma-separated source IDs to filter by]:source ids:' + '--view=[Log view name or ID, applies the saved filters of the view as defaults]:view:' + ) +} + +_appsignal_cli__logs__tail() { + local -a base_args app_args log_filter_args + _appsignal_cli_base_args + _appsignal_cli_app_args + _appsignal_cli_log_filter_args + _arguments -s -S $base_args $app_args $log_filter_args +} + +_appsignal_cli__logs__search() { + local -a base_args app_args log_filter_args + _appsignal_cli_base_args + _appsignal_cli_app_args + _appsignal_cli_log_filter_args + _arguments -s -S $base_args $app_args $log_filter_args \ + '--start=[Start time, ISO 8601]:timestamp:' \ + '--end=[End time, ISO 8601]:timestamp:' \ + '(--page-all)--limit=[Maximum number of log lines to return, at most 100]:limit:' \ + '(--page-all)--order=[Sort order, ASC for oldest first or DESC for newest first]:order:(ASC DESC)' \ + '(--limit --order)--page-all[Paginate to fetch all results, requires --start]' +} + +_appsignal_cli__logs__views() { + local -a base_args app_args + _appsignal_cli_base_args + _appsignal_cli_app_args + _arguments -s -S $base_args $app_args +} + +_appsignal_cli__logs__sources() { + local -a base_args app_args + _appsignal_cli_base_args + _appsignal_cli_app_args + _arguments -s -S $base_args $app_args +} + +_appsignal_cli__logs__metrics__list() { + local -a base_args app_args + _appsignal_cli_base_args + _appsignal_cli_app_args + _arguments -s -S $base_args $app_args +} + +_appsignal_cli__logs__metrics__create() { + local -a base_args app_args + _appsignal_cli_base_args + _appsignal_cli_app_args + _arguments -s -S $base_args $app_args \ + '--name=[Metric configuration name]:name:' \ + '--query=[Query expression to match against log lines]:query:' \ + '*--source-id=[Scope the metric to a source ID, repeat to add more]:source id:' \ + '*--metric=[Metric definition in key=value form, for example name=log.error_count,type=counter]:metric:' +} + +_appsignal_cli__logs__metrics__update() { + local -a base_args app_args + _appsignal_cli_base_args + _appsignal_cli_app_args + _arguments -s -S $base_args $app_args \ + '--id=[ID of the metric to update]:metric id:' \ + '--name=[Metric configuration name]:name:' \ + '--query=[Query expression to match against log lines]:query:' \ + '(--clear-sources)*--source-id=[Scope the metric to a source ID, repeat to add more]:source id:' \ + '(--source-id)--clear-sources[Remove every source from the metric]' \ + '(--clear-metrics)*--metric=[Metric definition in key=value form]:metric:' \ + '(--metric)--clear-metrics[Remove every metric definition]' +} + +_appsignal_cli__logs__metrics__delete() { + local -a base_args app_args + _appsignal_cli_base_args + _appsignal_cli_app_args + _arguments -s -S $base_args $app_args \ + '--id=[ID of the metric to delete]:metric id:' +} + +_appsignal_cli__logs__metrics() { + local curcontext="$curcontext" state line ret=1 + local -a commands + commands=( + 'list:List log-derived metrics for an app' + 'create:Create a new log-derived metric' + 'update:Update a log-derived metric' + 'delete:Delete a log-derived metric' + ) + + _arguments -C '1: :->cmd' '*:: :->args' && ret=0 + + case $state in + cmd) + _describe -t commands 'logs metrics command' commands && ret=0 + ;; + args) + case $words[1] in + list) _appsignal_cli__logs__metrics__list && ret=0 ;; + create) _appsignal_cli__logs__metrics__create && ret=0 ;; + update) _appsignal_cli__logs__metrics__update && ret=0 ;; + delete) _appsignal_cli__logs__metrics__delete && ret=0 ;; + esac + ;; + esac + + return ret +} + +_appsignal_cli__logs__triggers__list() { + local -a base_args app_args + _appsignal_cli_base_args + _appsignal_cli_app_args + _arguments -s -S $base_args $app_args +} + +_appsignal_cli__logs__triggers__create() { + local -a base_args app_args + _appsignal_cli_base_args + _appsignal_cli_app_args + _arguments -s -S $base_args $app_args \ + '--name=[Trigger name]:name:' \ + '--query=[Query expression to match against log lines]:query:' \ + '*--source-id=[Scope the trigger to a source ID, repeat to add more]:source id:' \ + '--description=[Optional description shown with the trigger]:description:' \ + '*--notifier-id=[Notifier to alert, repeat to add more]:notifier id:' \ + '*--severity=[Match only this severity, repeat to add more]:severity:(UNKNOWN TRACE DEBUG INFO NOTICE WARN ERROR CRITICAL ALERT FATAL)' +} + +_appsignal_cli__logs__triggers__update() { + local -a base_args app_args + _appsignal_cli_base_args + _appsignal_cli_app_args + _arguments -s -S $base_args $app_args \ + '--id=[ID of the trigger to update]:trigger id:' \ + '--name=[Trigger name]:name:' \ + '--query=[Query expression to match against log lines]:query:' \ + '(--clear-sources)*--source-id=[Scope the trigger to a source ID, repeat to add more]:source id:' \ + '(--source-id)--clear-sources[Remove every source from the trigger]' \ + '(--clear-description)--description=[Optional description shown with the trigger]:description:' \ + '(--description)--clear-description[Remove the description]' \ + '(--clear-notifiers)*--notifier-id=[Notifier to alert, repeat to add more]:notifier id:' \ + '(--notifier-id)--clear-notifiers[Remove every notifier from the trigger]' \ + '(--clear-severities)*--severity=[Match only this severity, repeat to add more]:severity:(UNKNOWN TRACE DEBUG INFO NOTICE WARN ERROR CRITICAL ALERT FATAL)' \ + '(--severity)--clear-severities[Match every severity]' +} + +_appsignal_cli__logs__triggers__delete() { + local -a base_args app_args + _appsignal_cli_base_args + _appsignal_cli_app_args + _arguments -s -S $base_args $app_args \ + '--id=[ID of the trigger to delete]:trigger id:' +} + +_appsignal_cli__logs__triggers() { + local curcontext="$curcontext" state line ret=1 + local -a commands + commands=( + 'list:List log-based triggers for an app' + 'create:Create a new log-based trigger' + 'update:Update an existing log-based trigger' + 'delete:Delete a log-based trigger' + ) + + _arguments -C '1: :->cmd' '*:: :->args' && ret=0 + + case $state in + cmd) + _describe -t commands 'logs triggers command' commands && ret=0 + ;; + args) + case $words[1] in + list) _appsignal_cli__logs__triggers__list && ret=0 ;; + create) _appsignal_cli__logs__triggers__create && ret=0 ;; + update) _appsignal_cli__logs__triggers__update && ret=0 ;; + delete) _appsignal_cli__logs__triggers__delete && ret=0 ;; + esac + ;; + esac + + return ret +} + +_appsignal_cli__logs() { + local curcontext="$curcontext" state line ret=1 + local -a commands + commands=( + 'tail:Tail (stream) log lines in real time, with optional filters' + 'search:Search log lines (one-shot query)' + 'views:List saved log views (filter presets) for an app' + 'sources:List log sources for an app' + 'metrics:Create and manage log-derived metrics' + 'triggers:Create and manage log-based triggers' + ) + + _arguments -C '1: :->cmd' '*:: :->args' && ret=0 + + case $state in + cmd) + _describe -t commands 'logs command' commands && ret=0 + ;; + args) + case $words[1] in + tail) _appsignal_cli__logs__tail && ret=0 ;; + search) _appsignal_cli__logs__search && ret=0 ;; + views) _appsignal_cli__logs__views && ret=0 ;; + sources) _appsignal_cli__logs__sources && ret=0 ;; + metrics) _appsignal_cli__logs__metrics && ret=0 ;; + triggers) _appsignal_cli__logs__triggers && ret=0 ;; + esac + ;; + esac + + return ret +} + +_appsignal_cli__traces__list() { + local -a base_args app_args + _appsignal_cli_base_args + _appsignal_cli_app_args + _arguments -s -S $base_args $app_args \ + '--namespace=[Namespace to search in, for example web, background, graphql]:namespace:' \ + '--action=[Action name to fetch traces for, for example UsersController#show]:action:' \ + '--start=[Start time, ISO 8601, defaults to 24 hours ago]:timestamp:' \ + '--end=[End time, ISO 8601, defaults to now]:timestamp:' \ + '--min-duration-ms=[Minimum trace duration in milliseconds]:duration:' \ + '--query=[Filter by tags or revision, for example tag.region=eu-west]:query:' \ + '(--page-all)--limit=[Maximum number of traces to return, 1 to 100]:limit:' \ + '(--limit)--page-all[Paginate to fetch all traces]' +} + +_appsignal_cli__traces__incident() { + local -a base_args app_args + _appsignal_cli_base_args + _appsignal_cli_app_args + _arguments -s -S $base_args $app_args \ + '--number=[Performance incident number]:number:' \ + '--action=[Restrict lookup to one action if the incident has several]:action:' \ + '--start=[Start time, ISO 8601, defaults to 24 hours ago]:timestamp:' \ + '--end=[End time, ISO 8601, defaults to now]:timestamp:' \ + '--min-duration-ms=[Minimum trace duration in milliseconds]:duration:' \ + '--query=[Filter by tags or revision, for example tag.region=eu-west]:query:' \ + '(--page-all)--limit=[Maximum number of traces to return per action, 1 to 100]:limit:' \ + '(--limit)--page-all[Paginate to fetch all traces for each action]' +} + +_appsignal_cli__traces__errors() { + local -a base_args app_args + _appsignal_cli_base_args + _appsignal_cli_app_args + _arguments -s -S $base_args $app_args \ + '--digest=[Exception incident digest]:digest:' \ + '--query=[Filter by tags or revision, for example tag.region=eu-west]:query:' \ + '(--page-all)--limit=[Maximum number of error traces to return, 1 to 100]:limit:' \ + '(--limit)--page-all[Paginate to fetch all error traces]' +} + +_appsignal_cli__traces__show() { + local -a base_args app_args + _appsignal_cli_base_args + _appsignal_cli_app_args + _arguments -s -S $base_args $app_args \ + '--namespace=[Namespace to search in, for example web, background, graphql]:namespace:' \ + '--action=[Action name for the trace, for example UsersController#show]:action:' \ + '--trace-id=[Trace ID returned by traces list]:trace id:' \ + '--span-id=[Span ID to inspect within the trace]:span id:' \ + '--include-sensitive[Include HTTP headers, request parameters, session data and function parameters]' \ + '--start=[Start time, ISO 8601, defaults to 24 hours ago]:timestamp:' \ + '--end=[End time, ISO 8601, defaults to now]:timestamp:' +} + +_appsignal_cli__traces__show_error() { + local -a base_args app_args + _appsignal_cli_base_args + _appsignal_cli_app_args + _arguments -s -S $base_args $app_args \ + '--digest=[Exception incident digest]:digest:' \ + '--trace-id=[Trace ID returned by traces errors or traces incident]:trace id:' \ + '--span-id=[Span ID to inspect within the trace]:span id:' \ + '--include-sensitive[Include HTTP headers, request parameters, session data and function parameters]' +} + +_appsignal_cli__traces__show_incident() { + local -a base_args app_args + _appsignal_cli_base_args + _appsignal_cli_app_args + _arguments -s -S $base_args $app_args \ + '--number=[Performance or exception incident number]:number:' \ + '--trace-id=[Trace ID returned by traces incident]:trace id:' \ + '--span-id=[Span ID to inspect within the trace]:span id:' \ + '--include-sensitive[Include HTTP headers, request parameters, session data and function parameters]' \ + '--start=[Start time, ISO 8601, defaults to 24 hours ago]:timestamp:' \ + '--end=[End time, ISO 8601, defaults to now]:timestamp:' +} + +_appsignal_cli__traces() { + local curcontext="$curcontext" state line ret=1 + local -a commands + commands=( + 'list:List performance samples/traces for an action' + 'incident:List performance samples/traces for an incident' + 'errors:List error traces for an exception digest' + 'show:Show a performance sample/trace span tree, or one span with --span-id' + 'show-error:Show an error trace span tree, or one span with --span-id' + 'show-incident:Show a trace from a performance or exception incident' + ) + + _arguments -C '1: :->cmd' '*:: :->args' && ret=0 + + case $state in + cmd) + _describe -t commands 'traces command' commands && ret=0 + ;; + args) + case $words[1] in + list) _appsignal_cli__traces__list && ret=0 ;; + incident) _appsignal_cli__traces__incident && ret=0 ;; + errors) _appsignal_cli__traces__errors && ret=0 ;; + show) _appsignal_cli__traces__show && ret=0 ;; + show-error) _appsignal_cli__traces__show_error && ret=0 ;; + show-incident) _appsignal_cli__traces__show_incident && ret=0 ;; + esac + ;; + esac + + return ret +} + +_appsignal_cli__dashboards__list() { + local -a base_args app_args + _appsignal_cli_base_args + _appsignal_cli_app_args + _arguments -s -S $base_args $app_args +} + +_appsignal_cli__dashboards__create() { + local -a base_args app_args + _appsignal_cli_base_args + _appsignal_cli_app_args + _arguments -s -S $base_args $app_args \ + '--title=[Dashboard title]:title:' \ + '--description=[Dashboard description]:description:' +} + +_appsignal_cli__dashboards__update() { + local -a base_args app_args + _appsignal_cli_base_args + _appsignal_cli_app_args + _arguments -s -S $base_args $app_args \ + '--id=[ID of the dashboard to update]:dashboard id:' \ + '--title=[Dashboard title]:title:' \ + '--description=[Dashboard description]:description:' +} + +_appsignal_cli__dashboards() { + local curcontext="$curcontext" state line ret=1 + local -a commands + commands=( + 'list:List dashboards for an application' + 'create:Create a new dashboard' + 'update:Update an existing dashboard' + ) + + _arguments -C '1: :->cmd' '*:: :->args' && ret=0 + + case $state in + cmd) + _describe -t commands 'dashboards command' commands && ret=0 + ;; + args) + case $words[1] in + list) _appsignal_cli__dashboards__list && ret=0 ;; + create) _appsignal_cli__dashboards__create && ret=0 ;; + update) _appsignal_cli__dashboards__update && ret=0 ;; + esac + ;; + esac + + return ret +} + +# `triggers create` and `triggers update` share this block. Note that --format +# here is the trigger's own value format, not the global --output alias, so the +# global flag is offered as -o/--output only. +_appsignal_cli_trigger_definition_args() { + trigger_definition_args=( + '--name=[Display name for the trigger, defaults to the metric name]:name:' + '--metric-name=[Metric name to monitor]:metric name:' + '--kind=[Trigger kind or classification, for example Advanced, Performance, HostCPUUsage]:kind:' + '--field=[Metric field to compare]:field:(count counter gauge mean p90 p95)' + '--comparison-operator=[Comparison operator]:operator:(> >= < <= == !=)' + '--condition-value=[Threshold value to compare against]:value:' + '--warmup-duration=[Warmup duration in minutes before opening an alert]:minutes:' + '--cooldown-duration=[Cooldown duration in minutes before closing an alert]:minutes:' + '--notifier-ids=[Comma-separated notifier IDs to attach to the trigger]:notifier ids:' + '*--tag=[Tag filter in key=value form, repeat or use commas]:tag:' + '--description=[Optional description shown with the trigger]:description:' + '--no-match-is-zero[Treat missing datapoints as 0]' + '--dashboard-id=[Dashboard to link the trigger to]:dashboard id:' + '--format=[Format for the metric value, for example duration, number, percent]:metric format:' + '--format-input=[Input unit for the size format, for example byte, kilobyte, megabyte]:unit:' + ) +} + +_appsignal_cli__triggers__list() { + local -a base_args app_args + _appsignal_cli_base_args + _appsignal_cli_app_args + _arguments -s -S $base_args $app_args \ + '--metric-name=[Filter by metric name]:metric name:' \ + '--kind=[Filter by trigger kind]:kind:' \ + '*--tag=[Tag filter in key=value form, repeat or use commas]:tag:' +} + +_appsignal_cli__triggers__create() { + local -a app_args trigger_definition_args + _appsignal_cli_app_args + _appsignal_cli_trigger_definition_args + _arguments -s -S $app_args $trigger_definition_args \ + '(- *)'{-h,--help}'[Print help]' \ + '(-o --output)'{-o,--output}'=[Output format for command results]:format:(human json)' +} + +_appsignal_cli__triggers__update() { + local -a app_args trigger_definition_args + _appsignal_cli_app_args + _appsignal_cli_trigger_definition_args + _arguments -s -S $app_args $trigger_definition_args \ + '(- *)'{-h,--help}'[Print help]' \ + '(-o --output)'{-o,--output}'=[Output format for command results]:format:(human json)' \ + '--id=[ID of the existing trigger to update]:trigger id:' +} + +_appsignal_cli__triggers__archive() { + local -a base_args app_args + _appsignal_cli_base_args + _appsignal_cli_app_args + _arguments -s -S $base_args $app_args \ + '--id=[ID of the trigger to archive]:trigger id:' +} + +_appsignal_cli__triggers() { + local curcontext="$curcontext" state line ret=1 + local -a commands + commands=( + 'list:List triggers for an application' + 'create:Create a new anomaly detection trigger' + 'update:Update a trigger by creating a new version linked to the existing trigger' + 'archive:Archive a trigger and close its associated alerts/incidents' + ) + + _arguments -C '1: :->cmd' '*:: :->args' && ret=0 + + case $state in + cmd) + _describe -t commands 'triggers command' commands && ret=0 + ;; + args) + case $words[1] in + list) _appsignal_cli__triggers__list && ret=0 ;; + create) _appsignal_cli__triggers__create && ret=0 ;; + update) _appsignal_cli__triggers__update && ret=0 ;; + archive) _appsignal_cli__triggers__archive && ret=0 ;; + esac + ;; + esac + + return ret +} + +_appsignal_cli__feedback() { + local -a base_args + _appsignal_cli_base_args + _arguments -s -S $base_args \ + '(:)'{-m,--message}'=[Feedback text, read from stdin if omitted]:text:' \ + '(--no-email)--email=[Contact email for follow-up, saved for next time]:email:' \ + '(--email)--no-email[Do not include a contact email, even if one is saved]' \ + '(-m --message)*:feedback text:' +} + +_appsignal_cli__skill__install() { + local -a base_args + _appsignal_cli_base_args + _arguments -s -S $base_args \ + '*--target=[Install target]:target:(opencode codex claude all)' \ + '--dir=[Install into this skills root directory instead of the default of the target]:directory:_files -/' \ + '--force[Overwrite an existing installed skill]' +} + +_appsignal_cli__skill__update() { + local -a base_args + _appsignal_cli_base_args + _arguments -s -S $base_args \ + '*--target=[Update target]:target:(opencode codex claude all)' \ + '--dir=[Update a skill installed in this skills root directory]:directory:_files -/' +} + +_appsignal_cli__skill__status() { + local -a base_args + _appsignal_cli_base_args + _arguments -s -S $base_args \ + '*--target=[Status target]:target:(opencode codex claude all)' \ + '--dir=[Check a skill installed in this skills root directory]:directory:_files -/' +} + +_appsignal_cli__skill() { + local curcontext="$curcontext" state line ret=1 + local -a commands + commands=( + 'install:Install the bundled AppSignal skill into an agent skills directory' + 'update:Update an installed AppSignal skill to the bundled version' + 'status:Show whether installed AppSignal skills are current' + ) + + _arguments -C '1: :->cmd' '*:: :->args' && ret=0 + + case $state in + cmd) + _describe -t commands 'skill command' commands && ret=0 + ;; + args) + case $words[1] in + install) _appsignal_cli__skill__install && ret=0 ;; + update) _appsignal_cli__skill__update && ret=0 ;; + status) _appsignal_cli__skill__status && ret=0 ;; + esac + ;; + esac + + return ret +} + +_appsignal-cli() { + local curcontext="$curcontext" state line ret=1 + local -a commands + commands=( + 'about:Show a more playful overview of the CLI' + 'auth:Configure AppSignal authentication' + 'apps:List, find, and inspect your AppSignal applications' + 'project:Initialize a project-local AppSignal config' + 'incidents:List and inspect incidents' + 'logs:Stream, search, and inspect application logs' + 'traces:Fetch and inspect performance samples/traces' + 'dashboards:Create and update dashboards' + 'triggers:List and manage anomaly detection triggers' + 'feedback:Send feedback about appsignal-cli to AppSignal' + 'skill:Install the bundled AppSignal LLM skill' + 'help:Print the help of the given subcommands' + ) + + _arguments -C \ + '(- *)'{-h,--help}'[Print help]' \ + '(- *)'{-V,--version}'[Print version]' \ + '(-o --output --format)'{-o,--output,--format}'=[Output format for command results]:format:(human json)' \ + '1: :->cmd' \ + '*:: :->args' && ret=0 + + case $state in + cmd) + _describe -t commands 'appsignal-cli command' commands && ret=0 + ;; + args) + case $words[1] in + about) _appsignal_cli__about && ret=0 ;; + auth) _appsignal_cli__auth && ret=0 ;; + apps) _appsignal_cli__apps && ret=0 ;; + project) _appsignal_cli__project && ret=0 ;; + incidents) _appsignal_cli__incidents && ret=0 ;; + logs) _appsignal_cli__logs && ret=0 ;; + # `samples` and `sample` are aliases of `traces` in the CLI itself. + traces|samples|sample) _appsignal_cli__traces && ret=0 ;; + dashboards) _appsignal_cli__dashboards && ret=0 ;; + triggers) _appsignal_cli__triggers && ret=0 ;; + feedback) _appsignal_cli__feedback && ret=0 ;; + skill) _appsignal_cli__skill && ret=0 ;; + help) _describe -t commands 'appsignal-cli command' commands && ret=0 ;; + *) _files && ret=0 ;; + esac + ;; + esac + + return ret +} + +_appsignal-cli "$@" diff --git a/plugins/appsignal-cli/appsignal-cli.plugin.zsh b/plugins/appsignal-cli/appsignal-cli.plugin.zsh new file mode 100644 index 000000000..14420059b --- /dev/null +++ b/plugins/appsignal-cli/appsignal-cli.plugin.zsh @@ -0,0 +1,108 @@ +# AppSignal CLI: completion, aliases and guard rails. +# https://docs.appsignal.com/cli + +if (( ! $+commands[appsignal-cli] )); then + return +fi + +# Does this invocation need confirming? Sets REPLY to the reason. +# +# The command path is collected from the leading bare words. Only the global +# flags may precede a subcommand, so the first other flag ends the path: that +# way an option value such as `--app "My App"` is never mistaken for one. +function _appsignal_cli_is_destructive() { + zstyle -T ':omz:plugins:appsignal-cli' confirm-destructive || return 1 + + # Nobody to answer: CI and pipelines are unaffected. + [[ -t 0 ]] || return 1 + + local -a args=("$@") path numbers + local arg state="" + integer i=1 n=$# path_done=0 + + while (( i <= n )); do + arg="$args[i]" + case "$arg" in + --) break ;; + --state=*) state="${arg#--state=}"; path_done=1 ;; + --state) state="$args[i+1]"; (( i++ )); path_done=1 ;; + --number=*) numbers+=(${(s:,:)${arg#--number=}}); path_done=1 ;; + # --number takes one or more values, comma-separated or repeated. + --number) + path_done=1 + while (( i < n )) && [[ "$args[i+1]" != -* ]]; do + (( i++ )) + numbers+=(${(s:,:)args[i]}) + done + ;; + # The output flag is global, so it can appear before the subcommand. + -o|--output|--format) (( i++ )) ;; + -o*|--output=*|--format=*) ;; + -*) path_done=1 ;; + *) (( path_done )) || path+=("$arg") ;; + esac + (( i++ )) + done + + case "${(j: :)path[1,3]}" in + "logs metrics delete") + REPLY="deleting a log-derived metric cannot be undone." + return 0 + ;; + "logs triggers delete") + REPLY="deleting a log-based trigger cannot be undone." + return 0 + ;; + "triggers archive") + REPLY="archiving a trigger also closes its alerts and incidents." + return 0 + ;; + "incidents update") + if [[ "${(U)state}" == CLOSED ]] && (( $#numbers > 1 )); then + REPLY="this closes $#numbers incidents at once." + return 0 + fi + ;; + esac + + return 1 +} + +# Adds a confirmation prompt before irreversible operations. Everything else +# passes straight through, so `logs tail` keeps streaming and pipes stay clean. +function appsignal-cli() { + local REPLY + if _appsignal_cli_is_destructive "$@"; then + print -u2 -- "appsignal-cli: $REPLY" + if ! read -q "?Continue? [y/N] "; then + print -u2 -- "" + return 130 + fi + print -u2 -- "" + fi + + command appsignal-cli "$@" +} + +# Complete the aliases like the commands they stand in for. +if (( $+functions[compdef] )); then + _aslog() { + words=(appsignal-cli logs tail ${words[2,-1]}) + (( CURRENT += 2 )) + _appsignal-cli + } + + _asinc() { + words=(appsignal-cli incidents list ${words[2,-1]}) + (( CURRENT += 2 )) + _appsignal-cli + } + + compdef _appsignal-cli asig + compdef _aslog aslog + compdef _asinc asinc +fi + +alias asig='appsignal-cli' +alias aslog='appsignal-cli logs tail' +alias asinc='appsignal-cli incidents list'