Skip to content

Commit f62ffa6

Browse files
feat(cli): add account plan command [AI-72] (#1262)
* feat(cli): add `account plan` command to show entitlements and feature limits Adds a new `account` topic group with `plan` as its first subcommand. The command calls GET /v1/accounts/me/entitlements and supports: - Default summary view (plan name, add-ons, metered table, flag count) - Detail view for a specific entitlement key (e.g. BROWSER_CHECKS) - Filtering by --type (metered|flag) and --search - Standard output formats (table, json, md) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * test(cli): add unit and e2e tests for account plan command - Register AccountPlan in command-metadata.spec.ts (unit test for readOnly, destructive, idempotent metadata properties) - Add e2e tests covering: summary view, JSON/md output, detail view by key, --type and --search filters, error cases (unknown key, mutual exclusivity) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(cli): update help e2e test to include account topic The help output test has hardcoded expected output that needs to include the new account topic. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(cli): improve account plan table output - Bump Name column width from 45 to 50 to fit longest entitlement names - Add dimmed Key column so users can copy keys for detail lookups - Clarify hint text to explain both --type flag and --type metered Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(cli): fix account plan JSON filtering, search, and error handling - JSON output now respects --type and --search filters instead of silently ignoring them and dumping the full response - --search now matches on entitlement key in addition to name and description, since keys are displayed in the table - Narrow try/catch to API call only so this.error() for unknown keys produces a clean oclif error instead of being caught and re-wrapped with a misleading "Failed to fetch account plan" prefix Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(cli): update e2e test for oclif error exit code this.error() exits with code 2 and writes to stderr, not stdout. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 76d4ac2 commit f62ffa6

File tree

8 files changed

+509
-0
lines changed

8 files changed

+509
-0
lines changed
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
import config from 'config'
2+
import { describe, it, expect } from 'vitest'
3+
4+
import { runChecklyCli } from '../run-checkly'
5+
6+
describe('checkly account plan', () => {
7+
it('should show plan summary with default output', async () => {
8+
const result = await runChecklyCli({
9+
args: ['account', 'plan'],
10+
apiKey: config.get('apiKey'),
11+
accountId: config.get('accountId'),
12+
})
13+
expect(result.status).toBe(0)
14+
expect(result.stdout).toContain('Plan:')
15+
expect(result.stdout).toContain('Metered entitlements')
16+
expect(result.stdout).toContain('additional features enabled')
17+
})
18+
19+
it('should output valid JSON with --output json', async () => {
20+
const result = await runChecklyCli({
21+
args: ['account', 'plan', '--output', 'json'],
22+
apiKey: config.get('apiKey'),
23+
accountId: config.get('accountId'),
24+
})
25+
expect(result.status).toBe(0)
26+
const parsed = JSON.parse(result.stdout)
27+
expect(parsed).toHaveProperty('plan')
28+
expect(parsed).toHaveProperty('planDisplayName')
29+
expect(parsed).toHaveProperty('addons')
30+
expect(parsed).toHaveProperty('entitlements')
31+
expect(Array.isArray(parsed.entitlements)).toBe(true)
32+
expect(parsed.entitlements.length).toBeGreaterThan(0)
33+
expect(parsed.entitlements[0]).toHaveProperty('key')
34+
expect(parsed.entitlements[0]).toHaveProperty('type')
35+
expect(parsed.entitlements[0]).toHaveProperty('enabled')
36+
})
37+
38+
it('should output markdown with --output md', async () => {
39+
const result = await runChecklyCli({
40+
args: ['account', 'plan', '--output', 'md'],
41+
apiKey: config.get('apiKey'),
42+
accountId: config.get('accountId'),
43+
})
44+
expect(result.status).toBe(0)
45+
expect(result.stdout).toContain('# Plan:')
46+
expect(result.stdout).toContain('| Name | Limit |')
47+
})
48+
49+
it('should show detail view for a specific entitlement key', async () => {
50+
const result = await runChecklyCli({
51+
args: ['account', 'plan', 'BROWSER_CHECKS'],
52+
apiKey: config.get('apiKey'),
53+
accountId: config.get('accountId'),
54+
})
55+
expect(result.status).toBe(0)
56+
expect(result.stdout).toContain('BROWSER_CHECKS')
57+
expect(result.stdout).toContain('Browser checks')
58+
})
59+
60+
it('should output single entitlement as JSON', async () => {
61+
const result = await runChecklyCli({
62+
args: ['account', 'plan', 'BROWSER_CHECKS', '--output', 'json'],
63+
apiKey: config.get('apiKey'),
64+
accountId: config.get('accountId'),
65+
})
66+
expect(result.status).toBe(0)
67+
const parsed = JSON.parse(result.stdout)
68+
expect(parsed.key).toBe('BROWSER_CHECKS')
69+
expect(parsed.type).toBe('metered')
70+
})
71+
72+
it('should filter by --type metered', async () => {
73+
const result = await runChecklyCli({
74+
args: ['account', 'plan', '--type', 'metered'],
75+
apiKey: config.get('apiKey'),
76+
accountId: config.get('accountId'),
77+
})
78+
expect(result.status).toBe(0)
79+
expect(result.stdout).toContain('LIMIT')
80+
expect(result.stdout).toContain('entitlement')
81+
})
82+
83+
it('should filter by --type flag', async () => {
84+
const result = await runChecklyCli({
85+
args: ['account', 'plan', '--type', 'flag'],
86+
apiKey: config.get('apiKey'),
87+
accountId: config.get('accountId'),
88+
})
89+
expect(result.status).toBe(0)
90+
expect(result.stdout).toContain('ENABLED')
91+
expect(result.stdout).toContain('entitlement')
92+
})
93+
94+
it('should filter by --search', async () => {
95+
const result = await runChecklyCli({
96+
args: ['account', 'plan', '--search', 'browser'],
97+
apiKey: config.get('apiKey'),
98+
accountId: config.get('accountId'),
99+
})
100+
expect(result.status).toBe(0)
101+
expect(result.stdout).toContain('Browser')
102+
expect(result.stdout).toContain('entitlement')
103+
})
104+
105+
it('should fail for unknown entitlement key', async () => {
106+
const result = await runChecklyCli({
107+
args: ['account', 'plan', 'NONEXISTENT_KEY'],
108+
apiKey: config.get('apiKey'),
109+
accountId: config.get('accountId'),
110+
})
111+
expect(result.status).toBe(2)
112+
expect(result.stderr).toContain('not found')
113+
})
114+
115+
it('should fail when combining key with --type', async () => {
116+
const result = await runChecklyCli({
117+
args: ['account', 'plan', 'BROWSER_CHECKS', '--type', 'metered'],
118+
apiKey: config.get('apiKey'),
119+
accountId: config.get('accountId'),
120+
})
121+
expect(result.status).not.toBe(0)
122+
})
123+
})

packages/cli/e2e/__tests__/help.spec.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ describe('help', () => {
4949
trigger Trigger your existing checks on Checkly.`)
5050

5151
expect(stdout).toContain(`ADDITIONAL COMMANDS
52+
account View and manage your Checkly account.
5253
checks List and inspect checks in your Checkly account.
5354
destroy Destroy your project with all its related resources.
5455
env Manage Checkly environment variables.

packages/cli/package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,9 @@
6464
"message": "<%= config.name %> update available from <%= chalk.greenBright(config.version) %> to <%= chalk.greenBright(latest) %>. To update, run `npm install -D checkly@latest`"
6565
},
6666
"topics": {
67+
"account": {
68+
"description": "View and manage your Checkly account."
69+
},
6770
"checks": {
6871
"description": "List and inspect checks in your Checkly account."
6972
},

packages/cli/src/commands/__tests__/command-metadata.spec.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,8 +34,10 @@ import ImportCancel from '../import/cancel'
3434
import PwTest from '../pw-test'
3535
import SyncPlaywright from '../sync-playwright'
3636
import SkillsInstall from '../skills/install'
37+
import AccountPlan from '../account/plan'
3738

3839
const commands: Array<[string, typeof BaseCommand]> = [
40+
['account plan', AccountPlan],
3941
['checks list', ChecksList],
4042
['checks get', ChecksGet],
4143
['checks stats', ChecksStats],
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
import { Args, Flags } from '@oclif/core'
2+
import { AuthCommand } from '../authCommand'
3+
import { outputFlag } from '../../helpers/flags'
4+
import * as api from '../../rest/api'
5+
import type { OutputFormat } from '../../formatters/render'
6+
import {
7+
formatPlanSummary,
8+
formatEntitlementDetail,
9+
formatFilteredEntitlements,
10+
} from '../../formatters/account-plan'
11+
12+
export default class AccountPlan extends AuthCommand {
13+
static coreCommand = false
14+
static hidden = false
15+
static readOnly = true
16+
static idempotent = true
17+
static description = 'Show your account plan, entitlements, and feature limits.'
18+
19+
static args = {
20+
key: Args.string({
21+
required: false,
22+
description: 'Entitlement key to look up (e.g. BROWSER_CHECKS). Shows detail view.',
23+
}),
24+
}
25+
26+
static flags = {
27+
type: Flags.string({
28+
char: 't',
29+
description: 'Filter entitlements by type.',
30+
options: ['metered', 'flag'],
31+
}),
32+
search: Flags.string({
33+
char: 's',
34+
description: 'Search entitlements by name or description.',
35+
}),
36+
output: outputFlag({ default: 'table' }),
37+
}
38+
39+
async run (): Promise<void> {
40+
const { args, flags } = await this.parse(AccountPlan)
41+
this.style.outputFormat = flags.output
42+
43+
// Validate: key arg is mutually exclusive with --type and --search
44+
if (args.key && (flags.type || flags.search)) {
45+
this.error('Cannot use --type or --search when looking up a specific entitlement key.')
46+
}
47+
48+
let plan
49+
try {
50+
const resp = await api.entitlements.getAll()
51+
plan = resp.data
52+
} catch (err: any) {
53+
this.style.longError('Failed to fetch account plan.', err)
54+
process.exitCode = 1
55+
return
56+
}
57+
58+
// Single key lookup
59+
if (args.key) {
60+
const entitlement = plan.entitlements.find(e => e.key === args.key)
61+
if (!entitlement) {
62+
this.error(`Entitlement "${args.key}" not found. Use "checkly account plan" to see available keys.`)
63+
}
64+
65+
if (flags.output === 'json') {
66+
this.log(JSON.stringify(entitlement, null, 2))
67+
return
68+
}
69+
70+
const fmt: OutputFormat = flags.output === 'md' ? 'md' : 'terminal'
71+
this.log(formatEntitlementDetail(plan, entitlement, fmt))
72+
return
73+
}
74+
75+
// Apply filters (--type and --search)
76+
const hasFilters = flags.type || flags.search
77+
let filtered = plan.entitlements
78+
79+
if (flags.type) {
80+
filtered = filtered.filter(e => e.type === flags.type)
81+
}
82+
83+
if (flags.search) {
84+
const term = flags.search.toLowerCase()
85+
filtered = filtered.filter(e =>
86+
e.key.toLowerCase().includes(term)
87+
|| e.name.toLowerCase().includes(term)
88+
|| e.description.toLowerCase().includes(term),
89+
)
90+
}
91+
92+
// JSON output (respects filters)
93+
if (flags.output === 'json') {
94+
this.log(JSON.stringify(hasFilters ? filtered : plan, null, 2))
95+
return
96+
}
97+
98+
const fmt: OutputFormat = flags.output === 'md' ? 'md' : 'terminal'
99+
100+
// Filtered view
101+
if (hasFilters) {
102+
this.log(formatFilteredEntitlements(plan, filtered, fmt))
103+
return
104+
}
105+
106+
// Default summary view
107+
this.log(formatPlanSummary(plan, fmt))
108+
}
109+
}

0 commit comments

Comments
 (0)