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
@@ -0,0 +1,192 @@
1
+ import { uuid } from '../../core/schema.js';
2
+ import { BodyMode, CommandKind, Toolset, defineCommand, defineGroup } from '../registry.js';
3
+ import { PRODUCT_CLI, productInput, productParams, withProduct } from './shared.js';
4
+
5
+ const LINKING_USAGE = `Usage:
6
+ outrank-cli products linking sources get <product-id>
7
+ outrank-cli products linking sources update <product-id> --json <object>
8
+ outrank-cli products linking detect <product-id>
9
+ outrank-cli products linking target-config get <product-id>
10
+ outrank-cli products linking target-config update <product-id> --json <object>
11
+ outrank-cli products linking targets list <product-id>
12
+ outrank-cli products linking targets add-manual <product-id> --json <object>
13
+ outrank-cli products linking targets upsert <product-id> --json <object>
14
+ outrank-cli products linking targets clear-customizations <product-id> <target-id>
15
+ outrank-cli products linking targets delete <product-id> <target-id>`;
16
+
17
+ const SOURCES_USAGE =
18
+ 'Usage:\n outrank-cli products linking sources get <product-id>\n' +
19
+ ' outrank-cli products linking sources update <product-id> --json <object>';
20
+ const TARGET_CONFIG_USAGE =
21
+ 'Usage:\n outrank-cli products linking target-config get <product-id>\n' +
22
+ ' outrank-cli products linking target-config update <product-id> --json <object>';
23
+ const TARGETS_USAGE = `Usage:
24
+ outrank-cli products linking targets list <product-id>
25
+ outrank-cli products linking targets add-manual <product-id> --json <object>
26
+ outrank-cli products linking targets upsert <product-id> --json <object>
27
+ outrank-cli products linking targets clear-customizations <product-id> <target-id>
28
+ outrank-cli products linking targets delete <product-id> <target-id>`;
29
+
30
+ const TOOLSETS = [Toolset.LINKING];
31
+ const LINKING_PATH = '/products/{productId}/linking';
32
+ const TARGET_PATH = `${LINKING_PATH}/targets/{targetId}`;
33
+ const TARGET_CLI = withProduct(['targetId']);
34
+
35
+ const targetInput = () => ({ ...productInput(), targetId: uuid().required() });
36
+ const targetParams = (input) => ({ productId: input.productId, targetId: input.targetId });
37
+
38
+ const getAndSet = ({ id, resource, method, updateSummary, getSummary, usage, guidance, unknownNoun, aliases }) =>
39
+ defineGroup({
40
+ id: `products.linking.${id}`,
41
+ help: { usage, guidance },
42
+ unknown: (word) => ({
43
+ message: `Unknown products linking ${id} subcommand: ${word}.`,
44
+ guidance: `Run \`outrank-cli products linking ${id} --help\` to see available ${unknownNoun} commands.`,
45
+ }),
46
+ children: [
47
+ defineCommand({
48
+ id: `products.linking.${id}.get`,
49
+ summary: getSummary,
50
+ kind: CommandKind.READ,
51
+ toolsets: TOOLSETS,
52
+ input: productInput(),
53
+ cli: PRODUCT_CLI,
54
+ run: (input, ctx) =>
55
+ ctx.http({ method: 'GET', path: `${LINKING_PATH}/${resource}`, pathParams: productParams(input) }),
56
+ }),
57
+ defineCommand({
58
+ id: `products.linking.${id}.update`,
59
+ summary: updateSummary,
60
+ kind: CommandKind.WRITE,
61
+ toolsets: TOOLSETS,
62
+ input: productInput(),
63
+ cli: { ...PRODUCT_CLI, aliases },
64
+ body: BodyMode.JSON,
65
+ run: (input, ctx) =>
66
+ ctx.http({ method, path: `${LINKING_PATH}/${resource}`, pathParams: productParams(input), body: input.body }),
67
+ }),
68
+ ],
69
+ });
70
+
71
+ const sourcesGroup = getAndSet({
72
+ id: 'sources',
73
+ resource: 'sources',
74
+ method: 'PATCH',
75
+ getSummary: 'Get the internal-linking sources of a product.',
76
+ updateSummary: 'Change the primary or additional internal-linking sources from a JSON body.',
77
+ usage: SOURCES_USAGE,
78
+ guidance: 'Use `get` to inspect current linking sources and `update` to change the primary or additional sources.',
79
+ unknownNoun: 'linking-source',
80
+ aliases: ['set'],
81
+ });
82
+
83
+ const targetConfigGroup = getAndSet({
84
+ id: 'target-config',
85
+ resource: 'target-config',
86
+ method: 'PUT',
87
+ getSummary: 'Get the product-wide internal-linking target configuration.',
88
+ updateSummary: 'Replace the product-wide internal-linking target configuration from a JSON body.',
89
+ usage: TARGET_CONFIG_USAGE,
90
+ guidance: 'Use `get` to inspect product-wide linking target behavior and `update` to change it.',
91
+ unknownNoun: 'target-config',
92
+ aliases: [],
93
+ });
94
+
95
+ const detect = defineCommand({
96
+ id: 'products.linking.detect',
97
+ summary: 'Detect internal-linking targets for a product (long-running).',
98
+ kind: CommandKind.WRITE,
99
+ toolsets: TOOLSETS,
100
+ input: productInput(),
101
+ cli: PRODUCT_CLI,
102
+ longRunning: { pollTool: 'products.linking.targets.list' },
103
+ run: (input, ctx) => ctx.http({ method: 'POST', path: `${LINKING_PATH}/detect`, pathParams: productParams(input) }),
104
+ });
105
+
106
+ const targetsList = defineCommand({
107
+ id: 'products.linking.targets.list',
108
+ summary: 'List the saved internal-linking targets of a product.',
109
+ kind: CommandKind.READ,
110
+ toolsets: TOOLSETS,
111
+ input: productInput(),
112
+ cli: PRODUCT_CLI,
113
+ run: (input, ctx) => ctx.http({ method: 'GET', path: `${LINKING_PATH}/targets`, pathParams: productParams(input) }),
114
+ });
115
+
116
+ const targetsBody = ({ id, summary, suffix }) =>
117
+ defineCommand({
118
+ id: `products.linking.targets.${id}`,
119
+ summary,
120
+ kind: CommandKind.WRITE,
121
+ toolsets: TOOLSETS,
122
+ input: productInput(),
123
+ cli: PRODUCT_CLI,
124
+ body: BodyMode.JSON,
125
+ run: (input, ctx) =>
126
+ ctx.http({
127
+ method: 'POST',
128
+ path: `${LINKING_PATH}/targets/${suffix}`,
129
+ pathParams: productParams(input),
130
+ body: input.body,
131
+ }),
132
+ });
133
+
134
+ const targetsAddManual = targetsBody({
135
+ id: 'add-manual',
136
+ summary: 'Add a manual internal-linking target from a JSON body.',
137
+ suffix: 'manual',
138
+ });
139
+ const targetsUpsert = targetsBody({
140
+ id: 'upsert',
141
+ summary: 'Upsert an internal-linking target from a JSON body.',
142
+ suffix: 'upsert',
143
+ });
144
+
145
+ const targetsClearCustomizations = defineCommand({
146
+ id: 'products.linking.targets.clear-customizations',
147
+ summary: 'Clear the manual customizations of one internal-linking target.',
148
+ kind: CommandKind.WRITE,
149
+ toolsets: TOOLSETS,
150
+ input: targetInput(),
151
+ cli: TARGET_CLI,
152
+ run: (input, ctx) => ctx.http({ method: 'PATCH', path: TARGET_PATH, pathParams: targetParams(input) }),
153
+ });
154
+
155
+ const targetsDelete = defineCommand({
156
+ id: 'products.linking.targets.delete',
157
+ summary: 'Delete one internal-linking target.',
158
+ kind: CommandKind.DESTRUCTIVE,
159
+ toolsets: TOOLSETS,
160
+ input: targetInput(),
161
+ cli: TARGET_CLI,
162
+ run: (input, ctx) => ctx.http({ method: 'DELETE', path: TARGET_PATH, pathParams: targetParams(input) }),
163
+ });
164
+
165
+ const targetsGroup = defineGroup({
166
+ id: 'products.linking.targets',
167
+ help: {
168
+ usage: TARGETS_USAGE,
169
+ guidance:
170
+ 'Use `list` to inspect saved targets, `add-manual` or `upsert` to save URLs, and clear/delete for cleanup.',
171
+ },
172
+ unknown: (word) => ({
173
+ message: `Unknown products linking targets subcommand: ${word}.`,
174
+ guidance: 'Run `outrank-cli products linking targets --help` to see available linking-target commands.',
175
+ }),
176
+ children: [targetsList, targetsAddManual, targetsUpsert, targetsClearCustomizations, targetsDelete],
177
+ });
178
+
179
+ export const linkingGroup = defineGroup({
180
+ id: 'products.linking',
181
+ help: {
182
+ usage: LINKING_USAGE,
183
+ guidance:
184
+ 'Use `sources` for source selection, `detect` for discovery, `targets` for saved URLs, and `target-config` ' +
185
+ 'for product-wide rules.',
186
+ },
187
+ unknown: (word) => ({
188
+ message: `Unknown products linking subcommand: ${word}.`,
189
+ guidance: 'Run `outrank-cli products linking --help` to see available linking commands.',
190
+ }),
191
+ children: [sourcesGroup, detect, targetConfigGroup, targetsGroup],
192
+ });
@@ -0,0 +1,8 @@
1
+ import { uuid } from '../../core/schema.js';
2
+
3
+ export const PRODUCT_CLI = { positionals: ['productId'] };
4
+
5
+ export const productInput = () => ({ productId: uuid().required() });
6
+ export const productParams = (input) => ({ productId: input.productId });
7
+
8
+ export const withProduct = (extraPositionals) => ({ positionals: ['productId', ...extraPositionals] });
@@ -0,0 +1,272 @@
1
+ import { MAX_TOOL_NAME_LENGTH, RESERVED_OPTION_NAMES } from '../core/constants.js';
2
+ import { CliErrorCode, codedError, isEnvelope } from '../core/errors.js';
3
+ import { isField, json, object } from '../core/schema.js';
4
+
5
+ export const CommandKind = Object.freeze({
6
+ READ: 'read',
7
+ WRITE: 'write',
8
+ DESTRUCTIVE: 'destructive',
9
+ MONEY: 'money',
10
+ LOCAL: 'local',
11
+ });
12
+
13
+ export const Toolset = Object.freeze({
14
+ CORE: 'core',
15
+ INTEGRATIONS: 'integrations',
16
+ GSC: 'gsc',
17
+ LINKING: 'linking',
18
+ BACKLINKS: 'backlinks',
19
+ KEYWORDS: 'keywords',
20
+ ARTICLES: 'articles',
21
+ IMPROVEMENTS: 'improvements',
22
+ MARKETPLACE_SELLER: 'marketplace-seller',
23
+ MARKETPLACE_BUYER: 'marketplace-buyer',
24
+ BILLING: 'billing',
25
+ });
26
+
27
+ export const BodyMode = Object.freeze({
28
+ NONE: 'none',
29
+ EMPTY_OBJECT: 'empty-object',
30
+ JSON: 'json',
31
+ OPTIONAL_JSON: 'optional-json',
32
+ OPTIONAL_JSON_OR_EMPTY_OBJECT: 'optional-json-or-empty-object',
33
+ });
34
+
35
+ export const JSON_BODY_MODES = Object.freeze([
36
+ BodyMode.JSON,
37
+ BodyMode.OPTIONAL_JSON,
38
+ BodyMode.OPTIONAL_JSON_OR_EMPTY_OBJECT,
39
+ ]);
40
+
41
+ export const NodeType = Object.freeze({
42
+ GROUP: 'group',
43
+ COMMAND: 'command',
44
+ });
45
+
46
+ export const BODY_INPUT_NAME = 'body';
47
+ const MCP_FLAG_NAMES = Object.freeze(['sensitiveBody', 'untrustedOutput', 'redact']);
48
+ const ID_PATTERN = /^[a-z][a-z0-9-]*(\.[a-z][a-z0-9-]*)*$/;
49
+ const NO_RESULT_GUIDANCE = 'This is a CLI bug: the command finished without producing a result. Report the command.';
50
+
51
+ const registryError = (message) => new Error(`Command registry: ${message}`);
52
+
53
+ export const toKebabCase = (name) => name.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`);
54
+ export const toSnakeCase = (name) => name.replace(/[A-Z]/g, (letter) => `_${letter.toLowerCase()}`);
55
+ export const cliWords = (id) => id.split('.');
56
+ export const toolName = (id) => id.replaceAll('.', '_').replaceAll('-', '_');
57
+ export const lastSegment = (id) => id.slice(id.lastIndexOf('.') + 1);
58
+ export const optionField = (entry) => (typeof entry === 'string' ? entry : entry.field);
59
+
60
+ const bodyInputFor = (body, bodyField, bodyMerge) => {
61
+ if (!JSON_BODY_MODES.includes(body)) return {};
62
+ const base = bodyField ?? (bodyMerge ? object() : json());
63
+ if (body === BodyMode.JSON) return { [BODY_INPUT_NAME]: base.required() };
64
+ if (body === BodyMode.OPTIONAL_JSON_OR_EMPTY_OBJECT) return { [BODY_INPUT_NAME]: base.default({}) };
65
+ return { [BODY_INPUT_NAME]: base };
66
+ };
67
+
68
+ const assertNoBodyInput = (id, input) => {
69
+ if (Object.hasOwn(input, BODY_INPUT_NAME)) {
70
+ throw registryError(`${id}: declare the request body with bodyField, not input.${BODY_INPUT_NAME}`);
71
+ }
72
+ return input;
73
+ };
74
+
75
+ const normalizeCommand = ({
76
+ id,
77
+ summary,
78
+ description,
79
+ input = {},
80
+ cli = {},
81
+ kind,
82
+ toolsets,
83
+ mcp = true,
84
+ body = BodyMode.NONE,
85
+ bodyField,
86
+ bodyMerge,
87
+ longRunning,
88
+ entryPoint = false,
89
+ offline = false,
90
+ allowUnknownProfile = false,
91
+ refine,
92
+ help,
93
+ run,
94
+ }) => ({
95
+ type: NodeType.COMMAND,
96
+ id,
97
+ summary,
98
+ description: description ?? summary,
99
+ input: { ...assertNoBodyInput(id, input), ...bodyInputFor(body, bodyField, bodyMerge) },
100
+ cli,
101
+ kind,
102
+ toolsets,
103
+ mcp,
104
+ body,
105
+ bodyMerge,
106
+ longRunning,
107
+ entryPoint,
108
+ offline,
109
+ allowUnknownProfile,
110
+ refine,
111
+ help,
112
+ run,
113
+ });
114
+
115
+ export const defineCommand = (definition) => normalizeCommand(definition);
116
+
117
+ export const defineGroup = ({ id, help, unknown, children, description, command }) => ({
118
+ type: NodeType.GROUP,
119
+ id,
120
+ help,
121
+ unknown,
122
+ description,
123
+ children,
124
+ ...(command ? { ...normalizeCommand({ ...command, id }), type: NodeType.GROUP, help, children } : {}),
125
+ });
126
+
127
+ const assertMcpShape = (node) => {
128
+ if (node.mcp === true || node.mcp === false) return;
129
+ if (!node.mcp || typeof node.mcp !== 'object') throw registryError(`${node.id}: mcp must be a boolean or an object`);
130
+ for (const key of Object.keys(node.mcp)) {
131
+ if (!MCP_FLAG_NAMES.includes(key)) throw registryError(`${node.id}: unknown mcp flag ${key}`);
132
+ }
133
+ if (node.mcp.redact !== undefined && !Array.isArray(node.mcp.redact)) {
134
+ throw registryError(`${node.id}: mcp.redact must be an array of field names`);
135
+ }
136
+ };
137
+
138
+ const assertCliShape = (node) => {
139
+ const inputNames = Object.keys(node.input);
140
+ const positionals = node.cli.positionals ?? [];
141
+ for (const positional of positionals) {
142
+ if (!inputNames.includes(positional)) throw registryError(`${node.id}: positional ${positional} is not an input`);
143
+ }
144
+ const optionNames = new Set();
145
+ for (const [option, entry] of Object.entries(node.cli.options ?? {})) {
146
+ const field = optionField(entry);
147
+ if (!inputNames.includes(field)) {
148
+ throw registryError(`${node.id}: option --${option} maps to unknown input ${field}`);
149
+ }
150
+ if (RESERVED_OPTION_NAMES.includes(option)) throw registryError(`${node.id}: option --${option} is reserved`);
151
+ optionNames.add(option);
152
+ }
153
+ for (const name of inputNames) {
154
+ if (positionals.includes(name) || name === BODY_INPUT_NAME) continue;
155
+ const declared = Object.values(node.cli.options ?? {}).some((entry) => optionField(entry) === name);
156
+ const option = declared ? undefined : toKebabCase(name);
157
+ if (option !== undefined) {
158
+ if (RESERVED_OPTION_NAMES.includes(option)) throw registryError(`${node.id}: input ${name} shadows --${option}`);
159
+ if (optionNames.has(option)) throw registryError(`${node.id}: option --${option} is declared twice`);
160
+ optionNames.add(option);
161
+ }
162
+ }
163
+ for (const placeholder of Object.keys(node.cli.placeholders ?? {})) {
164
+ if (!inputNames.includes(placeholder)) {
165
+ throw registryError(`${node.id}: placeholder ${placeholder} is not an input`);
166
+ }
167
+ }
168
+ const snakeNames = inputNames.map(toSnakeCase);
169
+ if (new Set(snakeNames).size !== snakeNames.length) throw registryError(`${node.id}: snake_case input names collide`);
170
+ };
171
+
172
+ const assertCommandShape = (node) => {
173
+ if (!Object.values(CommandKind).includes(node.kind)) throw registryError(`${node.id}: unknown kind ${node.kind}`);
174
+ if (!Array.isArray(node.toolsets) || node.toolsets.length === 0) {
175
+ throw registryError(`${node.id}: toolsets must be a non-empty array`);
176
+ }
177
+ for (const toolset of node.toolsets) {
178
+ if (!Object.values(Toolset).includes(toolset)) throw registryError(`${node.id}: unknown toolset ${toolset}`);
179
+ }
180
+ if (typeof node.run !== 'function') throw registryError(`${node.id}: run must be a function`);
181
+ if (typeof node.summary !== 'string' || node.summary === '') throw registryError(`${node.id}: summary is required`);
182
+ const bodyIsMode = Object.values(BodyMode).includes(node.body);
183
+ const bodyIsFixed = Boolean(node.body) && typeof node.body === 'object';
184
+ if (!bodyIsMode && !bodyIsFixed) throw registryError(`${node.id}: body must be a BodyMode or a fixed object`);
185
+ if (node.bodyMerge !== undefined && (!JSON_BODY_MODES.includes(node.body) || typeof node.bodyMerge !== 'object')) {
186
+ throw registryError(`${node.id}: bodyMerge requires body: json and an object mapping`);
187
+ }
188
+ for (const field of Object.values(node.bodyMerge ?? {})) {
189
+ if (!(field in node.input)) throw registryError(`${node.id}: bodyMerge references unknown input ${field}`);
190
+ }
191
+ if (node.longRunning !== undefined && typeof node.longRunning?.pollTool !== 'string') {
192
+ throw registryError(`${node.id}: longRunning needs a pollTool id`);
193
+ }
194
+ if (node.refine !== undefined && typeof node.refine !== 'function') {
195
+ throw registryError(`${node.id}: refine must be a function`);
196
+ }
197
+ if (typeof node.offline !== 'boolean' || typeof node.allowUnknownProfile !== 'boolean') {
198
+ throw registryError(`${node.id}: offline and allowUnknownProfile must be booleans`);
199
+ }
200
+ for (const [name, field] of Object.entries(node.input)) {
201
+ if (!isField(field)) throw registryError(`${node.id}: input ${name} is not a field`);
202
+ }
203
+ assertMcpShape(node);
204
+ assertCliShape(node);
205
+ const name = toolName(node.id);
206
+ if (name.length > MAX_TOOL_NAME_LENGTH) throw registryError(`${node.id}: tool name ${name} is too long`);
207
+ };
208
+
209
+ const assertGroupShape = (node) => {
210
+ if (!node.help || typeof node.help.usage !== 'string') throw registryError(`${node.id}: group help is required`);
211
+ if (typeof node.unknown !== 'function') throw registryError(`${node.id}: group unknown() is required`);
212
+ const words = new Set();
213
+ for (const child of node.children ?? []) {
214
+ for (const word of [lastSegment(child.id), ...(child.cli?.aliases ?? [])]) {
215
+ if (words.has(word)) throw registryError(`${node.id}: duplicate child word ${word}`);
216
+ words.add(word);
217
+ }
218
+ }
219
+ };
220
+
221
+ const walk = (node, parentId, seen, commands) => {
222
+ if (!ID_PATTERN.test(node.id)) throw registryError(`invalid id ${node.id}`);
223
+ if (parentId !== null && !node.id.startsWith(`${parentId}.`)) {
224
+ throw registryError(`${node.id} must be nested under ${parentId}`);
225
+ }
226
+ if (seen.has(node.id)) throw registryError(`duplicate id ${node.id}`);
227
+ seen.add(node.id);
228
+ if (node.type === NodeType.COMMAND) {
229
+ assertCommandShape(node);
230
+ commands.push(node);
231
+ return;
232
+ }
233
+ assertGroupShape(node);
234
+ if (node.run !== undefined) {
235
+ assertCommandShape(node);
236
+ commands.push(node);
237
+ }
238
+ for (const child of node.children ?? []) walk(child, node.id, seen, commands);
239
+ };
240
+
241
+ export const createRegistry = (groups) => {
242
+ const seen = new Set();
243
+ const commands = [];
244
+ for (const group of groups) {
245
+ if (group.type !== NodeType.GROUP) throw registryError(`${group.id} is not a group`);
246
+ walk(group, null, seen, commands);
247
+ }
248
+ const byId = new Map(commands.map((command) => [command.id, command]));
249
+ const byToolName = new Map();
250
+ for (const command of commands) {
251
+ const name = toolName(command.id);
252
+ if (byToolName.has(name)) {
253
+ throw registryError(`tool name ${name} is used by ${byToolName.get(name).id} and ${command.id}`);
254
+ }
255
+ byToolName.set(name, command);
256
+ if (command.longRunning && !byId.has(command.longRunning.pollTool)) {
257
+ throw registryError(`${command.id}: pollTool ${command.longRunning.pollTool} is not a command`);
258
+ }
259
+ }
260
+ return Object.freeze({ groups, commands, byId, byToolName });
261
+ };
262
+
263
+ export const findChild = (group, word) =>
264
+ (group.children ?? []).find((child) => lastSegment(child.id) === word || child.cli?.aliases?.includes(word));
265
+
266
+ export const assertEnvelope = (node, result) => {
267
+ if (!isEnvelope(result)) {
268
+ const message = `Command ${node.id} returned no result envelope.`;
269
+ throw codedError(CliErrorCode.UNEXPECTED_ERROR, message, NO_RESULT_GUIDANCE);
270
+ }
271
+ return result;
272
+ };
@@ -1,6 +1,4 @@
1
- import { hasFlag, parseArgs } from '../core/args.js';
2
- import { requestAgentApi } from '../core/api.js';
3
- import { cliSuccess } from '../core/errors.js';
1
+ import { CommandKind, Toolset, defineCommand, defineGroup } from './registry.js';
4
2
 
5
3
  export const SUBSCRIPTION_HELP = `Usage:
6
4
  outrank-cli subscription status
@@ -9,31 +7,23 @@ Commands:
9
7
  status Return the current subscription, plan, trial, quota, and add-on status.
10
8
  `;
11
9
 
12
- export const handleSubscriptionCommand = async (argv) => {
13
- const subcommand = argv[0];
14
- const parsed = parseArgs(argv.slice(1));
15
- const wantsHelp =
16
- !subcommand || subcommand === '--help' || subcommand === '-h' || subcommand === 'help' || hasFlag(parsed, 'help');
17
-
18
- if (wantsHelp) {
19
- return cliSuccess(
20
- {
21
- usage: SUBSCRIPTION_HELP.trim(),
22
- },
23
- 'Use `status` to inspect the current plan, quota, trial, and add-on state.',
24
- );
25
- }
26
-
27
- if (subcommand === 'status') {
28
- const response = await requestAgentApi(parsed, {
29
- method: 'GET',
30
- path: '/subscription/status',
31
- });
32
- return response.payload;
33
- }
34
-
35
- throw Object.assign(new Error(`Unknown subscription subcommand: ${subcommand}.`), {
36
- cliCode: 'CLI_INVALID_USAGE',
10
+ export const subscriptionGroup = defineGroup({
11
+ id: 'subscription',
12
+ help: {
13
+ usage: SUBSCRIPTION_HELP,
14
+ guidance: 'Use `status` to inspect the current plan, quota, trial, and add-on state.',
15
+ },
16
+ unknown: (word) => ({
17
+ message: `Unknown subscription subcommand: ${word}.`,
37
18
  guidance: 'Run `outrank-cli subscription --help` to see available subscription commands.',
38
- });
39
- };
19
+ }),
20
+ children: [
21
+ defineCommand({
22
+ id: 'subscription.status',
23
+ summary: 'Return the current subscription, plan, trial, quota, and add-on status.',
24
+ kind: CommandKind.READ,
25
+ toolsets: [Toolset.CORE],
26
+ run: (_input, ctx) => ctx.http({ method: 'GET', path: '/subscription/status' }),
27
+ }),
28
+ ],
29
+ });
@@ -1,6 +1,4 @@
1
- import { hasFlag, parseArgs } from '../core/args.js';
2
- import { requestAgentApi } from '../core/api.js';
3
- import { cliSuccess } from '../core/errors.js';
1
+ import { CommandKind, Toolset, defineCommand, defineGroup } from './registry.js';
4
2
 
5
3
  export const USAGE_HELP = `Usage:
