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,311 @@
1
+ import {
2
+ ALLOWED_BASE_URL_HOSTS,
3
+ ALLOW_CUSTOM_BASE_URL_ENV,
4
+ API_KEY_ENV,
5
+ BASE_URL_ENV,
6
+ DEFAULT_BASE_URL,
7
+ LOOPBACK_HOSTNAMES,
8
+ PROFILE_ENV,
9
+ ProfileSource,
10
+ Surface,
11
+ } from './constants.js';
12
+ import { CliErrorCode, codedError } from './errors.js';
13
+ import {
14
+ describeUntrustedValue,
15
+ getConfigPath,
16
+ isSet,
17
+ isValidProfileName,
18
+ loadConfig,
19
+ readProfiles,
20
+ } from './config.js';
21
+ import { requestAgentApi } from './http.js';
22
+ import { profileSelectedByEnvHint } from './profileSelection.js';
23
+
24
+ export { ProfileSource, Surface };
25
+
26
+ export const ContextSource = Object.freeze({
27
+ FLAG: 'flag',
28
+ ENV: 'env',
29
+ PROFILE: 'profile',
30
+ DEFAULT: 'default',
31
+ MISSING: 'missing',
32
+ });
33
+
34
+ const TRUTHY_ENV_VALUES = new Set(['1', 'true', 'yes']);
35
+ const HTTPS_PROTOCOL = 'https:';
36
+ const HTTP_PROTOCOL = 'http:';
37
+ const CLI_BINARY = 'outrank-cli';
38
+ const PROFILE_FLAG = '--profile';
39
+
40
+ const pickFirst = (candidates) => candidates.find(([, value]) => isSet(value)) ?? [ContextSource.MISSING, undefined];
41
+
42
+ const baseUrlGuidance =
43
+ 'Pass a full https origin such as https://www.outrank.so via --base-url or OUTRANK_BASE_URL, or remove the ' +
44
+ 'override to use the default.';
45
+
46
+ const allowlistGuidance = (host) =>
47
+ `Only ${ALLOWED_BASE_URL_HOSTS.join(', ')} are accepted by default. If ${host} is a deployment you control, set ` +
48
+ `${ALLOW_CUSTOM_BASE_URL_ENV}=1 or pass --allow-custom-base-url. Never point a real API key at an unknown host.`;
49
+
50
+ const cleartextGuidance =
51
+ 'The API key travels in the Authorization header, so only https is accepted. Plain http is allowed only for ' +
52
+ `loopback hosts (${LOOPBACK_HOSTNAMES.join(', ')}) together with ${ALLOW_CUSTOM_BASE_URL_ENV}=1.`;
53
+
54
+ const storedProfileOnlyGuidance = (host) =>
55
+ `This server sends the API key to ${ALLOWED_BASE_URL_HOSTS.join(' or ')} only, unless both the key and the base ` +
56
+ `URL come from the same stored profile. If ${host} is a deployment you control, run ` +
57
+ '`outrank-cli auth login <api-key> --profile <name> --base-url <url> --allow-custom-base-url` in a terminal, ' +
58
+ 'then start the server with `--profile <name>`. Environment variables cannot unlock a custom host here.';
59
+
60
+ const profileNameGuidance =
61
+ 'Profile names start with a letter or digit and may contain letters, digits, dots, dashes and underscores.';
62
+
63
+ const describeProfileSource = (source) => (source === ProfileSource.ENV ? PROFILE_ENV : PROFILE_FLAG);
64
+
65
+ const HIDDEN_PROFILES_NOTE = 'Run `outrank-cli auth profiles` in a terminal to see the stored profiles.';
66
+
67
+ const describeStoredProfiles = (profiles, revealLocalDetails) => {
68
+ if (!revealLocalDetails) return HIDDEN_PROFILES_NOTE;
69
+ const names = Object.keys(profiles).sort();
70
+ return names.length === 0
71
+ ? 'No profiles are stored yet.'
72
+ : `Stored profiles: ${names.join(', ')}. Run \`outrank-cli auth profiles\` to inspect them.`;
73
+ };
74
+
75
+ const unknownProfileGuidance = (profiles, source, revealLocalDetails) => {
76
+ const stored = describeStoredProfiles(profiles, revealLocalDetails);
77
+ return (
78
+ `${stored} Run \`outrank-cli auth login <api-key> --profile <name>\` to create a new one.` +
79
+ profileSelectedByEnvHint(source)
80
+ );
81
+ };
82
+
83
+ const configFileReference = (revealLocalDetails) =>
84
+ revealLocalDetails
85
+ ? 'the config file at details.config_path'
86
+ : 'the outrank-cli config file (run `outrank-cli auth status` in a terminal to see its path)';
87
+
88
+ const storedBaseUrlGuidance = (profile, revealLocalDetails) =>
89
+ `Edit the baseUrl of profile ${profile} in ${configFileReference(revealLocalDetails)}, run ` +
90
+ `\`outrank-cli auth login <api-key> --profile ${profile} --base-url <url>\` to replace it, or pass --base-url ` +
91
+ 'for this call. `auth status`, `auth profiles` and `auth logout` keep working with the broken value.';
92
+
93
+ const emptyFlagError = (flag) =>
94
+ codedError(
95
+ CliErrorCode.INVALID_USAGE,
96
+ `${flag} was given without a value.`,
97
+ `Pass a value after ${flag}, or drop the option to fall back to the environment or the stored config.`,
98
+ );
99
+
100
+ const invalidBaseUrlError = (shown) =>
101
+ codedError(CliErrorCode.INVALID_USAGE, `Invalid base URL: ${shown}`, baseUrlGuidance);
102
+
103
+ export const normalizeBaseUrl = (value) => {
104
+ let url;
105
+ try {
106
+ url = new URL(value);
107
+ } catch {
108
+ throw invalidBaseUrlError(describeUntrustedValue(value));
109
+ }
110
+ if (url.protocol !== HTTPS_PROTOCOL && url.protocol !== HTTP_PROTOCOL) {
111
+ throw invalidBaseUrlError(JSON.stringify(`${url.protocol}//${url.host}`));
112
+ }
113
+ return url.origin;
114
+ };
115
+
116
+ const assertAllowedBaseUrl = (baseUrl, allowCustomBaseUrl, guidanceFor = allowlistGuidance) => {
117
+ const url = new URL(baseUrl);
118
+ const loopback = LOOPBACK_HOSTNAMES.includes(url.hostname) || LOOPBACK_HOSTNAMES.includes(`[${url.hostname}]`);
119
+ if (url.protocol === HTTP_PROTOCOL && !(loopback && allowCustomBaseUrl)) {
120
+ throw codedError(CliErrorCode.INVALID_USAGE, `Base URL ${baseUrl} is not https.`, cleartextGuidance);
121
+ }
122
+ if (allowCustomBaseUrl || ALLOWED_BASE_URL_HOSTS.includes(url.host)) return;
123
+ throw codedError(
124
+ CliErrorCode.INVALID_USAGE,
125
+ `Base URL host ${url.host} is not allowed.`,
126
+ guidanceFor(url.host),
127
+ );
128
+ };
129
+
130
+ const assertFlagsHaveValues = (flags) => {
131
+ if (flags.apiKey === '') throw emptyFlagError('--api-key');
132
+ if (flags.baseUrl === '') throw emptyFlagError('--base-url');
133
+ if (flags.profile === '') throw emptyFlagError('--profile');
134
+ };
135
+
136
+ const selectProfile = ({ flags, env, state, allowUnknownProfile, revealLocalDetails }) => {
137
+ const [source, name] = pickFirst([
138
+ [ProfileSource.FLAG, flags.profile],
139
+ [ProfileSource.ENV, env[PROFILE_ENV]],
140
+ [ProfileSource.DEFAULT, state.defaultProfile],
141
+ ]);
142
+ if (!isValidProfileName(name)) {
143
+ throw codedError(
144
+ CliErrorCode.INVALID_USAGE,
145
+ `Invalid profile name ${describeUntrustedValue(name)} (from ${describeProfileSource(source)}).`,
146
+ profileNameGuidance,
147
+ );
148
+ }
149
+ const explicit = source !== ProfileSource.DEFAULT;
150
+ const known = Object.hasOwn(state.profiles, name);
151
+ if (explicit && !known && !allowUnknownProfile) {
152
+ throw codedError(
153
+ CliErrorCode.INVALID_USAGE,
154
+ `Unknown profile: ${name} (from ${describeProfileSource(source)}).`,
155
+ unknownProfileGuidance(state.profiles, source, revealLocalDetails),
156
+ );
157
+ }
158
+ return { name, source, explicit, known, values: known ? state.profiles[name] : {} };
159
+ };
160
+
161
+ const normalizeResolvedBaseUrl = ({ value, source, profileName, tolerateStored, revealLocalDetails }) => {
162
+ try {
163
+ return normalizeBaseUrl(value);
164
+ } catch (error) {
165
+ if (source !== ContextSource.PROFILE) throw error;
166
+ if (tolerateStored) return null;
167
+ throw codedError(
168
+ CliErrorCode.INVALID_USAGE,
169
+ `Invalid base URL stored in profile ${profileName}.`,
170
+ storedBaseUrlGuidance(profileName, revealLocalDetails),
171
+ { config_path: getConfigPath() },
172
+ );
173
+ }
174
+ };
175
+
176
+ export const resolveContext = ({
177
+ flags = {},
178
+ env = {},
179
+ config = {},
180
+ enforceAllowlist = true,
181
+ allowUnknownProfile = false,
182
+ customBaseUrlFromStoredProfileOnly = false,
183
+ revealLocalDetails = true,
184
+ } = {}) => {
185
+ assertFlagsHaveValues(flags);
186
+ const profile = selectProfile({
187
+ flags,
188
+ env,
189
+ state: readProfiles(config),
190
+ allowUnknownProfile,
191
+ revealLocalDetails,
192
+ });
193
+ const envApiKey = profile.explicit ? undefined : env[API_KEY_ENV];
194
+ const envBaseUrl = profile.explicit ? undefined : env[BASE_URL_ENV];
195
+ const [apiKeySource, apiKey] = pickFirst([
196
+ [ContextSource.FLAG, flags.apiKey],
197
+ [ContextSource.ENV, envApiKey],
198
+ [ContextSource.PROFILE, profile.values.apiKey],
199
+ ]);
200
+ const [baseUrlSource, rawBaseUrl] = pickFirst([
201
+ [ContextSource.FLAG, flags.baseUrl],
202
+ [ContextSource.ENV, envBaseUrl],
203
+ [ContextSource.PROFILE, profile.values.baseUrl],
204
+ [ContextSource.DEFAULT, DEFAULT_BASE_URL],
205
+ ]);
206
+ const storedPair = apiKeySource === ContextSource.PROFILE && baseUrlSource === ContextSource.PROFILE;
207
+ const requestedCustomBaseUrl =
208
+ typeof flags.allowCustomBaseUrl === 'boolean'
209
+ ? flags.allowCustomBaseUrl
210
+ : TRUTHY_ENV_VALUES.has(env[ALLOW_CUSTOM_BASE_URL_ENV]);
211
+ const allowCustomBaseUrl = customBaseUrlFromStoredProfileOnly ? storedPair : requestedCustomBaseUrl;
212
+ const baseUrl = normalizeResolvedBaseUrl({
213
+ value: rawBaseUrl,
214
+ source: baseUrlSource,
215
+ profileName: profile.name,
216
+ tolerateStored: !enforceAllowlist,
217
+ revealLocalDetails,
218
+ });
219
+ if (enforceAllowlist && baseUrlSource !== ContextSource.DEFAULT) {
220
+ const guidanceFor = customBaseUrlFromStoredProfileOnly ? storedProfileOnlyGuidance : allowlistGuidance;
221
+ assertAllowedBaseUrl(baseUrl, allowCustomBaseUrl, guidanceFor);
222
+ }
223
+
224
+ return {
225
+ apiKey,
226
+ baseUrl,
227
+ storedBaseUrl: profile.values.baseUrl,
228
+ profile: { name: profile.name, source: profile.source, explicit: profile.explicit, known: profile.known },
229
+ source: {
230
+ apiKey: apiKeySource,
231
+ baseUrl: baseUrlSource,
232
+ envIgnored: apiKeySource !== ContextSource.ENV && isSet(env[API_KEY_ENV]),
233
+ baseUrlEnvIgnored: baseUrlSource !== ContextSource.ENV && isSet(env[BASE_URL_ENV]),
234
+ },
235
+ };
236
+ };
237
+
238
+ export const loadContext = async (
239
+ flags = {},
240
+ { enforceAllowlist = true, allowUnknownProfile = false, customBaseUrlFromStoredProfileOnly = false } = {},
241
+ ) =>
242
+ resolveContext({
243
+ flags,
244
+ env: process.env,
245
+ config: await loadConfig(),
246
+ enforceAllowlist,
247
+ allowUnknownProfile,
248
+ customBaseUrlFromStoredProfileOnly,
249
+ });
250
+
251
+ const renderCliReference = (spec, args) => {
252
+ const words = spec.id.split('.');
253
+ const positionals = (spec.cli?.positionals ?? []).map((field) => args[field]).filter((value) => value !== undefined);
254
+ const optionNames = Object.fromEntries(
255
+ Object.entries(spec.cli?.options ?? {}).map(([option, entry]) => [
256
+ typeof entry === 'string' ? entry : entry.field,
257
+ option,
258
+ ]),
259
+ );
260
+ const options = Object.entries(args)
261
+ .filter(([field, value]) => value !== undefined && !(spec.cli?.positionals ?? []).includes(field))
262
+ .map(([field, value]) => {
263
+ const option = optionNames[field] ?? field.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`);
264
+ return value === true ? `--${option}` : `--${option} ${value}`;
265
+ });
266
+ return [CLI_BINARY, ...words, ...positionals, ...options].join(' ');
267
+ };
268
+
269
+ const toSnakeCase = (name) => name.replace(/[A-Z]/g, (letter) => `_${letter.toLowerCase()}`);
270
+
271
+ const renderMcpReference = (spec, args) => {
272
+ const toolName = spec.id.replaceAll('.', '_').replaceAll('-', '_');
273
+ const entries = Object.entries(args).filter(([, value]) => value !== undefined);
274
+ if (entries.length === 0) return toolName;
275
+ const toolArgs = Object.fromEntries(entries.map(([field, value]) => [toSnakeCase(field), value]));
276
+ return `${toolName} ${JSON.stringify(toolArgs)}`;
277
+ };
278
+
279
+ export const createCommandContext = ({
280
+ resolved,
281
+ registry,
282
+ surface,
283
+ signal,
284
+ cwd,
285
+ uploadDir,
286
+ usageGuidance,
287
+ timeouts = {},
288
+ }) => {
289
+ const ctx = {
290
+ surface,
291
+ apiKey: resolved.apiKey,
292
+ baseUrl: resolved.baseUrl,
293
+ storedBaseUrl: resolved.storedBaseUrl,
294
+ profile: resolved.profile,
295
+ source: resolved.source,
296
+ signal,
297
+ cwd,
298
+ uploadDir,
299
+ usageGuidance,
300
+ longRunning: false,
301
+ ...timeouts,
302
+ request: (request) => requestAgentApi(ctx, request),
303
+ http: async (request) => (await requestAgentApi(ctx, request)).result,
304
+ ref: (id, args = {}) => {
305
+ const spec = registry.byId.get(id);
306
+ if (!spec) throw new Error(`ctx.ref: unknown command id ${id}`);
307
+ return surface === Surface.MCP ? renderMcpReference(spec, args) : renderCliReference(spec, args);
308
+ },
309
+ };
310
+ return ctx;
311
+ };
@@ -1,3 +1,25 @@
1
+ import { redactSecrets } from './redact.js';
2
+
3
+ export const CliErrorCode = Object.freeze({
4
+ AUTH_REQUIRED: 'CLI_AUTH_REQUIRED',
5
+ NETWORK_ERROR: 'CLI_NETWORK_ERROR',
6
+ NON_JSON_RESPONSE: 'CLI_NON_JSON_RESPONSE',
7
+ UPSTREAM_ERROR: 'CLI_UPSTREAM_ERROR',
8
+ INVALID_USAGE: 'CLI_INVALID_USAGE',
9
+ UNEXPECTED_ERROR: 'CLI_UNEXPECTED_ERROR',
10
+ OPERATION_MAY_STILL_BE_RUNNING: 'CLI_OPERATION_MAY_STILL_BE_RUNNING',
11
+ CONFIG_UNREADABLE: 'CLI_CONFIG_UNREADABLE',
12
+ CONFIG_UNWRITABLE: 'CLI_CONFIG_UNWRITABLE',
13
+ });
14
+
15
+ export const USAGE_GUIDANCE =
16
+ 'Run the current command again with --help to see the required arguments and input format.';
17
+ export const MCP_USAGE_GUIDANCE = 'Check the tool description and input schema, then call the tool again.';
18
+ export const UNEXPECTED_ERROR_GUIDANCE =
19
+ 'Retry with --help for usage details. If the command and inputs look correct, inspect the error details and ' +
20
+ 'try again.';
21
+ export const UNEXPECTED_ERROR_MESSAGE = 'Unexpected CLI failure.';
22
+
1
23
  export const cliError = (code, message, guidance, details) => ({
2
24
  ok: false,
3
25
  error: {
@@ -13,3 +35,20 @@ export const cliSuccess = (data, guidance) => ({
13
35
  data,
14
36
  agent_guidance: guidance,
15
37
  });
38
+
39
+ export const codedError = (code, message, guidance, details) =>
40
+ Object.assign(new Error(message), { cliCode: code, guidance, ...(details ? { details } : {}) });
41
+
42
+ export const usageError = (message, guidance = USAGE_GUIDANCE) =>
43
+ codedError(CliErrorCode.INVALID_USAGE, message, guidance);
44
+
45
+ export const isEnvelope = (value) =>
46
+ Boolean(value) && typeof value === 'object' && !Array.isArray(value) && typeof value.ok === 'boolean';
47
+
48
+ export const toErrorEnvelope = (error, { usageGuidance = USAGE_GUIDANCE } = {}) => {
49
+ const message = error?.message || UNEXPECTED_ERROR_MESSAGE;
50
+ const code = error?.cliCode || CliErrorCode.UNEXPECTED_ERROR;
51
+ const rawGuidance = error?.guidance || UNEXPECTED_ERROR_GUIDANCE;
52
+ const guidance = rawGuidance === USAGE_GUIDANCE ? usageGuidance : rawGuidance;
53
+ return cliError(code, redactSecrets(message), redactSecrets(guidance), error?.details);
54
+ };
@@ -0,0 +1,71 @@
1
+ import { BODY_INPUT_NAME, BodyMode, JSON_BODY_MODES, assertEnvelope } from '../commands/registry.js';
2
+ import { LONG_RUNNING_GATEWAY_STATUSES, Surface } from './constants.js';
3
+ import { CliErrorCode, cliError, toErrorEnvelope } from './errors.js';
4
+ import { validateInput } from './schema.js';
5
+
6
+ const INTERRUPTED_DETAILS = Object.freeze(['timed_out', 'cancelled', 'outcome_unknown']);
7
+
8
+ const stillRunningGuidance = (ctx, spec) =>
9
+ `The request did not complete, but the server may still be processing it. Do not retry; poll ${ctx.ref(
10
+ spec.longRunning.pollTool,
11
+ )} until the work shows up, then continue from there.`;
12
+
13
+ const mayStillBeRunning = (ctx, spec, message, details) =>
14
+ cliError(CliErrorCode.OPERATION_MAY_STILL_BE_RUNNING, message, stillRunningGuidance(ctx, spec), details);
15
+
16
+ const mapLongRunningResult = (spec, result, ctx) => {
17
+ if (!spec.longRunning || result.ok !== false) return result;
18
+ const status = result.error?.details?.status;
19
+ if (result.error?.code === CliErrorCode.UPSTREAM_ERROR && LONG_RUNNING_GATEWAY_STATUSES.includes(status)) {
20
+ return mayStillBeRunning(ctx, spec, result.error.message, result.error.details);
21
+ }
22
+ return result;
23
+ };
24
+
25
+ const mapLongRunningError = (spec, error, ctx) => {
26
+ const interrupted = INTERRUPTED_DETAILS.some((detail) => error?.details?.[detail] === true);
27
+ if (!spec.longRunning || error?.cliCode !== CliErrorCode.NETWORK_ERROR || !interrupted) return null;
28
+ return mayStillBeRunning(ctx, spec, error.message, error.details);
29
+ };
30
+
31
+ const splitBodyField = (fields) => {
32
+ const { [BODY_INPUT_NAME]: bodyField, ...rest } = fields;
33
+ return { bodyField, rest };
34
+ };
35
+
36
+ const resolveBody = async (spec, loadBody) => {
37
+ if (spec.body === BodyMode.NONE) return undefined;
38
+ if (spec.body === BodyMode.EMPTY_OBJECT) return {};
39
+ if (JSON_BODY_MODES.includes(spec.body)) return loadBody();
40
+ return spec.body;
41
+ };
42
+
43
+ const mergeBody = (spec, input, body) => {
44
+ if (!spec.bodyMerge || body === undefined) return body;
45
+ const merged = { ...body };
46
+ for (const [bodyKey, field] of Object.entries(spec.bodyMerge)) {
47
+ if (input[field] !== undefined) merged[bodyKey] = input[field];
48
+ }
49
+ return merged;
50
+ };
51
+
52
+ export const executeCommand = async ({ spec, raw, loadBody = async () => undefined, ctx, labels = {} }) => {
53
+ const validation = { ...labels, splitLists: ctx.surface !== Surface.MCP };
54
+ try {
55
+ const { bodyField, rest } = splitBodyField(spec.input);
56
+ const input = validateInput(rest, raw, validation);
57
+ const rawBody = await resolveBody(spec, loadBody);
58
+ if (bodyField) {
59
+ const validated = validateInput({ [BODY_INPUT_NAME]: bodyField }, { [BODY_INPUT_NAME]: rawBody }, validation);
60
+ if (BODY_INPUT_NAME in validated) input[BODY_INPUT_NAME] = mergeBody(spec, input, validated[BODY_INPUT_NAME]);
61
+ } else if (rawBody !== undefined) {
62
+ input[BODY_INPUT_NAME] = rawBody;
63
+ }
64
+ if (spec.refine) spec.refine(input, ctx);
65
+ ctx.longRunning = Boolean(spec.longRunning);
66
+ const result = assertEnvelope(spec, await spec.run(input, ctx));
67
+ return mapLongRunningResult(spec, result, ctx);
68
+ } catch (error) {
69
+ return mapLongRunningError(spec, error, ctx) ?? toErrorEnvelope(error, { usageGuidance: ctx.usageGuidance });
70
+ }
71
+ };
@@ -0,0 +1,186 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+
4
+ import { MAX_IMAGE_UPLOAD_BYTES } from './constants.js';
5
+ import { usageError } from './errors.js';
6
+
7
+ const ImageMime = Object.freeze({
8
+ JPEG: 'image/jpeg',
9
+ PNG: 'image/png',
10
+ WEBP: 'image/webp',
11
+ GIF: 'image/gif',
12
+ });
13
+
14
+ export const IMAGE_MIME_BY_EXTENSION = Object.freeze({
15
+ '.jpg': ImageMime.JPEG,
16
+ '.jpeg': ImageMime.JPEG,
17
+ '.png': ImageMime.PNG,
18
+ '.webp': ImageMime.WEBP,
19
+ '.gif': ImageMime.GIF,
20
+ });
21
+
22
+ const SUPPORTED_EXTENSIONS_TEXT = 'Use one of: .jpg, .jpeg, .png, .webp, .gif.';
23
+ const SNIFF_LENGTH = 12;
24
+ const WEBP_RIFF_OFFSET = 8;
25
+
26
+ const MAGIC_SIGNATURES = Object.freeze({
27
+ [ImageMime.PNG]: [{ offset: 0, bytes: [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a] }],
28
+ [ImageMime.JPEG]: [{ offset: 0, bytes: [0xff, 0xd8, 0xff] }],
29
+ [ImageMime.GIF]: [
30
+ { offset: 0, bytes: [0x47, 0x49, 0x46, 0x38, 0x37, 0x61] },
31
+ { offset: 0, bytes: [0x47, 0x49, 0x46, 0x38, 0x39, 0x61] },
32
+ ],
33
+ [ImageMime.WEBP]: [
34
+ { offset: 0, bytes: [0x52, 0x49, 0x46, 0x46], then: { offset: WEBP_RIFF_OFFSET, bytes: [0x57, 0x45, 0x42, 0x50] } },
35
+ ],
36
+ });
37
+
38
+ const matchesAt = (buffer, { offset, bytes }) =>
39
+ buffer.length >= offset + bytes.length && bytes.every((byte, index) => buffer[offset + index] === byte);
40
+
41
+ const matchesSignature = (buffer, signature) =>
42
+ matchesAt(buffer, signature) && (signature.then === undefined || matchesAt(buffer, signature.then));
43
+
44
+ export const sniffImageMime = (buffer) => {
45
+ const head = buffer.subarray(0, SNIFF_LENGTH);
46
+ for (const [mime, signatures] of Object.entries(MAGIC_SIGNATURES)) {
47
+ if (signatures.some((signature) => matchesSignature(head, signature))) return mime;
48
+ }
49
+ return undefined;
50
+ };
51
+
52
+ const readGuidance = (absolutePath) => `Confirm that ${absolutePath} exists and is readable, then retry.`;
53
+ const OUTSIDE_GUIDANCE = 'Move or copy the image into the allowed upload directory, then pass its path again.';
54
+ const OVERSIZE_GUIDANCE = 'Resize or compress the image below the limit, then retry.';
55
+ const FILE_NAME_GUIDANCE = 'Rename the file using letters, digits, dots, dashes, underscores or spaces, then retry.';
56
+ const PARENT_SEGMENT = '..';
57
+ const MAX_FILE_NAME_LENGTH = 255;
58
+ const FIRST_PRINTABLE_CODE = 0x20;
59
+ const DELETE_CODE = 0x7f;
60
+
61
+ const isInside = (directory, target) => {
62
+ const relative = path.relative(directory, target);
63
+ if (relative === '' || relative === PARENT_SEGMENT || path.isAbsolute(relative)) return false;
64
+ return !relative.startsWith(`${PARENT_SEGMENT}${path.sep}`);
65
+ };
66
+
67
+ const outsideError = (allowedDir) => usageError(`The file must be inside ${allowedDir}.`, OUTSIDE_GUIDANCE);
68
+
69
+ const oversizeError = (subject, size) =>
70
+ usageError(
71
+ `${subject} is ${size} bytes; the maximum upload size is ${MAX_IMAGE_UPLOAD_BYTES} bytes.`,
72
+ OVERSIZE_GUIDANCE,
73
+ );
74
+
75
+ const hasControlCharacter = (text) =>
76
+ [...text].some((character) => {
77
+ const code = character.codePointAt(0);
78
+ return code < FIRST_PRINTABLE_CODE || code === DELETE_CODE;
79
+ });
80
+
81
+ const assertSafeFileName = (fileName) => {
82
+ if (fileName.length > MAX_FILE_NAME_LENGTH || hasControlCharacter(fileName)) {
83
+ throw usageError('The image file name is too long or contains control characters.', FILE_NAME_GUIDANCE);
84
+ }
85
+ };
86
+
87
+ const toUpload = (buffer, fileName) => {
88
+ assertSafeFileName(fileName);
89
+ const extension = path.extname(fileName).toLowerCase();
90
+ const fileType = IMAGE_MIME_BY_EXTENSION[extension];
91
+ if (!fileType) {
92
+ throw usageError(`Unsupported image file extension: ${extension || '(none)'}.`, SUPPORTED_EXTENSIONS_TEXT);
93
+ }
94
+ const sniffed = sniffImageMime(buffer);
95
+ if (sniffed !== fileType) {
96
+ throw usageError(
97
+ `The file content does not look like ${fileType}${sniffed ? ` (detected ${sniffed})` : ''}: ${fileName}`,
98
+ 'Make sure the file is a real image whose bytes match its extension, then retry.',
99
+ );
100
+ }
101
+ return { file_base64: buffer.toString('base64'), file_name: fileName, file_type: fileType };
102
+ };
103
+
104
+ export const readImageUploadFromBase64 = ({ base64, fileName }) => {
105
+ if (typeof base64 !== 'string' || base64 === '' || typeof fileName !== 'string' || fileName === '') {
106
+ throw usageError('An image upload needs file_base64 and file_name.', 'Pass both fields, or a file path instead.');
107
+ }
108
+ const buffer = Buffer.from(base64, 'base64');
109
+ if (buffer.length === 0 || buffer.toString('base64').replace(/=+$/, '') !== base64.replace(/=+$/, '')) {
110
+ throw usageError('file_base64 is not valid base64.', 'Encode the image bytes as standard base64, then retry.');
111
+ }
112
+ if (buffer.length > MAX_IMAGE_UPLOAD_BYTES) throw oversizeError('The image', buffer.length);
113
+ return toUpload(buffer, path.basename(fileName));
114
+ };
115
+
116
+ const resolveAllowedDir = async (allowedDir) => {
117
+ if (allowedDir === undefined) return undefined;
118
+ return { lexical: path.resolve(allowedDir), real: await fs.realpath(allowedDir), shown: allowedDir };
119
+ };
120
+
121
+ const assertLexicallyInside = (filePath, allowed) => {
122
+ if (allowed === undefined) return;
123
+ const target = path.resolve(filePath);
124
+ if (!isInside(allowed.lexical, target) && !isInside(allowed.real, target)) throw outsideError(allowed.shown);
125
+ };
126
+
127
+ const resolveReadPath = async (filePath, allowed) => {
128
+ if (allowed === undefined) return filePath;
129
+ const realFile = await fs.realpath(filePath);
130
+ if (!isInside(allowed.real, realFile)) throw outsideError(allowed.shown);
131
+ return realFile;
132
+ };
133
+
134
+ const readOpenedFile = async (readPath) => {
135
+ const handle = await fs.open(readPath, 'r');
136
+ try {
137
+ const stats = await handle.stat();
138
+ if (!stats.isFile()) throw usageError(`Not a file: ${readPath}`, readGuidance(readPath));
139
+ if (stats.size > MAX_IMAGE_UPLOAD_BYTES) throw oversizeError('The file', stats.size);
140
+ return await handle.readFile();
141
+ } finally {
142
+ await handle.close();
143
+ }
144
+ };
145
+
146
+ export const readImageUpload = async ({ filePath, allowedDir }) => {
147
+ if (typeof filePath !== 'string' || filePath === '') {
148
+ throw usageError('Missing required option: --file <path>.', 'Pass a local image path with `--file <path>`.');
149
+ }
150
+ if (!path.isAbsolute(filePath)) {
151
+ throw usageError(`The file path must be absolute: ${filePath}`, 'Resolve the path before passing it.');
152
+ }
153
+ const allowed = await resolveAllowedDir(allowedDir);
154
+ assertLexicallyInside(filePath, allowed);
155
+
156
+ let readPath;
157
+ let stats;
158
+ try {
159
+ readPath = await resolveReadPath(filePath, allowed);
160
+ stats = await fs.stat(readPath);
161
+ } catch (error) {
162
+ if (error?.cliCode) throw error;
163
+ throw usageError(`Failed to read file: ${error.message}`, readGuidance(filePath));
164
+ }
165
+ if (!stats.isFile()) {
166
+ throw usageError(`Not a file: ${filePath}`, readGuidance(filePath));
167
+ }
168
+ if (stats.size > MAX_IMAGE_UPLOAD_BYTES) throw oversizeError('The file', stats.size);
169
+
170
+ const fileName = path.basename(filePath);
171
+ const extension = path.extname(fileName).toLowerCase();
172
+ if (!IMAGE_MIME_BY_EXTENSION[extension]) {
173
+ throw usageError(`Unsupported image file extension: ${extension || '(none)'}.`, SUPPORTED_EXTENSIONS_TEXT);
174
+ }
175
+
176
+ let buffer;
177
+ try {
178
+ buffer = await readOpenedFile(readPath);
179
+ } catch (error) {
180
+ if (error?.cliCode) throw error;
181
+ throw usageError(`Failed to read file: ${error.message}`, readGuidance(filePath));
182
+ }
183
+ if (buffer.length > MAX_IMAGE_UPLOAD_BYTES) throw oversizeError('The file', buffer.length);
184
+
185
+ return toUpload(buffer, fileName);
186
+ };