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/README.md +46 -0
- package/dist/api.js +63 -0
- package/dist/chains.js +75 -0
- package/dist/clients.js +140 -0
- package/dist/credentials.js +51 -0
- package/dist/doctor.js +197 -0
- package/dist/fund.js +54 -0
- package/dist/index.js +410 -0
- package/dist/mcp-bridge.js +115 -0
- package/dist/output.js +48 -0
- package/dist/pairing.js +37 -0
- package/dist/publish.js +174 -0
- package/package.json +20 -0
package/README.md
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# sella-cli
|
|
2
|
+
|
|
3
|
+
Terminal onboarding for [Sella](https://sella.network) — the marketplace where agents buy datasets,
|
|
4
|
+
models, and machine-payable APIs.
|
|
5
|
+
|
|
6
|
+
```bash
|
|
7
|
+
npx sella-cli init
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
`init` detects your agent clients (Claude Code, Claude Desktop, Cursor, Windsurf, VS Code, Cline),
|
|
11
|
+
installs the Sella MCP server into each, and walks you through pairing. Installed users get the
|
|
12
|
+
`sella` binary.
|
|
13
|
+
|
|
14
|
+
## Commands
|
|
15
|
+
|
|
16
|
+
| Command | What it does |
|
|
17
|
+
|---|---|
|
|
18
|
+
| `sella init` | Detect clients → install the Sella MCP server → pair → verify |
|
|
19
|
+
| `sella sandbox <query>` | Try Sella with **no signup** — search the live marketplace (rate-limited) |
|
|
20
|
+
| `sella pair` | Pair this machine only (`--setup-code <code>` / `SELLA_SETUP_CODE`, or `--email` + OTP) |
|
|
21
|
+
| `sella clients` | List detected clients (`--install` to write configs, `--client a,b` to filter, `--dry-run`) |
|
|
22
|
+
| `sella doctor` | Verify the install: endpoint, credentials, auth, wallets, x402 pay-quote — each failure names its fix |
|
|
23
|
+
| `sella status` | Show your API key + AgentWallet funding/balances, with each chain marked **live** vs **deposit-only** |
|
|
24
|
+
| `sella fund` | Per-chain USDC deposit addresses + funding page (QR + fiat on-ramp), labelled live vs deposit-only |
|
|
25
|
+
| `sella mcp` | Run as a local **stdio MCP server** that proxies Sella with your stored key — for clients that can't use a remote MCP endpoint |
|
|
26
|
+
| `sella publish init <file.csv>` | Scaffold `sella-dataset.json` + a structural pre-check of the CSV |
|
|
27
|
+
| `sella publish push` | Publish the dataset via the same route the web dashboard uses — a CSV → live listing without a browser |
|
|
28
|
+
|
|
29
|
+
`status`/`fund` read the live chain registry (`GET {backend}/api/payments/chains`) to label each
|
|
30
|
+
wallet **live · spendable** vs **deposit-only · settle pending**, so the CLI tracks the multi-chain
|
|
31
|
+
rollout automatically — no per-chain update needed. If the backend is unreachable they still list
|
|
32
|
+
your wallets, just without the label.
|
|
33
|
+
|
|
34
|
+
## Contract (every command)
|
|
35
|
+
|
|
36
|
+
- `--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.
|
|
38
|
+
- `NO_COLOR` / `--no-color` honored; no spinner-only feedback; color never carries meaning alone.
|
|
39
|
+
- Config writes are idempotent, preserve other servers, and leave a one-time `.sella-backup`.
|
|
40
|
+
- `SELLA_MCP_URL` overrides the endpoint (self-hosted / staging).
|
|
41
|
+
|
|
42
|
+
## Security
|
|
43
|
+
|
|
44
|
+
Install links and configs written before pairing carry **no secrets** — Sella's bootstrap MCP tools
|
|
45
|
+
(`sella_setup_claim`, `sella_auth_*`) are callable without a token, so the agent pairs itself and
|
|
46
|
+
stores its own credentials. Never paste API keys into URLs or shell history.
|
package/dist/api.js
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Thin client over Sella's noauth bootstrap MCP tools (CLI sprint C2). No new auth paths:
|
|
3
|
+
* the CLI speaks the exact same tools/call surface agents use (sella_setup_claim,
|
|
4
|
+
* sella_auth_start/complete), so CLI, web, and agent onboarding cannot drift.
|
|
5
|
+
*/
|
|
6
|
+
export async function callToolRaw(mcpUrl, name, args, fetchImpl = fetch) {
|
|
7
|
+
let res;
|
|
8
|
+
try {
|
|
9
|
+
res = await fetchImpl(mcpUrl, {
|
|
10
|
+
method: 'POST',
|
|
11
|
+
headers: { 'content-type': 'application/json' },
|
|
12
|
+
body: JSON.stringify({ jsonrpc: '2.0', id: name, method: 'tools/call', params: { name, arguments: args } }),
|
|
13
|
+
});
|
|
14
|
+
}
|
|
15
|
+
catch (err) {
|
|
16
|
+
throw new Error(`Could not reach the Sella endpoint at ${mcpUrl} (${err instanceof Error ? err.message : 'network error'}). ` +
|
|
17
|
+
'Check your connection, proxy settings, or SELLA_MCP_URL.');
|
|
18
|
+
}
|
|
19
|
+
if (res.status === 429) {
|
|
20
|
+
throw new Error('Sandbox rate limit reached. Run `sella pair` for unmetered access.');
|
|
21
|
+
}
|
|
22
|
+
if (!res.ok) {
|
|
23
|
+
throw new Error(`Sella endpoint at ${mcpUrl} responded ${res.status}. Check SELLA_MCP_URL.`);
|
|
24
|
+
}
|
|
25
|
+
const body = (await res.json());
|
|
26
|
+
const structured = body?.result?.structuredContent;
|
|
27
|
+
if (structured && typeof structured === 'object')
|
|
28
|
+
return structured;
|
|
29
|
+
const text = body?.result?.content?.[0]?.text;
|
|
30
|
+
if (typeof text === 'string') {
|
|
31
|
+
try {
|
|
32
|
+
return JSON.parse(text);
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
/* fall through */
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
throw new Error('Unexpected response shape from the Sella MCP endpoint.');
|
|
39
|
+
}
|
|
40
|
+
export const callAuthTool = (mcpUrl, name, args, fetchImpl = fetch) => callToolRaw(mcpUrl, name, args, fetchImpl);
|
|
41
|
+
/** Zero-identity demo: read-only free tools only (list_market_apis + search_datasets). */
|
|
42
|
+
export async function sandboxSearch(mcpUrl, query, fetchImpl = fetch) {
|
|
43
|
+
// Tolerate a single tool being flaky, but never swallow the rate limit — that's what the user
|
|
44
|
+
// needs to see (it tells them to pair).
|
|
45
|
+
const soft = (err) => {
|
|
46
|
+
if (err instanceof Error && /rate limit/i.test(err.message))
|
|
47
|
+
throw err;
|
|
48
|
+
return {};
|
|
49
|
+
};
|
|
50
|
+
const [datasets, apis] = await Promise.all([
|
|
51
|
+
callToolRaw(mcpUrl, 'search_datasets', { query, limit: 5 }, fetchImpl).catch(soft),
|
|
52
|
+
callToolRaw(mcpUrl, 'list_market_apis', { query, limit: 5 }, fetchImpl).catch(soft),
|
|
53
|
+
]);
|
|
54
|
+
// search_datasets returns the array directly; list_market_apis wraps it in `catalogue`.
|
|
55
|
+
const asArray = (v, ...keys) => Array.isArray(v) ? v : keys.map((k) => v?.[k]).find(Array.isArray) || [];
|
|
56
|
+
return {
|
|
57
|
+
datasets: asArray(datasets, 'datasets', 'results').slice(0, 5),
|
|
58
|
+
apis: asArray(apis, 'catalogue', 'providers', 'apis', 'results').slice(0, 5),
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
export const claimSetupCode = (mcpUrl, code, fetchImpl) => callAuthTool(mcpUrl, 'sella_setup_claim', { code, client: 'sella-cli' }, fetchImpl);
|
|
62
|
+
export const startEmailOtp = (mcpUrl, email, fetchImpl) => callAuthTool(mcpUrl, 'sella_auth_start', { email }, fetchImpl);
|
|
63
|
+
export const completeEmailOtp = (mcpUrl, email, otpCode, fetchImpl) => callAuthTool(mcpUrl, 'sella_auth_complete', { email, otp_code: otpCode }, fetchImpl);
|
package/dist/chains.js
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CLI-side reader for the backend chain registry (CLI sprint — registry binding).
|
|
3
|
+
*
|
|
4
|
+
* The registry is OWNED by the backend and served at `GET {apiBase}/api/payments/chains`
|
|
5
|
+
* (see .tasks/PAYMENTS_CHAIN_REGISTRY_CONTRACT.md). The CLI deliberately holds NO copy of it —
|
|
6
|
+
* a bundled mirror would be a third source of truth to drift against the backend + Next mirror.
|
|
7
|
+
* Instead it fetches the live registry (using the `apiBase` already stored in the AgentWallet
|
|
8
|
+
* config at pairing) and degrades to *no annotation* when the backend is unreachable, so `status`
|
|
9
|
+
* and `fund` keep working offline. Zero new dependency: built-in fetch + AbortController, and a
|
|
10
|
+
* hard timeout so a slow backend can never hang a headless run.
|
|
11
|
+
*/
|
|
12
|
+
const REGISTRY_TIMEOUT_MS = 2500;
|
|
13
|
+
/**
|
|
14
|
+
* Fetch the registry keyed by chain id. Never throws and never hangs — returns an empty map when
|
|
15
|
+
* `apiBase` is absent, the backend is unreachable/slow, or the shape is unexpected. An empty map
|
|
16
|
+
* means "don't annotate", which is exactly the pre-registry behaviour.
|
|
17
|
+
*/
|
|
18
|
+
export async function fetchChainRegistry(apiBase, fetchImpl = fetch) {
|
|
19
|
+
const map = new Map();
|
|
20
|
+
if (!apiBase)
|
|
21
|
+
return map;
|
|
22
|
+
const url = `${apiBase.replace(/\/+$/, '')}/api/payments/chains`;
|
|
23
|
+
const controller = new AbortController();
|
|
24
|
+
const timer = setTimeout(() => controller.abort(), REGISTRY_TIMEOUT_MS);
|
|
25
|
+
// Don't let the timeout keep a fast-exiting CLI process alive.
|
|
26
|
+
timer.unref?.();
|
|
27
|
+
try {
|
|
28
|
+
const res = await fetchImpl(url, { signal: controller.signal });
|
|
29
|
+
if (!res.ok)
|
|
30
|
+
return map;
|
|
31
|
+
const body = (await res.json());
|
|
32
|
+
const chains = Array.isArray(body?.chains) ? body.chains : [];
|
|
33
|
+
for (const c of chains) {
|
|
34
|
+
if (c && typeof c.id === 'string')
|
|
35
|
+
map.set(c.id, c);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
/* offline / slow / bad shape — annotation is optional, never blocks the command */
|
|
40
|
+
}
|
|
41
|
+
finally {
|
|
42
|
+
clearTimeout(timer);
|
|
43
|
+
}
|
|
44
|
+
return map;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* What can the agent actually do with funds on this chain *today*?
|
|
48
|
+
* - `live` — Sella can settle an outbound provider payment from here → spendable now.
|
|
49
|
+
* - `deposit-only` — accepts deposits, but outbound settle isn't proven yet (fund it, can't spend).
|
|
50
|
+
* - `inbound-only` — settles inbound (agents paying Sella) with no agent-wallet spend, e.g. Stellar.
|
|
51
|
+
* - `unknown` — chain not in the registry (or registry unreachable).
|
|
52
|
+
*/
|
|
53
|
+
export function chainCapability(info) {
|
|
54
|
+
if (!info)
|
|
55
|
+
return 'unknown';
|
|
56
|
+
if (info.settle?.outbound === true)
|
|
57
|
+
return 'live';
|
|
58
|
+
if (info.funding?.crypto === true)
|
|
59
|
+
return 'deposit-only';
|
|
60
|
+
if (info.settle?.inbound === true)
|
|
61
|
+
return 'inbound-only';
|
|
62
|
+
return 'unknown';
|
|
63
|
+
}
|
|
64
|
+
export function capabilityLabel(cap) {
|
|
65
|
+
switch (cap) {
|
|
66
|
+
case 'live':
|
|
67
|
+
return 'live · spendable';
|
|
68
|
+
case 'deposit-only':
|
|
69
|
+
return 'deposit-only · settle pending';
|
|
70
|
+
case 'inbound-only':
|
|
71
|
+
return 'inbound-only';
|
|
72
|
+
default:
|
|
73
|
+
return '';
|
|
74
|
+
}
|
|
75
|
+
}
|
package/dist/clients.js
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import * as fs from 'node:fs';
|
|
2
|
+
import * as path from 'node:path';
|
|
3
|
+
export function defaultEnv() {
|
|
4
|
+
return {
|
|
5
|
+
platform: process.platform,
|
|
6
|
+
home: process.env.HOME || process.env.USERPROFILE || '',
|
|
7
|
+
appData: process.env.APPDATA,
|
|
8
|
+
};
|
|
9
|
+
}
|
|
10
|
+
function appSupport(env, ...segments) {
|
|
11
|
+
if (env.platform === 'darwin')
|
|
12
|
+
return path.join(env.home, 'Library', 'Application Support', ...segments);
|
|
13
|
+
if (env.platform === 'win32')
|
|
14
|
+
return path.join(env.appData || path.join(env.home, 'AppData', 'Roaming'), ...segments);
|
|
15
|
+
return path.join(env.home, '.config', ...segments);
|
|
16
|
+
}
|
|
17
|
+
function httpEntry(mcpUrl, apiKey) {
|
|
18
|
+
return {
|
|
19
|
+
url: mcpUrl,
|
|
20
|
+
...(apiKey ? { headers: { Authorization: `Bearer ${apiKey}` } } : {}),
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
/** Claude Desktop and Cline speak stdio only — bridge to the remote endpoint via mcp-remote. */
|
|
24
|
+
function stdioBridgeEntry(mcpUrl, apiKey) {
|
|
25
|
+
return {
|
|
26
|
+
command: 'npx',
|
|
27
|
+
args: ['-y', 'mcp-remote', mcpUrl, ...(apiKey ? ['--header', `Authorization: Bearer ${apiKey}`] : [])],
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
const CLIENTS = [
|
|
31
|
+
{
|
|
32
|
+
id: 'claude-code',
|
|
33
|
+
name: 'Claude Code',
|
|
34
|
+
marker: (env) => path.join(env.home, '.claude'),
|
|
35
|
+
configPath: (env) => path.join(env.home, '.claude.json'),
|
|
36
|
+
apply: (doc, mcpUrl, apiKey) => {
|
|
37
|
+
doc.mcpServers = doc.mcpServers || {};
|
|
38
|
+
doc.mcpServers.sella = { type: 'http', ...httpEntry(mcpUrl, apiKey) };
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
id: 'claude-desktop',
|
|
43
|
+
name: 'Claude Desktop',
|
|
44
|
+
marker: (env) => appSupport(env, 'Claude'),
|
|
45
|
+
configPath: (env) => appSupport(env, 'Claude', 'claude_desktop_config.json'),
|
|
46
|
+
apply: (doc, mcpUrl, apiKey) => {
|
|
47
|
+
doc.mcpServers = doc.mcpServers || {};
|
|
48
|
+
doc.mcpServers.sella = stdioBridgeEntry(mcpUrl, apiKey);
|
|
49
|
+
},
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
id: 'cursor',
|
|
53
|
+
name: 'Cursor',
|
|
54
|
+
marker: (env) => path.join(env.home, '.cursor'),
|
|
55
|
+
configPath: (env) => path.join(env.home, '.cursor', 'mcp.json'),
|
|
56
|
+
apply: (doc, mcpUrl, apiKey) => {
|
|
57
|
+
doc.mcpServers = doc.mcpServers || {};
|
|
58
|
+
doc.mcpServers.sella = httpEntry(mcpUrl, apiKey);
|
|
59
|
+
},
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
id: 'windsurf',
|
|
63
|
+
name: 'Windsurf',
|
|
64
|
+
marker: (env) => path.join(env.home, '.codeium', 'windsurf'),
|
|
65
|
+
configPath: (env) => path.join(env.home, '.codeium', 'windsurf', 'mcp_config.json'),
|
|
66
|
+
apply: (doc, mcpUrl, apiKey) => {
|
|
67
|
+
doc.mcpServers = doc.mcpServers || {};
|
|
68
|
+
const entry = { serverUrl: mcpUrl };
|
|
69
|
+
if (apiKey)
|
|
70
|
+
entry.headers = { Authorization: `Bearer ${apiKey}` };
|
|
71
|
+
doc.mcpServers.sella = entry;
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
id: 'vscode',
|
|
76
|
+
name: 'VS Code',
|
|
77
|
+
marker: (env) => appSupport(env, 'Code', 'User'),
|
|
78
|
+
configPath: (env) => appSupport(env, 'Code', 'User', 'mcp.json'),
|
|
79
|
+
apply: (doc, mcpUrl, apiKey) => {
|
|
80
|
+
doc.servers = doc.servers || {};
|
|
81
|
+
doc.servers.sella = { type: 'http', ...httpEntry(mcpUrl, apiKey) };
|
|
82
|
+
},
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
id: 'cline',
|
|
86
|
+
name: 'Cline',
|
|
87
|
+
marker: (env) => appSupport(env, 'Code', 'User', 'globalStorage', 'saoudrizwan.claude-dev'),
|
|
88
|
+
configPath: (env) => appSupport(env, 'Code', 'User', 'globalStorage', 'saoudrizwan.claude-dev', 'settings', 'cline_mcp_settings.json'),
|
|
89
|
+
apply: (doc, mcpUrl, apiKey) => {
|
|
90
|
+
doc.mcpServers = doc.mcpServers || {};
|
|
91
|
+
doc.mcpServers.sella = stdioBridgeEntry(mcpUrl, apiKey);
|
|
92
|
+
},
|
|
93
|
+
},
|
|
94
|
+
];
|
|
95
|
+
export function detectClients(env = defaultEnv()) {
|
|
96
|
+
return CLIENTS.map((spec) => ({
|
|
97
|
+
id: spec.id,
|
|
98
|
+
name: spec.name,
|
|
99
|
+
installed: fs.existsSync(spec.marker(env)),
|
|
100
|
+
configPath: spec.configPath(env),
|
|
101
|
+
}));
|
|
102
|
+
}
|
|
103
|
+
/** Merge the Sella server entry into one client's config. Never touches other entries. */
|
|
104
|
+
export function installSellaIntoClient(id, opts) {
|
|
105
|
+
const env = opts.env || defaultEnv();
|
|
106
|
+
const spec = CLIENTS.find((c) => c.id === id);
|
|
107
|
+
if (!spec)
|
|
108
|
+
throw new Error(`Unknown client: ${id}`);
|
|
109
|
+
const configPath = spec.configPath(env);
|
|
110
|
+
const exists = fs.existsSync(configPath);
|
|
111
|
+
let doc = {};
|
|
112
|
+
if (exists) {
|
|
113
|
+
const raw = fs.readFileSync(configPath, 'utf8').trim();
|
|
114
|
+
if (raw) {
|
|
115
|
+
try {
|
|
116
|
+
doc = JSON.parse(raw);
|
|
117
|
+
}
|
|
118
|
+
catch {
|
|
119
|
+
throw new Error(`${spec.name} config at ${configPath} is not valid JSON — fix or remove it, then re-run.`);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
const before = JSON.stringify(doc);
|
|
124
|
+
spec.apply(doc, opts.mcpUrl, opts.apiKey);
|
|
125
|
+
const after = JSON.stringify(doc, null, 2);
|
|
126
|
+
if (exists && JSON.stringify(JSON.parse(before)) === JSON.stringify(JSON.parse(after))) {
|
|
127
|
+
return { id, configPath, action: 'unchanged' };
|
|
128
|
+
}
|
|
129
|
+
let backupPath;
|
|
130
|
+
if (!opts.dryRun) {
|
|
131
|
+
fs.mkdirSync(path.dirname(configPath), { recursive: true });
|
|
132
|
+
if (exists && before.trim() && before !== '{}') {
|
|
133
|
+
backupPath = `${configPath}.sella-backup`;
|
|
134
|
+
if (!fs.existsSync(backupPath))
|
|
135
|
+
fs.writeFileSync(backupPath, before === '' ? '' : JSON.stringify(JSON.parse(before), null, 2));
|
|
136
|
+
}
|
|
137
|
+
fs.writeFileSync(configPath, after + '\n');
|
|
138
|
+
}
|
|
139
|
+
return { id, configPath, action: exists ? 'updated' : 'created', backupPath };
|
|
140
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import * as fs from 'node:fs';
|
|
2
|
+
import * as path from 'node:path';
|
|
3
|
+
import { execFileSync } from 'node:child_process';
|
|
4
|
+
const KEYCHAIN_SERVICE = 'sella-cli';
|
|
5
|
+
function defaultExec(file, args) {
|
|
6
|
+
execFileSync(file, args, { stdio: 'ignore' });
|
|
7
|
+
}
|
|
8
|
+
function write600(filePath, data) {
|
|
9
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
10
|
+
fs.writeFileSync(filePath, JSON.stringify(data, null, 2) + '\n', { mode: 0o600 });
|
|
11
|
+
}
|
|
12
|
+
/** macOS keychain via the built-in `security` CLI (no native deps). Other OSes: file fallback. */
|
|
13
|
+
function tryKeychain(apiKey, env, execFile) {
|
|
14
|
+
if (env.platform !== 'darwin')
|
|
15
|
+
return false;
|
|
16
|
+
try {
|
|
17
|
+
execFile('security', ['add-generic-password', '-U', '-s', KEYCHAIN_SERVICE, '-a', 'default', '-w', apiKey]);
|
|
18
|
+
return true;
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
return false;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
export function saveCredentials(payload, opts) {
|
|
25
|
+
const { env } = opts;
|
|
26
|
+
const files = [];
|
|
27
|
+
if (payload.walletConfig) {
|
|
28
|
+
const p = path.join(env.home, '.sella-wallet.json');
|
|
29
|
+
write600(p, payload.walletConfig);
|
|
30
|
+
files.push(p);
|
|
31
|
+
}
|
|
32
|
+
if (payload.agentWallet?.status === 'provisioned' && payload.agentWallet.config) {
|
|
33
|
+
const p = path.join(env.home, '.sella-agentwallet', 'config.json');
|
|
34
|
+
write600(p, payload.agentWallet.config);
|
|
35
|
+
files.push(p);
|
|
36
|
+
}
|
|
37
|
+
const keychainOk = Boolean(payload.apiKey) && opts.allowKeychain && tryKeychain(payload.apiKey, env, opts.execFile || defaultExec);
|
|
38
|
+
if (!keychainOk && payload.apiKey) {
|
|
39
|
+
const p = path.join(env.home, '.sella', 'credentials.json');
|
|
40
|
+
write600(p, {
|
|
41
|
+
apiKey: payload.apiKey,
|
|
42
|
+
mcpServerUrl: payload.mcpServerUrl || '',
|
|
43
|
+
createdAt: new Date().toISOString(),
|
|
44
|
+
});
|
|
45
|
+
files.push(p);
|
|
46
|
+
}
|
|
47
|
+
const custodyNote = keychainOk
|
|
48
|
+
? `API key stored in the macOS keychain (service "${KEYCHAIN_SERVICE}"). Wallet files are owner-read-only; client configs carry the key in plaintext — that is how MCP clients read headers today.`
|
|
49
|
+
: `Secrets stored as owner-read-only files (0600)${env.platform === 'win32' ? ' — on Windows, protect your user profile' : ''}. Client configs carry the key in plaintext — that is how MCP clients read headers today. Never commit or share these files.`;
|
|
50
|
+
return { files, keyStorage: keychainOk ? 'keychain' : 'file', custodyNote };
|
|
51
|
+
}
|
package/dist/doctor.js
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import * as fs from 'node:fs';
|
|
2
|
+
import * as path from 'node:path';
|
|
3
|
+
import { execFileSync } from 'node:child_process';
|
|
4
|
+
import { fetchChainRegistry, chainCapability } from './chains.js';
|
|
5
|
+
const KEYCHAIN_SERVICE = 'sella-cli';
|
|
6
|
+
function defaultExecOut(file, args) {
|
|
7
|
+
return execFileSync(file, args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
|
|
8
|
+
}
|
|
9
|
+
/** Stored API key: macOS keychain first, then the 0600 fallback file. */
|
|
10
|
+
export function loadStoredKey(env, execOut = defaultExecOut) {
|
|
11
|
+
if (env.platform === 'darwin') {
|
|
12
|
+
try {
|
|
13
|
+
const key = execOut('security', ['find-generic-password', '-s', KEYCHAIN_SERVICE, '-a', 'default', '-w']).trim();
|
|
14
|
+
if (key)
|
|
15
|
+
return { apiKey: key, source: 'keychain' };
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
/* not in keychain — fall through */
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
try {
|
|
22
|
+
const raw = fs.readFileSync(path.join(env.home, '.sella', 'credentials.json'), 'utf8');
|
|
23
|
+
const parsed = JSON.parse(raw);
|
|
24
|
+
if (parsed.apiKey)
|
|
25
|
+
return { apiKey: parsed.apiKey, source: 'file' };
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
/* no file */
|
|
29
|
+
}
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
async function rpc(mcpUrl, body, fetchImpl, apiKey) {
|
|
33
|
+
const res = await fetchImpl(mcpUrl, {
|
|
34
|
+
method: 'POST',
|
|
35
|
+
headers: {
|
|
36
|
+
'content-type': 'application/json',
|
|
37
|
+
...(apiKey ? { authorization: `Bearer ${apiKey}` } : {}),
|
|
38
|
+
},
|
|
39
|
+
body: JSON.stringify({ jsonrpc: '2.0', id: 'doctor', ...body }),
|
|
40
|
+
});
|
|
41
|
+
if (!res.ok)
|
|
42
|
+
throw new Error(`HTTP ${res.status}`);
|
|
43
|
+
return res.json();
|
|
44
|
+
}
|
|
45
|
+
export async function runDoctor(opts) {
|
|
46
|
+
const fetchImpl = opts.fetchImpl || fetch;
|
|
47
|
+
const checks = [];
|
|
48
|
+
const origin = opts.mcpUrl.replace(/\/api\/mcp\/?$/, '');
|
|
49
|
+
// 1. endpoint — server reachable, speaks JSON-RPC
|
|
50
|
+
let toolNames = [];
|
|
51
|
+
try {
|
|
52
|
+
const listed = await rpc(opts.mcpUrl, { method: 'tools/list', params: {} }, fetchImpl);
|
|
53
|
+
toolNames = (listed?.result?.tools || []).map((t) => String(t.name));
|
|
54
|
+
checks.push({
|
|
55
|
+
id: 'endpoint',
|
|
56
|
+
label: 'Sella MCP endpoint reachable',
|
|
57
|
+
ok: toolNames.length > 0,
|
|
58
|
+
detail: `${opts.mcpUrl} — ${toolNames.length} tools advertised`,
|
|
59
|
+
...(toolNames.length === 0 ? { fix: 'The server answered but advertised no tools — check SELLA_MCP_URL points at /api/mcp.' } : {}),
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
catch (err) {
|
|
63
|
+
checks.push({
|
|
64
|
+
id: 'endpoint',
|
|
65
|
+
label: 'Sella MCP endpoint reachable',
|
|
66
|
+
ok: false,
|
|
67
|
+
detail: err instanceof Error ? err.message : 'network error',
|
|
68
|
+
fix: `Cannot reach ${opts.mcpUrl}. Check your connection/proxy (HTTPS_PROXY) or set SELLA_MCP_URL.`,
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
// 2. bootstrap — onboarding tools advertised (pairing possible from this machine)
|
|
72
|
+
if (toolNames.length) {
|
|
73
|
+
const hasBootstrap = toolNames.includes('sella_setup_claim') && toolNames.includes('sella_auth_start');
|
|
74
|
+
checks.push({
|
|
75
|
+
id: 'bootstrap',
|
|
76
|
+
label: 'Onboarding tools advertised',
|
|
77
|
+
ok: hasBootstrap,
|
|
78
|
+
detail: hasBootstrap ? 'sella_setup_claim + sella_auth_* available' : `advertised: ${toolNames.slice(0, 6).join(', ')}…`,
|
|
79
|
+
...(hasBootstrap ? {} : { fix: 'The endpoint is not a Sella MCP server — fix SELLA_MCP_URL.' }),
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
// 3. credentials — stored key present
|
|
83
|
+
const stored = loadStoredKey(opts.env, opts.execOut);
|
|
84
|
+
checks.push({
|
|
85
|
+
id: 'credentials',
|
|
86
|
+
label: 'Stored API key',
|
|
87
|
+
ok: Boolean(stored),
|
|
88
|
+
detail: stored ? `found in ${stored.source}` : 'no key in keychain or ~/.sella/credentials.json',
|
|
89
|
+
...(stored ? {} : { fix: 'Run `sella pair` (or `sella init`) to pair this machine.' }),
|
|
90
|
+
});
|
|
91
|
+
// 4. auth — the stored key actually authenticates a protected call
|
|
92
|
+
if (stored) {
|
|
93
|
+
try {
|
|
94
|
+
const called = await rpc(opts.mcpUrl, { method: 'tools/call', params: { name: 'list_datasets', arguments: { limit: 1 } } }, fetchImpl, stored.apiKey);
|
|
95
|
+
const isError = Boolean(called?.result?.isError) || Boolean(called?.error);
|
|
96
|
+
checks.push({
|
|
97
|
+
id: 'auth',
|
|
98
|
+
label: 'API key authenticates',
|
|
99
|
+
ok: !isError,
|
|
100
|
+
detail: isError ? 'tools/call rejected the key' : 'authenticated tools/call succeeded',
|
|
101
|
+
...(isError ? { fix: 'The key was likely rotated or revoked (Settings → Connected agents). Re-run `sella pair`.' } : {}),
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
catch (err) {
|
|
105
|
+
checks.push({
|
|
106
|
+
id: 'auth',
|
|
107
|
+
label: 'API key authenticates',
|
|
108
|
+
ok: false,
|
|
109
|
+
detail: err instanceof Error ? err.message : 'call failed',
|
|
110
|
+
fix: 'The key was rejected (401). Re-run `sella pair` to mint a fresh one.',
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
// 5. wallets — local signing config present and parseable
|
|
115
|
+
const walletPath = path.join(opts.env.home, '.sella-wallet.json');
|
|
116
|
+
const agentWalletPath = path.join(opts.env.home, '.sella-agentwallet', 'config.json');
|
|
117
|
+
let walletOk = false;
|
|
118
|
+
let walletDetail = 'missing ~/.sella-wallet.json';
|
|
119
|
+
try {
|
|
120
|
+
JSON.parse(fs.readFileSync(walletPath, 'utf8'));
|
|
121
|
+
walletOk = true;
|
|
122
|
+
walletDetail = `${walletPath} ok${fs.existsSync(agentWalletPath) ? ' · AgentWallet config ok' : ' · AgentWallet config absent (proxy payments off — re-run `sella pair` later)'}`;
|
|
123
|
+
}
|
|
124
|
+
catch {
|
|
125
|
+
/* stays missing/corrupt */
|
|
126
|
+
}
|
|
127
|
+
checks.push({
|
|
128
|
+
id: 'wallets',
|
|
129
|
+
label: 'Wallet config files',
|
|
130
|
+
ok: walletOk,
|
|
131
|
+
detail: walletDetail,
|
|
132
|
+
...(walletOk ? {} : { fix: 'Run `sella pair` — wallet keys are delivered once at pairing.' }),
|
|
133
|
+
});
|
|
134
|
+
// 6. quote — paid-call economics computable (price + platform fee + settleable chains)
|
|
135
|
+
try {
|
|
136
|
+
const list = await (await fetchImpl(`${origin}/api/market/catalog?limit=1`)).json();
|
|
137
|
+
const slug = list?.providers?.[0]?.id;
|
|
138
|
+
const detail = slug ? (await (await fetchImpl(`${origin}/api/market/catalog/${encodeURIComponent(slug)}`)).json()) : null;
|
|
139
|
+
const quote = detail?.quote;
|
|
140
|
+
checks.push({
|
|
141
|
+
id: 'quote',
|
|
142
|
+
label: 'x402 pay quote',
|
|
143
|
+
ok: Boolean(quote),
|
|
144
|
+
detail: quote
|
|
145
|
+
? `${slug}: $${quote.totalUSD} total (${quote.feeBps} bps fee) on ${quote.payChains?.join('/') || 'no chains'}`
|
|
146
|
+
: 'catalog reachable but no quote returned',
|
|
147
|
+
...(quote ? {} : { fix: 'Catalog/quote API unavailable — paid calls may still work; check the server logs.' }),
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
catch (err) {
|
|
151
|
+
checks.push({
|
|
152
|
+
id: 'quote',
|
|
153
|
+
label: 'x402 pay quote',
|
|
154
|
+
ok: false,
|
|
155
|
+
detail: err instanceof Error ? err.message : 'quote fetch failed',
|
|
156
|
+
fix: 'Could not fetch a pay quote from the catalog API — check the marketplace is up.',
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
return { ok: checks.every((c) => c.ok), checks };
|
|
160
|
+
}
|
|
161
|
+
/** `sella status`: who am I + AgentWallet balances when the proxy config exists. */
|
|
162
|
+
export async function runStatus(opts) {
|
|
163
|
+
const stored = loadStoredKey(opts.env, opts.execOut);
|
|
164
|
+
const out = {
|
|
165
|
+
key: stored ? { present: true, source: stored.source, prefix: stored.apiKey.slice(0, 16) } : { present: false },
|
|
166
|
+
};
|
|
167
|
+
const agentWalletPath = path.join(opts.env.home, '.sella-agentwallet', 'config.json');
|
|
168
|
+
try {
|
|
169
|
+
const config = JSON.parse(fs.readFileSync(agentWalletPath, 'utf8'));
|
|
170
|
+
out.agentWallet = { username: config.username, apiBase: config.apiBase };
|
|
171
|
+
if (config.apiBase && config.username && config.apiToken) {
|
|
172
|
+
const res = await (opts.fetchImpl || fetch)(`${config.apiBase}/api/wallets/${encodeURIComponent(config.username)}`, {
|
|
173
|
+
headers: { authorization: `Bearer ${config.apiToken}` },
|
|
174
|
+
});
|
|
175
|
+
if (res.ok) {
|
|
176
|
+
const wallet = (await res.json());
|
|
177
|
+
// Annotate each wallet with what it can do today (spendable vs deposit-only) from the
|
|
178
|
+
// live registry. Best-effort: if the registry is unreachable, wallets pass through as-is.
|
|
179
|
+
const registry = await fetchChainRegistry(config.apiBase, opts.fetchImpl || fetch);
|
|
180
|
+
const wallets = Array.isArray(wallet.wallets) && registry.size > 0
|
|
181
|
+
? wallet.wallets.map((w) => {
|
|
182
|
+
const spec = registry.get(String(w.chain));
|
|
183
|
+
return spec ? { ...w, capability: chainCapability(spec) } : w;
|
|
184
|
+
})
|
|
185
|
+
: wallet.wallets;
|
|
186
|
+
out.agentWallet = { ...out.agentWallet, fundingStatus: wallet.fundingStatus, wallets };
|
|
187
|
+
}
|
|
188
|
+
else {
|
|
189
|
+
out.agentWallet = { ...out.agentWallet, error: `backend responded ${res.status}` };
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
catch {
|
|
194
|
+
out.agentWallet = { present: false, hint: 'Re-run `sella pair` to provision the AgentWallet.' };
|
|
195
|
+
}
|
|
196
|
+
return out;
|
|
197
|
+
}
|
package/dist/fund.js
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import * as fs from 'node:fs';
|
|
2
|
+
import * as path from 'node:path';
|
|
3
|
+
import { fetchChainRegistry, chainCapability } from './chains.js';
|
|
4
|
+
function platformOrigin(mcpUrl) {
|
|
5
|
+
return mcpUrl.replace(/\/api\/mcp\/?$/, '') || 'https://sella.network';
|
|
6
|
+
}
|
|
7
|
+
export function getFundingInfo(env, mcpUrl = 'https://sella.network/api/mcp') {
|
|
8
|
+
const fundingUrl = `${platformOrigin(mcpUrl)}/dashboard/funding`;
|
|
9
|
+
const configPath = path.join(env.home, '.sella-agentwallet', 'config.json');
|
|
10
|
+
let config = null;
|
|
11
|
+
try {
|
|
12
|
+
config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
config = null;
|
|
16
|
+
}
|
|
17
|
+
if (!config?.wallets) {
|
|
18
|
+
return {
|
|
19
|
+
paired: false,
|
|
20
|
+
wallets: [],
|
|
21
|
+
fundingUrl,
|
|
22
|
+
note: 'No AgentWallet on this machine yet. Run `sella pair` (or `sella init`) to provision it, then re-run `sella fund`.',
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
const wallets = Object.entries(config.wallets)
|
|
26
|
+
.filter(([, v]) => v && typeof v.address === 'string' && v.address)
|
|
27
|
+
.map(([chain, v]) => ({ chain, address: v.address }));
|
|
28
|
+
return {
|
|
29
|
+
paired: wallets.length > 0,
|
|
30
|
+
username: typeof config.username === 'string' ? config.username : undefined,
|
|
31
|
+
apiBase: typeof config.apiBase === 'string' ? config.apiBase : undefined,
|
|
32
|
+
wallets,
|
|
33
|
+
fundingUrl,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Best-effort annotation of each wallet with its live capability from the backend registry.
|
|
38
|
+
* When the backend is unreachable the info is returned unchanged (no `capability` field), so
|
|
39
|
+
* `sella fund` still works fully offline — it just can't say "spendable vs deposit-only".
|
|
40
|
+
*/
|
|
41
|
+
export async function annotateFunding(info, fetchImpl = fetch) {
|
|
42
|
+
if (!info.paired || !info.apiBase)
|
|
43
|
+
return info;
|
|
44
|
+
const registry = await fetchChainRegistry(info.apiBase, fetchImpl);
|
|
45
|
+
if (registry.size === 0)
|
|
46
|
+
return info;
|
|
47
|
+
return {
|
|
48
|
+
...info,
|
|
49
|
+
wallets: info.wallets.map((w) => {
|
|
50
|
+
const spec = registry.get(w.chain);
|
|
51
|
+
return spec ? { ...w, capability: chainCapability(spec) } : w;
|
|
52
|
+
}),
|
|
53
|
+
};
|
|
54
|
+
}
|