Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ Either way you get a real paid API response. No API keys. No native ETH. No brid
| `selat skill run <name> [--param value ...]` | Run an installed agent skill, passing its params as `--flags`. `--max-amount` is hard-capped at **$1.10** for everyone (`isTTY` / `--allow-high-max-amount` do not raise it). Requires an armed session budget (`selat budget start` writing `session.json`; env cannot arm). |
| `selat skill compare "<intent>" --live-probe [--limit N] [--json] [--pay]` | Vet catalog candidates for an intent **side-by-side, without accounts or keys**: shortlists the top N (default 5) via the same federated discovery as `selat search`, free-probes each candidate's live 402 at its **catalog serviceUrl** (never settles; `--live-probe` is required — it acknowledges the probe may invoke each target's method/body), and prints one aligned table — live price, rail (direct x402 / routed MPP), probe latency, reachability, and the selat-skills registry reliability badge — sorted reachable-first, then price. `--json` for machine consumption. `--pay` adds one **capped settled test call** per candidate (asks for confirmation first; `--yes` to authorize non-interactively; `--max-amount` overrides the per-call cap, which otherwise defaults to live price + 25 % clamped to $1.10 — an explicit flag is also hard-capped at $1.10 for everyone; `isTTY` / `--allow-high-max-amount` do not raise it; requires a file-armed session budget) and saves each response body as an output sample. Apify prepaid-token candidates are probed but skipped by `--pay` — test those with `selat run`. |
| `selat skill new/validate/verify/register/submit` | Author and contribute a skill: scaffold → static SOP check → live-402 verify (writes the receipt that gates submission) → index entry → PR to [selat-skills](https://github.com/SELAT-AI/selat-skills). `submit` opens the PR **from your fork by default** unless you have write access to the skills repo; `--fork` / `--no-fork` force either flow. |
| `selat fund [--chain ... --amount ... --method direct\|eco]` | Top up Gateway balance. Dry-runs first; requires explicit confirm. Refused while `selat freeze` is on. **Deposits have a 0.5 USDC minimum** (the Circle CLI's Gateway floor — `fund` refuses smaller amounts up front rather than mid-flow). The eco path requires **Circle CLI ≥1.0.0** and refuses older installs (their eco pipeline is the retired one that stranded deposits; fix with `circle update`). **Both methods are gasless** — the deposit runs through your agent wallet (a smart-contract account) with sponsored gas, so you never need to hold native ETH. The difference is **destination**: **`--method direct`** keeps the balance on the chain you deposited from; **`--method eco`** sources from Base (Circle CLI ≥1.0.0's eco coverage) but settles the resulting Gateway balance on **Polygon** regardless of source chain. After an Eco deposit, pay and check balance with `--chain polygon` (not the source chain), or the call fails with `insufficient_balance`. Fundable chains (base, optimism, arbitrum, polygon, ethereum, avalanche, unichain) are the intersection of [Gateway-supported](https://developers.circle.com/gateway/references/supported-blockchains) and [Agent-Wallet-supported](https://developers.circle.com/agent-stack/agent-wallets/supported-blockchains) chains — Gateway-only chains (e.g. HyperEVM, Sei, Sonic, World Chain) are excluded because the agent wallet doesn't exist there, and Agent-Wallet-only chains (e.g. Monad) have no Gateway deposit path. |
| `selat fund [--chain ... --amount ... --method direct\|eco]` | Top up Gateway balance. Dry-runs first; requires explicit confirm. Refused while `selat freeze` is on. **Deposits have a 0.5 USDC minimum** (the Circle CLI's Gateway floor — `fund` refuses smaller amounts up front rather than mid-flow). `selat` requires **Circle CLI ≥1.1.1** (`selat init` upgrades an older install; `selat doctor` reports the version). The eco path additionally refuses pre-1.0.0 installs (their eco pipeline is the retired one that stranded deposits). **Both methods are gasless** — the deposit runs through your agent wallet (a smart-contract account) with sponsored gas, so you never need to hold native ETH. The difference is **destination**: **`--method direct`** keeps the balance on the chain you deposited from; **`--method eco`** sources from Base (Circle CLI ≥1.0.0's eco coverage) but settles the resulting Gateway balance on **Polygon** regardless of source chain. After an Eco deposit, pay and check balance with `--chain polygon` (not the source chain), or the call fails with `insufficient_balance`. Fundable chains (base, optimism, arbitrum, polygon, ethereum, avalanche, unichain, arc — direct only) are the intersection of [Gateway-supported](https://developers.circle.com/gateway/references/supported-blockchains) and [Agent-Wallet-supported](https://developers.circle.com/agent-stack/agent-wallets/supported-blockchains) chains — Gateway-only chains (e.g. HyperEVM, Sei, Sonic, World Chain) are excluded because the agent wallet doesn't exist there, and Agent-Wallet-only chains (e.g. Monad) have no Gateway deposit path. |
| `selat fund --onramp [--address 0x..]` | **Fiat funding (buy USDC with a card).** Mints a **Circle Onramp** session and prints/opens a browser widget URL — you pick the amount in the widget, and nothing moves until you complete the purchase there (the CLI never sees your card). Sessions are **chain-scoped** to the chains `selat fund` can deposit from, so purchased USDC never lands somewhere it would strand. `--address` targets any of your agent wallets (default: the configured one; a warning prints when they differ). This is SELAT's built-in onramp — **not** Transak (`circle wallet fund --method fiat`), which is a different, unscoped flow. Once the purchase lands on-chain, re-run `selat fund` to deposit into Gateway. |
| `selat spend [--json\|--wallet 0x..]` | Unified spend report (read-only): settled spend from the `selat-pay` ledger (per-call payments + Apify token buys, with a charged-but-failed/disputable total) plus Apify token utilization (consumed vs remaining, flagging prepaid-balance waste). |
| `selat refund claim\|query <quote-id> [--chain …]` | File or query a refund claim for a prior paid `selatx…` quote via `selat-pay`. Auth (SIWx), not a payment — does not consume session budget or `--max-amount`. Remaining flags (`--chain`, `--router-url`, signer flags) pass through unchanged. |
Expand Down
80 changes: 67 additions & 13 deletions lib/circle.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { randomUUID } from "node:crypto";
import { tmpdir } from "node:os";
import { join } from "node:path";

import { sh, hasBin } from "./sh.mjs";
import { sh, hasBin, binVersion } from "./sh.mjs";
import { readConfig } from "./config.mjs";
import { adjustCircleCache, cachedCall, invalidateCircleCache } from "./circle-cache.mjs";

Expand Down Expand Up @@ -53,25 +53,79 @@ export async function hasCircle() {
}

/**
* Ensure the Circle CLI is installed. No-op if `circle` is already on PATH;
* otherwise install @circle-fin/cli globally. Installing the binary moves no
* money — wallet login (OTP), creation, and funding still happen interactively
* under the user's own Circle account, so self-custody is preserved.
* Oldest Circle CLI this stack works with. 1.1.1 is the first release that
* lists Arc mainnet (ARC) and accepts it as a `gateway deposit` source; the
* selat-discovery skill's Arc deposit path assumes it. Mirrored by the skill's
* `@circle-fin/cli` peer floor.
*/
export const MIN_CIRCLE_CLI_VERSION = "1.1.1";

/** Installed Circle CLI version ("1.1.1"), or null when unreadable / absent. */
export async function circleVersion() {
if (!(await hasCircle())) return null;
const line = await binVersion(CIRCLE_BIN);
return line?.match(/\d+\.\d+\.\d+/)?.[0] ?? null;
}

/** "1.1.1" >= "1.0.0" on dotted numeric versions. Unparseable → false. */
export function versionAtLeast(version, min) {
if (!version) return false;
const parts = (v) => String(v).trim().split(".").map((n) => Number(n));
const [a, b] = [parts(version), parts(min)];
if (a.some((n) => !Number.isFinite(n))) return false;
for (let i = 0; i < 3; i++) {
const d = (a[i] ?? 0) - (b[i] ?? 0);
if (d !== 0) return d > 0;
}
return true;
}

/**
* What ensureCircle has to do: "ok" (installed and at/above the floor),
* "upgrade" (installed but older — or unreadable, which is treated as older
* so a broken shim gets reinstalled), or "install". Pure; exported for tests.
*/
export function circleInstallPlan({ installed, version, min = MIN_CIRCLE_CLI_VERSION }) {
if (!installed) return "install";
return versionAtLeast(version, min) ? "ok" : "upgrade";
}

/**
* Ensure the Circle CLI is installed at or above MIN_CIRCLE_CLI_VERSION.
* No-op when it already is; otherwise `npm install -g @circle-fin/cli@latest`
* (which also upgrades an older install in place). Installing the binary
* moves no money — wallet login (OTP), creation, and funding still happen
* interactively under the user's own Circle account, so self-custody is
* preserved.
*
* Mirrors scripts/install-circle-cli.sh: retry with an isolated temp cache if
* the default npm cache is broken (some agent hosts inherit stale root-owned
* npm state). Returns true if `circle` resolves on PATH afterward.
*/
export async function ensureCircle() {
if (await hasCircle()) return true;
* npm state). Returns true if `circle` resolves on PATH at/above the floor
* afterward.
*/
export async function ensureCircle({ log = () => {} } = {}) {
const plan = circleInstallPlan({ installed: await hasCircle(), version: await circleVersion() });
if (plan === "ok") return true;
// A global npm install is a host mutation. Refuse it under the test runner
// (a fixture that mis-answers --version must not upgrade the developer's
// real Circle CLI) and when the caller opts out explicitly.
if (process.env.NODE_TEST_CONTEXT || process.env.SELAT_NO_INSTALL === "1") {
log(`Circle CLI ${plan} needed but installs are disabled (SELAT_NO_INSTALL / test context).`);
return false;
}
if (!(await hasBin("npm"))) return false;
let r = await sh("npm", ["install", "-g", "@circle-fin/cli"], { inherit: true });
if (r.code === 0 && (await hasCircle())) return true;
if (plan === "upgrade") {
log(`Circle CLI ${(await circleVersion()) ?? "(unreadable)"} is below the ${MIN_CIRCLE_CLI_VERSION} floor — upgrading…`);
}
const spec = "@circle-fin/cli@latest";
const upToDate = async () => versionAtLeast(await circleVersion(), MIN_CIRCLE_CLI_VERSION);
let r = await sh("npm", ["install", "-g", spec], { inherit: true });
if (r.code === 0 && (await upToDate())) return true;
// os.tmpdir() honours TMPDIR on POSIX and TEMP/TMP on Windows, where the
// previous `/tmp` fallback was not a valid path.
const cache = join(tmpdir(), "selat-circle-npm-cache");
r = await sh("npm", ["install", "-g", "@circle-fin/cli", "--cache", cache], { inherit: true });
return r.code === 0 && (await hasCircle());
r = await sh("npm", ["install", "-g", spec, "--cache", cache], { inherit: true });
return r.code === 0 && (await upToDate());
}

/**
Expand Down
8 changes: 6 additions & 2 deletions lib/commands/doctor.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@ import { sh, binVersion } from "../sh.mjs";
import {
spendingPolicyByChain,
describeChainPolicy, authStatus, getAgentAddress, gatewayBalancesByChain, listAgentWallets, usdcBalances, hasCircle,
lastCircleReadError, gatewayBalanceCacheKey, GATEWAY_BALANCE_TTL_MS } from "../circle.mjs";
lastCircleReadError, gatewayBalanceCacheKey, GATEWAY_BALANCE_TTL_MS,
circleVersion, versionAtLeast, MIN_CIRCLE_CLI_VERSION } from "../circle.mjs";
import { circleCacheDisabled, circleCacheStatus } from "../circle-cache.mjs";
import { resolveSelatPay, selatPayVersion } from "../selat-pay.mjs";
import { readConfig, configPath } from "../config.mjs";
Expand Down Expand Up @@ -80,7 +81,10 @@ export async function doctor(args = []) {
// Circle CLI
console.log(fmt.bold("\nCircle CLI:"));
if (await hasCircle()) {
pass("circle binary on PATH");
const v = await circleVersion();
if (versionAtLeast(v, MIN_CIRCLE_CLI_VERSION)) pass(`circle ${v} on PATH`);
else if (v) fail(`circle ${v} is below the ${MIN_CIRCLE_CLI_VERSION} floor (Arc mainnet needs it) — run \`selat init\` or: npm install -g @circle-fin/cli@latest`);
else warn(`circle binary on PATH but \`circle --version\` is unreadable — expected >= ${MIN_CIRCLE_CLI_VERSION}`);
const status = await authStatus();
if (status.authed) pass(`authenticated${status.email ? " as " + status.email : ""}`);
else fail("not authenticated — run `selat init`");
Expand Down
25 changes: 16 additions & 9 deletions lib/commands/init.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ import { binVersion, isWindows } from "../sh.mjs";
import {
hasCircle,
ensureCircle,
circleVersion,
versionAtLeast,
MIN_CIRCLE_CLI_VERSION,
authStatus,
login,
createWallets,
Expand Down Expand Up @@ -99,16 +102,20 @@ export async function init(args) {

// [3/8] Circle CLI
console.log(fmt.step(3, STEPS, "Checking Circle CLI"));
if (await hasCircle()) {
console.log(" " + fmt.ok("Circle CLI already on PATH"));
const installedCircle = await circleVersion();
if ((await hasCircle()) && versionAtLeast(installedCircle, MIN_CIRCLE_CLI_VERSION)) {
console.log(" " + fmt.ok(`Circle CLI ${installedCircle} on PATH`));
} else {
// Auto-install the Circle CLI rather than bouncing the user out of setup.
// Installing the binary moves no money — wallet login (OTP), creation, and
// funding still happen interactively under the user's own Circle account
// in the steps below, preserving self-custody.
console.log(" " + fmt.dim("Circle CLI not found — installing @circle-fin/cli…"));
if (await ensureCircle()) {
console.log(" " + fmt.ok("Circle CLI installed"));
// Auto-install (or upgrade) the Circle CLI rather than bouncing the user
// out of setup. Installing the binary moves no money — wallet login (OTP),
// creation, and funding still happen interactively under the user's own
// Circle account in the steps below, preserving self-custody.
const why = (await hasCircle())
? `Circle CLI ${installedCircle ?? "(unreadable version)"} is below the ${MIN_CIRCLE_CLI_VERSION} floor — upgrading`
: "Circle CLI not found — installing";
console.log(" " + fmt.dim(`${why} @circle-fin/cli@latest…`));
if (await ensureCircle({ log: (m) => console.log(" " + fmt.dim(m)) })) {
console.log(" " + fmt.ok(`Circle CLI ${await circleVersion()} ready`));
} else {
console.error(" " + fmt.err("Circle CLI auto-install failed."));
console.error(" Install it manually: npm install -g @circle-fin/cli, then run `selat init` again.");
Expand Down
31 changes: 25 additions & 6 deletions lib/sh.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@

import { spawn, spawnSync } from "node:child_process";
import os from "node:os";
import { statSync } from "node:fs";
import { statSync, accessSync, constants } from "node:fs";
import { join, delimiter } from "node:path";

export const isWindows = process.platform === "win32";

Expand Down Expand Up @@ -151,11 +152,29 @@ export async function hasBin(name) {
const where = await sh("where", [name]).catch(() => ({ code: 1, stdout: "" }));
return where.code === 0 && where.stdout.trim().length > 0;
}
const result = await sh("command", ["-v", name]).catch(() => ({ code: 1 }));
if (result.code === 0 && result.stdout.trim()) return true;
// Fallback: try `which` on macOS/Linux.
const which = await sh("which", [name]).catch(() => ({ code: 1 }));
return which.code === 0 && which.stdout.trim().length > 0;
// Resolve against PATH directly rather than via `command -v` / `which`:
// those are themselves PATH lookups, so under a restricted PATH (test
// sandboxes, minimal containers) they vanish and every binary reads as
// missing — which is how a fixture `circle` went undetected and init
// tried to install one. No subprocess, so it is also cheaper on the
// `selat run` hot path.
return resolveOnPath(name) != null;
}

/** First executable named `name` on PATH, or null. POSIX only. */
export function resolveOnPath(name, { path = process.env.PATH ?? "" } = {}) {
for (const dir of path.split(delimiter)) {
if (!dir) continue;
const candidate = join(dir, name);
try {
if (!statSync(candidate).isFile()) continue;
accessSync(candidate, constants.X_OK);
return candidate;
} catch {
// not there / not executable — keep looking
}
}
return null;
}

/**
Expand Down
6 changes: 3 additions & 3 deletions test/chain-free-policy.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { mkdtempSync, writeFileSync, chmodSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { fileURLToPath } from "node:url";
import { closedEnv } from "./helpers/closed-env.mjs";

// Design principle, REVISED 2026-08-11: the BALANCE and BUDGET surfaces stay
// chain-free (Gateway is one balance; the budget rows read identically on
Expand Down Expand Up @@ -151,15 +152,14 @@ const pexecFile = promisify(execFile);
const selatBin = fileURLToPath(new URL("../bin/selat.mjs", import.meta.url));

function budgetEnv() {
return {
...process.env,
return closedEnv({
CIRCLE_BIN: fakeCircle,
SELAT_AGENT_WALLET_ADDRESS: ADDRESS,
SELAT_PAY_SESSION_PATH: join(dir, "no-session.json"),
SELAT_PAY_HISTORY_PATH: join(dir, "no-history.jsonl"),
SELAT_PAY_FREEZE_PATH: join(dir, "no-freeze.json"),
NO_COLOR: "1"
};
});
}

test("`selat budget` renders the true CUSTOM caps with zero chain language", async () => {
Expand Down
6 changes: 3 additions & 3 deletions test/circle-cache.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileS
import { join } from "node:path";
import { tmpdir } from "node:os";
import { fileURLToPath } from "node:url";
import { closedEnv } from "./helpers/closed-env.mjs";

// The Circle CLI costs 4–17 s per spawn and `selat run` spawned it before
// every payment just to pick the funded --chain. lib/circle-cache.mjs keeps
Expand Down Expand Up @@ -226,8 +227,7 @@ const pexecFile = promisify(execFile);
const selatBin = fileURLToPath(new URL("../bin/selat.mjs", import.meta.url));
const runSelat = (args, extraEnv = {}) =>
pexecFile(process.execPath, [selatBin, ...args], {
env: {
...process.env,
env: closedEnv({
CIRCLE_BIN: fakeCircle,
XDG_STATE_HOME: stateHome,
XDG_CONFIG_HOME: configHome,
Expand All @@ -236,7 +236,7 @@ const runSelat = (args, extraEnv = {}) =>
SELAT_PAY_FREEZE_PATH: join(dir, "no-freeze.json"),
NO_COLOR: "1",
...extraEnv
}
})
}).catch((e) => e);

// A stale-looking entry that says Base holds the funds; the fake circle says
Expand Down
31 changes: 31 additions & 0 deletions test/circle-version-floor.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import test from "node:test";
import assert from "node:assert/strict";

import { MIN_CIRCLE_CLI_VERSION, versionAtLeast, circleInstallPlan } from "../lib/circle.mjs";

// The stack needs Circle CLI >= 1.1.1 (first release that lists Arc mainnet).
// ensureCircle() decides install / upgrade / ok from this pure plan so an
// existing older install is upgraded instead of silently kept.

test("floor is 1.1.1", () => {
assert.equal(MIN_CIRCLE_CLI_VERSION, "1.1.1");
});

test("versionAtLeast compares dotted numerics, rejects garbage", () => {
assert.equal(versionAtLeast("1.1.1", "1.1.1"), true);
assert.equal(versionAtLeast("1.2.0", "1.1.1"), true);
assert.equal(versionAtLeast("2.0.0", "1.1.1"), true);
assert.equal(versionAtLeast("1.1.0", "1.1.1"), false);
assert.equal(versionAtLeast("1.0.0", "1.1.1"), false);
assert.equal(versionAtLeast(null, "1.1.1"), false);
assert.equal(versionAtLeast("1.1.1-canary", "1.1.1"), false);
});

test("circleInstallPlan: absent → install, old → upgrade, current → ok", () => {
assert.equal(circleInstallPlan({ installed: false, version: null }), "install");
assert.equal(circleInstallPlan({ installed: true, version: "1.0.0" }), "upgrade");
// Unreadable version on an installed binary is treated as old (reinstall).
assert.equal(circleInstallPlan({ installed: true, version: null }), "upgrade");
assert.equal(circleInstallPlan({ installed: true, version: "1.1.1" }), "ok");
assert.equal(circleInstallPlan({ installed: true, version: "1.3.0" }), "ok");
});
Loading
Loading