From 5bacb9ccc918c915049768b1946a228b24e1a5a2 Mon Sep 17 00:00:00 2001 From: "Gulshan Gill (Openclaw Bot)" Date: Mon, 6 Apr 2026 10:22:17 +0000 Subject: [PATCH] feat: add subscribe command for plan management and discount coupons --- src/__tests__/subscribe.test.js | 238 ++++++++++++++++++++++++++++++++ src/api.js | 55 ++++++++ src/cli.js | 24 +++- src/commands/subscribe.js | 209 ++++++++++++++++++++++++++++ src/schema.json | 39 ++++++ 5 files changed, 562 insertions(+), 3 deletions(-) create mode 100644 src/__tests__/subscribe.test.js create mode 100644 src/commands/subscribe.js diff --git a/src/__tests__/subscribe.test.js b/src/__tests__/subscribe.test.js new file mode 100644 index 00000000..40ffb48c --- /dev/null +++ b/src/__tests__/subscribe.test.js @@ -0,0 +1,238 @@ +/** + * Subscribe command tests + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { buildSubscribeCommands, formatPlansTable, formatPromoCode, formatSubscriptionsTable } from '../commands/subscribe.js'; +import { NansenError, ErrorCode } from '../api.js'; + +// ── Helpers ── + +function mockApi(overrides = {}) { + return { + apiKey: 'test-key', + baseUrl: 'https://api.nansen.ai', + appBaseUrl: 'https://app.nansen.ai', + defaultHeaders: {}, + getApiPlans: vi.fn(), + validatePromoCode: vi.fn(), + createStripeSubscription: vi.fn(), + createCoinbaseSubscription: vi.fn(), + createMoonpaySubscription: vi.fn(), + getActiveSubscriptions: vi.fn(), + cancelSubscription: vi.fn(), + ...overrides, + }; +} + +// ── Tests ── + +describe('subscribe command', () => { + let log, cmd; + + afterEach(() => { vi.restoreAllMocks(); }); + + beforeEach(() => { + log = vi.fn(); + cmd = buildSubscribeCommands({ log })['subscribe']; + }); + + // ── Help output ── + + describe('help output', () => { + it('shows top-level help when no subcommand', async () => { + await cmd([], mockApi(), {}, {}); + expect(log).toHaveBeenCalledWith(expect.stringContaining('SUBCOMMANDS')); + }); + + it('shows help for "help" subcommand', async () => { + await cmd(['help'], mockApi(), {}, {}); + expect(log).toHaveBeenCalledWith(expect.stringContaining('SUBCOMMANDS')); + }); + + it('shows plans help with --help flag', async () => { + await cmd(['plans'], mockApi(), { help: true }, {}); + expect(log).toHaveBeenCalledWith(expect.stringContaining('List available API plans')); + }); + + it('shows create help with --help flag', async () => { + await cmd(['create'], mockApi(), { help: true }, {}); + expect(log).toHaveBeenCalledWith(expect.stringContaining('--price-id')); + }); + }); + + // ── Plans ── + + describe('plans', () => { + it('calls getApiPlans and returns result', async () => { + const plans = [{ name: 'Pro', price: 9900, currency: 'usd', interval: 'month', priceId: 'price_123' }]; + const api = mockApi({ getApiPlans: vi.fn().mockResolvedValue(plans) }); + const result = await cmd(['plans'], api, {}, {}); + expect(api.getApiPlans).toHaveBeenCalled(); + expect(result).toEqual(plans); + }); + }); + + // ── Promo code ── + + describe('promo-code', () => { + it('validates a promo code', async () => { + const promo = { code: 'SAVE20', percentOff: 20 }; + const api = mockApi({ validatePromoCode: vi.fn().mockResolvedValue(promo) }); + const result = await cmd(['promo-code', 'SAVE20'], api, {}, {}); + expect(api.validatePromoCode).toHaveBeenCalledWith('SAVE20'); + expect(result).toEqual(promo); + }); + + it('throws when code is missing', async () => { + await expect(cmd(['promo-code'], mockApi(), {}, {})) + .rejects.toThrow('Required: '); + }); + }); + + // ── Create ── + + describe('create', () => { + it('creates a stripe subscription by default', async () => { + const resp = { id: 'sub_1', url: 'https://checkout.stripe.com/...' }; + const api = mockApi({ createStripeSubscription: vi.fn().mockResolvedValue(resp) }); + const result = await cmd(['create'], api, {}, { 'price-id': 'price_123', 'promo-code': 'SAVE20' }); + expect(api.createStripeSubscription).toHaveBeenCalledWith({ + priceId: 'price_123', + promotionCode: 'SAVE20', + paymentMethodId: undefined, + }); + expect(result).toEqual(resp); + }); + + it('creates a coinbase subscription', async () => { + const resp = { id: 'sub_2' }; + const api = mockApi({ createCoinbaseSubscription: vi.fn().mockResolvedValue(resp) }); + const result = await cmd(['create'], api, {}, { 'price-id': 'price_123', provider: 'coinbase' }); + expect(api.createCoinbaseSubscription).toHaveBeenCalledWith({ + priceId: 'price_123', + promotionCode: undefined, + }); + expect(result).toEqual(resp); + }); + + it('creates a moonpay subscription', async () => { + const resp = { id: 'sub_3' }; + const api = mockApi({ createMoonpaySubscription: vi.fn().mockResolvedValue(resp) }); + const result = await cmd(['create'], api, {}, { 'price-id': 'price_123', provider: 'moonpay' }); + expect(api.createMoonpaySubscription).toHaveBeenCalledWith({ + priceId: 'price_123', + promotionCode: undefined, + }); + expect(result).toEqual(resp); + }); + + it('throws when --price-id is missing', async () => { + await expect(cmd(['create'], mockApi(), {}, {})) + .rejects.toThrow('Required: --price-id'); + }); + + it('throws for unknown provider', async () => { + await expect(cmd(['create'], mockApi(), {}, { 'price-id': 'price_123', provider: 'paypal' })) + .rejects.toThrow('Unknown provider'); + }); + + it('passes --payment-method for stripe', async () => { + const api = mockApi({ createStripeSubscription: vi.fn().mockResolvedValue({}) }); + await cmd(['create'], api, {}, { 'price-id': 'price_123', 'payment-method': 'pm_abc' }); + expect(api.createStripeSubscription).toHaveBeenCalledWith({ + priceId: 'price_123', + promotionCode: undefined, + paymentMethodId: 'pm_abc', + }); + }); + }); + + // ── Cancel ── + + describe('cancel', () => { + it('calls cancelSubscription', async () => { + const api = mockApi({ cancelSubscription: vi.fn().mockResolvedValue({ success: true }) }); + const result = await cmd(['cancel'], api, {}, {}); + expect(api.cancelSubscription).toHaveBeenCalled(); + expect(result).toEqual({ success: true }); + }); + }); + + // ── Status ── + + describe('status', () => { + it('calls getActiveSubscriptions', async () => { + const subs = [{ id: 'sub_1', status: 'active', provider: 'stripe' }]; + const api = mockApi({ getActiveSubscriptions: vi.fn().mockResolvedValue(subs) }); + const result = await cmd(['status'], api, {}, {}); + expect(api.getActiveSubscriptions).toHaveBeenCalled(); + expect(result).toEqual(subs); + }); + }); + + // ── Unknown subcommand ── + + it('throws for unknown subcommand', async () => { + await expect(cmd(['bogus'], mockApi(), {}, {})) + .rejects.toThrow('Unknown subscribe subcommand'); + }); +}); + +// ── Formatting ── + +describe('formatPlansTable', () => { + it('formats plans as a table', () => { + const plans = [ + { name: 'Pro', price: 9900, currency: 'usd', interval: 'month', priceId: 'price_123' }, + { name: 'Enterprise', price: 49900, currency: 'usd', interval: 'year', priceId: 'price_456' }, + ]; + const table = formatPlansTable(plans); + expect(table).toContain('NAME'); + expect(table).toContain('PRICE'); + expect(table).toContain('Pro'); + expect(table).toContain('99.00 USD'); + expect(table).toContain('Enterprise'); + }); + + it('returns "No plans available" for empty array', () => { + expect(formatPlansTable([])).toBe('No plans available'); + }); +}); + +describe('formatPromoCode', () => { + it('formats percent off', () => { + const result = formatPromoCode({ code: 'SAVE20', percentOff: 20 }); + expect(result).toContain('SAVE20'); + expect(result).toContain('20%'); + }); + + it('formats amount off', () => { + const result = formatPromoCode({ code: 'FLAT10', amountOff: 1000, currency: 'usd' }); + expect(result).toContain('10.00 USD'); + }); + + it('formats firstTimeTransaction', () => { + const result = formatPromoCode({ code: 'NEW', percentOff: 10, firstTimeTransaction: true }); + expect(result).toContain('First-time only: yes'); + }); + + it('returns fallback for null', () => { + expect(formatPromoCode(null)).toBe('Invalid promo code'); + }); +}); + +describe('formatSubscriptionsTable', () => { + it('formats subscriptions as a table', () => { + const subs = [{ id: 'sub_1', status: 'active', provider: 'stripe' }]; + const table = formatSubscriptionsTable(subs); + expect(table).toContain('ID'); + expect(table).toContain('STATUS'); + expect(table).toContain('sub_1'); + expect(table).toContain('active'); + }); + + it('returns "No active subscriptions" for empty array', () => { + expect(formatSubscriptionsTable([])).toBe('No active subscriptions'); + }); +}); diff --git a/src/api.js b/src/api.js index e25200bd..b87dfa70 100644 --- a/src/api.js +++ b/src/api.js @@ -347,6 +347,9 @@ function loadConfig() { config.baseUrl = process.env.NANSEN_BASE_URL; } + // Nansen app backend (subscription endpoints) + config.appBaseUrl = process.env.NANSEN_APP_URL || 'https://app.nansen.ai'; + return config; } @@ -420,6 +423,7 @@ export class NansenAPI { constructor(apiKey = config.apiKey, baseUrl = config.baseUrl, options = {}) { this.apiKey = apiKey || null; this.baseUrl = baseUrl; + this.appBaseUrl = options.appBaseUrl || config.appBaseUrl; this.retryOptions = { ...DEFAULT_RETRY_OPTIONS, ...options.retry }; this.cacheOptions = { enabled: options.cache?.enabled ?? false, @@ -1361,6 +1365,57 @@ export class NansenAPI { async alertsDelete(alertId) { return this.request(`/api/v1/smart-alert/${encodeURIComponent(alertId)}`, {}, { method: 'DELETE' }); } + + // ============= Subscription Endpoints ============= + + /** + * Make a request to the Nansen app backend (app.nansen.ai). + * Reuses apiKey auth but targets appBaseUrl instead of baseUrl. + */ + async appRequest(endpoint, body = {}, options = {}) { + const savedBaseUrl = this.baseUrl; + this.baseUrl = this.appBaseUrl; + try { + return await this.request(endpoint, body, options); + } finally { + this.baseUrl = savedBaseUrl; + } + } + + async getApiPlans() { + return this.appRequest('/plans/api', {}, { method: 'GET' }); + } + + async validatePromoCode(code) { + return this.appRequest(`/promo-code/${encodeURIComponent(code)}`, {}, { method: 'GET' }); + } + + async createStripeSubscription({ priceId, promotionCode, paymentMethodId }) { + const body = { priceId }; + if (promotionCode) body.promotionCode = promotionCode; + if (paymentMethodId) body.paymentMethodId = paymentMethodId; + return this.appRequest('/subscription/stripe', body); + } + + async createCoinbaseSubscription({ priceId, promotionCode }) { + const body = { priceId }; + if (promotionCode) body.promotionCode = promotionCode; + return this.appRequest('/subscription/coinbase', body); + } + + async createMoonpaySubscription({ priceId, promotionCode }) { + const body = { priceId }; + if (promotionCode) body.promotionCode = promotionCode; + return this.appRequest('/subscription/moonpay', body); + } + + async getActiveSubscriptions() { + return this.appRequest('/subscription/recurring', {}, { method: 'GET' }); + } + + async cancelSubscription() { + return this.appRequest('/subscription/recurring', {}, { method: 'DELETE' }); + } } export default NansenAPI; diff --git a/src/cli.js b/src/cli.js index 102755ed..cea2ba0c 100644 --- a/src/cli.js +++ b/src/cli.js @@ -7,6 +7,7 @@ import { NansenAPI, NansenError, ErrorCode, saveConfig, deleteConfig, getConfigF import { buildWalletCommands } from './wallet.js'; import { buildTradingCommands } from './trading.js'; import { formatAlertsTable, buildAlertsCommands } from './commands/alerts.js'; +import { buildSubscribeCommands, formatPlansTable, formatSubscriptionsTable, formatPromoCode } from './commands/subscribe.js'; import { buildAgentCommands } from './commands/agent.js'; import { resolveAddress, isEnsName } from './ens.js'; import fs from 'fs'; @@ -703,6 +704,7 @@ COMMANDS: wallet create, list, show, export, default, delete, forget-password agent Ask the Nansen AI research agent (fast/expert modes) alerts list, create, update, toggle, delete + subscribe plans, promo-code, create, cancel, status web search, fetch account Show API key status, plan, and remaining credits login Save API key (--api-key , --human, or NANSEN_API_KEY env var) @@ -1620,7 +1622,7 @@ export async function runCLI(rawArgs, deps = {}) { return ''; }; - const commands = { ...buildCommands(deps), ...buildWalletCommands(deps), ...buildTradingCommands(deps), ...buildAlertsCommands(deps), ...buildAgentCommands(deps), ...commandOverrides }; + const commands = { ...buildCommands(deps), ...buildWalletCommands(deps), ...buildTradingCommands(deps), ...buildAlertsCommands(deps), ...buildSubscribeCommands(deps), ...buildAgentCommands(deps), ...commandOverrides }; if (flags.version || flags.v) { output(VERSION); @@ -1659,7 +1661,7 @@ export async function runCLI(rawArgs, deps = {}) { } // First try subcommand help // Skip for 'trade'/'alerts' — their handlers show their own rich usage - if (command && subcommand && command !== 'trade' && command !== 'alerts' && command !== 'agent') { + if (command && subcommand && command !== 'trade' && command !== 'alerts' && command !== 'subscribe' && command !== 'agent') { const subHelp = generateSubcommandHelp(command, subcommand); if (subHelp) { output(deprecationNote(command) + subHelp); @@ -1669,7 +1671,7 @@ export async function runCLI(rawArgs, deps = {}) { } // Then try command-level help (list subcommands) // Skip for 'trade'/'alerts' — let the handler show its own usage - const cmdSchemaLookup = command !== 'trade' && command !== 'alerts' && command !== 'agent' && (SCHEMA.commands[command] || SCHEMA.commands.research.subcommands[command]); + const cmdSchemaLookup = command !== 'trade' && command !== 'alerts' && command !== 'subscribe' && command !== 'agent' && (SCHEMA.commands[command] || SCHEMA.commands.research.subcommands[command]); if (command && cmdSchemaLookup) { const cmdSchema = cmdSchemaLookup; const lines = [`${command} — ${cmdSchema.description}`]; @@ -1783,6 +1785,22 @@ export async function runCLI(rawArgs, deps = {}) { return { type: 'success', data: result }; } + // Subscribe table formatting + if (command === 'subscribe' && table) { + if (subcommand === 'plans') { + output(formatPlansTable(Array.isArray(result) ? result : result?.plans ?? result?.data ?? [])); + return { type: 'success', data: result }; + } + if (subcommand === 'status') { + output(formatSubscriptionsTable(Array.isArray(result) ? result : result?.subscriptions ?? result?.data ?? [])); + return { type: 'success', data: result }; + } + if (subcommand === 'promo-code') { + output(formatPromoCode(result)); + return { type: 'success', data: result }; + } + } + // Output in requested format if (stream) { // Stream mode: output each record as a JSON line (NDJSON) diff --git a/src/commands/subscribe.js b/src/commands/subscribe.js new file mode 100644 index 00000000..d5465a03 --- /dev/null +++ b/src/commands/subscribe.js @@ -0,0 +1,209 @@ +/** + * Nansen CLI - Subscribe command + * Subscription management: plans, promo codes, create/cancel/status. + */ + +import { NansenError, ErrorCode } from '../api.js'; + +// ============= Formatting ============= + +export function formatPlansTable(plans) { + if (!Array.isArray(plans) || plans.length === 0) { + return 'No plans available'; + } + + const nameWidth = Math.max(4, Math.min(30, Math.max(...plans.map(p => (p.name || '').length)))); + const priceWidth = Math.max(5, Math.min(15, Math.max(...plans.map(p => formatPrice(p).length)))); + const intervalWidth = Math.max(8, Math.min(15, Math.max(...plans.map(p => (p.interval || '').length)))); + const idWidth = Math.max(8, Math.min(40, Math.max(...plans.map(p => (p.priceId || p.id || '').length)))); + + const lines = []; + const header = `${'NAME'.padEnd(nameWidth)} │ ${'PRICE'.padEnd(priceWidth)} │ ${'INTERVAL'.padEnd(intervalWidth)} │ ${'PRICE ID'.padEnd(idWidth)}`; + lines.push(header); + lines.push('─'.repeat(nameWidth) + '─┼─' + '─'.repeat(priceWidth) + '─┼─' + '─'.repeat(intervalWidth) + '─┼─' + '─'.repeat(idWidth)); + + for (const plan of plans) { + const name = (plan.name || '').slice(0, nameWidth).padEnd(nameWidth); + const price = formatPrice(plan).slice(0, priceWidth).padEnd(priceWidth); + const interval = (plan.interval || '').slice(0, intervalWidth).padEnd(intervalWidth); + const id = (plan.priceId || plan.id || '').slice(0, idWidth).padEnd(idWidth); + lines.push(`${name} │ ${price} │ ${interval} │ ${id}`); + } + + return lines.join('\n'); +} + +function formatPrice(plan) { + if (plan.price == null) return ''; + const amount = typeof plan.price === 'number' ? (plan.price / 100).toFixed(2) : String(plan.price); + const currency = (plan.currency || 'usd').toUpperCase(); + return `${amount} ${currency}`; +} + +export function formatPromoCode(promo) { + if (!promo) return 'Invalid promo code'; + const lines = []; + if (promo.code) lines.push(`Code: ${promo.code}`); + if (promo.percentOff != null) lines.push(`Discount: ${promo.percentOff}% off`); + if (promo.amountOff != null) { + const currency = (promo.currency || 'usd').toUpperCase(); + lines.push(`Discount: ${(promo.amountOff / 100).toFixed(2)} ${currency} off`); + } + if (promo.minimumAmount != null) { + const currency = (promo.currency || 'usd').toUpperCase(); + lines.push(`Minimum: ${(promo.minimumAmount / 100).toFixed(2)} ${currency}`); + } + if (promo.firstTimeTransaction != null) lines.push(`First-time only: ${promo.firstTimeTransaction ? 'yes' : 'no'}`); + if (Array.isArray(promo.appliesToPlanIds) && promo.appliesToPlanIds.length > 0) { + lines.push(`Applies to plans: ${promo.appliesToPlanIds.join(', ')}`); + } + return lines.join('\n'); +} + +export function formatSubscriptionsTable(subs) { + if (!Array.isArray(subs) || subs.length === 0) { + return 'No active subscriptions'; + } + + const idWidth = Math.max(2, Math.min(40, Math.max(...subs.map(s => (s.id || '').length)))); + const statusWidth = Math.max(6, Math.min(15, Math.max(...subs.map(s => (s.status || '').length)))); + const providerWidth = Math.max(8, Math.min(15, Math.max(...subs.map(s => (s.provider || '').length)))); + + const lines = []; + const header = `${'ID'.padEnd(idWidth)} │ ${'STATUS'.padEnd(statusWidth)} │ ${'PROVIDER'.padEnd(providerWidth)}`; + lines.push(header); + lines.push('─'.repeat(idWidth) + '─┼─' + '─'.repeat(statusWidth) + '─┼─' + '─'.repeat(providerWidth)); + + for (const sub of subs) { + const id = (sub.id || '').slice(0, idWidth).padEnd(idWidth); + const status = (sub.status || '').slice(0, statusWidth).padEnd(statusWidth); + const provider = (sub.provider || '').slice(0, providerWidth).padEnd(providerWidth); + lines.push(`${id} │ ${status} │ ${provider}`); + } + + return lines.join('\n'); +} + +// ============= Command Builder ============= + +export function buildSubscribeCommands(deps = {}) { + const { log = console.log } = deps; + + return { + 'subscribe': async (args, apiInstance, flags, options) => { + const sub = args[0]; + + const HELP = { + _top: `nansen subscribe — Subscription management + +SUBCOMMANDS: + plans List available API plans + promo-code Validate a promo/coupon code + create Create a new subscription + cancel Cancel active recurring subscription + status Show active subscription(s) + +Run: nansen subscribe --help`, + + plans: `nansen subscribe plans — List available API plans + +USAGE: + nansen subscribe plans [--table] [--pretty]`, + + 'promo-code': `nansen subscribe promo-code — Validate a promo/coupon code + +USAGE: + nansen subscribe promo-code [--pretty] + +EXAMPLES: + nansen subscribe promo-code SAVE20`, + + create: `nansen subscribe create — Create a new subscription + +USAGE: + nansen subscribe create --price-id [--promo-code ] [--provider stripe|coinbase|moonpay] [--payment-method ] + +OPTIONS: + --price-id Plan price ID (required, from "nansen subscribe plans") + --promo-code Promotion/coupon code (optional) + --provider Payment provider: stripe (default), coinbase, moonpay + --payment-method Stripe payment method ID (stripe only, optional) + +EXAMPLES: + nansen subscribe create --price-id price_abc123 + nansen subscribe create --price-id price_abc123 --promo-code SAVE20 + nansen subscribe create --price-id price_abc123 --provider coinbase`, + + cancel: `nansen subscribe cancel — Cancel active recurring subscription + +USAGE: + nansen subscribe cancel`, + + status: `nansen subscribe status — Show active subscription(s) + +USAGE: + nansen subscribe status [--table] [--pretty]`, + }; + + if (!sub || sub === 'help') { + log(HELP._top); + return; + } + + const handlers = { + 'plans': async () => { + return apiInstance.getApiPlans(); + }, + 'promo-code': async () => { + const code = args[1]; + if (!code) throw new NansenError('Required: ', ErrorCode.MISSING_PARAM); + return apiInstance.validatePromoCode(code); + }, + 'create': async () => { + const priceId = options['price-id']; + if (!priceId) throw new NansenError('Required: --price-id', ErrorCode.MISSING_PARAM); + const provider = options.provider || 'stripe'; + const promoCode = options['promo-code']; + + if (provider === 'stripe') { + return apiInstance.createStripeSubscription({ + priceId, + promotionCode: promoCode, + paymentMethodId: options['payment-method'], + }); + } else if (provider === 'coinbase') { + return apiInstance.createCoinbaseSubscription({ + priceId, + promotionCode: promoCode, + }); + } else if (provider === 'moonpay') { + return apiInstance.createMoonpaySubscription({ + priceId, + promotionCode: promoCode, + }); + } else { + throw new NansenError(`Unknown provider: ${provider}. Use stripe, coinbase, or moonpay`, ErrorCode.INVALID_PARAMS); + } + }, + 'cancel': async () => { + return apiInstance.cancelSubscription(); + }, + 'status': async () => { + return apiInstance.getActiveSubscriptions(); + }, + }; + + if (!handlers[sub]) { + throw new NansenError(`Unknown subscribe subcommand: ${sub}. Available: plans, promo-code, create, cancel, status`, ErrorCode.UNKNOWN); + } + + if (flags.help || flags.h || args[1] === 'help') { + // promo-code help: args[1] is 'help', not a code + log(HELP[sub] || HELP._top); + return; + } + + return await handlers[sub](); + }, + }; +} diff --git a/src/schema.json b/src/schema.json index 4a00936b..7aa75155 100644 --- a/src/schema.json +++ b/src/schema.json @@ -793,6 +793,45 @@ } } }, + "subscribe": { + "description": "Subscription management — plans, promo codes, create/cancel/status", + "subcommands": { + "plans": { + "description": "List available API plans", + "returns": ["name", "price", "currency", "interval", "priceId"] + }, + "promo-code": { + "description": "Validate a promo/coupon code. Usage: nansen subscribe promo-code ", + "returns": ["code", "percentOff", "amountOff", "currency", "minimumAmount", "firstTimeTransaction", "appliesToPlanIds"] + }, + "create": { + "description": "Create a new subscription", + "options": { + "price-id": { + "required": true, + "description": "Plan price ID (from nansen subscribe plans)" + }, + "promo-code": { + "description": "Promotion/coupon code" + }, + "provider": { + "default": "stripe", + "description": "Payment provider: stripe, coinbase, or moonpay" + }, + "payment-method": { + "description": "Stripe payment method ID (stripe only)" + } + } + }, + "cancel": { + "description": "Cancel active recurring subscription" + }, + "status": { + "description": "Show active subscription(s)", + "returns": ["id", "status", "provider"] + } + } + }, "trade": { "description": "DEX trading commands", "subcommands": {