diff --git a/packages/cli/src/ai-context/context.ts b/packages/cli/src/ai-context/context.ts index 53be0fdb4..52fbd8aeb 100644 --- a/packages/cli/src/ai-context/context.ts +++ b/packages/cli/src/ai-context/context.ts @@ -72,6 +72,10 @@ export const MANAGE_REFERENCES = [ id: 'manage-plan', description: 'Check account plan, entitlements, feature limits, and available locations (`account plan`)', }, + { + id: 'manage-account-members', + description: 'List account members and pending invites (`account members`)', + }, ] as const export const SKILL = { @@ -101,7 +105,7 @@ export const ACTIONS = [ }, { id: 'manage', - description: 'Understand your account plan, entitlements, and feature limits.', + description: 'Understand your account plan, entitlements, feature limits, members, and pending invites.', references: MANAGE_REFERENCES, }, ] as const diff --git a/packages/cli/src/ai-context/references/manage-account-members.md b/packages/cli/src/ai-context/references/manage-account-members.md new file mode 100644 index 000000000..eed7cdbe3 --- /dev/null +++ b/packages/cli/src/ai-context/references/manage-account-members.md @@ -0,0 +1,48 @@ +# Account Members + +List active account members and pending or expired account invites. + +## Usage + +```bash +npx checkly account members +npx checkly account members --output json +npx checkly account members --hide-id +``` + +## JSON response shape + +```json +{ + "members": [ + { + "type": "member", + "accountId": "11111111-1111-1111-1111-111111111111", + "userId": "22222222-2222-2222-2222-222222222222", + "name": "Owner User", + "email": "owner@example.com", + "role": "OWNER", + "status": "ACTIVE", + "createdAt": "2026-01-01T00:00:00.000Z", + "updatedAt": "2026-01-02T00:00:00.000Z", + "isSupportMembership": false, + "ssoEnabled": false, + "mfaEnabled": true + }, + { + "type": "invite", + "id": "33333333-3333-3333-3333-333333333333", + "accountId": "11111111-1111-1111-1111-111111111111", + "email": "pending@example.com", + "role": "READ_ONLY", + "status": "PENDING", + "inviterEmail": "owner@example.com", + "createdAt": "2026-01-03T00:00:00.000Z", + "updatedAt": "2026-01-03T00:00:00.000Z", + "expiresAt": "2026-02-03T00:00:00.000Z" + } + ] +} +``` + +Member roles are `OWNER`, `ADMIN`, `READ_WRITE`, `READ_RUN`, or `READ_ONLY`. Invite roles exclude `OWNER`. Invite statuses are `PENDING` or `EXPIRED`. diff --git a/packages/cli/src/ai-context/references/manage.md b/packages/cli/src/ai-context/references/manage.md index 90e62998e..1e92d3f30 100644 --- a/packages/cli/src/ai-context/references/manage.md +++ b/packages/cli/src/ai-context/references/manage.md @@ -1,6 +1,6 @@ # Account Management -Understand your account's plan, entitlements, and limits. +Understand your account's plan, entitlements, limits, members, and pending invites. ## Plan-aware workflow diff --git a/packages/cli/src/ai-context/skill.md b/packages/cli/src/ai-context/skill.md index e74861f20..32533cee0 100644 --- a/packages/cli/src/ai-context/skill.md +++ b/packages/cli/src/ai-context/skill.md @@ -1,6 +1,6 @@ --- name: checkly -description: Set up, create, test and manage monitoring checks using the Checkly CLI. Use when working with Agentic Checks, API Checks, Browser Checks, URL Monitors, ICMP Monitors, Playwright Check Suites, Heartbeat Monitors, Alert Channels, Dashboards, or Status Pages. Access Checkly account plan, entitlements and feature limits. +description: Set up, create, test and manage monitoring checks using the Checkly CLI. Use when working with Agentic Checks, API Checks, Browser Checks, URL Monitors, ICMP Monitors, Playwright Check Suites, Heartbeat Monitors, Alert Channels, Dashboards, or Status Pages. Access Checkly account plan, entitlements, feature limits, members, and pending invites. allowed-tools: Bash(npx:checkly:*) Bash(npm:install:*) metadata: author: checkly diff --git a/packages/cli/src/commands/__tests__/command-metadata.spec.ts b/packages/cli/src/commands/__tests__/command-metadata.spec.ts index 46550eeb6..0f941bbf0 100644 --- a/packages/cli/src/commands/__tests__/command-metadata.spec.ts +++ b/packages/cli/src/commands/__tests__/command-metadata.spec.ts @@ -35,8 +35,10 @@ import PwTest from '../pw-test' import SyncPlaywright from '../sync-playwright' import SkillsInstall from '../skills/install' import AccountPlan from '../account/plan' +import AccountMembers from '../account/members' const commands: Array<[string, typeof BaseCommand]> = [ + ['account members', AccountMembers], ['account plan', AccountPlan], ['checks list', ChecksList], ['checks get', ChecksGet], diff --git a/packages/cli/src/commands/account/members.ts b/packages/cli/src/commands/account/members.ts new file mode 100644 index 000000000..f30868eb7 --- /dev/null +++ b/packages/cli/src/commands/account/members.ts @@ -0,0 +1,46 @@ +import { Flags } from '@oclif/core' +import { AuthCommand } from '../authCommand' +import { outputFlag } from '../../helpers/flags' +import * as api from '../../rest/api' +import type { OutputFormat } from '../../formatters/render' +import { formatAccountMembers } from '../../formatters/account-members' + +export default class AccountMembers extends AuthCommand { + static hidden = false + static readOnly = true + static idempotent = true + static description = 'List account members and pending invites.' + + static flags = { + 'hide-id': Flags.boolean({ + description: 'Hide member and invite IDs in table output.', + default: false, + }), + 'output': outputFlag({ default: 'table' }), + } + + async run (): Promise { + const { flags } = await this.parse(AccountMembers) + this.style.outputFormat = flags.output + + try { + const { data } = await api.accountMembers.getAll() + + if (flags.output === 'json') { + this.log(JSON.stringify(data, null, 2)) + return + } + + if (data.members.length === 0) { + this.log('No account members found.') + return + } + + const fmt: OutputFormat = flags.output === 'md' ? 'md' : 'terminal' + this.log(formatAccountMembers(data.members, fmt, { showId: !flags['hide-id'] })) + } catch (err: any) { + this.style.longError('Failed to list account members.', err) + process.exitCode = 1 + } + } +} diff --git a/packages/cli/src/formatters/__tests__/account-members.spec.ts b/packages/cli/src/formatters/__tests__/account-members.spec.ts new file mode 100644 index 000000000..560b585da --- /dev/null +++ b/packages/cli/src/formatters/__tests__/account-members.spec.ts @@ -0,0 +1,87 @@ +import { describe, it, expect } from 'vitest' +import { stripAnsi } from '../render' +import { formatAccountMembers } from '../account-members' +import type { AccountMember } from '../../rest/account-members' + +const activeMember: AccountMember = { + type: 'member', + accountId: '11111111-1111-1111-1111-111111111111', + userId: '22222222-2222-2222-2222-222222222222', + name: 'Owner User', + email: 'owner@example.com', + role: 'OWNER', + status: 'ACTIVE', + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-02T00:00:00.000Z', + isSupportMembership: true, + ssoEnabled: true, + mfaEnabled: true, +} + +const pendingInvite: AccountMember = { + type: 'invite', + id: '33333333-3333-3333-3333-333333333333', + accountId: '11111111-1111-1111-1111-111111111111', + email: 'pending@example.com', + role: 'READ_ONLY', + status: 'PENDING', + inviterEmail: 'owner@example.com', + createdAt: '2026-01-03T00:00:00.000Z', + updatedAt: '2026-01-03T00:00:00.000Z', + expiresAt: '2026-02-03T00:00:00.000Z', +} + +const expiredInvite: AccountMember = { + ...pendingInvite, + id: '44444444-4444-4444-4444-444444444444', + email: 'expired@example.com', + status: 'EXPIRED', +} + +describe('formatAccountMembers', () => { + it('renders active member security and support fields', () => { + const result = stripAnsi(formatAccountMembers([activeMember], 'terminal')) + + expect(result).toContain('TYPE') + expect(result).toContain('owner@example.com') + expect(result).toContain('Owner User') + expect(result).toContain('OWNER') + expect(result).toContain('ACTIVE') + expect(result).toContain('yes') + expect(result).toContain('22222222-2222-2222-2222-222222222222') + }) + + it('renders pending invite with expiry and invite id', () => { + const result = stripAnsi(formatAccountMembers([pendingInvite], 'terminal')) + + expect(result).toContain('invite') + expect(result).toContain('pending@example.com') + expect(result).toContain('READ_ONLY') + expect(result).toContain('PENDING') + expect(result).toContain('2026-02-03 00:00:00') + expect(result).toContain('33333333-3333-3333-3333-333333333333') + }) + + it('renders expired invite status', () => { + const result = stripAnsi(formatAccountMembers([expiredInvite], 'terminal')) + + expect(result).toContain('expired@example.com') + expect(result).toContain('EXPIRED') + }) + + it('renders markdown output', () => { + const result = formatAccountMembers([activeMember, pendingInvite], 'md') + + expect(result).toContain('| Type | Email | Name | Role | Status | MFA | SSO | Support | Expires | ID |') + expect(result).toContain('| member | owner@example.com | Owner User | OWNER | ACTIVE | yes | yes | yes | - | 22222222-2222-2222-2222-222222222222 |') + expect(result).toContain('| invite | pending@example.com | - | READ_ONLY | PENDING | - | - | - | 2026-02-03 00:00:00 UTC | 33333333-3333-3333-3333-333333333333 |') + }) + + it('hides IDs when requested', () => { + const result = stripAnsi(formatAccountMembers([activeMember, pendingInvite], 'terminal', { showId: false })) + + expect(result).not.toContain('ID') + expect(result).not.toContain('22222222-2222-2222-2222-222222222222') + expect(result).not.toContain('33333333-3333-3333-3333-333333333333') + }) +}) diff --git a/packages/cli/src/formatters/account-members.ts b/packages/cli/src/formatters/account-members.ts new file mode 100644 index 000000000..45b2c537b --- /dev/null +++ b/packages/cli/src/formatters/account-members.ts @@ -0,0 +1,139 @@ +import chalk from 'chalk' +import type { AccountMember } from '../rest/account-members' +import { + type ColumnDef, + type OutputFormat, + formatDate, + renderTable, + truncateToWidth, +} from './render' + +export interface AccountMembersTableOptions { + showId?: boolean +} + +function boolSymbol (value: boolean | undefined, format: OutputFormat): string { + if (value === undefined) return format === 'terminal' ? chalk.dim('-') : '-' + if (format === 'md') return value ? 'yes' : '-' + return value ? chalk.green('yes') : chalk.dim('-') +} + +function memberName (member: AccountMember, format: OutputFormat): string { + if (member.type === 'invite') return format === 'terminal' ? chalk.dim('-') : '-' + return member.name ?? (format === 'terminal' ? chalk.dim('-') : '-') +} + +function memberId (member: AccountMember): string { + return member.type === 'member' ? member.userId : member.id +} + +function expiresAt (member: AccountMember, format: OutputFormat): string { + if (member.type === 'member') return format === 'terminal' ? chalk.dim('-') : '-' + return formatDate(member.expiresAt, format) +} + +function buildAccountMemberColumns ( + members: AccountMember[], + format: OutputFormat, + options: AccountMembersTableOptions = {}, +): ColumnDef[] { + if (format === 'md') { + const columns: ColumnDef[] = [ + { header: 'Type', value: m => m.type }, + { header: 'Email', value: m => m.email }, + { header: 'Name', value: (m, fmt) => memberName(m, fmt) }, + { header: 'Role', value: m => m.role }, + { header: 'Status', value: m => m.status }, + { header: 'MFA', value: (m, fmt) => boolSymbol(m.type === 'member' ? m.mfaEnabled : undefined, fmt) }, + { header: 'SSO', value: (m, fmt) => boolSymbol(m.type === 'member' ? m.ssoEnabled : undefined, fmt) }, + { header: 'Support', value: (m, fmt) => boolSymbol(m.type === 'member' ? m.isSupportMembership : undefined, fmt) }, + { header: 'Expires', value: (m, fmt) => expiresAt(m, fmt) }, + ] + + if (options.showId !== false) { + columns.push({ header: 'ID', value: memberId }) + } + + return columns + } + + const showId = options.showId !== false + const termWidth = process.stdout.columns || 120 + const idReserve = showId ? 38 : 0 + const fixedWidth = 8 + 13 + 10 + 5 + 5 + 9 + 25 + idReserve + const flexibleWidth = Math.max(24, termWidth - fixedWidth) + const hasNames = members.some(member => member.type === 'member' && member.name) + const emailWidth = Math.max(20, Math.min(34, Math.floor(flexibleWidth * (hasNames ? 0.6 : 1)))) + const nameWidth = hasNames ? Math.max(14, Math.min(24, flexibleWidth - emailWidth)) : 0 + + const columns: ColumnDef[] = [ + { + header: 'Type', + width: 8, + value: m => m.type, + }, + { + header: 'Email', + width: emailWidth, + value: m => truncateToWidth(m.email, emailWidth - 2), + }, + ] + + if (hasNames) { + columns.push({ + header: 'Name', + width: nameWidth, + value: (m, fmt) => truncateToWidth(memberName(m, fmt), nameWidth - 2), + }) + } + + columns.push( + { + header: 'Role', + width: 13, + value: m => m.role, + }, + { + header: 'Status', + width: 10, + value: m => m.status, + }, + { + header: 'MFA', + width: 5, + value: (m, fmt) => boolSymbol(m.type === 'member' ? m.mfaEnabled : undefined, fmt), + }, + { + header: 'SSO', + width: 5, + value: (m, fmt) => boolSymbol(m.type === 'member' ? m.ssoEnabled : undefined, fmt), + }, + { + header: 'Support', + width: 9, + value: (m, fmt) => boolSymbol(m.type === 'member' ? m.isSupportMembership : undefined, fmt), + }, + { + header: 'Expires', + width: 25, + value: (m, fmt) => truncateToWidth(expiresAt(m, fmt), 23), + }, + ) + + if (showId) { + columns.push({ + header: 'ID', + value: m => chalk.dim(memberId(m)), + }) + } + + return columns +} + +export function formatAccountMembers ( + members: AccountMember[], + format: OutputFormat, + options: AccountMembersTableOptions = {}, +): string { + return renderTable(buildAccountMemberColumns(members, format, options), members, format) +} diff --git a/packages/cli/src/rest/account-members.ts b/packages/cli/src/rest/account-members.ts new file mode 100644 index 000000000..22cf0d89b --- /dev/null +++ b/packages/cli/src/rest/account-members.ts @@ -0,0 +1,50 @@ +import type { AxiosInstance } from 'axios' + +export type AccountMemberRole = 'OWNER' | 'ADMIN' | 'READ_WRITE' | 'READ_RUN' | 'READ_ONLY' + +export interface ActiveAccountMember { + type: 'member' + accountId: string + userId: string + name: string | null + email: string + role: AccountMemberRole + status: 'ACTIVE' + createdAt: string + updatedAt: string + isSupportMembership: boolean + ssoEnabled: boolean + mfaEnabled: boolean +} + +export interface AccountInvite { + type: 'invite' + id: string + accountId: string + email: string + role: Exclude + status: 'PENDING' | 'EXPIRED' + inviterEmail: string + createdAt: string + updatedAt: string + expiresAt: string +} + +export type AccountMember = ActiveAccountMember | AccountInvite + +export interface AccountMembersResponse { + members: AccountMember[] +} + +class AccountMembers { + api: AxiosInstance + constructor (api: AxiosInstance) { + this.api = api + } + + getAll () { + return this.api.get('/v1/accounts/me/members') + } +} + +export default AccountMembers diff --git a/packages/cli/src/rest/api.ts b/packages/cli/src/rest/api.ts index 6e59ca848..5bb951ecc 100644 --- a/packages/cli/src/rest/api.ts +++ b/packages/cli/src/rest/api.ts @@ -23,6 +23,7 @@ import Incidents from './incidents' import Analytics from './analytics' import BatchAnalytics from './batch-analytics' import Entitlements from './entitlements' +import AccountMembers from './account-members' import Rca from './rca' import { handleErrorResponse, UnauthorizedError } from './errors' import { detectOperator } from '../helpers/cli-mode' @@ -126,4 +127,5 @@ export const incidents = new Incidents(api) export const analytics = new Analytics(api) export const batchAnalytics = new BatchAnalytics(api) export const entitlements = new Entitlements(api) +export const accountMembers = new AccountMembers(api) export const rca = new Rca(api) diff --git a/skills/checkly/SKILL.md b/skills/checkly/SKILL.md index 4b1716c18..317cd2df2 100644 --- a/skills/checkly/SKILL.md +++ b/skills/checkly/SKILL.md @@ -1,6 +1,6 @@ --- name: checkly -description: Set up, create, test and manage monitoring checks using the Checkly CLI. Use when working with Agentic Checks, API Checks, Browser Checks, URL Monitors, ICMP Monitors, Playwright Check Suites, Heartbeat Monitors, Alert Channels, Dashboards, or Status Pages. Access Checkly account plan, entitlements and feature limits. +description: Set up, create, test and manage monitoring checks using the Checkly CLI. Use when working with Agentic Checks, API Checks, Browser Checks, URL Monitors, ICMP Monitors, Playwright Check Suites, Heartbeat Monitors, Alert Channels, Dashboards, or Status Pages. Access Checkly account plan, entitlements, feature limits, members, and pending invites. allowed-tools: Bash(npx:checkly:*) Bash(npm:install:*) metadata: author: checkly @@ -49,4 +49,4 @@ Access check status, analyze failures, and investigate errors. Open incidents and lead customer communications via status pages. ### `npx checkly skills manage` -Understand your account plan, entitlements, and feature limits. +Understand your account plan, entitlements, feature limits, members, and pending invites.