outrank-cli 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/README.md +317 -20
  2. package/SKILL.md +20 -2
  3. package/bin/outrank-mcp.js +3 -0
  4. package/package.json +21 -5
  5. package/src/adapters/cli/jsonInput.js +42 -0
  6. package/src/adapters/cli/parse.js +124 -0
  7. package/src/adapters/cli/run.js +139 -0
  8. package/src/adapters/mcp/constants.js +49 -0
  9. package/src/adapters/mcp/descriptions.js +87 -0
  10. package/src/adapters/mcp/help.js +34 -0
  11. package/src/adapters/mcp/input.js +195 -0
  12. package/src/adapters/mcp/instructions.js +139 -0
  13. package/src/adapters/mcp/logger.js +45 -0
  14. package/src/adapters/mcp/main.js +36 -0
  15. package/src/adapters/mcp/options.js +118 -0
  16. package/src/adapters/mcp/result.js +33 -0
  17. package/src/adapters/mcp/server.js +174 -0
  18. package/src/adapters/mcp/session.js +274 -0
  19. package/src/adapters/mcp/toolNotes.js +426 -0
  20. package/src/adapters/mcp/toolsets.js +106 -0
  21. package/src/commands/articles.js +285 -301
  22. package/src/commands/auth.js +204 -86
  23. package/src/commands/billing.js +102 -104
  24. package/src/commands/improvements.js +319 -211
  25. package/src/commands/index.js +22 -0
  26. package/src/commands/keywords.js +266 -249
  27. package/src/commands/marketplace/buyer.js +386 -0
  28. package/src/commands/marketplace/index.js +83 -0
  29. package/src/commands/marketplace/notifications.js +60 -0
  30. package/src/commands/marketplace/seller.js +595 -0
  31. package/src/commands/marketplace/shared.js +27 -0
  32. package/src/commands/products/backlinks.js +181 -0
  33. package/src/commands/products/core.js +149 -0
  34. package/src/commands/products/gsc.js +250 -0
  35. package/src/commands/products/index.js +174 -0
  36. package/src/commands/products/integrations.js +343 -0
  37. package/src/commands/products/linking.js +192 -0
  38. package/src/commands/products/shared.js +8 -0
  39. package/src/commands/registry.js +272 -0
  40. package/src/commands/subscription.js +20 -30
  41. package/src/commands/usage.js +17 -30
  42. package/src/core/config.js +243 -62
  43. package/src/core/constants.js +101 -0
  44. package/src/core/context.js +311 -0
  45. package/src/core/errors.js +39 -0
  46. package/src/core/execute.js +71 -0
  47. package/src/core/files.js +186 -0
  48. package/src/core/http.js +393 -0
  49. package/src/core/profileSelection.js +7 -0
  50. package/src/core/query.js +11 -0
  51. package/src/core/redact.js +27 -0
  52. package/src/core/schema.js +222 -0
  53. package/src/core/version.js +12 -0
  54. package/src/index.js +30 -58
  55. package/src/commands/marketplace.js +0 -740
  56. package/src/commands/products.js +0 -1185
  57. package/src/core/api.js +0 -82
  58. package/src/core/args.js +0 -144
@@ -1,127 +1,245 @@
1
- import { getOption, hasFlag, parseArgs } from '../core/args.js';
2
- import { requestAgentApi } from '../core/api.js';
1
+ import { PROFILE_ENV } from '../core/constants.js';
3
2
  import {
4
- clearStoredAuth,
3
+ API_KEY_FIELD,
4
+ BASE_URL_FIELD,
5
+ clearProfileAuth,
5
6
  getConfigPath,
6
- getDefaultBaseUrl,
7
- resolveRuntimeConfig,
8
- storeAuthConfig,
7
+ loadProfiles,
8
+ redactApiKey,
9
+ storeProfileAuth,
9
10
  } from '../core/config.js';