6
4
  outrank-cli usage stats
@@ -9,31 +7,20 @@ Commands:
9
7
  stats Return current usage and quota metrics for articles, backlinks, free tools, and product slots.
10
8
  `;
11
9
 
12
- export const handleUsageCommand = async (argv) => {
13
- const subcommand = argv[0];
14
- const parsed = parseArgs(argv.slice(1));
15
- const wantsHelp =
16
- !subcommand || subcommand === '--help' || subcommand === '-h' || subcommand === 'help' || hasFlag(parsed, 'help');
17
-
18
- if (wantsHelp) {
19
- return cliSuccess(
20
- {
21
- usage: USAGE_HELP.trim(),
22
- },
23
- 'Use `stats` to inspect current quota consumption and activity.',
24
- );
25
- }
26
-
27
- if (subcommand === 'stats') {
28
- const response = await requestAgentApi(parsed, {
29
- method: 'GET',
30
- path: '/usage/stats',
31
- });
32
- return response.payload;
33
- }
34
-
35
- throw Object.assign(new Error(`Unknown usage subcommand: ${subcommand}.`), {
36
- cliCode: 'CLI_INVALID_USAGE',
10
+ export const usageGroup = defineGroup({
11
+ id: 'usage',
12
+ help: { usage: USAGE_HELP, guidance: 'Use `stats` to inspect current quota consumption and activity.' },
13
+ unknown: (word) => ({
14
+ message: `Unknown usage subcommand: ${word}.`,
37
15
  guidance: 'Run `outrank-cli usage --help` to see available usage commands.',
38
- });
39
- };
16
+ }),
17
+ children: [
18
+ defineCommand({
19
+ id: 'usage.stats',
20
+ summary: 'Return current usage and quota metrics for articles, backlinks, free tools, and product slots.',
21
+ kind: CommandKind.READ,
22
+ toolsets: [Toolset.CORE],
23
+ run: (_input, ctx) => ctx.http({ method: 'GET', path: '/usage/stats' }),
24
+ }),
25
+ ],
26
+ });