@usemo.com/sdk 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.
package/src/cli.js ADDED
@@ -0,0 +1,165 @@
1
+ #!/usr/bin/env node
2
+ import { readFile, writeFile, access } from 'node:fs/promises';
3
+ import { resolve } from 'node:path';
4
+ import { realpathSync } from 'node:fs';
5
+ import { pathToFileURL } from 'node:url';
6
+ import { catalog, downloadAsset, UseMoClient, UseMoError } from './client.js';
7
+ import { profileName, readConfig, writeConfig, resolveConfig, normalizeBase } from './config.js';
8
+
9
+ const flags = { json: 'boolean', help: 'boolean', version: 'boolean', 'dry-run': 'boolean', wait: 'boolean',
10
+ 'key-stdin': 'boolean', 'base-url': 'string', profile: 'string', data: 'string', out: 'string',
11
+ 'wait-timeout': 'number', 'poll-interval': 'number', timeout: 'number', query: 'string' };
12
+ const short = { j: 'json', h: 'help', v: 'version', p: 'profile', o: 'out' };
13
+ function fieldType(schema, root) {
14
+ if (schema.$ref) return fieldType(root.$defs[schema.$ref.split('/').at(-1)], root);
15
+ if (schema.anyOf) return fieldType(schema.anyOf.find(x => x.type !== 'null'), root);
16
+ return schema.type;
17
+ }
18
+ function parse(argv) {
19
+ const words = []; const options = {}; const fields = {};
20
+ // Determine the command before parsing its fields. Global options may precede it.
21
+ for (let i = 0; i < argv.length; i++) {
22
+ const token = argv[i];
23
+ if (token.startsWith('-')) {
24
+ const key = token.replace(/^--?/, '').split('=')[0];
25
+ const name = short[key] || key;
26
+ if (flags[name] && flags[name] !== 'boolean' && !token.includes('=')) i++;
27
+ else if (!flags[name]) break;
28
+ } else { words.push(token); if (words.length === 2) break; }
29
+ }
30
+ const aliases = { image: 'images', video: 'videos', login: 'auth login', logout: 'auth logout', whoami: 'auth status', use: 'profiles use' };
31
+ const first = aliases[words[0]] || words[0];
32
+ let command = first?.includes(' ') ? first : [first, words[1]].filter(Boolean).join(' ');
33
+ const single = ['schema', 'discover', 'doctor', 'help', 'mcp', 'download'];
34
+ if (single.includes(words[0])) command = words[0];
35
+ const tool = catalog.find(x => x.command === command);
36
+ const localCommands = ['auth login', 'auth logout', 'auth status', 'profiles list', 'profiles use', 'request'];
37
+ if (words[0] === 'request') command = 'request';
38
+ const consumed = first?.includes(' ') || single.includes(words[0]) || words[0] === 'request' ? 1 : 2;
39
+ const positionals = [];
40
+ let wordIndex = 0;
41
+ for (let i = 0; i < argv.length; i++) {
42
+ const token = argv[i];
43
+ if (!token.startsWith('-') || token === '-') { if (wordIndex++ >= consumed) positionals.push(token); continue; }
44
+ const [raw, ...rest] = token.replace(/^--?/, '').split('=');
45
+ const key = short[raw] || raw;
46
+ const field = key.replaceAll('-', '_');
47
+ const property = tool?.inputSchema.properties[field];
48
+ const type = flags[key] || (property && fieldType(property, tool.inputSchema));
49
+ if (!type) throw new UseMoError('invalid_input', `Unknown option ${token}. Run usemo ${command || ''} --help.`);
50
+ let value = rest.length ? rest.join('=') : type === 'boolean' ? true : argv[++i];
51
+ if (value === undefined || (typeof value === 'string' && value.startsWith('--'))) throw new UseMoError('invalid_input', `Missing value for --${key}.`);
52
+ if (type === 'boolean') { if (![true, 'true', 'false'].includes(value)) throw new UseMoError('invalid_input', `--${key} expects true or false.`); value = value === true || value === 'true'; }
53
+ if (['number', 'integer'].includes(type)) { value = Number(value); if (!Number.isFinite(value)) throw new UseMoError('invalid_input', `--${key} expects a number.`); }
54
+ if (['array', 'object'].includes(type)) { try { value = JSON.parse(value); } catch { throw new UseMoError('invalid_input', `--${key} expects JSON.`); } }
55
+ const target = flags[key] ? options : fields;
56
+ const targetKey = flags[key] ? key : field;
57
+ if (key === 'query') (options.query ||= []).push(value);
58
+ else if (Object.hasOwn(target, targetKey)) throw new UseMoError('invalid_input', `Duplicate option --${key}.`);
59
+ else target[targetKey] = value;
60
+ }
61
+ if (!command && (options.help || options.version)) command = 'help';
62
+ if (command && !tool && !single.includes(command) && !localCommands.includes(command)) throw new UseMoError('invalid_input', `Unknown command: ${command}`);
63
+ return { command: command || 'help', tool, positionals, options, fields };
64
+ }
65
+ async function stdinText() { let text = ''; for await (const chunk of process.stdin) text += chunk; return text; }
66
+ async function jsonInput(value) {
67
+ if (!value) return {};
68
+ try {
69
+ const text = value === '@-' ? await stdinText() : value.startsWith('@') ? await readFile(value.slice(1), 'utf8') : value;
70
+ const data = JSON.parse(text);
71
+ if (!data || typeof data !== 'object' || Array.isArray(data)) throw Error();
72
+ return data;
73
+ } catch { throw new UseMoError('invalid_input', '--data must be a JSON object, @file.json, or @- for stdin.'); }
74
+ }
75
+ export function help(tool) {
76
+ if (tool) return `usemo ${tool.command}${tool.positional ? ' [TEXT_OR_ID]' : ''}\n${tool.description}\n\nFields (also accepted in --data @file.json):\n` +
77
+ Object.entries(tool.inputSchema.properties).map(([key, value]) => ` --${key.replaceAll('_', '-')} ${value.description || value.title || ''}`).join('\n') + '\n\nOptions: --json --dry-run --wait --wait-timeout SECONDS --profile NAME --out receipt.json';
78
+ return `UseMo 0.1.0 — create videos, images and content\n\n` + catalog.map(x => ` ${x.command}`).join('\n') +
79
+ '\n\n login --key-stdin | logout | whoami\n profiles list | profiles use NAME\n schema [COMMAND] | discover [QUERY] | doctor\n request METHOD /api/v1/PATH --data JSON --query key=value\n mcp\n download HTTPS_URL --out FILE\n\nUse --help on a command for fields. --data accepts JSON, @file, or @-.\n--json emits JSON; --dry-run previews without network; --out saves JSON without overwriting.\nSet USEMO_API_KEY for automation. Run usemo-mcp for MCP stdio.\n';
80
+ }
81
+ async function output(value, options) {
82
+ const text = typeof value === 'string' && !options.json ? value : JSON.stringify(value, null, options.json ? 0 : 2);
83
+ if (options.out) await writeFile(options.out, text + '\n', { flag: 'wx', mode: 0o600 });
84
+ else process.stdout.write(text + '\n');
85
+ }
86
+ export async function main(argv = process.argv.slice(2)) {
87
+ const { command, tool, positionals, options, fields } = parse(argv);
88
+ if (options.version) return output({ version: '0.1.0', package: '@usemo.com/sdk' }, options);
89
+ if (options.help || command === 'help') return output(help(tool), options);
90
+ if (command === 'schema' || command === 'discover') {
91
+ const query = positionals.join(' ').toLowerCase();
92
+ const matches = catalog.filter(x => command === 'schema' ? x.command.includes(query) : query.split(' ').every(w => (x.command + ' ' + x.description).toLowerCase().includes(w)));
93
+ if (!matches.length) throw new UseMoError('invalid_input', 'No matching command.');
94
+ return output(matches, options);
95
+ }
96
+ if (options.out) {
97
+ try { await access(options.out); throw new UseMoError('file_exists', 'Output file already exists.'); }
98
+ catch (e) { if (e.code !== 'ENOENT') throw e; }
99
+ }
100
+ if (command === 'download') {
101
+ if (positionals.length !== 1 || !options.out) throw new UseMoError('invalid_input', 'Expected download HTTPS_URL --out FILE.');
102
+ const receipt = await downloadAsset(positionals[0], options.out, { timeout: options.timeout ?? 120 });
103
+ return output(receipt, { json: options.json });
104
+ }
105
+ if (command === 'mcp') { if (options.out || options.json) throw new UseMoError('invalid_input', 'MCP uses stdout for its protocol.'); const { start } = await import('./mcp.js'); return start(options); }
106
+ if (command.startsWith('profiles ') || command.startsWith('auth ')) {
107
+ const config = await readConfig();
108
+ const name = profileName(options.profile || process.env.USEMO_PROFILE || config.active || 'default');
109
+ if (command === 'profiles list') return output({ active: config.active, profiles: Object.keys(config.profiles) }, options);
110
+ if (command === 'profiles use') {
111
+ if (positionals.length !== 1 || !Object.hasOwn(config.profiles, positionals[0])) throw new UseMoError('invalid_input', 'Choose an existing profile.');
112
+ config.active = profileName(positionals[0]); await writeConfig(config); return output({ active: config.active }, options);
113
+ }
114
+ if (command === 'auth logout') { delete config.profiles[name]; if (config.active === name) config.active = Object.keys(config.profiles)[0] || 'default'; await writeConfig(config); return output({ logged_out: name, revoked: false }, options); }
115
+ if (command === 'auth login') {
116
+ if (!options['key-stdin']) throw new UseMoError('invalid_input', 'Provide a key from UseMo Settings → API Keys with usemo login --key-stdin, or set USEMO_API_KEY.');
117
+ if (options['dry-run']) throw new UseMoError('invalid_input', 'Login does not support --dry-run.');
118
+ const apiKey = (await stdinText()).trim();
119
+ if (!apiKey.startsWith('usm_') || /\s/.test(apiKey)) throw new UseMoError('invalid_input', 'Expected a UseMo API key beginning usm_.');
120
+ const baseUrl = normalizeBase(options['base-url'] || process.env.USEMO_API_BASE);
121
+ const client = new UseMoClient({ apiKey, baseUrl });
122
+ await client.run('brand get'); // Verify against a workspace-scoped read before persisting.
123
+ Object.defineProperty(config.profiles, name, { value: { apiKey, baseUrl }, enumerable: true, configurable: true, writable: true });
124
+ config.active = name; await writeConfig(config);
125
+ return output({ authenticated: true, profile: name, base_url: baseUrl }, options);
126
+ }
127
+ }
128
+ const selected = await resolveConfig({ profile: options.profile, baseUrl: options['base-url'] });
129
+ const client = new UseMoClient({ ...selected, timeout: options.timeout ?? 60 });
130
+ if (command === 'doctor') return output({ version: '0.1.0', node: process.version, profile: selected.profile, base_url: selected.baseUrl, credential_configured: Boolean(selected.apiKey), commands: catalog.length }, options);
131
+ if (command === 'auth status') { await client.run('brand get'); return output({ authenticated: true, profile: selected.profile, base_url: selected.baseUrl }, options); }
132
+ const input = await jsonInput(options.data);
133
+ for (const [key, value] of Object.entries(fields)) {
134
+ if (Object.hasOwn(input, key)) throw new UseMoError('invalid_input', `Field ${key} appears in both --data and flags.`);
135
+ input[key] = value;
136
+ }
137
+ if (command === 'request') {
138
+ if (positionals.length !== 2) throw new UseMoError('invalid_input', 'Expected request METHOD /api/v1/PATH.');
139
+ const [method, path] = positionals;
140
+ if (!['GET', 'POST', 'PUT', 'PATCH', 'DELETE'].includes(method)) throw new UseMoError('invalid_input', 'Invalid HTTP method.');
141
+ if (method === 'GET' && options.data) throw new UseMoError('invalid_input', 'Use --query for GET requests.');
142
+ const query = {};
143
+ for (const item of options.query || []) { const i = item.indexOf('='); if (i < 1) throw new UseMoError('invalid_input', '--query expects key=value.'); query[item.slice(0, i)] = item.slice(i + 1); }
144
+ const body = options.data ? input : undefined;
145
+ return output(options['dry-run'] ? { dry_run: true, method, path, query, body } : await client.request(method, path, { query, body }), options);
146
+ }
147
+ if (positionals.length > (tool.positional ? 1 : 0)) throw new UseMoError('invalid_input', 'Too many positional arguments; quote text containing spaces.');
148
+ if (positionals.length) {
149
+ if (Object.hasOwn(input, tool.positional)) throw new UseMoError('invalid_input', `Supply ${tool.positional} once.`);
150
+ input[tool.positional] = positionals[0];
151
+ }
152
+ // Validate polling controls before submitting work that spends credits.
153
+ const timeout = options['wait-timeout'] ?? 180; const interval = options['poll-interval'] ?? 5;
154
+ if (!(timeout > 0 && timeout <= 3600 && interval >= 0.1 && interval <= 60)) throw new UseMoError('invalid_input', 'Invalid wait timeout or poll interval.');
155
+ if (command === 'jobs wait') { if (options['wait-timeout'] !== undefined) input.max_wait_sec = timeout; if (options['poll-interval'] !== undefined) input.poll_interval_sec = interval; }
156
+ const result = await client.run(command, input, { dryRun: options['dry-run'], wait: options.wait, timeout, interval });
157
+ await output(result, options);
158
+ }
159
+ if (process.argv[1] && import.meta.url === pathToFileURL(realpathSync(resolve(process.argv[1]))).href) {
160
+ main().catch(error => {
161
+ const wrapped = error instanceof UseMoError ? error : new UseMoError('local_error', error.code === 'EEXIST' ? 'Output file already exists.' : 'Local operation failed. Check paths and permissions.');
162
+ process.stderr.write(JSON.stringify({ error: wrapped.toJSON() }) + '\n');
163
+ process.exitCode = wrapped.code === 'invalid_input' ? 2 : wrapped.code === 'wait_timeout' ? 3 : wrapped.code === 'job_failed' ? 4 : 1;
164
+ });
165
+ }
package/src/client.js ADDED
@@ -0,0 +1,153 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { randomUUID } from 'node:crypto';
3
+ import { performance } from 'node:perf_hooks';
4
+ import Ajv from 'ajv';
5
+ import addFormats from 'ajv-formats';
6
+ import { normalizeBase, UseMoError } from './config.js';
7
+ export { UseMoError } from './config.js';
8
+ export const catalog = JSON.parse(readFileSync(new URL('./catalog.json', import.meta.url), 'utf8'));
9
+ const ajv = new Ajv({ strict: false, allErrors: true, useDefaults: true });
10
+ addFormats(ajv);
11
+ const validators = new Map(catalog.map(tool => [tool.name, ajv.compile(tool.inputSchema)]));
12
+ const terminal = new Set(['completed', 'failed', 'cancelled', 'canceled']);
13
+
14
+ export function prepare(command, input = {}) {
15
+ const tool = catalog.find(t => t.command === command || t.name === command);
16
+ if (!tool) throw new UseMoError('invalid_input', `Unknown command or tool: ${command}`);
17
+ const data = structuredClone(input);
18
+ if (!validators.get(tool.name)(data)) {
19
+ throw new UseMoError('invalid_input', `Invalid input for ${tool.command}.`, {
20
+ validation: validators.get(tool.name).errors.map(({ instancePath, message }) => ({ path: instancePath, message })),
21
+ });
22
+ }
23
+ const key = data.idempotency_key;
24
+ delete data.idempotency_key;
25
+ for (const field of Object.keys(data)) if (data[field] === null) delete data[field];
26
+ let path = tool.path.replace(/\{(\w+)\}/g, (_, field) => {
27
+ const value = data[field]; delete data[field];
28
+ if (typeof value !== 'string' || !value.trim() || value === '.' || value === '..') throw new UseMoError('invalid_input', `Invalid ${field}.`);
29
+ return encodeURIComponent(value);
30
+ });
31
+ if (tool.command === 'videos generate') {
32
+ if (!data.input_image_url && !data.input_image_asset_id) throw new UseMoError('invalid_input', 'Supply a starting image URL or asset ID.');
33
+ data.input_image = data.input_image_url ? { source_url: data.input_image_url } : { asset_id: data.input_image_asset_id };
34
+ delete data.input_image_url; delete data.input_image_asset_id;
35
+ }
36
+ if (tool.command === 'videos quote' && !data.storyboard && !data.shots?.length) throw new UseMoError('invalid_input', 'Supply a storyboard or shots.');
37
+ if (tool.command === 'content create') data.delivery_mode = 'auto';
38
+ if (tool.command === 'jobs wait') { delete data.max_wait_sec; delete data.poll_interval_sec; }
39
+ return { tool, method: tool.method, path: '/api/v1' + path, ...(tool.method === 'GET' ? { query: data } : { body: data }), ...(key ? { idempotencyKey: key } : {}) };
40
+ }
41
+ function positive(value, name) {
42
+ if (!Number.isFinite(value) || value <= 0) throw new UseMoError('invalid_input', `${name} must be a positive finite number.`);
43
+ return value;
44
+ }
45
+ export class UseMoClient {
46
+ constructor({ apiKey, baseUrl, workspaceId, timeout = 60, fetch: fetchImpl = globalThis.fetch } = {}) {
47
+ this.apiKey = apiKey; this.baseUrl = normalizeBase(baseUrl); this.workspaceId = workspaceId;
48
+ this.timeout = positive(timeout, 'timeout'); this.fetch = fetchImpl;
49
+ }
50
+ async request(method, path, { body, query = {}, idempotencyKey, timeout = this.timeout, signal } = {}) {
51
+ if (!this.apiKey) throw new UseMoError('authentication_required', 'Set USEMO_API_KEY or run usemo login --key-stdin.');
52
+ if (!['GET', 'POST', 'PATCH', 'PUT', 'DELETE'].includes(method)) throw new UseMoError('invalid_input', 'Unsupported HTTP method.');
53
+ if (!/^\/api\/(?:v1\/)?[a-zA-Z0-9]/.test(path) || /[\\?#]/.test(path) || /%(?:2e|2f|5c)/i.test(path) || path.split('/').some(p => p === '..' || p === '.')) {
54
+ throw new UseMoError('invalid_input', 'Use a relative /api/v1/... path with query parameters supplied separately.');
55
+ }
56
+ const url = new URL(path, this.baseUrl);
57
+ for (const [k, v] of Object.entries(query)) if (v !== null && v !== undefined) url.searchParams.set(k, String(v));
58
+ const headers = { Accept: 'application/json', 'X-API-Key': this.apiKey, 'User-Agent': 'usemo-sdk/0.1.0' };
59
+ if (this.workspaceId) headers['X-Workspace-Id'] = this.workspaceId;
60
+ if (body !== undefined) headers['Content-Type'] = 'application/json';
61
+ if (idempotencyKey) headers['X-Idempotency-Key'] = idempotencyKey;
62
+ const controller = new AbortController();
63
+ const abort = () => controller.abort();
64
+ if (signal?.aborted) abort();
65
+ signal?.addEventListener('abort', abort, { once: true });
66
+ const timer = setTimeout(abort, positive(timeout, 'timeout') * 1000);
67
+ try {
68
+ const response = await this.fetch(url, { method, headers, redirect: 'error', signal: controller.signal,
69
+ ...(body === undefined ? {} : { body: JSON.stringify(body) }) });
70
+ const text = await response.text();
71
+ let data;
72
+ try { data = text ? JSON.parse(text) : {}; } catch { data = undefined; }
73
+ if (!response.ok) {
74
+ const hints = { 401: 'Invalid or expired API key.', 402: 'Insufficient workspace credits.', 403: 'Workspace, scope or plan does not permit this action.', 422: 'Request validation failed.', 429: 'Rate limited; wait before retrying.' };
75
+ const detail = JSON.stringify(data?.detail ?? data?.error ?? '').replaceAll(this.apiKey, '[redacted]').slice(0, 1000);
76
+ throw new UseMoError('api_error', hints[response.status] || `UseMo returned HTTP ${response.status}.`, {
77
+ status: response.status, detail, ...(idempotencyKey ? { idempotency_key: idempotencyKey } : {}),
78
+ });
79
+ }
80
+ if (data === undefined) throw new UseMoError('invalid_response', 'UseMo returned a non-JSON response.');
81
+ return data;
82
+ } catch (error) {
83
+ if (error instanceof UseMoError) throw error;
84
+ throw new UseMoError(controller.signal.aborted ? 'request_timeout' : 'network_error',
85
+ method === 'GET' ? 'Could not read the UseMo API response.' : 'Submission outcome is unknown. Inspect recent jobs before resubmitting.',
86
+ { ...(idempotencyKey ? { idempotency_key: idempotencyKey } : {}), path });
87
+ } finally { clearTimeout(timer); signal?.removeEventListener('abort', abort); }
88
+ }
89
+ async run(command, input = {}, options = {}) {
90
+ const plan = prepare(command, input);
91
+ if (options.dryRun) return { dry_run: true, method: plan.method, path: plan.path, body: plan.body, query: plan.query, idempotency_key: plan.idempotencyKey };
92
+ if (plan.tool.command === 'jobs wait') return this.wait(input.job_id, { timeout: input.max_wait_sec ?? 180, interval: input.poll_interval_sec ?? 5, signal: options.signal });
93
+ // Only endpoints verified to implement idempotency receive an automatic key.
94
+ const supportsKey = Object.hasOwn(plan.tool.inputSchema.properties, 'idempotency_key');
95
+ if (supportsKey && !plan.idempotencyKey) plan.idempotencyKey = randomUUID();
96
+ const result = await this.request(plan.method, plan.path, { ...plan, signal: options.signal });
97
+ const receipt = plan.idempotencyKey ? { ...result, idempotency_key: plan.idempotencyKey } : result;
98
+ if (options.wait) {
99
+ const id = result.job_id || result.job?.job_id || result.job?.id;
100
+ if (id) return { submission: receipt, job: await this.wait(id, options) };
101
+ }
102
+ return receipt;
103
+ }
104
+ async wait(jobId, { timeout = 180, interval = 5, signal } = {}) {
105
+ positive(timeout, 'wait timeout'); positive(interval, 'poll interval');
106
+ if (timeout > 3600 || interval < 0.1) throw new UseMoError('invalid_input', 'Wait timeout must be ≤3600 seconds and poll interval ≥0.1 seconds.');
107
+ const { path } = prepare('jobs get', { job_id: jobId });
108
+ const deadline = performance.now() + timeout * 1000;
109
+ let job;
110
+ while (performance.now() < deadline) {
111
+ if (signal?.aborted) throw new UseMoError('cancelled', 'Waiting cancelled; the remote job is still running.', { job_id: jobId });
112
+ try { job = await this.request('GET', path, { timeout: Math.min(this.timeout, Math.max(0.001, (deadline - performance.now()) / 1000)), signal }); }
113
+ catch (error) { if (error.code !== 'request_timeout') throw error; break; }
114
+ if (terminal.has(job.status)) {
115
+ if (job.status !== 'completed') throw new UseMoError('job_failed', `Job ${jobId} ${job.status}.`, { job_id: jobId, job });
116
+ return job;
117
+ }
118
+ await new Promise(resolve => {
119
+ const done = () => { clearTimeout(timer); signal?.removeEventListener('abort', done); resolve(); };
120
+ const timer = setTimeout(done, Math.max(0, Math.min(interval * 1000, deadline - performance.now())));
121
+ signal?.addEventListener('abort', done, { once: true });
122
+ });
123
+ }
124
+ throw new UseMoError('wait_timeout', 'Waiting timed out; the remote job was not cancelled. Resume with usemo jobs wait.', { job_id: jobId, job });
125
+ }
126
+ }
127
+
128
+ // Downloads are intentionally separate from the authenticated API transport.
129
+ // A CDN must never receive a workspace key, including on redirects.
130
+ export async function downloadAsset(url, destination, { fetch: fetchImpl = globalThis.fetch, timeout = 120 } = {}) {
131
+ const { createWriteStream } = await import('node:fs');
132
+ const { link, unlink, access } = await import('node:fs/promises');
133
+ const { pipeline } = await import('node:stream/promises');
134
+ const { dirname, join } = await import('node:path');
135
+ let parsed;
136
+ try { parsed = new URL(url); } catch { throw new UseMoError('invalid_input', 'Invalid asset URL.'); }
137
+ if (parsed.protocol !== 'https:' || parsed.username || parsed.password) throw new UseMoError('invalid_input', 'Asset downloads require an HTTPS URL without embedded credentials.');
138
+ try { await access(destination); throw new UseMoError('file_exists', 'Output file already exists.'); } catch (e) { if (e.code !== 'ENOENT') throw e; }
139
+ const temporary = join(dirname(destination), `.usemo-${randomUUID()}.part`);
140
+ const controller = new AbortController();
141
+ const timer = setTimeout(() => controller.abort(), positive(timeout, 'download timeout') * 1000);
142
+ try {
143
+ const response = await fetchImpl(parsed, { redirect: 'error', signal: controller.signal });
144
+ if (!response.ok || !response.body) throw new UseMoError('download_failed', 'Could not download the asset; refresh its URL from the library.');
145
+ await pipeline(response.body, createWriteStream(temporary, { flags: 'wx', mode: 0o600 }), { signal: controller.signal });
146
+ // Atomic no-clobber publication, even if another process creates the target.
147
+ await link(temporary, destination);
148
+ return { path: destination, content_type: response.headers.get('content-type') };
149
+ } catch (error) {
150
+ if (error instanceof UseMoError) throw error;
151
+ throw new UseMoError('download_failed', 'Asset transfer failed; no partial destination was written.');
152
+ } finally { clearTimeout(timer); await unlink(temporary).catch(() => {}); }
153
+ }
package/src/config.js ADDED
@@ -0,0 +1,58 @@
1
+ import { chmod, mkdir, readFile, rename, writeFile, unlink } from 'node:fs/promises';
2
+ import { homedir } from 'node:os';
3
+ import { join } from 'node:path';
4
+ import { randomUUID } from 'node:crypto';
5
+
6
+ export class UseMoError extends Error {
7
+ constructor(code, message, details = {}) { super(message); this.code = code; this.details = details; }
8
+ toJSON() { return { code: this.code, message: this.message, ...this.details }; }
9
+ }
10
+ export function normalizeBase(value = 'https://api.usemo.com') {
11
+ let url;
12
+ try { url = new URL(value); } catch { throw new UseMoError('invalid_input', 'Invalid API base URL.'); }
13
+ if (url.username || url.password || url.search || url.hash ||
14
+ !(url.protocol === 'https:' || (url.protocol === 'http:' && ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname)))) {
15
+ throw new UseMoError('invalid_input', 'API base must use HTTPS (HTTP is allowed on loopback only), without credentials, query or fragment.');
16
+ }
17
+ if (!['', '/', '/api', '/api/', '/api/v1', '/api/v1/'].includes(url.pathname)) {
18
+ throw new UseMoError('invalid_input', 'API base must be an origin, optionally ending in /api or /api/v1.');
19
+ }
20
+ return url.origin;
21
+ }
22
+ export const configDir = (env = process.env) => env.USEMO_CONFIG_DIR || join(env.XDG_CONFIG_HOME || join(homedir(), '.config'), 'usemo');
23
+ export async function readConfig(env = process.env) {
24
+ try {
25
+ const config = JSON.parse(await readFile(join(configDir(env), 'config.json'), 'utf8'));
26
+ if (config.version !== 1 || !config.profiles || typeof config.profiles !== 'object' || Array.isArray(config.profiles)) throw Error();
27
+ return config;
28
+ } catch (error) {
29
+ if (error.code === 'ENOENT') return { version: 1, active: 'default', profiles: {} };
30
+ throw new UseMoError('config_error', 'Cannot read UseMo config.json. Repair it before continuing.');
31
+ }
32
+ }
33
+ export async function writeConfig(config, env = process.env) {
34
+ const dir = configDir(env);
35
+ await mkdir(dir, { recursive: true, mode: 0o700 });
36
+ await chmod(dir, 0o700);
37
+ const temporary = join(dir, `.config-${randomUUID()}.tmp`);
38
+ try {
39
+ await writeFile(temporary, JSON.stringify(config, null, 2) + '\n', { mode: 0o600, flag: 'wx' });
40
+ await rename(temporary, join(dir, 'config.json'));
41
+ } finally { await unlink(temporary).catch(() => {}); }
42
+ }
43
+ export function profileName(name) {
44
+ if (!/^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,63}$/.test(name)) throw new UseMoError('invalid_input', 'Profile names must be 1–64 letters, digits, dots, dashes or underscores.');
45
+ return name;
46
+ }
47
+ export async function resolveConfig(options = {}, env = process.env) {
48
+ const config = await readConfig(env);
49
+ const name = profileName(options.profile || env.USEMO_PROFILE || config.active || 'default');
50
+ const profile = Object.hasOwn(config.profiles, name) ? config.profiles[name] : {};
51
+ const apiKey = env.USEMO_API_KEY || profile.apiKey;
52
+ const baseUrl = normalizeBase(options.baseUrl || env.USEMO_API_BASE || profile.baseUrl);
53
+ // Saved credentials are bound to the host where they were registered.
54
+ if (!env.USEMO_API_KEY && apiKey && baseUrl !== normalizeBase(profile.baseUrl)) {
55
+ throw new UseMoError('credential_origin_mismatch', 'This profile belongs to a different API host. Log in to a separate profile or explicitly provide USEMO_API_KEY.');
56
+ }
57
+ return { apiKey, baseUrl, profile: name, workspaceId: env.USEMO_WORKSPACE_ID || profile.workspaceId };
58
+ }
package/src/mcp.js ADDED
@@ -0,0 +1,55 @@
1
+ #!/usr/bin/env node
2
+ import { resolve } from 'node:path';
3
+ import { realpathSync } from 'node:fs';
4
+ import { pathToFileURL } from 'node:url';
5
+ import { Server } from '@modelcontextprotocol/sdk/server/index.js';
6
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
7
+ import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
8
+ import { catalog, UseMoClient, UseMoError } from './client.js';
9
+ import { resolveConfig } from './config.js';
10
+
11
+ export function createServer(client) {
12
+ const server = new Server({ name: 'usemo', version: '0.1.0' }, { capabilities: { tools: {} },
13
+ instructions: 'UseMo creates videos, images and content. Discover tools, read brand context, plan/quote, then create and poll jobs. Creation spends workspace credits. A timeout does not cancel a job. Do not blindly resubmit. Use the same idempotency_key after an uncertain submission when the tool supports it.' });
14
+ const discovery = [
15
+ { name: 'usemo_get_started', description: 'Get UseMo setup and workflow guidance without accessing workspace data.', inputSchema: { type: 'object', properties: {}, additionalProperties: false }, annotations: { readOnlyHint: true, openWorldHint: false } },
16
+ { name: 'usemo_discover_tools', description: 'Search available UseMo tools and schemas by name or description. Offline.', inputSchema: { type: 'object', properties: { query: { type: 'string' } }, additionalProperties: false }, annotations: { readOnlyHint: true, openWorldHint: false } },
17
+ ];
18
+ const tools = [...discovery, ...catalog.map(({ name, description, inputSchema, annotations }) => ({ name, description, inputSchema, annotations }))];
19
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools }));
20
+ server.setRequestHandler(CallToolRequestSchema, async (request, extra) => {
21
+ try {
22
+ const { name, arguments: args = {} } = request.params;
23
+ let result;
24
+ if (name === 'usemo_get_started') result = { workflow: 'brand → plan/quote → create → jobs wait → asset URL', cli: 'usemo --help', credentials: 'USEMO_API_KEY or usemo login --key-stdin', commands: catalog.map(x => x.command) };
25
+ else if (name === 'usemo_discover_tools') {
26
+ if (args.query !== undefined && typeof args.query !== 'string') throw new UseMoError('invalid_input', 'query must be a string.');
27
+ const words = (args.query || '').toLowerCase().split(/\s+/);
28
+ result = { tools: tools.filter(t => words.every(w => (t.name + ' ' + t.description).toLowerCase().includes(w))) };
29
+ } else {
30
+ result = await client.run(name, args, { signal: extra.signal });
31
+ // Hosted MCP exposes a compact workflow envelope; retain that contract
32
+ // while keeping the CLI/JS API's original REST responses intact.
33
+ if (name === 'usemo_create_video' && result.job) {
34
+ result = { ...result, job_id: result.job.job_id || result.job.id, status: result.job.status, job_path: result.job.job_path };
35
+ }
36
+ if (name === 'usemo_generate_content' && result.job) {
37
+ result = { ...result, job_id: result.job.job_id || result.job.id, status: result.job.status || 'queued', job_path: result.job.job_path };
38
+ }
39
+ const listKey = { usemo_list_jobs: 'jobs', usemo_list_assets: 'assets', usemo_list_twins: 'twins' }[name];
40
+ if (listKey && Array.isArray(result)) result = { [listKey]: result, count: result.length };
41
+ }
42
+ return { content: [{ type: 'text', text: JSON.stringify(result) }] };
43
+ } catch (error) {
44
+ const safe = error instanceof UseMoError ? error.toJSON() : { code: 'internal_error', message: 'UseMo tool failed.' };
45
+ return { isError: true, content: [{ type: 'text', text: JSON.stringify({ error: safe }) }] };
46
+ }
47
+ });
48
+ return server;
49
+ }
50
+ export async function start(options = {}) {
51
+ const client = new UseMoClient(await resolveConfig({ profile: options.profile, baseUrl: options['base-url'] }));
52
+ const server = createServer(client);
53
+ await server.connect(new StdioServerTransport());
54
+ }
55
+ if (process.argv[1] && import.meta.url === pathToFileURL(realpathSync(resolve(process.argv[1]))).href) start().catch(() => { process.stderr.write('UseMo MCP could not start. Check local configuration.\n'); process.exitCode = 1; });