10
- import { cliSuccess } from '../core/errors.js';
11
+ import { ContextSource, Surface, normalizeBaseUrl } from '../core/context.js';
12
+ import { cliSuccess, usageError } from '../core/errors.js';
13
+ import { assertHeaderSafeApiKey, requestAgentApi } from '../core/http.js';
14
+ import { profileSelectedByEnvHint } from '../core/profileSelection.js';
15
+ import { bool, string } from '../core/schema.js';
16
+ import { CommandKind, Toolset, defineCommand, defineGroup } from './registry.js';
11
17
 
12
18
  export const AUTH_HELP = `Usage:
13
- outrank-cli auth login <api-key> [--base-url <url>] [--no-verify]
19
+ outrank-cli auth login <api-key> [--profile <name>] [--default] [--base-url <url>] [--no-verify]
14
20
  outrank-cli auth whoami
15
21
  outrank-cli auth status
16
- outrank-cli auth logout
22
+ outrank-cli auth profiles
23
+ outrank-cli auth logout [--profile <name>]
17
24
 
18
25
  Commands:
19
- login Store an API key locally and optionally verify it immediately.
26
+ login Store an API key in a profile (the default profile unless --profile is given) and optionally verify it.
20
27
  whoami Verify auth and return the current user and organization details.
21
- status Show the currently resolved auth and base-url configuration.
22
- logout Remove the stored API key from local config.
28
+ status Show the resolved profile, where its key and base URL came from, and the config path.
29
+ profiles List stored profiles with redacted keys and mark the default one.
30
+ logout Remove the stored API key from a profile.
23
31
 
24
32
  Notes:
25
- Environment variables override local config: OUTRANK_API_KEY, OUTRANK_BASE_URL.
33
+ Precedence: --api-key and --base-url win; then a profile selected with --profile or OUTRANK_PROFILE
34
+ (a selected profile ignores OUTRANK_API_KEY and OUTRANK_BASE_URL); then OUTRANK_API_KEY and
35
+ OUTRANK_BASE_URL; then the default profile from local config.
36
+ login verifies against and stores the resolved base URL: --base-url, else OUTRANK_BASE_URL (ignored when
37
+ a profile is selected), else the profile's stored value, else the default.
26
38
  Optional config-dir override: OUTRANK_CONFIG_DIR
27
39
  Local config path: ~/.outrank/config.json
28
40
  `;
29
41
 
