outrank-cli 0.1.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.
@@ -0,0 +1,144 @@
1
+ import { readJsonFile, readStdin, resolvePath } from './io.js';
2
+
3
+ const invalidUsageError = (message) =>
4
+ Object.assign(new Error(message), {
5
+ cliCode: 'CLI_INVALID_USAGE',
6
+ guidance: 'Run the current command again with --help to see the required arguments and input format.',
7
+ });
8
+
9
+ export const parseArgs = (argv) => {
10
+ const positionals = [];
11
+ const options = new Map();
12
+ const flags = new Set();
13
+
14
+ for (let i = 0; i < argv.length; i += 1) {
15
+ const token = argv[i];
16
+
17
+ if (token === '--') {
18
+ positionals.push(...argv.slice(i + 1));
19
+ break;
20
+ }
21
+
22
+ if (token === '-h') {
23
+ flags.add('help');
24
+ continue;
25
+ }
26
+
27
+ if (token === '-v') {
28
+ flags.add('version');
29
+ continue;
30
+ }
31
+
32
+ if (!token.startsWith('--')) {
33
+ positionals.push(token);
34
+ continue;
35
+ }
36
+
37
+ const eqIndex = token.indexOf('=');
38
+ if (eqIndex >= 0) {
39
+ const key = token.slice(2, eqIndex);
40
+ const value = token.slice(eqIndex + 1);
41
+ const current = options.get(key) ?? [];
42
+ current.push(value);
43
+ options.set(key, current);
44
+ continue;
45
+ }
46
+
47
+ const key = token.slice(2);
48
+ const next = argv[i + 1];
49
+ if (next !== undefined && !next.startsWith('--')) {
50
+ const current = options.get(key) ?? [];
51
+ current.push(next);
52
+ options.set(key, current);
53
+ i += 1;
54
+ continue;
55
+ }
56
+
57
+ flags.add(key);
58
+ }
59
+
60
+ return { positionals, options, flags };
61
+ };
62
+
63
+ export const hasFlag = (parsed, name) => parsed.flags.has(name);
64
+
65
+ export const getOption = (parsed, name) => {
66
+ const values = parsed.options.get(name);
67
+ return values ? values[values.length - 1] : undefined;
68
+ };
69
+
70
+ export const getOptions = (parsed, name) => parsed.options.get(name) ?? [];
71
+
72
+ export const getRequiredPositional = (parsed, index, label) => {
73
+ const value = parsed.positionals[index];
74
+ if (!value) {
75
+ throw invalidUsageError(`Missing required argument: ${label}.`);
76
+ }
77
+ return value;
78
+ };
79
+
80
+ export const parseIntegerOption = (parsed, name, { required = false, min, max } = {}) => {
81
+ const raw = getOption(parsed, name);
82
+ if (raw === undefined) {
83
+ if (required) {
84
+ throw invalidUsageError(`Missing required option: --${name}.`);
85
+ }
86
+ return undefined;
87
+ }
88
+
89
+ const value = Number(raw);
90
+ if (!Number.isInteger(value)) {
91
+ throw invalidUsageError(`--${name} must be an integer.`);
92
+ }
93
+ if (min !== undefined && value < min) {
94
+ throw invalidUsageError(`--${name} must be >= ${min}.`);
95
+ }
96
+ if (max !== undefined && value > max) {
97
+ throw invalidUsageError(`--${name} must be <= ${max}.`);
98
+ }
99
+ return value;
100
+ };
101
+
102
+ export const readJsonInput = async (parsed) => {
103
+ const inline = getOption(parsed, 'json');
104
+ const file = getOption(parsed, 'json-file');
105
+ const useStdin = hasFlag(parsed, 'json-stdin');
106
+
107
+ const sources = [inline ? 'inline' : null, file ? 'file' : null, useStdin ? 'stdin' : null].filter(
108
+ Boolean,
109
+ );
110
+ if (sources.length === 0) {
111
+ throw invalidUsageError('Provide one of: --json, --json-file, or --json-stdin.');
112
+ }
113
+ if (sources.length > 1) {
114
+ throw invalidUsageError(
115
+ 'Use only one JSON input source: --json, --json-file, or --json-stdin.',
116
+ );
117
+ }
118
+
119
+ if (inline) {
120
+ try {
121
+ return JSON.parse(inline);
122
+ } catch (error) {
123
+ throw invalidUsageError(`Invalid JSON in --json: ${error.message}`);
124
+ }
125
+ }
126
+
127
+ if (file) {
128
+ try {
129
+ return await readJsonFile(resolvePath(file));
130
+ } catch (error) {
131
+ throw invalidUsageError(error.message);
132
+ }
133
+ }
134
+
135
+ const stdin = await readStdin();
136
+ if (!stdin.trim()) {
137
+ throw invalidUsageError('No JSON was received on stdin.');
138
+ }
139
+ try {
140
+ return JSON.parse(stdin);
141
+ } catch (error) {
142
+ throw invalidUsageError(`Invalid JSON from stdin: ${error.message}`);
143
+ }
144
+ };
@@ -0,0 +1,80 @@
1
+ import os from 'node:os';
2
+ import path from 'node:path';
3
+ import fs from 'node:fs/promises';
4
+
5
+ import { ensureDirectory, fileExists } from './io.js';
6
+
7
+ const DEFAULT_BASE_URL = 'https://outrank.so';
8
+
9
+ const getConfigDir = () => process.env.OUTRANK_CONFIG_DIR || path.join(os.homedir(), '.outrank');
10
+ export const getConfigPath = () => path.join(getConfigDir(), 'config.json');
11
+
12
+ export const loadConfig = async () => {
13
+ const configPath = getConfigPath();
14
+ if (!(await fileExists(configPath))) {
15
+ return {};
16
+ }
17
+
18
+ const raw = await fs.readFile(configPath, 'utf8');
19
+ try {
20
+ const config = JSON.parse(raw);
21
+ return config && typeof config === 'object' ? config : {};
22
+ } catch {
23
+ return {};
24
+ }
25
+ };
26
+
27
+ export const saveConfig = async (config) => {
28
+ const configPath = getConfigPath();
29
+ await ensureDirectory(path.dirname(configPath));
30
+ await fs.writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`, 'utf8');
31
+ };
32
+
33
+ export const clearStoredAuth = async () => {
34
+ const configPath = getConfigPath();
35
+ const current = await loadConfig();
36
+ if (!(await fileExists(configPath))) {
37
+ return current;
38
+ }
39
+ const next = { ...current };
40
+ delete next.apiKey;
41
+ await saveConfig(next);
42
+ return next;
43
+ };
44
+
45
+ const normalizeBaseUrl = (value) => {
46
+ const url = new URL(value || DEFAULT_BASE_URL);
47
+ return url.origin.replace(/\/$/, '');
48
+ };
49
+
50
+ export const resolveRuntimeConfig = async (parsed) => {
51
+ const config = await loadConfig();
52
+ const apiKey =
53
+ process.env.OUTRANK_API_KEY ||
54
+ (parsed ? parsed.options.get('api-key')?.slice(-1)[0] : undefined) ||
55
+ config.apiKey;
56
+ const baseUrl =
57
+ process.env.OUTRANK_BASE_URL ||
58
+ (parsed ? parsed.options.get('base-url')?.slice(-1)[0] : undefined) ||
59
+ config.baseUrl ||
60
+ DEFAULT_BASE_URL;
61
+
62
+ return {
63
+ apiKey,
64
+ baseUrl: normalizeBaseUrl(baseUrl),
65
+ config,
66
+ };
67
+ };
68
+
69
+ export const storeAuthConfig = async ({ apiKey, baseUrl }) => {
70
+ const current = await loadConfig();
71
+ const next = {
72
+ ...current,
73
+ ...(apiKey ? { apiKey } : {}),
74
+ ...(baseUrl ? { baseUrl: normalizeBaseUrl(baseUrl) } : {}),
75
+ };
76
+ await saveConfig(next);
77
+ return next;
78
+ };
79
+
80
+ export const getDefaultBaseUrl = () => DEFAULT_BASE_URL;
@@ -0,0 +1,15 @@
1
+ export const cliError = (code, message, guidance, details) => ({
2
+ ok: false,
3
+ error: {
4
+ code,
5
+ message,
6
+ ...(details ? { details } : {}),
7
+ },
8
+ agent_guidance: guidance,
9
+ });
10
+
11
+ export const cliSuccess = (data, guidance) => ({
12
+ ok: true,
13
+ data,
14
+ agent_guidance: guidance,
15
+ });
package/src/core/io.js ADDED
@@ -0,0 +1,49 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+
4
+ export const emitJson = (value) => {
5
+ process.stdout.write(`${JSON.stringify(value, null, 2)}\n`);
6
+ };
7
+
8
+ export const emitAndExit = (value, code = 0) => {
9
+ emitJson(value);
10
+ process.exitCode = code;
11
+ };
12
+
13
+ export const fileExists = async (filePath) => {
14
+ try {
15
+ await fs.access(filePath);
16
+ return true;
17
+ } catch {
18
+ return false;
19
+ }
20
+ };
21
+
22
+ export const readJsonFile = async (filePath) => {
23
+ const content = await fs.readFile(filePath, 'utf8');
24
+ try {
25
+ return JSON.parse(content);
26
+ } catch (error) {
27
+ throw new Error(`Invalid JSON in ${filePath}: ${error.message}`);
28
+ }
29
+ };
30
+
31
+ export const ensureDirectory = async (dirPath) => {
32
+ await fs.mkdir(dirPath, { recursive: true });
33
+ };
34
+
35
+ export const resolvePath = (value) => path.resolve(process.cwd(), value);
36
+
37
+ export const readStdin = async () => {
38
+ if (process.stdin.isTTY) return '';
39
+
40
+ return new Promise((resolve, reject) => {
41
+ let data = '';
42
+ process.stdin.setEncoding('utf8');
43
+ process.stdin.on('data', (chunk) => {
44
+ data += chunk;
45
+ });
46
+ process.stdin.on('end', () => resolve(data));
47
+ process.stdin.on('error', reject);
48
+ });
49
+ };
package/src/index.js ADDED
@@ -0,0 +1,134 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { readFileSync } from 'node:fs';
4
+
5
+ import { parseArgs } from './core/args.js';
6
+ import { emitAndExit } from './core/io.js';
7
+ import { cliError, cliSuccess } from './core/errors.js';
8
+ import { handleAuthCommand } from './commands/auth.js';
9
+ import { handleProductsCommand } from './commands/products.js';
10
+ import { handleBillingCommand } from './commands/billing.js';
11
+ import { handleSubscriptionCommand } from './commands/subscription.js';
12
+ import { handleUsageCommand } from './commands/usage.js';
13
+ import { handleKeywordsCommand } from './commands/keywords.js';
14
+ import { handleArticlesCommand } from './commands/articles.js';
15
+
16
+ const getVersion = () => {
17
+ try {
18
+ const packageJson = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
19
+ return packageJson.version || 'unknown';
20
+ } catch {
21
+ return 'unknown';
22
+ }
23
+ };
24
+
25
+ const ROOT_HELP = `Outrank CLI
26
+
27
+ Agent-first CLI for Outrank's public agent API.
28
+
29
+ Usage:
30
+ outrank-cli <group> <command> [options]
31
+ outrank-cli <group> --help
32
+
33
+ Groups:
34
+ auth Store or verify API-key authentication.
35
+ products List, create, inspect, and configure products, article settings, and Google Search Console.
36
+ keywords Inspect, check write status, add, reschedule, delete, suggest, generate, and export keyword plans.
37
+ articles Inspect article metadata and bodies, edit content, retry publish, precreate, and generate.
38
+ subscription Inspect plan, trial, quota, and add-on status.
39
+ usage Inspect current usage and quota consumption.
40
+ billing Product-slot purchase flows.
41
+
42
+ Global Auth Options:
43
+ --api-key <key> Override the API key for this command only.
44
+ --base-url <url> Override the Outrank base URL for this command only.
45
+
46
+ JSON Input Conventions:
47
+ Complex write commands accept one of:
48
+ --json '<object>'
49
+ --json-file payload.json
50
+ --json-stdin
51
+
52
+ Examples:
53
+ outrank-cli auth login outr_live_xxx
54
+ outrank-cli auth whoami
55
+ outrank-cli products list --limit 20
56
+ outrank-cli keywords list --product 00000000-0000-0000-0000-000000000000 --scope all
57
+ outrank-cli articles list --product 00000000-0000-0000-0000-000000000000 --status published
58
+ outrank-cli subscription status
59
+ outrank-cli usage stats
60
+ outrank-cli products create --json-file product.json
61
+ outrank-cli billing portal-url
62
+ outrank-cli products output-settings update <product-id> --json-file output-settings.json
63
+ outrank-cli products target-audiences replace <product-id> --json-file target-audiences.json
64
+ outrank-cli products gsc connect <product-id>
65
+ outrank-cli products gsc performance <product-id>
66
+ outrank-cli billing purchase-products quote --additional-products 2
67
+ `;
68
+
69
+ const main = async () => {
70
+ const argv = process.argv.slice(2);
71
+ const parsed = parseArgs(argv);
72
+ const first = argv[0];
73
+
74
+ if (argv.length === 0 || first === '--help' || first === '-h' || first === 'help') {
75
+ emitAndExit(
76
+ cliSuccess(
77
+ {
78
+ usage: ROOT_HELP.trim(),
79
+ },
80
+ 'Choose a command group, then run that group with --help for detailed usage.',
81
+ ),
82
+ );
83
+ return;
84
+ }
85
+
86
+ if (first === '--version' || first === '-v' || first === 'version') {
87
+ emitAndExit(
88
+ cliSuccess(
89
+ {
90
+ version: getVersion(),
91
+ },
92
+ 'Version returned.',
93
+ ),
94
+ );
95
+ return;
96
+ }
97
+
98
+ const group = argv[0];
99
+ const rest = argv.slice(1);
100
+
101
+ let result;
102
+ if (group === 'auth') {
103
+ result = await handleAuthCommand(rest);
104
+ } else if (group === 'products') {
105
+ result = await handleProductsCommand(rest);
106
+ } else if (group === 'keywords') {
107
+ result = await handleKeywordsCommand(rest);
108
+ } else if (group === 'articles') {
109
+ result = await handleArticlesCommand(rest);
110
+ } else if (group === 'subscription') {
111
+ result = await handleSubscriptionCommand(rest);
112
+ } else if (group === 'usage') {
113
+ result = await handleUsageCommand(rest);
114
+ } else if (group === 'billing') {
115
+ result = await handleBillingCommand(rest);
116
+ } else {
117
+ throw Object.assign(new Error(`Unknown command group: ${group}.`), {
118
+ cliCode: 'CLI_INVALID_USAGE',
119
+ guidance: 'Run `outrank-cli --help` to see the available top-level groups.',
120
+ });
121
+ }
122
+
123
+ emitAndExit(result, result?.ok === false ? 1 : 0);
124
+ };
125
+
126
+ main().catch((error) => {
127
+ const message = error?.message || 'Unexpected CLI failure.';
128
+ const code = error?.cliCode || 'CLI_UNEXPECTED_ERROR';
129
+ const guidance =
130
+ error?.guidance ||
131
+ 'Retry with --help for usage details. If the command and inputs look correct, inspect the error details and try again.';
132
+
133
+ emitAndExit(cliError(code, message, guidance), 1);
134
+ });