sella-cli 0.5.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/dist/index.js ADDED
@@ -0,0 +1,410 @@
1
+ #!/usr/bin/env node
2
+ import * as readline from 'node:readline/promises';
3
+ import * as path from 'node:path';
4
+ import { detectClients, installSellaIntoClient, defaultEnv } from './clients.js';
5
+ import { pair } from './pairing.js';
6
+ import { runDoctor, runStatus, loadStoredKey } from './doctor.js';
7
+ import { runMcpBridge } from './mcp-bridge.js';
8
+ import { scaffoldCard, pushDataset, CARD_FILENAME } from './publish.js';
9
+ import { sandboxSearch } from './api.js';
10
+ import { getFundingInfo, annotateFunding } from './fund.js';
11
+ import { capabilityLabel } from './chains.js';
12
+ import { defaultIo, Printer } from './output.js';
13
+ const DEFAULT_MCP_URL = 'https://sella.network/api/mcp';
14
+ const VERSION = '0.5.0';
15
+ function parseFlags(argv) {
16
+ const flags = {
17
+ json: false, yes: false, noColor: false, dryRun: false, noKeychain: false,
18
+ help: false, version: false, positional: [],
19
+ };
20
+ for (let i = 0; i < argv.length; i += 1) {
21
+ const arg = argv[i];
22
+ if (arg === '--json')
23
+ flags.json = true;
24
+ else if (arg === '--yes' || arg === '-y')
25
+ flags.yes = true;
26
+ else if (arg === '--no-color')
27
+ flags.noColor = true;
28
+ else if (arg === '--dry-run')
29
+ flags.dryRun = true;
30
+ else if (arg === '--no-keychain')
31
+ flags.noKeychain = true;
32
+ else if (arg === '--client' || arg === '--clients')
33
+ flags.clients = argv[(i += 1)];
34
+ else if (arg === '--setup-code')
35
+ flags.setupCode = argv[(i += 1)];
36
+ else if (arg === '--email')
37
+ flags.email = argv[(i += 1)];
38
+ else if (arg === '--card')
39
+ flags.card = argv[(i += 1)];
40
+ else if (arg === '--install')
41
+ flags.positional.push('--install');
42
+ else if (arg === '--help' || arg === '-h')
43
+ flags.help = true;
44
+ else if (arg === '--version' || arg === '-v')
45
+ flags.version = true;
46
+ else
47
+ flags.positional.push(arg);
48
+ }
49
+ return flags;
50
+ }
51
+ const HELP = `sella — the Sella onboarding CLI
52
+
53
+ Usage: sella <command> [options]
54
+
55
+ Commands:
56
+ init Install the Sella MCP server into your agent clients, pair, done
57
+ pair Pair this machine only (setup code or email + OTP)
58
+ sandbox Try Sella with no signup — search the live marketplace (rate-limited)
59
+ clients List detected agent clients (--install to write configs)
60
+ doctor Verify the install: endpoint, credentials, auth, wallets, pay-quote
61
+ status Show your key + AgentWallet balances
62
+ fund Show deposit addresses + funding links to add USDC to your agent wallet
63
+ mcp Run as a local stdio MCP server that proxies Sella with your stored key
64
+ publish Publish a dataset from a CSV: 'publish init <file.csv>' then 'publish push'
65
+
66
+ Options:
67
+ --setup-code <code> Pair with a dashboard setup code (also: SELLA_SETUP_CODE env)
68
+ --email <address> Pair with email + OTP (interactive)
69
+ --card <path> Dataset card for 'publish push' (default ./sella-dataset.json)
70
+ --client <ids> Comma-separated subset (claude-code,claude-desktop,cursor,windsurf,vscode,cline)
71
+ --no-keychain Skip the OS keychain; store the key in a 0600 file (also: SELLA_NO_KEYCHAIN)
72
+ --dry-run Show what would change without writing
73
+ --json Machine-readable output (one JSON document on stdout)
74
+ --yes, -y Assume yes for prompts (non-interactive)
75
+ --no-color Disable colors (NO_COLOR env honored too)
76
+ --help, -h Show this help
77
+ --version, -v Show version
78
+
79
+ Environment:
80
+ SELLA_MCP_URL Override the MCP endpoint (default ${DEFAULT_MCP_URL})
81
+ SELLA_SETUP_CODE Setup code for headless/CI pairing
82
+ SELLA_NO_KEYCHAIN Set to 1 to force file storage for the API key
83
+ `;
84
+ function selectClientIds(flags, detected) {
85
+ if (flags.clients) {
86
+ return flags.clients.split(',').map((s) => s.trim()).filter(Boolean);
87
+ }
88
+ return detected.filter((c) => c.installed).map((c) => c.id);
89
+ }
90
+ function installAll(ctx, flags, printer, apiKey) {
91
+ const detected = detectClients(ctx.env);
92
+ const targets = selectClientIds(flags, detected);
93
+ const results = [];
94
+ let failed = false;
95
+ for (const id of targets) {
96
+ try {
97
+ const result = installSellaIntoClient(id, { mcpUrl: ctx.mcpUrl, env: ctx.env, dryRun: flags.dryRun, apiKey });
98
+ results.push({ ...result });
99
+ printer.ok(`${id}: ${flags.dryRun ? `would be ${result.action}` : result.action} → ${result.configPath}`);
100
+ }
101
+ catch (err) {
102
+ failed = true;
103
+ const message = err instanceof Error ? err.message : String(err);
104
+ results.push({ id, error: message });
105
+ printer.error(`${id}: ${message}`);
106
+ }
107
+ }
108
+ return { results, failed, targets };
109
+ }
110
+ function makeAsk(ctx) {
111
+ if (!ctx.io.isTTY)
112
+ return undefined;
113
+ return async (question) => {
114
+ const rl = readline.createInterface({ input: process.stdin, output: process.stderr });
115
+ try {
116
+ return await rl.question(question);
117
+ }
118
+ finally {
119
+ rl.close();
120
+ }
121
+ };
122
+ }
123
+ async function runPair(ctx, flags, printer) {
124
+ const outcome = await pair({
125
+ mcpUrl: ctx.mcpUrl,
126
+ env: ctx.env,
127
+ interactive: ctx.io.isTTY && !flags.yes && !flags.json,
128
+ setupCode: flags.setupCode || ctx.io.env.SELLA_SETUP_CODE,
129
+ email: flags.email,
130
+ allowKeychain: !flags.noKeychain && !ctx.io.env.SELLA_NO_KEYCHAIN,
131
+ ask: makeAsk(ctx),
132
+ });
133
+ if (!outcome.ok) {
134
+ printer.error(outcome.error);
135
+ return { code: outcome.exitCode, summary: { paired: false, error: outcome.error } };
136
+ }
137
+ printer.ok(`Paired via ${outcome.method} — status: ${outcome.payload.status || 'ok'}`);
138
+ for (const file of outcome.save.files)
139
+ printer.ok(`wrote ${file}`);
140
+ printer.info(outcome.save.custodyNote);
141
+ if (outcome.payload.agentWallet?.status === 'unavailable') {
142
+ printer.warn(`AgentWallet not provisioned (${outcome.payload.agentWallet.reason || 'backend unavailable'}) — MCP access still works; re-run \`sella pair\` later.`);
143
+ }
144
+ return {
145
+ code: 0,
146
+ apiKey: outcome.payload.apiKey,
147
+ summary: {
148
+ paired: true,
149
+ method: outcome.method,
150
+ status: outcome.payload.status,
151
+ keyStorage: outcome.save.keyStorage,
152
+ files: outcome.save.files,
153
+ agentWallet: outcome.payload.agentWallet?.status || 'none',
154
+ },
155
+ };
156
+ }
157
+ async function previewSandbox(ctx, flags, printer) {
158
+ if (flags.json)
159
+ return; // cosmetic; skip in machine mode
160
+ try {
161
+ const result = await sandboxSearch(ctx.mcpUrl, 'data');
162
+ const n = result.datasets.length + result.apis.length;
163
+ if (n === 0)
164
+ return;
165
+ printer.info('Here’s a taste of what your agent can buy on Sella (no signup needed):');
166
+ for (const d of result.datasets.slice(0, 2))
167
+ printer.info(` dataset ${d.title || d.id}`);
168
+ for (const a of result.apis.slice(0, 2))
169
+ printer.info(` api ${a.name || ''}`);
170
+ printer.info('');
171
+ }
172
+ catch {
173
+ /* best-effort — never blocks onboarding */
174
+ }
175
+ }
176
+ async function cmdInit(ctx, flags, printer) {
177
+ printer.info('Sella init — 1/3 install, 2/3 pair, 3/3 verify.\n');
178
+ await previewSandbox(ctx, flags, printer);
179
+ const install = installAll(ctx, flags, printer);
180
+ if (install.targets.length === 0) {
181
+ printer.error('No agent clients found (and none specified via --client). Nothing to install into.');
182
+ printer.jsonOut({ installed: [], paired: false, error: 'no_clients' });
183
+ return 2;
184
+ }
185
+ printer.info('\nPairing this machine…');
186
+ const paired = await runPair(ctx, flags, printer);
187
+ let reinstalled = [];
188
+ if (paired.code === 0 && paired.apiKey && !flags.dryRun) {
189
+ reinstalled = installAll(ctx, flags, printer, paired.apiKey).results;
190
+ printer.info('\nDone. Your agent clients are connected to Sella — fund your wallet to enable paid calls:');
191
+ printer.info(' https://sella.network/dashboard/funding');
192
+ }
193
+ printer.jsonOut({ installed: install.results, pairing: paired.summary, reinstalled });
194
+ return install.failed ? 1 : paired.code;
195
+ }
196
+ export async function runCli(argv, io = defaultIo(), env = defaultEnv()) {
197
+ const flags = parseFlags(argv);
198
+ const printer = new Printer(io, flags.json, flags.noColor);
199
+ const ctx = { io, env, mcpUrl: io.env.SELLA_MCP_URL || DEFAULT_MCP_URL };
200
+ const command = flags.positional[0];
201
+ if (flags.version) {
202
+ io.stdout(VERSION);
203
+ return 0;
204
+ }
205
+ if (flags.help || !command) {
206
+ io.stdout(HELP);
207
+ return 0;
208
+ }
209
+ try {
210
+ switch (command) {
211
+ case 'clients': {
212
+ const install = flags.positional.includes('--install');
213
+ if (!install) {
214
+ const detected = detectClients(ctx.env);
215
+ printer.jsonOut({ clients: detected });
216
+ printer.info('Detected agent clients:');
217
+ for (const c of detected) {
218
+ printer.info(` ${c.installed ? '[found] ' : '[absent] '} ${c.name.padEnd(15)} ${c.configPath}`);
219
+ }
220
+ printer.info('\nRun `sella clients --install` to add the Sella MCP server to the found clients.');
221
+ return 0;
222
+ }
223
+ const { results, failed, targets } = installAll(ctx, flags, printer);
224
+ if (targets.length === 0) {
225
+ printer.error('No agent clients found (and none specified via --client). Nothing to install into.');
226
+ printer.jsonOut({ installed: [], error: 'no_clients' });
227
+ return 2;
228
+ }
229
+ printer.jsonOut({ dryRun: flags.dryRun, installed: results });
230
+ return failed ? 1 : 0;
231
+ }
232
+ case 'pair': {
233
+ const paired = await runPair(ctx, flags, printer);
234
+ printer.jsonOut(paired.summary || {});
235
+ return paired.code;
236
+ }
237
+ case 'init':
238
+ return await cmdInit(ctx, flags, printer);
239
+ case 'sandbox': {
240
+ const query = flags.positional.slice(1).filter((s) => s !== '--install').join(' ').trim();
241
+ if (!query) {
242
+ printer.error('Usage: sella sandbox <query> (e.g. `sella sandbox "web search"`)');
243
+ return 2;
244
+ }
245
+ const result = await sandboxSearch(ctx.mcpUrl, query);
246
+ printer.jsonOut(result);
247
+ if (!flags.json) {
248
+ const total = result.datasets.length + result.apis.length;
249
+ printer.info(`Sandbox results for "${query}" (no account, rate-limited):`);
250
+ for (const d of result.datasets) {
251
+ printer.info(` dataset ${d.title || d.id}${d.priceUSDC ? ` — $${d.priceUSDC}/call` : ' — free'}`);
252
+ }
253
+ for (const a of result.apis) {
254
+ printer.info(` api ${a.name || ''}${a.chains?.length ? ` [${a.chains.join('/')}]` : ''}`);
255
+ }
256
+ printer.info(total ? '\nPair to buy any of these: `sella init`.' : 'No matches — try a broader query.');
257
+ }
258
+ return 0;
259
+ }
260
+ case 'fund': {
261
+ const info = await annotateFunding(getFundingInfo(ctx.env, ctx.mcpUrl));
262
+ printer.jsonOut(info);
263
+ if (!flags.json) {
264
+ if (!info.paired) {
265
+ printer.error('Not paired yet — run `sella pair` first, then `sella fund`.');
266
+ return 2;
267
+ }
268
+ printer.info('Fund your Sella agent wallet with USDC:\n');
269
+ for (const w of info.wallets) {
270
+ const label = w.capability ? capabilityLabel(w.capability) : '';
271
+ printer.info(` ${w.chain.padEnd(9)} ${w.address}${label ? ` (${label})` : ''}`);
272
+ }
273
+ printer.info(`\nFiat on-ramp (card / Apple Pay) + QR codes: ${info.fundingUrl}`);
274
+ printer.info('Send only USDC on the matching chain. Balances appear after on-chain confirmation.');
275
+ if (info.wallets.some((w) => w.capability === 'deposit-only')) {
276
+ printer.info('Chains marked deposit-only accept funds now; spending from them turns on as Sella verifies settlement.');
277
+ }
278
+ }
279
+ return info.paired ? 0 : 2;
280
+ }
281
+ case 'doctor': {
282
+ const result = await runDoctor({ mcpUrl: ctx.mcpUrl, env: ctx.env });
283
+ printer.jsonOut(result);
284
+ if (!flags.json) {
285
+ printer.info('Sella doctor:');
286
+ for (const check of result.checks) {
287
+ if (check.ok)
288
+ printer.ok(`${check.label} — ${check.detail}`);
289
+ else {
290
+ printer.error(`${check.label} — ${check.detail}`);
291
+ if (check.fix)
292
+ printer.info(` ↳ ${check.fix}`);
293
+ }
294
+ }
295
+ printer.info(result.ok ? '\nAll checks passed. Your agent is ready to buy.' : '\nSome checks failed — see the fixes above.');
296
+ }
297
+ return result.ok ? 0 : 1;
298
+ }
299
+ case 'status': {
300
+ const status = await runStatus({ mcpUrl: ctx.mcpUrl, env: ctx.env });
301
+ printer.jsonOut(status);
302
+ if (!flags.json) {
303
+ const key = status.key;
304
+ printer.info(key.present ? `API key: ${key.prefix}… (${key.source})` : 'API key: not paired — run `sella pair`');
305
+ const aw = status.agentWallet;
306
+ if (aw?.username) {
307
+ printer.info(`AgentWallet: ${aw.username}${aw.fundingStatus ? ` · ${aw.fundingStatus}` : ''}`);
308
+ for (const w of aw.wallets || []) {
309
+ const cap = w.capability;
310
+ const label = cap ? capabilityLabel(cap) : '';
311
+ printer.info(` ${String(w.chain).padEnd(9)} ${w.address}${label ? ` (${label})` : ''}`);
312
+ }
313
+ }
314
+ else {
315
+ printer.info('AgentWallet: not provisioned');
316
+ }
317
+ }
318
+ return 0;
319
+ }
320
+ case 'publish': {
321
+ const sub = flags.positional[1];
322
+ const origin = ctx.mcpUrl.replace(/\/api\/mcp\/?$/, '') || 'https://sella.network';
323
+ if (sub === 'init') {
324
+ const csv = flags.positional.slice(2).find((s) => !s.startsWith('--'));
325
+ if (!csv) {
326
+ printer.error('Usage: sella publish init <file.csv>');
327
+ return 2;
328
+ }
329
+ let result;
330
+ try {
331
+ result = scaffoldCard(csv);
332
+ }
333
+ catch (err) {
334
+ printer.error(`Could not read ${csv}: ${err instanceof Error ? err.message : String(err)}`);
335
+ return 2;
336
+ }
337
+ printer.jsonOut({ cardPath: result.cardPath, wroteNew: result.wroteNew, card: result.card, precheck: result.precheck });
338
+ if (!flags.json) {
339
+ printer.ok(result.wroteNew ? `Scaffolded ${result.cardPath}` : `Card already exists (left unchanged): ${result.cardPath}`);
340
+ printer.info(`Pre-check: ${result.precheck.columns.length} columns, ${result.precheck.rows} rows.`);
341
+ for (const w of result.precheck.warnings)
342
+ printer.warn(w);
343
+ printer.info('\nNext: set title / description / price in the card, then run `sella publish push`.');
344
+ }
345
+ return 0;
346
+ }
347
+ if (sub === 'push') {
348
+ const stored = loadStoredKey(ctx.env);
349
+ if (!stored) {
350
+ printer.error('Not paired — run `sella pair` first, then `sella publish push`.');
351
+ printer.jsonOut({ ok: false, error: 'not_paired' });
352
+ return 2;
353
+ }
354
+ const cardPath = flags.card || path.join(process.cwd(), CARD_FILENAME);
355
+ const result = await pushDataset({ origin, apiKey: stored.apiKey, cardPath });
356
+ printer.jsonOut(result);
357
+ if (!flags.json) {
358
+ if (!result.ok) {
359
+ printer.error(result.error || 'Publish failed.');
360
+ if (result.fieldErrors)
361
+ for (const [k, v] of Object.entries(result.fieldErrors))
362
+ printer.error(` ${k}: ${v}`);
363
+ }
364
+ else {
365
+ printer.ok(`Published — status: ${result.status}`);
366
+ printer.info(` listing: ${result.listingUrl}`);
367
+ if (result.status === 'manual_review')
368
+ printer.info(' Sella is reviewing it; it goes live once approved.');
369
+ else if (result.status === 'processing')
370
+ printer.info(' Still scoring — re-check later with `sella status` or the dashboard.');
371
+ }
372
+ }
373
+ return result.ok ? 0 : 1;
374
+ }
375
+ printer.error('Usage: sella publish <init|push>');
376
+ return 2;
377
+ }
378
+ case 'mcp': {
379
+ // Long-running stdio MCP server: stdout is the JSON-RPC channel — only the bridge writes
380
+ // there. Startup notes and the credential status go to stderr so they never corrupt it.
381
+ const stored = loadStoredKey(ctx.env);
382
+ if (stored) {
383
+ ctx.io.stderr(`ok Sella MCP bridge → ${ctx.mcpUrl} (key from ${stored.source})`);
384
+ }
385
+ else {
386
+ ctx.io.stderr('warn No stored key — bridging free/sandbox tools only; run `sella pair` to enable paid tools.');
387
+ }
388
+ return await runMcpBridge({ mcpUrl: ctx.mcpUrl, apiKey: stored?.apiKey });
389
+ }
390
+ default:
391
+ printer.error(`Unknown command: ${command}. Run \`sella --help\`.`);
392
+ return 2;
393
+ }
394
+ }
395
+ catch (err) {
396
+ printer.error(err instanceof Error ? err.message : String(err));
397
+ printer.jsonOut({ error: err instanceof Error ? err.message : String(err) });
398
+ return 1;
399
+ }
400
+ }
401
+ // Bin entry (skipped when imported by tests).
402
+ const isMain = process.argv[1]?.endsWith('index.js') || process.argv[1]?.endsWith('sella');
403
+ if (isMain) {
404
+ runCli(process.argv.slice(2))
405
+ .then((code) => process.exit(code))
406
+ .catch((err) => {
407
+ console.error(err instanceof Error ? err.message : String(err));
408
+ process.exit(1);
409
+ });
410
+ }
@@ -0,0 +1,115 @@
1
+ /**
2
+ * `sella mcp` (CLI sprint C6): the package doubles as a **stdio MCP server**.
3
+ *
4
+ * Many agent clients can only launch an MCP server as a local command over stdio — they don't
5
+ * support remote HTTP MCP endpoints. This bridge lets those clients use Sella anyway: it reads
6
+ * newline-delimited JSON-RPC from stdin, forwards each request to the remote `/api/mcp` with the
7
+ * machine's stored Bearer token injected, and writes the response back on stdout. One credential
8
+ * (paired once via `sella pair`) unlocks Sella in every stdio-only client, with no key ever pasted
9
+ * into a client config.
10
+ *
11
+ * Contract: stdout is the MCP channel — ONLY JSON-RPC lines go there. All human logging goes to
12
+ * stderr. Notifications (no `id`) get no reply and aren't forwarded (our stateless proxy has no
13
+ * lifecycle state). Network/HTTP failures become JSON-RPC errors so the client sees them instead
14
+ * of a dropped connection.
15
+ */
16
+ import * as readline from 'node:readline';
17
+ function isRequest(msg) {
18
+ if (!msg || typeof msg !== 'object' || Array.isArray(msg))
19
+ return false;
20
+ const id = msg.id;
21
+ return id !== undefined && id !== null; // notifications omit id → no reply expected
22
+ }
23
+ /**
24
+ * Forward one JSON-RPC message to the remote endpoint. Returns the response object to write back,
25
+ * or `null` for notifications (nothing to reply). Never throws — failures become JSON-RPC errors.
26
+ */
27
+ export async function forwardMessage(msg, opts) {
28
+ if (!isRequest(msg))
29
+ return null;
30
+ const id = msg.id;
31
+ const fetchImpl = opts.fetchImpl || fetch;
32
+ try {
33
+ const res = await fetchImpl(opts.mcpUrl, {
34
+ method: 'POST',
35
+ headers: {
36
+ 'content-type': 'application/json',
37
+ ...(opts.apiKey ? { authorization: `Bearer ${opts.apiKey}` } : {}),
38
+ },
39
+ body: JSON.stringify(msg),
40
+ });
41
+ const text = await res.text();
42
+ if (!res.ok) {
43
+ const hint = res.status === 401 ? ' — run `sella pair` to (re)authenticate this machine' : '';
44
+ return { jsonrpc: '2.0', id, error: { code: -32000, message: `Sella endpoint responded ${res.status}${hint}` } };
45
+ }
46
+ if (!text)
47
+ return { jsonrpc: '2.0', id, result: null };
48
+ try {
49
+ return JSON.parse(text);
50
+ }
51
+ catch {
52
+ return { jsonrpc: '2.0', id, error: { code: -32603, message: 'Malformed response from the Sella MCP endpoint' } };
53
+ }
54
+ }
55
+ catch (err) {
56
+ return {
57
+ jsonrpc: '2.0',
58
+ id,
59
+ error: { code: -32001, message: `Could not reach the Sella endpoint: ${err instanceof Error ? err.message : 'network error'}` },
60
+ };
61
+ }
62
+ }
63
+ /**
64
+ * Run the stdio bridge: pipe stdin (newline-delimited JSON-RPC) → remote → stdout, until stdin
65
+ * closes. Resolves 0 after all in-flight forwards settle. Requests are matched by `id`, so the
66
+ * concurrent forwarding here is safe even if responses complete out of order.
67
+ */
68
+ export function runMcpBridge(opts) {
69
+ const input = opts.input || process.stdin;
70
+ const write = opts.output || ((line) => process.stdout.write(line + '\n'));
71
+ return new Promise((resolve) => {
72
+ const rl = readline.createInterface({ input, crlfDelay: Infinity });
73
+ let pending = 0;
74
+ let closed = false;
75
+ const settle = () => {
76
+ if (closed && pending === 0)
77
+ resolve(0);
78
+ };
79
+ rl.on('line', (line) => {
80
+ const trimmed = line.trim();
81
+ if (!trimmed)
82
+ return;
83
+ let msg;
84
+ try {
85
+ msg = JSON.parse(trimmed);
86
+ }
87
+ catch {
88
+ write(JSON.stringify({ jsonrpc: '2.0', id: null, error: { code: -32700, message: 'Parse error' } }));
89
+ return;
90
+ }
91
+ pending += 1;
92
+ const work = Array.isArray(msg)
93
+ ? Promise.all(msg.map((m) => forwardMessage(m, opts))).then((rs) => rs.filter((r) => r !== null))
94
+ : forwardMessage(msg, opts);
95
+ Promise.resolve(work)
96
+ .then((result) => {
97
+ if (Array.isArray(result)) {
98
+ if (result.length)
99
+ write(JSON.stringify(result));
100
+ }
101
+ else if (result !== null) {
102
+ write(JSON.stringify(result));
103
+ }
104
+ })
105
+ .finally(() => {
106
+ pending -= 1;
107
+ settle();
108
+ });
109
+ });
110
+ rl.on('close', () => {
111
+ closed = true;
112
+ settle();
113
+ });
114
+ });
115
+ }
package/dist/output.js ADDED
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Output contract (inclusiveness is a feature, not a vibe — CLI sprint §3):
3
+ * - `--json` mode prints exactly one JSON document to stdout and nothing else.
4
+ * - Colors honor NO_COLOR / --no-color / non-TTY. Never communicate through color alone.
5
+ * - No spinner-only feedback: every long step prints a plain line a screen reader can speak.
6
+ */
7
+ export function defaultIo() {
8
+ return {
9
+ stdout: (line) => process.stdout.write(line + '\n'),
10
+ stderr: (line) => process.stderr.write(line + '\n'),
11
+ isTTY: Boolean(process.stdout.isTTY),
12
+ env: process.env,
13
+ };
14
+ }
15
+ export class Printer {
16
+ io;
17
+ json;
18
+ useColor;
19
+ constructor(io, json, noColorFlag) {
20
+ this.io = io;
21
+ this.json = json;
22
+ this.useColor = io.isTTY && !json && !noColorFlag && !io.env.NO_COLOR;
23
+ }
24
+ paint(code, text) {
25
+ return this.useColor ? `[${code}m${text}` : text;
26
+ }
27
+ info(text) {
28
+ if (!this.json)
29
+ this.io.stdout(text);
30
+ }
31
+ ok(text) {
32
+ if (!this.json)
33
+ this.io.stdout(`${this.paint('32', 'ok')} ${text}`);
34
+ }
35
+ warn(text) {
36
+ if (!this.json)
37
+ this.io.stderr(`${this.paint('33', 'warn')} ${text}`);
38
+ }
39
+ error(text) {
40
+ // Errors go to stderr even in --json mode so the JSON document on stdout stays parseable.
41
+ this.io.stderr(`${this.paint('31', 'error')} ${text}`);
42
+ }
43
+ /** The single stdout document in --json mode. */
44
+ jsonOut(payload) {
45
+ if (this.json)
46
+ this.io.stdout(JSON.stringify(payload, null, 2));
47
+ }
48
+ }
@@ -0,0 +1,37 @@
1
+ import { claimSetupCode, completeEmailOtp, startEmailOtp } from './api.js';
2
+ import { saveCredentials } from './credentials.js';
3
+ const NO_TTY_HELP = 'Non-interactive run without credentials. Pass --setup-code <SELLA-…>, set SELLA_SETUP_CODE, ' +
4
+ 'or run interactively. Mint a code at https://sella.network/onboarding (Connect your agent).';
5
+ export async function pair(opts) {
6
+ const finish = (payload, method) => {
7
+ if (!payload.ok || !payload.apiKey) {
8
+ return { ok: false, error: payload.error || 'Pairing failed.', exitCode: 1 };
9
+ }
10
+ const save = saveCredentials(payload, { env: opts.env, allowKeychain: opts.allowKeychain, execFile: opts.execFile });
11
+ return { ok: true, payload, save, method };
12
+ };
13
+ // Path 1: setup code (explicit, env, or prompted).
14
+ let code = opts.setupCode?.trim();
15
+ if (!code && opts.interactive && !opts.email && opts.ask) {
16
+ const answer = (await opts.ask('Paste a Sella setup code (SELLA-…), or press Enter to pair with email + OTP: ')).trim();
17
+ if (answer)
18
+ code = answer;
19
+ }
20
+ if (code) {
21
+ return finish(await claimSetupCode(opts.mcpUrl, code, opts.fetchImpl), 'setup-code');
22
+ }
23
+ // Path 2: email + OTP — interactive only.
24
+ if (!opts.interactive || !opts.ask) {
25
+ return { ok: false, error: NO_TTY_HELP, exitCode: 2 };
26
+ }
27
+ const email = (opts.email || (await opts.ask('Email address for your Sella account: '))).trim();
28
+ if (!email)
29
+ return { ok: false, error: 'An email address is required to pair.', exitCode: 2 };
30
+ const started = await startEmailOtp(opts.mcpUrl, email, opts.fetchImpl);
31
+ if (started.ok === false) {
32
+ const wait = started.retryAfterMs ? ` Try again in ${Math.ceil(started.retryAfterMs / 1000)}s.` : '';
33
+ return { ok: false, error: (started.error || 'Could not send the verification code.') + wait, exitCode: 1 };
34
+ }
35
+ const otp = (await opts.ask('6-digit code from your inbox: ')).trim();
36
+ return finish(await completeEmailOtp(opts.mcpUrl, email, otp, opts.fetchImpl), 'email-otp');
37
+ }