30
- export const handleAuthCommand = async (argv) => {
31
- const subcommand = argv[0];
32
- const parsed = parseArgs(argv.slice(1));
33
- const wantsHelp =
34
- !subcommand || subcommand === '--help' || subcommand === '-h' || subcommand === 'help' || hasFlag(parsed, 'help');
42
+ const WHOAMI_PATH = '/auth/whoami';
43
+ const CONFIG_PATH_FIELD = 'config_path';
44
+ const ENV_IGNORED_SUFFIX = ' (env ignored)';
45
+ const LOGIN_MISSING_KEY_GUIDANCE = 'Run `outrank-cli auth login <api-key>` or pass `--api-key <api-key>`, then retry.';
46
+ const LOGIN_SAVED_GUIDANCE =
47
+ 'Credentials saved. Run `outrank-cli auth whoami` to confirm the active workspace at any time.';
48
+ const loginSavedToProfileGuidance = (profile) =>
49
+ `Credentials saved to profile ${profile}, which is not the default. Pass --profile ${profile} or set ` +
50
+ `${PROFILE_ENV}=${profile} to use it, or re-run login with --default to make it the default.`;
51
+ const STATUS_GUIDANCE =
52
+ 'Confirm this target before running live commands. If you use a non-default Outrank deployment, set ' +
53
+ '`OUTRANK_BASE_URL` first.';
54
+ const STATUS_UNKNOWN_PROFILE_GUIDANCE =
55
+ 'The selected profile is not stored. Run `outrank-cli auth profiles` to see the stored profiles, or ' +
56
+ '`outrank-cli auth login <api-key> --profile <name>` to create it.';
57
+ const STATUS_UNKNOWN_PROFILE_WITH_KEY_GUIDANCE =
58
+ 'The selected profile is not stored; the key in use comes from --api-key, not from a profile. Run ' +
59
+ '`outrank-cli auth login <api-key> --profile <name>` to store it.';
60
+ const statusInvalidStoredBaseUrlGuidance = (profile) =>
61
+ `The base URL stored in profile ${profile} is not a valid URL, so authenticated commands will fail. Edit it in ` +
62
+ `the config file, or run \`outrank-cli auth login <api-key> --profile ${profile} --base-url <url>\` to replace it.`;
63
+ const LOGIN_BASE_URL_FROM_ENV_NOTE =
64
+ ' The base URL came from OUTRANK_BASE_URL and is now stored in the profile; unset the variable if that was not ' +
65
+ 'intended and run login again with --base-url.';
35
66
 
36
- if (wantsHelp) {
37
- return cliSuccess(
38
- {
39
- usage: AUTH_HELP.trim(),
40
- },
41
- 'Choose one of: login, whoami, status, logout.',
42
- );
67
+ const statusGuidance = (ctx) => {
68
+ if (ctx.profile.explicit && !ctx.profile.known) {
69
+ const base = ctx.apiKey ? STATUS_UNKNOWN_PROFILE_WITH_KEY_GUIDANCE : STATUS_UNKNOWN_PROFILE_GUIDANCE;
70
+ return base + profileSelectedByEnvHint(ctx.profile.source);
71
+ }
72
+ if (ctx.baseUrl === null) {
73
+ return statusInvalidStoredBaseUrlGuidance(ctx.profile.name) + profileSelectedByEnvHint(ctx.profile.source);
74
+ }
75
+ return STATUS_GUIDANCE;
76
+ };
77
+
78
+ const describeStoredBaseUrl = (value) => {
79
+ if (value === undefined) return { base_url: null, base_url_valid: true };
80
+ try {
81
+ return { base_url: normalizeBaseUrl(value), base_url_valid: true };
82
+ } catch {
83
+ return { base_url: null, base_url_valid: false };
43
84
  }
85
+ };
86
+ const LOGOUT_GUIDANCE =
87
+ 'Stored API key removed. Any future authenticated call now requires OUTRANK_API_KEY or a fresh `auth login`.';
88
+ const logoutProfileGuidance = (profile) =>
89
+ `Stored API key removed from profile ${profile}. Other profiles are untouched; run ` +
90
+ `\`outrank-cli auth login <api-key> --profile ${profile}\` to store a new key.`;
91
+ const logoutNothingStoredGuidance = (profile) =>
92
+ `Profile ${profile} had no stored API key, so nothing changed. Run \`outrank-cli auth profiles\` to see ` +
93
+ 'which profiles hold keys.';
94
+ const PROFILES_EMPTY_GUIDANCE =
95
+ 'No profiles are stored. Run `outrank-cli auth login <api-key>` (optionally with --profile <name>) to create one.';
96
+ const PROFILES_GUIDANCE =
97
+ 'Select a profile per call with --profile <name> or per shell with OUTRANK_PROFILE. A selected profile ignores ' +
98
+ 'OUTRANK_API_KEY and OUTRANK_BASE_URL; --api-key and --base-url still win.';
44
99
 
45
- if (subcommand === 'login') {
46
- const apiKey = parsed.positionals[0] ?? getOption(parsed, 'api-key');
47
- if (!apiKey) {
48
- throw Object.assign(new Error('Missing API key.'), {
49
- cliCode: 'CLI_INVALID_USAGE',
50
- guidance:
51
- 'Run `outrank-cli auth login <api-key>` or pass `--api-key <api-key>`, then retry.',
52
- });
53
- }
100
+ const configPathField = (ctx) => (ctx.surface === Surface.MCP ? {} : { [CONFIG_PATH_FIELD]: getConfigPath() });
54
101
 
55
- const baseUrl = getOption(parsed, 'base-url') ?? getDefaultBaseUrl();
56
- if (!hasFlag(parsed, 'no-verify')) {
57
- const verifyParsed = parseArgs(['--api-key', apiKey, '--base-url', baseUrl]);
58
- const response = await requestAgentApi(verifyParsed, {
59
- method: 'GET',
60
- path: '/auth/whoami',
61
- });
62
- if (!response.ok) {
63
- return response.payload;
64
- }
65
- }
102
+ const renderSource = (source, envIgnored) => (envIgnored ? `${source}${ENV_IGNORED_SUFFIX}` : source);
66
103
 
67
- const saved = await storeAuthConfig({ apiKey, baseUrl });
104
+ const login = defineCommand({
105
+ id: 'auth.login',
106
+ summary: 'Store an API key in a profile and optionally verify it immediately.',
107
+ kind: CommandKind.LOCAL,
108
+ toolsets: [Toolset.CORE],
109
+ mcp: false,
110
+ allowUnknownProfile: true,
111
+ input: { apiKey: string(), noVerify: bool(), makeDefault: bool() },
112
+ cli: {
113
+ positionals: ['apiKey'],
114
+ placeholders: { apiKey: '<api-key>' },
115
+ options: { 'no-verify': 'noVerify', default: 'makeDefault' },
116
+ },
117
+ run: async (input, ctx) => {
118
+ const apiKey = input.apiKey ?? (ctx.source.apiKey === ContextSource.FLAG ? ctx.apiKey : undefined);
119
+ if (!apiKey) throw usageError('Missing API key.', LOGIN_MISSING_KEY_GUIDANCE);
120
+ assertHeaderSafeApiKey(apiKey);
121
+ if (!input.noVerify) {
122
+ const { result } = await requestAgentApi({ ...ctx, apiKey }, { method: 'GET', path: WHOAMI_PATH });
123
+ if (!result.ok) return result;
124
+ }
125
+ const saved = await storeProfileAuth({
126
+ profile: ctx.profile.name,
127
+ apiKey,
128
+ baseUrl: ctx.baseUrl,
129
+ makeDefault: input.makeDefault,
130
+ });
131
+ const envNote = ctx.source.baseUrl === ContextSource.ENV ? LOGIN_BASE_URL_FROM_ENV_NOTE : '';
68
132
  return cliSuccess(
69
133
  {
70
134
  stored: {
135
+ profile: saved.profile,
136
+ is_default: saved.isDefault,
71
137
  api_key: true,
72
- base_url: saved.baseUrl,
73
- config_path: getConfigPath(),
138
+ base_url: saved.stored[BASE_URL_FIELD],
139
+ base_url_source: ctx.source.baseUrl,
140
+ [CONFIG_PATH_FIELD]: getConfigPath(),
74
141
  },
75
142
  },
76
- 'Credentials saved. Run `outrank-cli auth whoami` to confirm the active workspace at any time.',
143
+ (saved.isDefault ? LOGIN_SAVED_GUIDANCE : loginSavedToProfileGuidance(saved.profile)) + envNote,
77
144
  );
78
- }
145
+ },
146
+ });
79
147
 
80
- if (subcommand === 'whoami') {
81
- const response = await requestAgentApi(parsed, {
82
- method: 'GET',
83
- path: '/auth/whoami',
84
- });
85
- return response.payload;
86
- }
148
+ const whoami = defineCommand({
149
+ id: 'auth.whoami',
150
+ summary: 'Verify auth and return the current user and organization details.',
151
+ kind: CommandKind.READ,
152
+ toolsets: [Toolset.CORE],
153
+ run: (_input, ctx) => ctx.http({ method: 'GET', path: WHOAMI_PATH }),
154
+ });
155
+
156
+ const status = defineCommand({
157
+ id: 'auth.status',
158
+ summary: 'Show the resolved profile, auth source and base-url configuration (the key itself is never shown).',
159
+ kind: CommandKind.LOCAL,
160
+ toolsets: [Toolset.CORE],
161
+ mcp: { redact: [CONFIG_PATH_FIELD] },
162
+ offline: true,
163
+ allowUnknownProfile: true,
164
+ run: (_input, ctx) =>
165
+ cliSuccess(
166
+ {
167
+ profile: ctx.profile.name,
168
+ profile_source: ctx.profile.source,
169
+ profile_stored: ctx.profile.known,
170
+ base_url: ctx.baseUrl,
171
+ base_url_valid: ctx.baseUrl !== null,
172
+ api_key_present: Boolean(ctx.apiKey),
173
+ api_key_source: renderSource(ctx.source.apiKey, ctx.source.envIgnored),
174
+ base_url_source: renderSource(ctx.source.baseUrl, ctx.source.baseUrlEnvIgnored),
175
+ ...configPathField(ctx),
176
+ },
177
+ statusGuidance(ctx),
178
+ ),
179
+ });
87
180
 
88
- if (subcommand === 'status') {
89
- const runtime = await resolveRuntimeConfig(parsed);
181
+ const profiles = defineCommand({
182
+ id: 'auth.profiles',
183
+ summary: 'List stored profiles with redacted keys and mark the default one.',
184
+ kind: CommandKind.LOCAL,
185
+ toolsets: [Toolset.CORE],
186
+ mcp: false,
187
+ offline: true,
188
+ allowUnknownProfile: true,
189
+ run: async (_input, ctx) => {
190
+ const state = await loadProfiles();
191
+ const names = Object.keys(state.profiles).sort();
90
192
  return cliSuccess(
91
193
  {
92
- base_url: runtime.baseUrl,
93
- api_key_present: Boolean(runtime.apiKey),
94
- api_key_source: process.env.OUTRANK_API_KEY
95
- ? 'env'
96
- : runtime.config.apiKey
97
- ? 'config'
98
- : 'missing',
99
- base_url_source: process.env.OUTRANK_BASE_URL
100
- ? 'env'
101
- : runtime.config.baseUrl
102
- ? 'config'
103
- : 'default',
104
- config_path: getConfigPath(),
194
+ default_profile: state.defaultProfile,
195
+ active_profile: ctx.profile.name,
196
+ active_profile_stored: ctx.profile.known,
197
+ profiles: names.map((name) => ({
198
+ name,
199
+ is_default: name === state.defaultProfile,
200
+ api_key_present: Boolean(state.profiles[name][API_KEY_FIELD]),
201
+ api_key_suffix: redactApiKey(state.profiles[name][API_KEY_FIELD]),
202
+ ...describeStoredBaseUrl(state.profiles[name][BASE_URL_FIELD]),
203
+ })),
204
+ [CONFIG_PATH_FIELD]: getConfigPath(),
105
205
  },
106
- 'Confirm this target before running live commands. If you use a non-default Outrank deployment, set `OUTRANK_BASE_URL` first.',
206
+ names.length === 0 ? PROFILES_EMPTY_GUIDANCE : PROFILES_GUIDANCE,
107
207
  );
108
- }
208
+ },
209
+ });
210
+
211
+ const logoutGuidance = (ctx, removed) => {
212
+ if (!removed) return logoutNothingStoredGuidance(ctx.profile.name);
213
+ return ctx.profile.explicit ? logoutProfileGuidance(ctx.profile.name) : LOGOUT_GUIDANCE;
214
+ };
109
215
 
110
- if (subcommand === 'logout') {
111
- const runtime = await resolveRuntimeConfig(parsed);
112
- await clearStoredAuth();
216
+ const logout = defineCommand({
217
+ id: 'auth.logout',
218
+ summary: 'Remove the stored API key from a profile.',
219
+ kind: CommandKind.LOCAL,
220
+ toolsets: [Toolset.CORE],
221
+ mcp: false,
222
+ offline: true,
223
+ run: async (_input, ctx) => {
224
+ const { removed } = await clearProfileAuth({ profile: ctx.profile.name });
113
225
  return cliSuccess(
114
226
  {
115
- cleared: ['apiKey'],
116
- config_path: getConfigPath(),
117
- base_url_still_configured: Boolean(runtime.config.baseUrl),
227
+ cleared: removed ? [API_KEY_FIELD] : [],
228
+ profile: ctx.profile.name,
229
+ [CONFIG_PATH_FIELD]: getConfigPath(),
230
+ base_url_still_configured: Boolean(ctx.storedBaseUrl),
118
231
  },
119
- 'Stored API key removed. Any future authenticated call now requires OUTRANK_API_KEY or a fresh `auth login`.',
232
+ logoutGuidance(ctx, removed),
120
233
  );
121
- }
234
+ },
235
+ });
122
236
 
123
- throw Object.assign(new Error(`Unknown auth subcommand: ${subcommand}.`), {
124
- cliCode: 'CLI_INVALID_USAGE',
237
+ export const authGroup = defineGroup({
238
+ id: 'auth',
239
+ help: { usage: AUTH_HELP, guidance: 'Choose one of: login, whoami, status, profiles, logout.' },
240
+ unknown: (word) => ({
241
+ message: `Unknown auth subcommand: ${word}.`,
125
242
  guidance: 'Run `outrank-cli auth --help` to see available auth commands.',
126
- });
127
- };
243
+ }),
244
+ children: [login, whoami, status, profiles, logout],
245
+ });
@@ -1,8 +1,14 @@
1
1
  import { randomUUID } from 'node:crypto';
2
2
 
3
- import { parseArgs, hasFlag, parseIntegerOption, getOption } from '../core/args.js';
4
- import { requestAgentApi } from '../core/api.js';
3
+ import {
4
+ BILLING_MAX_ADDITIONAL_PRODUCTS,
5
+ BILLING_MIN_ADDITIONAL_PRODUCTS,
6
+ BILLING_MIN_CONFIRM_CHARGE_CENTS,
7
+ HTTP_STATUS_CONFLICT,
8
+ } from '../core/constants.js';
5
9
  import { cliSuccess } from '../core/errors.js';
10
+ import { int, string } from '../core/schema.js';
11
+ import { CommandKind, Toolset, defineCommand, defineGroup } from './registry.js';
6
12
 
7
13
  export const BILLING_HELP = `Usage:
8
14
  outrank-cli billing portal-url
@@ -16,114 +22,106 @@ Notes:
16
22
  buy requires the server-quoted recurring total in cents and an explicit idempotency key.
17
23
  `;
18
24
 
19
- const handlePurchaseProducts = async (argv) => {
20
- const subcommand = argv[0];
21
- const parsed = parseArgs(argv.slice(1));
22
- const wantsHelp =
23
- !subcommand || subcommand === '--help' || subcommand === '-h' || subcommand === 'help' || hasFlag(parsed, 'help');
24
-
25
- if (wantsHelp) {
26
- return cliSuccess(
27
- {
28
- usage:
29
- 'Usage:\n outrank-cli billing purchase-products quote --additional-products <n> [--idempotency-key <key>]\n outrank-cli billing purchase-products buy --additional-products <n> --confirm-charge-cents <n> --idempotency-key <key>',
30
- },
31
- 'Use `quote` first, then `buy` with the quoted recurring total and a stable idempotency key.',
32
- );
33
- }
34
-
35
- const additionalProducts = parseIntegerOption(parsed, 'additional-products', {
36
- required: true,
37
- min: 1,
38
- max: 30,
39
- });
40
-
41
- if (subcommand === 'quote') {
42
- const idempotencyKey = getOption(parsed, 'idempotency-key') || randomUUID();
43
- const response = await requestAgentApi(parsed, {
25
+ const PURCHASE_PRODUCTS_HELP =
26
+ 'Usage:\n' +
27
+ ' outrank-cli billing purchase-products quote --additional-products <n> [--idempotency-key <key>]\n' +
28
+ ' outrank-cli billing purchase-products buy --additional-products <n> --confirm-charge-cents <n> ' +
29
+ '--idempotency-key <key>';
30
+
31
+ const PURCHASE_PRODUCTS_PATH = '/billing/purchase-products';
32
+ const QUOTE_GUIDANCE =
33
+ 'Quote received. Review the recurring total and, if the human approves, run the matching `buy` command with ' +
34
+ '--confirm-charge-cents set to the quoted amount.';
35
+ const buyKeyGuidance = () =>
36
+ `Generate a stable unique key for this purchase, for example ${randomUUID()}, then retry with ` +
37
+ '--idempotency-key <value>.';
38
+
39
+ const additionalProducts = () =>
40
+ int({ min: BILLING_MIN_ADDITIONAL_PRODUCTS, max: BILLING_MAX_ADDITIONAL_PRODUCTS }).required();
41
+
42
+ const isQuote = (status, result) =>
43
+ status === HTTP_STATUS_CONFLICT &&
44
+ result.ok === false &&
45
+ Number.isInteger(result.error?.details?.expected_recurring_total_cents);
46
+
47
+ const portalUrl = defineCommand({
48
+ id: 'billing.portal-url',
49
+ summary: 'Return a Stripe customer-portal link for the human to open in a browser.',
50
+ kind: CommandKind.READ,
51
+ toolsets: [Toolset.BILLING],
52
+ entryPoint: true,
53
+ run: (_input, ctx) => ctx.http({ method: 'GET', path: '/billing/portal-url' }),
54
+ });
55
+
56
+ const quote = defineCommand({
57
+ id: 'billing.purchase-products.quote',
58
+ summary: 'Request pricing for additional product slots without authorizing the charge.',
59
+ kind: CommandKind.READ,
60
+ toolsets: [Toolset.BILLING],
61
+ entryPoint: true,
62
+ input: { additionalProducts: additionalProducts(), idempotencyKey: string() },
63
+ run: async (input, ctx) => {
64
+ const idempotencyKey = input.idempotencyKey ?? randomUUID();
65
+ const { result, status } = await ctx.request({
44
66
  method: 'POST',
45
- path: '/billing/purchase-products',
46
- body: {
47
- additional_products: additionalProducts,
48
- },
67
+ path: PURCHASE_PRODUCTS_PATH,
68
+ body: { additional_products: input.additionalProducts },
49
69
  idempotencyKey,
50
70
  });
51
-
52
- if (!response.ok && response.status === 409) {
71
+ if (isQuote(status, result)) {
53
72
  return cliSuccess(
54
- {
55
- quote: response.payload.error?.details ?? null,
56
- api_response: response.payload,
57
- idempotency_key: idempotencyKey,
58
- },
59
- 'Quote received. Review the recurring total and, if the human approves, run the matching `buy` command with --confirm-charge-cents set to the quoted amount.',
73
+ { quote: result.error.details, api_response: result, idempotency_key: idempotencyKey },
74
+ QUOTE_GUIDANCE,
60
75
  );
61
76
  }
62
-
63
- return response.payload;
64
- }
65
-
66
- if (subcommand === 'buy') {
67
- const confirmChargeCents = parseIntegerOption(parsed, 'confirm-charge-cents', {
68
- required: true,
69
- min: 0,
70
- });
71
- const idempotencyKey = getOption(parsed, 'idempotency-key');
72
- if (!idempotencyKey) {
73
- throw Object.assign(new Error('Missing required option: --idempotency-key.'), {
74
- cliCode: 'CLI_INVALID_USAGE',
75
- guidance:
76
- `Generate a stable unique key for this purchase, for example ${randomUUID()}, then retry with --idempotency-key <value>.`,
77
- });
77
+ if (result.ok && result.data && typeof result.data === 'object' && !Array.isArray(result.data)) {
78
+ return { ...result, data: { ...result.data, idempotency_key: idempotencyKey } };
78
79
  }
79
-
80
- const response = await requestAgentApi(parsed, {
80
+ return result;
81
+ },
82
+ });
83
+
84
+ const buy = defineCommand({
85
+ id: 'billing.purchase-products.buy',
86
+ summary: 'Buy additional product slots at the quoted recurring total.',
87
+ kind: CommandKind.MONEY,
88
+ toolsets: [Toolset.BILLING],
89
+ input: {
90
+ additionalProducts: additionalProducts(),
91
+ confirmChargeCents: int({ min: BILLING_MIN_CONFIRM_CHARGE_CENTS }).required(),
92
+ idempotencyKey: string().required({ guidance: buyKeyGuidance() }),
93
+ },
94
+ run: (input, ctx) =>
95
+ ctx.http({
81
96
  method: 'POST',
82
- path: '/billing/purchase-products',
83
- body: {
84
- additional_products: additionalProducts,
85
- confirm_charge_cents: confirmChargeCents,
86
- },
87
- idempotencyKey,
88
- });
89
- return response.payload;
90
- }
91
-
92
- throw Object.assign(new Error(`Unknown billing purchase-products subcommand: ${subcommand}.`), {
93
- cliCode: 'CLI_INVALID_USAGE',
94
- guidance:
95
- 'Run `outrank-cli billing purchase-products --help` to see the available purchase-products commands.',
96
- });
97
- };
98
-
99
- export const handleBillingCommand = async (argv) => {
100
- const subcommand = argv[0];
101
- const parsed = parseArgs(argv.slice(1));
102
- const wantsHelp = !subcommand || subcommand === '--help' || subcommand === '-h' || subcommand === 'help';
103
-
104
- if (wantsHelp) {
105
- return cliSuccess(
106
- {
107
- usage: BILLING_HELP.trim(),
108
- },
109
- 'Choose one of the currently available billing groups: `portal-url` or `purchase-products`.',
110
- );
111
- }
112
-
113
- if (subcommand === 'portal-url') {
114
- const response = await requestAgentApi(parsed, {
115
- method: 'GET',
116
- path: '/billing/portal-url',
117
- });
118
- return response.payload;
119
- }
120
-
121
- if (subcommand === 'purchase-products') {
122
- return handlePurchaseProducts(argv.slice(1));
123
- }
124
-
125
- throw Object.assign(new Error(`Unknown billing subcommand: ${subcommand}.`), {
126
- cliCode: 'CLI_INVALID_USAGE',
97
+ path: PURCHASE_PRODUCTS_PATH,
98
+ body: { additional_products: input.additionalProducts, confirm_charge_cents: input.confirmChargeCents },
99
+ idempotencyKey: input.idempotencyKey,
100
+ }),
101
+ });
102
+
103
+ const purchaseProducts = defineGroup({
104
+ id: 'billing.purchase-products',
105
+ help: {
106
+ usage: PURCHASE_PRODUCTS_HELP,
107
+ guidance: 'Use `quote` first, then `buy` with the quoted recurring total and a stable idempotency key.',
108
+ },
109
+ unknown: (word) => ({
110
+ message: `Unknown billing purchase-products subcommand: ${word}.`,
111
+ guidance: 'Run `outrank-cli billing purchase-products --help` to see the available purchase-products commands.',
112
+ }),
113
+ children: [quote, buy],
114
+ });
115
+
116
+ export const billingGroup = defineGroup({
117
+ id: 'billing',
118
+ help: {
119
+ usage: BILLING_HELP,
120
+ guidance: 'Choose one of the currently available billing groups: `portal-url` or `purchase-products`.',
121
+ },
122
+ unknown: (word) => ({
123
+ message: `Unknown billing subcommand: ${word}.`,
127
124
  guidance: 'Run `outrank-cli billing --help` to see available billing commands.',
128
- });
129
- };
125
+ }),
126
+ children: [portalUrl, purchaseProducts],
127
+ });