@funyard-org/cli 0.0.0-stage → 0.6.2

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.
package/src/cli.js ADDED
@@ -0,0 +1,324 @@
1
+ #!/usr/bin/env node
2
+ import { agentFlags } from './agents.js';
3
+ import { randomBytes } from 'node:crypto';
4
+ import { parseArgs } from 'node:util';
5
+ import { FunyardClient, FunyardError, DEFAULT_BASE_URL, validateBaseURL, redact, safeError } from './client.js';
6
+ import { readConfig, writeConfig, readSecret } from './config.js';
7
+ import { browserLogin } from './browser-login.js';
8
+ import { setupAgent } from './agent-setup.js';
9
+ import { selectAgent } from './agent-selection.js';
10
+ import { runMCP } from './mcp.js';
11
+
12
+ const HELP = `funyard — independent client (Node >=22)
13
+ Usage: funyard <command> [options]
14
+ Commands:
15
+ setup [--agent ${agentFlags}] Detect your Agent, install Agent instructions, authorize in browser and verify access
16
+ login | auth login Authorize through your browser (use --key for a hidden API key prompt)
17
+ agent setup [--agent ${agentFlags}] Detect your Agent, install FunYard instructions for your Agent
18
+ logout Remove the stored API key (not environment variables)
19
+ config [--base-url ORIGIN] Show safe config or set API origin
20
+ me Show account
21
+ explore [DOMAIN] [--capability ID | --group ID | --search TEXT]
22
+ Browse what you can call: domains -> capabilities -> field groups -> fields
23
+ call CAPABILITY --arg NAME=VALUE ... [--fields A,B]
24
+ Call any listed capability, including ones newer than this CLI
25
+ capabilities [ID] List capabilities or show one
26
+ fields [--category NAME] List field ids, their billing group and group price
27
+ fields-quote --fields A,B Dry-run price for a field list (nothing reserved)
28
+ quote SYMBOL | --symbol SYMBOL finance.quote
29
+ get --symbol SYMBOL --fields A,B finance.get: fields by id, charged per field group
30
+ crypto --coin ID --fields A,B crypto.get: e.g. --coin bitcoin --fields crypto.market.price
31
+ economy --series ID --fields A,B economy.get: e.g. --series UNRATE --fields economy.latest.value
32
+ tiktok|instagram|youtube --handle NAME --fields A,B public social profiles, e.g. tiktok.profile.followers
33
+ linkedin --company SLUG --fields A,B linkedin.get: company pages only, e.g. --company stripe
34
+ trending --platform weibo social.trending: current hot-search list
35
+ search --query TEXT web.search
36
+ news --query TEXT news.search
37
+ read --url URL web.read
38
+ company --domain DOMAIN company.discover
39
+ research --query TEXT research.search
40
+ status --request-id ID | --call-id ID
41
+ usage Show latest 100 account ledger entries
42
+ cancel --call-id ID
43
+ mcp Serve newline-delimited stdio MCP JSON-RPC
44
+ Options: --base-url ORIGIN, --request-id ID (required for paid calls), --json, --help, --version
45
+ Search/news: --language LANGUAGE --region REGION; research: --language LANGUAGE
46
+ Start with \`funyard explore\`; every level shows prices, arguments and whether this key may call it.
47
+ MCP: one tool per available capability (finance_get, crypto_get, web_search, ...) plus explore, get_call, cancel_call, me, capabilities, fields, fields_quote.
48
+ Paid calls require an explicit stable request_id; no client generates one silently.
49
+ Authentication: FUNYARD_API_KEY or a permission-600 config file.
50
+ Do not pass API keys as command-line arguments or URL parameters.
51
+ `;
52
+
53
+ let activeKey;
54
+ let jsonOutput = process.argv.includes('--json');
55
+ function readable(value, prefix = '') {
56
+ if (Array.isArray(value))
57
+ return value.length
58
+ ? value.map((item, index) => readable(item, `${prefix}${index + 1}. `)).join('\n')
59
+ : `${prefix}(none)`;
60
+ if (value && typeof value === 'object') {
61
+ return Object.entries(value)
62
+ .map(([key, item]) =>
63
+ item && typeof item === 'object'
64
+ ? `${prefix}${key}:\n${readable(item, `${prefix} `)}`
65
+ : `${prefix}${key}: ${item === null ? '—' : String(item)}`,
66
+ )
67
+ .join('\n');
68
+ }
69
+ return `${prefix}${String(value)}`;
70
+ }
71
+ async function main() {
72
+ let parsed;
73
+ try {
74
+ parsed = parseArgs({
75
+ allowPositionals: true,
76
+ strict: true,
77
+ options: {
78
+ 'base-url': { type: 'string' },
79
+ key: { type: 'boolean' },
80
+ 'no-browser': { type: 'boolean' },
81
+ agent: { type: 'string' },
82
+ 'request-id': { type: 'string' },
83
+ 'call-id': { type: 'string' },
84
+ capability: { type: 'string' },
85
+ group: { type: 'string' },
86
+ search: { type: 'string' },
87
+ arg: { type: 'string', multiple: true },
88
+ symbol: { type: 'string' },
89
+ coin: { type: 'string' },
90
+ series: { type: 'string' },
91
+ handle: { type: 'string' },
92
+ company: { type: 'string' },
93
+ platform: { type: 'string' },
94
+ fields: { type: 'string' },
95
+ category: { type: 'string' },
96
+ query: { type: 'string' },
97
+ url: { type: 'string' },
98
+ domain: { type: 'string' },
99
+ language: { type: 'string' },
100
+ region: { type: 'string' },
101
+ start: { type: 'string' },
102
+ end: { type: 'string' },
103
+ page: { type: 'string' },
104
+ 'page-size': { type: 'string' },
105
+ help: { type: 'boolean', short: 'h' },
106
+ version: { type: 'boolean' },
107
+ json: { type: 'boolean' },
108
+ },
109
+ });
110
+ } catch {
111
+ throw new FunyardError('Invalid command or options. Run funyard --help. API keys are never accepted as arguments.');
112
+ }
113
+ const { values: flags, positionals } = parsed;
114
+ jsonOutput = Boolean(flags.json);
115
+ if (flags.help || (!positionals.length && !flags.version))
116
+ return process.stdout.write(jsonOutput ? `${JSON.stringify({ help: HELP })}\n` : HELP);
117
+ if (flags.version) return process.stdout.write(jsonOutput ? '{"version":"0.6.2"}\n' : '0.6.2\n');
118
+ let [command, ...rest] = positionals;
119
+ if (command === 'auth' && rest[0] === 'login') {
120
+ command = 'login';
121
+ rest = rest.slice(1);
122
+ }
123
+ const allowed = {
124
+ setup: ['agent', 'no-browser'],
125
+ login: ['key', 'no-browser'],
126
+ agent: ['agent'],
127
+ logout: [],
128
+ config: [],
129
+ me: [],
130
+ capabilities: [],
131
+ fields: ['category'],
132
+ explore: ['domain', 'capability', 'group', 'search'],
133
+ call: ['arg', 'fields', 'request-id'],
134
+ 'fields-quote': ['fields'],
135
+ usage: [],
136
+ mcp: [],
137
+ quote: ['symbol', 'request-id'],
138
+ get: ['symbol', 'fields', 'request-id'],
139
+ crypto: ['coin', 'fields', 'request-id'],
140
+ economy: ['series', 'fields', 'request-id'],
141
+ tiktok: ['handle', 'fields', 'request-id'],
142
+ instagram: ['handle', 'fields', 'request-id'],
143
+ youtube: ['handle', 'fields', 'request-id'],
144
+ linkedin: ['company', 'fields', 'request-id'],
145
+ trending: ['platform', 'request-id'],
146
+ search: ['query', 'request-id', 'language', 'region'],
147
+ news: ['query', 'request-id', 'language', 'region'],
148
+ read: ['url', 'request-id'],
149
+ company: ['domain', 'request-id'],
150
+ research: ['query', 'request-id', 'language'],
151
+ status: ['call-id', 'request-id'],
152
+ cancel: ['call-id'],
153
+ };
154
+ if (
155
+ !Object.hasOwn(allowed, command) ||
156
+ rest.length > (['capabilities', 'quote', 'agent', 'explore', 'call'].includes(command) ? 1 : 0) ||
157
+ Object.keys(flags).some(flag => !['base-url', 'json', ...allowed[command]].includes(flag))
158
+ )
159
+ throw new FunyardError('Invalid command or options. Run funyard --help.');
160
+ if (command === 'quote' && rest[0]) {
161
+ if (flags.symbol !== undefined) throw new FunyardError('Provide a positional symbol or --symbol, not both.');
162
+ flags.symbol = rest[0];
163
+ }
164
+ if (command === 'agent') {
165
+ if (rest[0] !== 'setup') throw new FunyardError(`Use funyard agent setup [--agent ${agentFlags}].`);
166
+ const installed = await setupAgent(await selectAgent(flags.agent));
167
+ return process.stdout.write(JSON.stringify(installed) + '\n');
168
+ }
169
+ const config = await readConfig();
170
+ const baseURL = validateBaseURL(
171
+ flags['base-url'] || process.env.FUNYARD_BASE_URL || config.baseURL || DEFAULT_BASE_URL,
172
+ );
173
+ const credentialOrigin = Object.hasOwn(config, 'credentialOrigin') ? config.credentialOrigin : config.baseURL;
174
+ const print = value => {
175
+ const safe = redact(value, activeKey);
176
+ process.stdout.write(`${jsonOutput ? safe : readable(JSON.parse(safe))}\n`);
177
+ };
178
+ if (command === 'config') {
179
+ if (flags['base-url'])
180
+ await writeConfig({
181
+ ...config,
182
+ ...(config.apiKey ? { credentialOrigin: credentialOrigin || null } : {}),
183
+ baseURL,
184
+ });
185
+ return print({ baseURL, hasStoredKey: Boolean(config.apiKey), credentialOrigin: credentialOrigin || null });
186
+ }
187
+ if (command === 'logout') {
188
+ const { apiKey, ...remaining } = config;
189
+ await writeConfig(remaining);
190
+ return print({ loggedOut: true, environmentKeyStillSet: Boolean(process.env.FUNYARD_API_KEY) });
191
+ }
192
+ if (command === 'login' || command === 'setup') {
193
+ // Refuse conflicting Agent instructions before starting an authorization request.
194
+ const installed = command === 'setup' ? await setupAgent(await selectAgent(flags.agent)) : undefined;
195
+ // Per-origin installation secret survives logout and failed grants; never print it or put it in a URL.
196
+ const installations =
197
+ config.installations && typeof config.installations === 'object' && !Array.isArray(config.installations)
198
+ ? config.installations
199
+ : {};
200
+ const installationSecret =
201
+ typeof installations[baseURL] === 'string' && /^[A-Za-z0-9_-]{43}$/.test(installations[baseURL])
202
+ ? installations[baseURL]
203
+ : randomBytes(32).toString('base64url');
204
+ if (!flags.key) {
205
+ config.installations = { ...installations, [baseURL]: installationSecret };
206
+ await writeConfig(config);
207
+ }
208
+ activeKey = flags.key
209
+ ? await readSecret()
210
+ : (await browserLogin(baseURL, { noBrowser: flags['no-browser'], installationSecret })).apiKey;
211
+ // Persist a rotated credential before read-only verification; a transient /me failure must not lose it.
212
+ if (!flags.key) await writeConfig({ ...config, baseURL, credentialOrigin: baseURL, apiKey: activeKey });
213
+ const client = new FunyardClient({ apiKey: activeKey, baseURL });
214
+ await client.me();
215
+ await writeConfig({ ...config, baseURL, credentialOrigin: baseURL, apiKey: activeKey });
216
+ if (installed) {
217
+ await client.capabilities();
218
+ return print({
219
+ loggedIn: true,
220
+ baseURL,
221
+ agent: installed.agent,
222
+ skill: installed.path,
223
+ verified: true,
224
+ next: installed.next,
225
+ });
226
+ }
227
+ return print({ loggedIn: true, baseURL, environmentKeyStillSet: Boolean(process.env.FUNYARD_API_KEY) });
228
+ }
229
+ if (
230
+ process.env.FUNYARD_API_KEY &&
231
+ !flags['base-url'] &&
232
+ !process.env.FUNYARD_BASE_URL &&
233
+ baseURL !== DEFAULT_BASE_URL
234
+ )
235
+ throw new FunyardError(
236
+ 'An environment API key cannot implicitly use a non-default origin from config. Set FUNYARD_BASE_URL or pass --base-url explicitly, or unset FUNYARD_API_KEY to use the origin-bound stored key.',
237
+ { code: 'EXPLICIT_ORIGIN_REQUIRED' },
238
+ );
239
+ if (!process.env.FUNYARD_API_KEY && config.apiKey && credentialOrigin !== baseURL)
240
+ throw new FunyardError(
241
+ 'Stored API key belongs to a different or unknown origin. Run funyard login --base-url with the intended origin; legacy credentials.json is never imported automatically.',
242
+ );
243
+ activeKey = process.env.FUNYARD_API_KEY || config.apiKey;
244
+ const client = new FunyardClient({ apiKey: activeKey, baseURL });
245
+ if (command === 'mcp') return runMCP(client);
246
+ if (command === 'me') return print(await client.me());
247
+ if (command === 'capabilities') return print(await client.capabilities(rest[0], { raw: jsonOutput }));
248
+ if (command === 'usage') return print(await client.usage());
249
+ if (command === 'fields') return print(await client.fields(flags.category));
250
+ if (command === 'explore') {
251
+ if (rest[0] !== undefined && flags.domain !== undefined)
252
+ throw new FunyardError('Provide a positional domain or --domain, not both.');
253
+ return print(
254
+ await client.explore({
255
+ domain: rest[0] ?? flags.domain,
256
+ capability: flags.capability,
257
+ group: flags.group,
258
+ q: flags.search,
259
+ }),
260
+ );
261
+ }
262
+ if (command === 'call') {
263
+ // Any capability the catalog lists, including ones added after this release: --arg name=value.
264
+ if (!rest[0]) throw new FunyardError('Missing capability, e.g. funyard call crypto.get --arg coin=bitcoin.');
265
+ const args = {};
266
+ for (const pair of flags.arg ?? []) {
267
+ const at = pair.indexOf('=');
268
+ if (at < 1 || Object.hasOwn(args, pair.slice(0, at)))
269
+ throw new FunyardError('Each --arg is name=value, once per name.', { code: 'INVALID_ARGUMENTS' });
270
+ args[pair.slice(0, at)] = pair.slice(at + 1);
271
+ }
272
+ if (flags.fields !== undefined) {
273
+ if (Object.hasOwn(args, 'fields')) throw new FunyardError('Provide fields once.');
274
+ args.fields = flags.fields;
275
+ }
276
+ return print(await client.call(rest[0], args, { requestId: flags['request-id'] }));
277
+ }
278
+ if (command === 'fields-quote') {
279
+ if (!flags.fields?.trim()) throw new FunyardError('Missing --fields.');
280
+ return print(await client.fieldsQuote(flags.fields));
281
+ }
282
+ if (command === 'status')
283
+ return print(await client.status({ callId: flags['call-id'], requestId: flags['request-id'] }));
284
+ if (command === 'cancel') return print(await client.cancel(flags['call-id']));
285
+ const mapping = {
286
+ quote: ['finance.quote', 'symbol'],
287
+ get: ['finance.get', 'symbol'],
288
+ crypto: ['crypto.get', 'coin'],
289
+ economy: ['economy.get', 'series'],
290
+ tiktok: ['tiktok.get', 'handle'],
291
+ instagram: ['instagram.get', 'handle'],
292
+ youtube: ['youtube.get', 'handle'],
293
+ linkedin: ['linkedin.get', 'company'],
294
+ trending: ['social.trending', 'platform'],
295
+ search: ['web.search', 'query'],
296
+ news: ['news.search', 'query'],
297
+ read: ['web.read', 'url'],
298
+ company: ['company.discover', 'domain'],
299
+ research: ['research.search', 'query'],
300
+ };
301
+ const [capability, field] = mapping[command];
302
+ if (!flags[field]?.trim()) throw new FunyardError(`Missing --${field}.`);
303
+ const fielded = ['get', 'crypto', 'economy', 'tiktok', 'instagram', 'youtube', 'linkedin'].includes(command);
304
+ if (fielded && !flags.fields?.trim()) throw new FunyardError('Missing --fields.');
305
+ const requestId = flags['request-id'];
306
+ const response = await client.call(
307
+ capability,
308
+ {
309
+ [field]: flags[field],
310
+ ...(fielded ? { fields: flags.fields } : {}),
311
+ ...(flags.language === undefined ? {} : { language: flags.language }),
312
+ ...(flags.region === undefined ? {} : { region: flags.region }),
313
+ },
314
+ { requestId },
315
+ );
316
+ print(response);
317
+ }
318
+
319
+ main().catch(error => {
320
+ const safe = redact(safeError(error), activeKey);
321
+ // Stderr remains structured even when stdout uses the human-readable view.
322
+ process.stderr.write(`${safe}\n`);
323
+ process.exitCode = 1;
324
+ });
@@ -0,0 +1,97 @@
1
+ import type {
2
+ Arguments,
3
+ CapabilityId,
4
+ CallResponse,
5
+ CatalogResponse,
6
+ FieldsCatalogResponse,
7
+ FieldsQuoteResponse,
8
+ MeResponse,
9
+ UsageResponse,
10
+ } from './protocol/index.js';
11
+ export type {
12
+ Arguments,
13
+ BillingLine,
14
+ CapabilityId,
15
+ Result,
16
+ EmptyResult,
17
+ CallResponse,
18
+ CallSubmission,
19
+ CatalogResponse,
20
+ FieldsCatalogResponse,
21
+ FieldsQuoteResponse,
22
+ MeResponse,
23
+ UsageResponse,
24
+ ErrorResponse,
25
+ } from './protocol/index.js';
26
+ export { REQUEST_ID, CALL_ID } from './protocol/index.js';
27
+ export const DEFAULT_BASE_URL: string;
28
+ export interface ClientOptions {
29
+ apiKey?: string;
30
+ FUNYARD_API_KEY?: string;
31
+ baseURL?: string;
32
+ FUNYARD_BASE_URL?: string;
33
+ timeoutMs?: number;
34
+ fetch?: typeof fetch;
35
+ onDiagnostic?: (event: Record<string, unknown>) => void;
36
+ }
37
+ export class FunyardError extends Error {
38
+ status?: number;
39
+ code?: string;
40
+ requestId?: string;
41
+ callId?: string;
42
+ reason?: string;
43
+ retryAfter?: number;
44
+ constructor(
45
+ message: string,
46
+ options?: {
47
+ status?: number;
48
+ code?: string;
49
+ requestId?: string;
50
+ callId?: string;
51
+ reason?: string;
52
+ retryAfter?: number;
53
+ },
54
+ );
55
+ }
56
+ export class FunyardClient {
57
+ constructor(options?: ClientOptions, extra?: Pick<ClientOptions, 'timeoutMs' | 'fetch' | 'onDiagnostic'>);
58
+ readonly apiKey: string;
59
+ readonly baseURL: string;
60
+ me(signal?: AbortSignal): Promise<MeResponse>;
61
+ capabilities(id?: CapabilityId, options?: { signal?: AbortSignal }): Promise<CatalogResponse>;
62
+ usage(options?: Record<string, never>): Promise<UsageResponse>;
63
+ fields(category?: string, options?: { signal?: AbortSignal }): Promise<FieldsCatalogResponse>;
64
+ fieldsQuote(fields: string | readonly string[], options?: { signal?: AbortSignal }): Promise<FieldsQuoteResponse>;
65
+ call<C extends CapabilityId>(
66
+ capability: C,
67
+ args: Arguments<C>,
68
+ options: { requestId: string; signal?: AbortSignal },
69
+ ): Promise<CallResponse<C>>;
70
+ create<C extends CapabilityId>(
71
+ capability: C,
72
+ requestId: string,
73
+ args: Arguments<C>,
74
+ signal?: AbortSignal,
75
+ ): Promise<CallResponse<C>>;
76
+ status(
77
+ ids: { callId: string; requestId?: never } | { callId?: never; requestId: string },
78
+ signal?: AbortSignal,
79
+ ): Promise<CallResponse>;
80
+ getCall(
81
+ ids: { call_id: string; request_id?: never } | { call_id?: never; request_id: string },
82
+ signal?: AbortSignal,
83
+ ): Promise<CallResponse>;
84
+ cancel(callId: string, signal?: AbortSignal): Promise<CallResponse>;
85
+ request(
86
+ path: string,
87
+ options?: { method?: 'GET' | 'POST'; body?: unknown; requestId?: string; callId?: string; signal?: AbortSignal },
88
+ ): Promise<unknown>;
89
+ request(
90
+ method: 'GET' | 'POST',
91
+ route: string,
92
+ options?: { body?: unknown; requestID?: string; callID?: string; signal?: AbortSignal },
93
+ ): Promise<unknown>;
94
+ }
95
+ export function validateBaseURL(value: string): string;
96
+ export function safeError(error: unknown): Record<string, unknown>;
97
+ export function redact(value: unknown, secret?: string): string;