sella-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.
- package/README.md +11 -6
- package/dist/index.js +165 -42
- package/dist/postinstall.js +38 -0
- package/dist/ui.js +310 -0
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -4,18 +4,22 @@ Terminal onboarding for [Sella](https://sella.network) — the marketplace where
|
|
|
4
4
|
models, and machine-payable APIs.
|
|
5
5
|
|
|
6
6
|
```bash
|
|
7
|
-
npx sella-cli
|
|
7
|
+
npx sella-cli
|
|
8
8
|
```
|
|
9
9
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
10
|
+
That single command is the whole onboarding: with no stored credentials and a human at the
|
|
11
|
+
terminal, the guided wizard starts by itself (`init` is the explicit spelling). It previews the
|
|
12
|
+
marketplace, detects your agent clients (Claude Code, Claude Desktop, Cursor, Windsurf, VS Code,
|
|
13
|
+
Cline), installs the Sella MCP server into each, pairs the machine (arrow-key choice of setup code
|
|
14
|
+
or email + 6-digit code), wires your key into the client configs, and finishes with a live
|
|
15
|
+
`doctor` verification. Installed users get the `sella` binary; `npm install` also prints a
|
|
16
|
+
one-line pointer after install (silent in CI and non-TTY runs — installs are never interrupted).
|
|
13
17
|
|
|
14
18
|
## Commands
|
|
15
19
|
|
|
16
20
|
| Command | What it does |
|
|
17
21
|
|---|---|
|
|
18
|
-
| `sella init` |
|
|
22
|
+
| `sella init` | Guided wizard: detect clients → install the Sella MCP server → pair → verify (auto-runs on bare `sella` when unpaired) |
|
|
19
23
|
| `sella sandbox <query>` | Try Sella with **no signup** — search the live marketplace (rate-limited) |
|
|
20
24
|
| `sella pair` | Pair this machine only (`--setup-code <code>` / `SELLA_SETUP_CODE`, or `--email` + OTP) |
|
|
21
25
|
| `sella clients` | List detected clients (`--install` to write configs, `--client a,b` to filter, `--dry-run`) |
|
|
@@ -34,7 +38,8 @@ your wallets, just without the label.
|
|
|
34
38
|
## Contract (every command)
|
|
35
39
|
|
|
36
40
|
- `--json` → exactly one JSON document on stdout; errors on stderr; meaningful exit codes.
|
|
37
|
-
- `--yes` / env vars for every prompt — agents run this CLI too; nothing may hang on a TTY
|
|
41
|
+
- `--yes` / env vars for every prompt — agents run this CLI too; nothing may hang on a TTY (the
|
|
42
|
+
wizard renders as plain sequential lines off-TTY and never auto-starts in `--yes`/`--json` runs).
|
|
38
43
|
- `NO_COLOR` / `--no-color` honored; no spinner-only feedback; color never carries meaning alone.
|
|
39
44
|
- Config writes are idempotent, preserve other servers, and leave a one-time `.sella-backup`.
|
|
40
45
|
- `SELLA_MCP_URL` overrides the endpoint (self-hosted / staging).
|
package/dist/index.js
CHANGED
|
@@ -10,8 +10,9 @@ import { sandboxSearch } from './api.js';
|
|
|
10
10
|
import { getFundingInfo, annotateFunding } from './fund.js';
|
|
11
11
|
import { capabilityLabel } from './chains.js';
|
|
12
12
|
import { defaultIo, Printer } from './output.js';
|
|
13
|
+
import { Ui } from './ui.js';
|
|
13
14
|
const DEFAULT_MCP_URL = 'https://sella.network/api/mcp';
|
|
14
|
-
const VERSION = '0.
|
|
15
|
+
const VERSION = '0.6.0';
|
|
15
16
|
function parseFlags(argv) {
|
|
16
17
|
const flags = {
|
|
17
18
|
json: false, yes: false, noColor: false, dryRun: false, noKeychain: false,
|
|
@@ -53,7 +54,8 @@ const HELP = `sella — the Sella onboarding CLI
|
|
|
53
54
|
Usage: sella <command> [options]
|
|
54
55
|
|
|
55
56
|
Commands:
|
|
56
|
-
init
|
|
57
|
+
init Guided onboarding: install the Sella MCP server, pair, verify
|
|
58
|
+
(also runs when you invoke \`sella\` with no command and no credentials yet)
|
|
57
59
|
pair Pair this machine only (setup code or email + OTP)
|
|
58
60
|
sandbox Try Sella with no signup — search the live marketplace (rate-limited)
|
|
59
61
|
clients List detected agent clients (--install to write configs)
|
|
@@ -87,7 +89,16 @@ function selectClientIds(flags, detected) {
|
|
|
87
89
|
}
|
|
88
90
|
return detected.filter((c) => c.installed).map((c) => c.id);
|
|
89
91
|
}
|
|
90
|
-
function
|
|
92
|
+
function uiEmitter(ui, printer) {
|
|
93
|
+
return {
|
|
94
|
+
info: (t) => ui.step(t),
|
|
95
|
+
ok: (t) => ui.ok(t),
|
|
96
|
+
warn: (t) => ui.warn(t),
|
|
97
|
+
// Errors always reach stderr too (screen readers, log capture, --json pipelines).
|
|
98
|
+
error: (t) => printer.error(t),
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
function installAll(ctx, flags, emit, apiKey) {
|
|
91
102
|
const detected = detectClients(ctx.env);
|
|
92
103
|
const targets = selectClientIds(flags, detected);
|
|
93
104
|
const results = [];
|
|
@@ -96,13 +107,13 @@ function installAll(ctx, flags, printer, apiKey) {
|
|
|
96
107
|
try {
|
|
97
108
|
const result = installSellaIntoClient(id, { mcpUrl: ctx.mcpUrl, env: ctx.env, dryRun: flags.dryRun, apiKey });
|
|
98
109
|
results.push({ ...result });
|
|
99
|
-
|
|
110
|
+
emit.ok(`${id}: ${flags.dryRun ? `would be ${result.action}` : result.action} → ${result.configPath}`);
|
|
100
111
|
}
|
|
101
112
|
catch (err) {
|
|
102
113
|
failed = true;
|
|
103
114
|
const message = err instanceof Error ? err.message : String(err);
|
|
104
115
|
results.push({ id, error: message });
|
|
105
|
-
|
|
116
|
+
emit.error(`${id}: ${message}`);
|
|
106
117
|
}
|
|
107
118
|
}
|
|
108
119
|
return { results, failed, targets };
|
|
@@ -120,26 +131,26 @@ function makeAsk(ctx) {
|
|
|
120
131
|
}
|
|
121
132
|
};
|
|
122
133
|
}
|
|
123
|
-
async function runPair(ctx, flags,
|
|
134
|
+
async function runPair(ctx, flags, emit, extra = {}) {
|
|
124
135
|
const outcome = await pair({
|
|
125
136
|
mcpUrl: ctx.mcpUrl,
|
|
126
137
|
env: ctx.env,
|
|
127
138
|
interactive: ctx.io.isTTY && !flags.yes && !flags.json,
|
|
128
|
-
setupCode: flags.setupCode || ctx.io.env.SELLA_SETUP_CODE,
|
|
129
|
-
email: flags.email,
|
|
139
|
+
setupCode: extra.setupCode ?? (flags.setupCode || ctx.io.env.SELLA_SETUP_CODE),
|
|
140
|
+
email: extra.email ?? flags.email,
|
|
130
141
|
allowKeychain: !flags.noKeychain && !ctx.io.env.SELLA_NO_KEYCHAIN,
|
|
131
|
-
ask: makeAsk(ctx),
|
|
142
|
+
ask: extra.ask ?? makeAsk(ctx),
|
|
132
143
|
});
|
|
133
144
|
if (!outcome.ok) {
|
|
134
|
-
|
|
145
|
+
emit.error(outcome.error);
|
|
135
146
|
return { code: outcome.exitCode, summary: { paired: false, error: outcome.error } };
|
|
136
147
|
}
|
|
137
|
-
|
|
148
|
+
emit.ok(`Paired via ${outcome.method} — status: ${outcome.payload.status || 'ok'}`);
|
|
138
149
|
for (const file of outcome.save.files)
|
|
139
|
-
|
|
140
|
-
|
|
150
|
+
emit.ok(`wrote ${file}`);
|
|
151
|
+
emit.info(outcome.save.custodyNote);
|
|
141
152
|
if (outcome.payload.agentWallet?.status === 'unavailable') {
|
|
142
|
-
|
|
153
|
+
emit.warn(`AgentWallet not provisioned (${outcome.payload.agentWallet.reason || 'backend unavailable'}) — MCP access still works; re-run \`sella pair\` later.`);
|
|
143
154
|
}
|
|
144
155
|
return {
|
|
145
156
|
code: 0,
|
|
@@ -154,43 +165,129 @@ async function runPair(ctx, flags, printer) {
|
|
|
154
165
|
},
|
|
155
166
|
};
|
|
156
167
|
}
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
168
|
+
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
169
|
+
/**
|
|
170
|
+
* Wizard input collection: honors --setup-code/--email/SELLA_SETUP_CODE first (identical to the
|
|
171
|
+
* headless path), and only prompts when a human is present.
|
|
172
|
+
*/
|
|
173
|
+
async function collectPairChoice(ui, flags, ctx) {
|
|
174
|
+
const preset = flags.setupCode || ctx.io.env.SELLA_SETUP_CODE;
|
|
175
|
+
if (preset || flags.email || !ui.interactive)
|
|
176
|
+
return { setupCode: preset, email: flags.email };
|
|
177
|
+
const method = await ui.select('How do you want to pair this machine?', [
|
|
178
|
+
{ value: 'setup-code', label: 'Setup code', hint: 'paste one from the Sella dashboard' },
|
|
179
|
+
{ value: 'email-otp', label: 'Email + code', hint: 'we email you a 6-digit code' },
|
|
180
|
+
]);
|
|
181
|
+
if (method === 'setup-code') {
|
|
182
|
+
const code = await ui.text('Paste your setup code', {
|
|
183
|
+
placeholder: 'SELLA-XXXX-XXXX-XXXX-XXXX',
|
|
184
|
+
validate: (v) => (v ? undefined : 'A setup code is required — mint one on the dashboard (Connect your agent).'),
|
|
185
|
+
});
|
|
186
|
+
return { setupCode: code };
|
|
187
|
+
}
|
|
188
|
+
const email = await ui.text('Email for your Sella account', {
|
|
189
|
+
validate: (v) => (EMAIL_RE.test(v) ? undefined : 'Enter a valid email address.'),
|
|
190
|
+
});
|
|
191
|
+
return { email };
|
|
192
|
+
}
|
|
193
|
+
/** The one prompt pair() asks itself (the emailed OTP) — styled when the wizard is active. */
|
|
194
|
+
function wizardAsk(ui, ctx) {
|
|
195
|
+
if (!ui.interactive)
|
|
196
|
+
return makeAsk(ctx);
|
|
197
|
+
return async (question) => {
|
|
198
|
+
if (/6-digit/i.test(question)) {
|
|
199
|
+
return ui.text('6-digit code from your inbox', {
|
|
200
|
+
validate: (v) => (/^\d{6}$/.test(v) ? undefined : 'Enter exactly the 6 digits from the email.'),
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
return ui.text(question.replace(/:\s*$/, ''));
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
async function cmdInit(ctx, flags, printer) {
|
|
207
|
+
const ui = new Ui(ctx.io, { json: flags.json, noColor: flags.noColor, yes: flags.yes });
|
|
208
|
+
const emit = ui.pretty ? uiEmitter(ui, printer) : printer;
|
|
209
|
+
const origin = ctx.mcpUrl.replace(/\/api\/mcp\/?$/, '') || 'https://sella.network';
|
|
210
|
+
let host = ctx.mcpUrl;
|
|
211
|
+
try {
|
|
212
|
+
host = new URL(ctx.mcpUrl).host;
|
|
213
|
+
}
|
|
214
|
+
catch {
|
|
215
|
+
/* keep full URL */
|
|
216
|
+
}
|
|
217
|
+
ui.intro('Sella — connect your agents', `v${VERSION} · ${host}`);
|
|
218
|
+
// A taste of the marketplace (free sandbox, best-effort — never blocks onboarding).
|
|
219
|
+
const taste = ui.spinner('Peeking at the marketplace (free, no signup)…');
|
|
160
220
|
try {
|
|
161
221
|
const result = await sandboxSearch(ctx.mcpUrl, 'data');
|
|
162
|
-
const
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
printer.info(` api ${a.name || ''}`);
|
|
170
|
-
printer.info('');
|
|
222
|
+
const picks = [
|
|
223
|
+
...result.datasets.slice(0, 2).map((d) => `dataset ${d.title || d.id}`),
|
|
224
|
+
...result.apis.slice(0, 2).map((a) => `api ${a.name || ''}`),
|
|
225
|
+
];
|
|
226
|
+
taste.succeed(picks.length ? 'A taste of what your agent can buy here:' : 'Marketplace preview is empty right now.');
|
|
227
|
+
for (const p of picks)
|
|
228
|
+
ui.detail(p);
|
|
171
229
|
}
|
|
172
230
|
catch {
|
|
173
|
-
|
|
231
|
+
taste.succeed('Marketplace preview skipped.');
|
|
174
232
|
}
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
printer.error('No agent clients found (and none specified via --client). Nothing to install into.');
|
|
233
|
+
ui.bar();
|
|
234
|
+
// Step 1/3 — install
|
|
235
|
+
const detected = detectClients(ctx.env);
|
|
236
|
+
const targets = selectClientIds(flags, detected);
|
|
237
|
+
if (targets.length === 0) {
|
|
238
|
+
emit.error('No agent clients found (and none specified via --client). Nothing to install into.');
|
|
182
239
|
printer.jsonOut({ installed: [], paired: false, error: 'no_clients' });
|
|
240
|
+
ui.outro('Nothing to do.');
|
|
183
241
|
return 2;
|
|
184
242
|
}
|
|
185
|
-
|
|
186
|
-
const
|
|
243
|
+
ui.step(`Step 1/3 · Install the Sella MCP server (${targets.join(', ')})`);
|
|
244
|
+
const install = installAll(ctx, flags, emit);
|
|
245
|
+
ui.bar();
|
|
246
|
+
// Step 2/3 — pair
|
|
247
|
+
ui.step('Step 2/3 · Pair this machine with your Sella account');
|
|
248
|
+
const choice = await collectPairChoice(ui, flags, ctx);
|
|
249
|
+
if (choice.email)
|
|
250
|
+
ui.step(`Requesting a verification code for ${choice.email}…`);
|
|
251
|
+
const paired = await runPair(ctx, flags, emit, { ...choice, ask: wizardAsk(ui, ctx) });
|
|
187
252
|
let reinstalled = [];
|
|
188
253
|
if (paired.code === 0 && paired.apiKey && !flags.dryRun) {
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
254
|
+
ui.bar();
|
|
255
|
+
ui.step('Wiring your API key into the client configs');
|
|
256
|
+
reinstalled = installAll(ctx, flags, emit, paired.apiKey).results;
|
|
257
|
+
}
|
|
258
|
+
// Step 3/3 — verify (report-only: exit codes stay install/pair-driven so automation is stable)
|
|
259
|
+
let verifySummary;
|
|
260
|
+
if (paired.code === 0 && !flags.dryRun) {
|
|
261
|
+
ui.bar();
|
|
262
|
+
const sp = ui.spinner('Step 3/3 · Verifying the install (sella doctor)…');
|
|
263
|
+
try {
|
|
264
|
+
const verify = await runDoctor({ mcpUrl: ctx.mcpUrl, env: ctx.env });
|
|
265
|
+
const passed = verify.checks.filter((c) => c.ok).length;
|
|
266
|
+
sp.succeed(`Step 3/3 · Verified — ${passed}/${verify.checks.length} doctor checks passed`);
|
|
267
|
+
for (const check of verify.checks.filter((c) => !c.ok)) {
|
|
268
|
+
ui.warn(`${check.label} — ${check.detail}`);
|
|
269
|
+
if (check.fix)
|
|
270
|
+
ui.detail(`fix: ${check.fix}`);
|
|
271
|
+
}
|
|
272
|
+
verifySummary = { ok: verify.ok, passed, total: verify.checks.length };
|
|
273
|
+
}
|
|
274
|
+
catch (err) {
|
|
275
|
+
sp.fail('Step 3/3 · Verify could not run (network?) — try `sella doctor` later.');
|
|
276
|
+
verifySummary = { ok: false, error: err instanceof Error ? err.message : String(err) };
|
|
277
|
+
}
|
|
192
278
|
}
|
|
193
|
-
|
|
279
|
+
if (paired.code === 0) {
|
|
280
|
+
ui.note('Sella is connected', [
|
|
281
|
+
`Fund your agent wallet: ${origin}/dashboard/funding`,
|
|
282
|
+
'Try it free: sella sandbox "web search"',
|
|
283
|
+
'Health check: sella doctor',
|
|
284
|
+
]);
|
|
285
|
+
ui.outro(flags.dryRun ? 'Dry run complete — nothing was written.' : 'Done — your agents can now buy on Sella.');
|
|
286
|
+
}
|
|
287
|
+
else {
|
|
288
|
+
ui.outro('Pairing incomplete — fix the issue above, then re-run `sella init`.');
|
|
289
|
+
}
|
|
290
|
+
printer.jsonOut({ installed: install.results, pairing: paired.summary, reinstalled, verify: verifySummary });
|
|
194
291
|
return install.failed ? 1 : paired.code;
|
|
195
292
|
}
|
|
196
293
|
export async function runCli(argv, io = defaultIo(), env = defaultEnv()) {
|
|
@@ -202,7 +299,19 @@ export async function runCli(argv, io = defaultIo(), env = defaultEnv()) {
|
|
|
202
299
|
io.stdout(VERSION);
|
|
203
300
|
return 0;
|
|
204
301
|
}
|
|
205
|
-
if (flags.help
|
|
302
|
+
if (flags.help) {
|
|
303
|
+
io.stdout(HELP);
|
|
304
|
+
return 0;
|
|
305
|
+
}
|
|
306
|
+
if (!command) {
|
|
307
|
+
// Bare `sella` / `npx sella-cli`: a human at a TTY with no credentials yet gets the
|
|
308
|
+
// onboarding wizard directly (that IS the task); everyone else gets help. Never in
|
|
309
|
+
// --json/--yes runs — automation must not fall into an interactive flow.
|
|
310
|
+
const interactive = io.isTTY && Boolean(process.stdin.isTTY) && !flags.json && !flags.yes;
|
|
311
|
+
if (interactive && !loadStoredKey(ctx.env)) {
|
|
312
|
+
printer.info('No Sella credentials on this machine yet — starting onboarding (Ctrl+C to abort, `sella --help` for commands).');
|
|
313
|
+
return await cmdInit(ctx, flags, printer);
|
|
314
|
+
}
|
|
206
315
|
io.stdout(HELP);
|
|
207
316
|
return 0;
|
|
208
317
|
}
|
|
@@ -230,7 +339,21 @@ export async function runCli(argv, io = defaultIo(), env = defaultEnv()) {
|
|
|
230
339
|
return failed ? 1 : 0;
|
|
231
340
|
}
|
|
232
341
|
case 'pair': {
|
|
233
|
-
const
|
|
342
|
+
const ui = new Ui(ctx.io, { json: flags.json, noColor: flags.noColor, yes: flags.yes });
|
|
343
|
+
const emit = ui.pretty ? uiEmitter(ui, printer) : printer;
|
|
344
|
+
let host = ctx.mcpUrl;
|
|
345
|
+
try {
|
|
346
|
+
host = new URL(ctx.mcpUrl).host;
|
|
347
|
+
}
|
|
348
|
+
catch {
|
|
349
|
+
/* keep full URL */
|
|
350
|
+
}
|
|
351
|
+
ui.intro('Sella — pair this machine', `v${VERSION} · ${host}`);
|
|
352
|
+
const choice = await collectPairChoice(ui, flags, ctx);
|
|
353
|
+
if (choice.email)
|
|
354
|
+
ui.step(`Requesting a verification code for ${choice.email}…`);
|
|
355
|
+
const paired = await runPair(ctx, flags, emit, { ...choice, ask: wizardAsk(ui, ctx) });
|
|
356
|
+
ui.outro(paired.code === 0 ? 'Paired — try `sella doctor` next.' : 'Pairing failed — see above.');
|
|
234
357
|
printer.jsonOut(paired.summary || {});
|
|
235
358
|
return paired.code;
|
|
236
359
|
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* npm postinstall pointer (CLI sprint C7). Constraints that shape this file:
|
|
3
|
+
* - npm ≥7 runs dependency lifecycle scripts with piped, usually-hidden output, and CI runs
|
|
4
|
+
* must NEVER gain an interactive step — so we do not auto-launch the wizard here.
|
|
5
|
+
* - Instead the wizard auto-starts on the first bare `sella` / `npx sella-cli` run with no
|
|
6
|
+
* stored credentials (see index.ts). This script only prints a one-glance pointer when a
|
|
7
|
+
* human terminal will actually show it.
|
|
8
|
+
* - It must never fail or block an install: everything is wrapped, and we always exit 0.
|
|
9
|
+
*/
|
|
10
|
+
function main() {
|
|
11
|
+
const env = process.env;
|
|
12
|
+
const ci = Boolean(env.CI || env.CONTINUOUS_INTEGRATION || env.GITHUB_ACTIONS || env.BUILD_NUMBER || env.TEAMCITY_VERSION);
|
|
13
|
+
if (ci || !process.stdout.isTTY)
|
|
14
|
+
return;
|
|
15
|
+
const globalInstall = env.npm_config_global === 'true';
|
|
16
|
+
const cmd = globalInstall ? 'sella' : 'npx sella';
|
|
17
|
+
const color = !env.NO_COLOR;
|
|
18
|
+
const dim = (s) => (color ? `\x1b[90m${s}\x1b[0m` : s);
|
|
19
|
+
const bold = (s) => (color ? `\x1b[1m${s}\x1b[0m` : s);
|
|
20
|
+
process.stdout.write([
|
|
21
|
+
'',
|
|
22
|
+
` ${bold('sella-cli installed.')}`,
|
|
23
|
+
` Finish onboarding (installs the MCP server into your agent clients, pairs, verifies):`,
|
|
24
|
+
'',
|
|
25
|
+
` ${bold(cmd)}`,
|
|
26
|
+
'',
|
|
27
|
+
` ${dim('Docs: https://sella.network · Try without an account: `' + cmd + ' sandbox "web search"`')}`,
|
|
28
|
+
'',
|
|
29
|
+
].join('\n'));
|
|
30
|
+
}
|
|
31
|
+
try {
|
|
32
|
+
main();
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
/* a postinstall pointer must never break an install */
|
|
36
|
+
}
|
|
37
|
+
process.exit(0);
|
|
38
|
+
export {};
|
package/dist/ui.js
ADDED
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
import * as readline from 'node:readline/promises';
|
|
2
|
+
const FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
|
3
|
+
let cursorHooked = false;
|
|
4
|
+
function ensureCursorRestoredOnExit() {
|
|
5
|
+
if (cursorHooked)
|
|
6
|
+
return;
|
|
7
|
+
cursorHooked = true;
|
|
8
|
+
process.on('exit', () => {
|
|
9
|
+
if (process.stdout.isTTY)
|
|
10
|
+
process.stdout.write('\x1b[?25h');
|
|
11
|
+
});
|
|
12
|
+
}
|
|
13
|
+
export class Ui {
|
|
14
|
+
io;
|
|
15
|
+
/** Glyph/ANSI rendering is worth doing (real TTY, not machine output). */
|
|
16
|
+
pretty;
|
|
17
|
+
/** Prompts are allowed (pretty or plain — but a human is present). */
|
|
18
|
+
interactive;
|
|
19
|
+
color;
|
|
20
|
+
quiet;
|
|
21
|
+
constructor(io, opts) {
|
|
22
|
+
this.io = io;
|
|
23
|
+
this.quiet = opts.json;
|
|
24
|
+
this.pretty = io.isTTY && Boolean(process.stdout.isTTY) && !opts.json;
|
|
25
|
+
this.color = this.pretty && !opts.noColor && !io.env.NO_COLOR;
|
|
26
|
+
this.interactive = io.isTTY && Boolean(process.stdin.isTTY) && !opts.json && !opts.yes;
|
|
27
|
+
}
|
|
28
|
+
// ── paint helpers ──────────────────────────────────────────────────────────
|
|
29
|
+
paint(code, text) {
|
|
30
|
+
return this.color ? `\x1b[${code}m${text}\x1b[0m` : text;
|
|
31
|
+
}
|
|
32
|
+
dim(text) {
|
|
33
|
+
return this.paint('90', text);
|
|
34
|
+
}
|
|
35
|
+
accent(text) {
|
|
36
|
+
return this.paint('36', text);
|
|
37
|
+
}
|
|
38
|
+
width() {
|
|
39
|
+
return Math.max(40, Math.min(process.stdout.columns || 80, 100));
|
|
40
|
+
}
|
|
41
|
+
out(text) {
|
|
42
|
+
if (!this.quiet)
|
|
43
|
+
this.io.stdout(text);
|
|
44
|
+
}
|
|
45
|
+
// ── static rendering ───────────────────────────────────────────────────────
|
|
46
|
+
intro(title, meta) {
|
|
47
|
+
if (this.quiet)
|
|
48
|
+
return;
|
|
49
|
+
if (!this.pretty) {
|
|
50
|
+
this.out(`${title}${meta ? ` (${meta})` : ''}`);
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
this.out('');
|
|
54
|
+
this.out(`${this.dim('┌')} ${this.paint('1', title)}${meta ? ` ${this.dim(meta)}` : ''}`);
|
|
55
|
+
this.bar();
|
|
56
|
+
}
|
|
57
|
+
outro(text) {
|
|
58
|
+
if (this.quiet)
|
|
59
|
+
return;
|
|
60
|
+
if (!this.pretty) {
|
|
61
|
+
this.out(text);
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
this.out(`${this.dim('└')} ${text}`);
|
|
65
|
+
this.out('');
|
|
66
|
+
}
|
|
67
|
+
bar() {
|
|
68
|
+
if (!this.quiet && this.pretty)
|
|
69
|
+
this.out(this.dim('│'));
|
|
70
|
+
}
|
|
71
|
+
step(text) {
|
|
72
|
+
if (this.quiet)
|
|
73
|
+
return;
|
|
74
|
+
this.out(this.pretty ? `${this.dim('◇')} ${text}` : text);
|
|
75
|
+
}
|
|
76
|
+
ok(text) {
|
|
77
|
+
if (this.quiet)
|
|
78
|
+
return;
|
|
79
|
+
this.out(this.pretty ? `${this.paint('32', '◇')} ${text}` : `ok ${text}`);
|
|
80
|
+
}
|
|
81
|
+
warn(text) {
|
|
82
|
+
if (this.quiet)
|
|
83
|
+
return;
|
|
84
|
+
this.out(this.pretty ? `${this.paint('33', '▲')} ${text}` : `warn ${text}`);
|
|
85
|
+
}
|
|
86
|
+
fail(text) {
|
|
87
|
+
if (this.quiet)
|
|
88
|
+
return;
|
|
89
|
+
this.out(this.pretty ? `${this.paint('31', '✕')} ${text}` : `error ${text}`);
|
|
90
|
+
}
|
|
91
|
+
/** Indented supporting line under a step. */
|
|
92
|
+
detail(text) {
|
|
93
|
+
if (this.quiet)
|
|
94
|
+
return;
|
|
95
|
+
this.out(this.pretty ? `${this.dim('│')} ${text}` : ` ${text}`);
|
|
96
|
+
}
|
|
97
|
+
/** Boxed panel for the final summary / next steps. */
|
|
98
|
+
note(title, lines) {
|
|
99
|
+
if (this.quiet)
|
|
100
|
+
return;
|
|
101
|
+
if (!this.pretty) {
|
|
102
|
+
this.out('');
|
|
103
|
+
this.out(title);
|
|
104
|
+
for (const line of lines)
|
|
105
|
+
this.out(` ${line}`);
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
const inner = this.width() - 6;
|
|
109
|
+
const wrapped = [];
|
|
110
|
+
for (const line of lines) {
|
|
111
|
+
if (line.length <= inner) {
|
|
112
|
+
wrapped.push(line);
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
let rest = line;
|
|
116
|
+
while (rest.length > inner) {
|
|
117
|
+
const cut = rest.lastIndexOf(' ', inner);
|
|
118
|
+
const at = cut > inner / 2 ? cut : inner;
|
|
119
|
+
wrapped.push(rest.slice(0, at));
|
|
120
|
+
rest = rest.slice(at).trimStart();
|
|
121
|
+
}
|
|
122
|
+
if (rest)
|
|
123
|
+
wrapped.push(rest);
|
|
124
|
+
}
|
|
125
|
+
const w = Math.min(this.width() - 2, Math.max(title.length, ...wrapped.map((l) => l.length), 20) + 4);
|
|
126
|
+
this.bar();
|
|
127
|
+
this.out(`${this.dim('╭' + '─'.repeat(w) + '╮')}`);
|
|
128
|
+
// Pad from the raw string length, then paint — ANSI codes must not count toward width.
|
|
129
|
+
const pad = (s, painted = s) => `${this.dim('│')} ${painted}${' '.repeat(Math.max(0, w - s.length - 2))}${this.dim('│')}`;
|
|
130
|
+
this.out(pad(title, this.paint('1', title)));
|
|
131
|
+
for (const line of wrapped)
|
|
132
|
+
this.out(pad(line));
|
|
133
|
+
this.out(`${this.dim('╰' + '─'.repeat(w) + '╯')}`);
|
|
134
|
+
}
|
|
135
|
+
// ── spinner ────────────────────────────────────────────────────────────────
|
|
136
|
+
spinner(label) {
|
|
137
|
+
if (this.quiet) {
|
|
138
|
+
return { update: () => { }, succeed: () => { }, fail: () => { } };
|
|
139
|
+
}
|
|
140
|
+
if (!this.pretty) {
|
|
141
|
+
this.out(`… ${label}`);
|
|
142
|
+
return {
|
|
143
|
+
update: () => { },
|
|
144
|
+
succeed: (text) => this.ok(text || label),
|
|
145
|
+
fail: (text) => this.fail(text || label),
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
ensureCursorRestoredOnExit();
|
|
149
|
+
let current = label;
|
|
150
|
+
let i = 0;
|
|
151
|
+
process.stdout.write('\x1b[?25l');
|
|
152
|
+
const render = () => {
|
|
153
|
+
const frame = this.accent(FRAMES[i % FRAMES.length]);
|
|
154
|
+
process.stdout.write(`\r\x1b[K${frame} ${current}`);
|
|
155
|
+
i += 1;
|
|
156
|
+
};
|
|
157
|
+
render();
|
|
158
|
+
const timer = setInterval(render, 80);
|
|
159
|
+
const finish = (glyphCode, glyph, text) => {
|
|
160
|
+
clearInterval(timer);
|
|
161
|
+
process.stdout.write(`\r\x1b[K`);
|
|
162
|
+
process.stdout.write('\x1b[?25h');
|
|
163
|
+
this.out(`${this.paint(glyphCode, glyph)} ${text || current}`);
|
|
164
|
+
};
|
|
165
|
+
return {
|
|
166
|
+
update: (text) => {
|
|
167
|
+
current = text;
|
|
168
|
+
},
|
|
169
|
+
succeed: (text) => finish('32', '◇', text),
|
|
170
|
+
fail: (text) => finish('31', '✕', text),
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
// ── prompts ────────────────────────────────────────────────────────────────
|
|
174
|
+
async plainQuestion(prompt) {
|
|
175
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stderr });
|
|
176
|
+
try {
|
|
177
|
+
return await rl.question(prompt);
|
|
178
|
+
}
|
|
179
|
+
finally {
|
|
180
|
+
rl.close();
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
async select(label, options, initial = 0) {
|
|
184
|
+
if (!this.interactive)
|
|
185
|
+
throw new Error(`Cannot prompt for "${label}" in a non-interactive run.`);
|
|
186
|
+
if (!this.pretty) {
|
|
187
|
+
this.out(label);
|
|
188
|
+
options.forEach((o, idx) => this.out(` ${idx + 1}) ${o.label}${o.hint ? ` — ${o.hint}` : ''}`));
|
|
189
|
+
for (;;) {
|
|
190
|
+
const answer = (await this.plainQuestion(`Choose 1-${options.length} (${initial + 1}): `)).trim();
|
|
191
|
+
if (!answer)
|
|
192
|
+
return options[initial].value;
|
|
193
|
+
const n = Number(answer);
|
|
194
|
+
if (Number.isInteger(n) && n >= 1 && n <= options.length)
|
|
195
|
+
return options[n - 1].value;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
ensureCursorRestoredOnExit();
|
|
199
|
+
let index = initial;
|
|
200
|
+
const height = options.length + 2;
|
|
201
|
+
const render = (first) => {
|
|
202
|
+
if (!first)
|
|
203
|
+
process.stdout.write(`\x1b[${height}A`);
|
|
204
|
+
process.stdout.write('\x1b[J');
|
|
205
|
+
process.stdout.write(`${this.accent('◆')} ${this.paint('1', label)}\n`);
|
|
206
|
+
options.forEach((o, idx) => {
|
|
207
|
+
const active = idx === index;
|
|
208
|
+
const dot = active ? this.accent('●') : this.dim('○');
|
|
209
|
+
const text = active ? o.label : this.dim(o.label);
|
|
210
|
+
const hint = o.hint && active ? ` ${this.dim(o.hint)}` : '';
|
|
211
|
+
process.stdout.write(`${this.dim('│')} ${dot} ${text}${hint}\n`);
|
|
212
|
+
});
|
|
213
|
+
process.stdout.write(`${this.dim('└')} ${this.dim('↑↓ move · enter select')}\n`);
|
|
214
|
+
};
|
|
215
|
+
process.stdout.write('\x1b[?25l');
|
|
216
|
+
render(true);
|
|
217
|
+
const value = await new Promise((resolve) => {
|
|
218
|
+
const stdin = process.stdin;
|
|
219
|
+
stdin.setRawMode?.(true);
|
|
220
|
+
stdin.resume();
|
|
221
|
+
const onData = (chunk) => {
|
|
222
|
+
const key = chunk.toString('utf8');
|
|
223
|
+
if (key === '\x03') {
|
|
224
|
+
cleanup();
|
|
225
|
+
process.stdout.write('\x1b[?25h\n');
|
|
226
|
+
process.exit(130);
|
|
227
|
+
}
|
|
228
|
+
if (key === '\x1b[A' || key === 'k') {
|
|
229
|
+
index = (index - 1 + options.length) % options.length;
|
|
230
|
+
render(false);
|
|
231
|
+
}
|
|
232
|
+
else if (key === '\x1b[B' || key === 'j') {
|
|
233
|
+
index = (index + 1) % options.length;
|
|
234
|
+
render(false);
|
|
235
|
+
}
|
|
236
|
+
else if (key === '\r' || key === '\n') {
|
|
237
|
+
cleanup();
|
|
238
|
+
resolve(options[index].value);
|
|
239
|
+
}
|
|
240
|
+
};
|
|
241
|
+
const cleanup = () => {
|
|
242
|
+
stdin.off('data', onData);
|
|
243
|
+
stdin.setRawMode?.(false);
|
|
244
|
+
stdin.pause();
|
|
245
|
+
};
|
|
246
|
+
stdin.on('data', onData);
|
|
247
|
+
});
|
|
248
|
+
// Collapse the block to one settled line.
|
|
249
|
+
process.stdout.write(`\x1b[${height}A\x1b[J`);
|
|
250
|
+
process.stdout.write('\x1b[?25h');
|
|
251
|
+
const chosen = options.find((o) => o.value === value);
|
|
252
|
+
this.out(`${this.paint('32', '◇')} ${label} ${this.dim('·')} ${chosen.label}`);
|
|
253
|
+
return value;
|
|
254
|
+
}
|
|
255
|
+
async text(label, opts = {}) {
|
|
256
|
+
if (!this.interactive)
|
|
257
|
+
throw new Error(`Cannot prompt for "${label}" in a non-interactive run.`);
|
|
258
|
+
if (!this.pretty) {
|
|
259
|
+
for (;;) {
|
|
260
|
+
const answer = (await this.plainQuestion(`${label}${opts.placeholder ? ` (${opts.placeholder})` : ''}: `)).trim();
|
|
261
|
+
const problem = opts.validate?.(answer);
|
|
262
|
+
if (!problem)
|
|
263
|
+
return answer;
|
|
264
|
+
this.io.stderr(`error ${problem}`);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
for (;;) {
|
|
268
|
+
process.stdout.write(`${this.accent('◆')} ${this.paint('1', label)}\n`);
|
|
269
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
270
|
+
let answer;
|
|
271
|
+
try {
|
|
272
|
+
answer = (await rl.question(`${this.dim('│')} ${this.dim('›')} `)).trim();
|
|
273
|
+
}
|
|
274
|
+
finally {
|
|
275
|
+
rl.close();
|
|
276
|
+
}
|
|
277
|
+
const problem = opts.validate?.(answer);
|
|
278
|
+
const promptVisibleLength = 5 + answer.length;
|
|
279
|
+
const lines = 1 + Math.max(1, Math.ceil(promptVisibleLength / (process.stdout.columns || 80)));
|
|
280
|
+
process.stdout.write(`\x1b[${lines}A\x1b[J`);
|
|
281
|
+
if (problem) {
|
|
282
|
+
this.out(`${this.paint('31', '✕')} ${label} ${this.dim('·')} ${problem}`);
|
|
283
|
+
continue;
|
|
284
|
+
}
|
|
285
|
+
this.out(`${this.paint('32', '◇')} ${label} ${this.dim('·')} ${opts.redactAs || answer || this.dim('(empty)')}`);
|
|
286
|
+
return answer;
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
async confirm(label, initial = true) {
|
|
290
|
+
if (!this.interactive)
|
|
291
|
+
return initial;
|
|
292
|
+
const suffix = initial ? 'Y/n' : 'y/N';
|
|
293
|
+
if (!this.pretty) {
|
|
294
|
+
const answer = (await this.plainQuestion(`${label} [${suffix}]: `)).trim().toLowerCase();
|
|
295
|
+
if (!answer)
|
|
296
|
+
return initial;
|
|
297
|
+
return answer.startsWith('y');
|
|
298
|
+
}
|
|
299
|
+
const value = await this.select(label, initial
|
|
300
|
+
? [
|
|
301
|
+
{ value: 'yes', label: 'Yes' },
|
|
302
|
+
{ value: 'no', label: 'No' },
|
|
303
|
+
]
|
|
304
|
+
: [
|
|
305
|
+
{ value: 'no', label: 'No' },
|
|
306
|
+
{ value: 'yes', label: 'Yes' },
|
|
307
|
+
]);
|
|
308
|
+
return value === 'yes';
|
|
309
|
+
}
|
|
310
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sella-cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"description": "Sella onboarding CLI — install Sella into your agent clients, pair, verify, fund, publish. (`npx sella-cli init`)",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"homepage": "https://sella.network",
|
|
@@ -12,7 +12,8 @@
|
|
|
12
12
|
"engines": { "node": ">=18" },
|
|
13
13
|
"scripts": {
|
|
14
14
|
"build": "tsc -p tsconfig.json",
|
|
15
|
-
"prepublishOnly": "npm run build"
|
|
15
|
+
"prepublishOnly": "npm run build",
|
|
16
|
+
"postinstall": "node dist/postinstall.js || exit 0"
|
|
16
17
|
},
|
|
17
18
|
"devDependencies": {
|
|
18
19
|
"typescript": "^5"
|