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 @@ -55,7 +55,7 @@ Either way you get a real paid API response. No API keys. No native ETH. No brid
| Command | What it does |
|---|---|
| `selat init` | Full bootstrap. Idempotent — safe to re-run. |
| `selat run "<intent>"` | Discover + rank + pay in one pipe. Sugar for the discovery skill's `rank.mjs --pick` payment plan. `--param key=value` (repeatable) fills endpoint parameters: path placeholders are substituted, query params appended/replaced, and body params merged for POST hints — when a pick is refused because required params are missing, `selat run` prints which ones and the exact `--param` retry line (nothing is charged). **`--max-amount <usd>`** is a last-wins per-call ceiling applied after the catalog `exec_hints` cap is validated (hostile duplicate caps are stripped). If omitted, catalog caps above **$1.10** are clamped to $1.10; an explicit `--max-amount` is **also** hard-capped at **$1.10** for everyone — `isTTY` and `--allow-high-max-amount` do not raise it (agent harnesses often allocate a PTY). Paid `selat run` refuses unless a session budget is armed by `selat budget start --amount <usd>` writing `session.json` (process env `SELAT_SESSION_BUDGET` and cwd `.env` cannot invent/arm a budget; `selat freeze` is the kill switch). For **Apify** picks, `selat run` uses the prepaid-token model (buy a token via the Router, then call the Actor with a Bearer token) — pass Actor input with `--input '<json>'` or `--input-file <path>`. An explicit `--max-amount` below the $1.05 token purchase is refused; the x402 default is not applied to Apify (prepaid uses a known $1.05 token purchase, not a per-call x402 cap). `--auto-rebuy` (**Apify picks only**) buys a replacement token ($1.05 — $1 of Apify credit + SELAT's 5% fee; a new spend) and retries once if the token drains mid-run; without it, a depleted token surfaces an error and re-running buys the replacement. On any non-Apify (per-call x402) pick the flag is ignored with a warning. `--dry-run --json` additionally emits **`exec`** — the resolved, directly spawnable `{ runner, cmd, argv, env? }` tuple the paid run would execute. Use it to run the plan you were just quoted: re-running the intent instead would re-rank and can land on a different service at a different price. `command` remains the shell-quoted human display of the same thing; `exec.argv` needs no shell and no quote-parsing. **`--endpoint <url>`** (with **`--method <verb>`** to disambiguate) pins the exact endpoint rather than letting the intent choose it — necessary because catalog services are merged across registries and one service can span unrelated capabilities, so a reworded intent can resolve to a different endpoint at a different price. The URL must be in the federated catalog; an unlisted one is refused (`reason: "endpoint-not-in-catalog"`), never paid, and a pinned endpoint failing the payment-layer reliability check is refused rather than substituted (`reason: "endpoint-unreliable"`). Requires `@selat-ai/selat-discovery` ≥ 0.22.0. |
| `selat run "<intent>"` | Discover + rank + pay in one pipe. Sugar for the discovery skill's `rank.mjs --pick` payment plan. `--param key=value` (repeatable) fills endpoint parameters: path placeholders are substituted, query params appended/replaced, and body params merged for POST hints — when a pick is refused because required params are missing, `selat run` prints which ones and the exact `--param` retry line (nothing is charged). **`--max-amount <usd>`** is a last-wins per-call ceiling applied after the catalog `exec_hints` cap is validated (hostile duplicate caps are stripped). If omitted, catalog caps above **$1.10** are clamped to $1.10; an explicit `--max-amount` is **also** hard-capped at **$1.10** for everyone — `isTTY` and `--allow-high-max-amount` do not raise it (agent harnesses often allocate a PTY). Paid `selat run` refuses unless a session budget is armed by `selat budget start --amount <usd>` writing `session.json` (process env `SELAT_SESSION_BUDGET` and cwd `.env` cannot invent/arm a budget; `selat freeze` is the kill switch). For **Apify** picks, `selat run` uses the prepaid-token model (buy a token via the Router, then call the Actor with a Bearer token) — pass Actor input with `--input '<json>'` or `--input-file <path>`. An explicit `--max-amount` below the $1.05 token purchase is refused; the x402 default is not applied to Apify (prepaid uses a known $1.05 token purchase, not a per-call x402 cap). `--auto-rebuy` (**Apify picks only**) buys a replacement token ($1.05 — $1 of Apify credit + SELAT's 5% fee; a new spend) and retries once if the token drains mid-run; without it, a depleted token surfaces an error and re-running buys the replacement. On any non-Apify (per-call x402) pick the flag is ignored with a warning. `--dry-run --json` additionally emits **`exec`** — the resolved, directly spawnable `{ runner, cmd, argv, env? }` tuple the paid run would execute. Use it to run the plan you were just quoted: re-running the intent instead would re-rank and can land on a different service at a different price. `command` remains the shell-quoted human display of the same thing; `exec.argv` needs no shell and no quote-parsing. **`--endpoint <url>`** (with **`--method <verb>`** to disambiguate) pins the exact endpoint rather than letting the intent choose it — necessary because catalog services are merged across registries and one service can span unrelated capabilities, so a reworded intent can resolve to a different endpoint at a different price. The URL must be in the federated catalog; an unlisted one is refused (`reason: "endpoint-not-in-catalog"`), never paid, and a pinned endpoint failing the payment-layer reliability check is refused rather than substituted (`reason: "endpoint-unreliable"`). Requires `@selat-ai/selat-discovery` ≥ 0.22.0. **`--payable-now`** narrows ranking to endpoints routable via `selat-pay` today (gateway-batched on a Circle-supported chain, erc-3009, or tempo-native) — the retry when a pick has no runnable `selat-pay` command from its indexed terms. |
| `selat skill list [--available]` | List installed skills, or the catalog of skills available to install — each with a live **reliability** badge (● ok / ● degraded / ● down / ○ unknown) from the selat-skills auto-verify registry. |
| `selat skill install <name\|path> [--force]` | Install an **agent skill** by name (from the public [selat-skills](https://github.com/SELAT-AI/selat-skills) registry) or from a local path. |
| `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). |
Expand Down
77 changes: 64 additions & 13 deletions lib/commands/run.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -40,11 +40,11 @@ import {
authorizeExplicitMaxAmount,
} from "../spend-guard.mjs";

const RUN_USAGE = `usage: selat run "<intent>" [--capability <name>] [--endpoint <url> [--method <verb>] [--allow-unlisted]] [--max-amount <usd>] [${ALLOW_HIGH_MAX_AMOUNT_FLAG}] [--dry-run] [--live-probe] [--param key=value ...] [--input '<json>' | --input-file <path>] [--auto-rebuy] [--json] [--verbose]`;
const RUN_USAGE = `usage: selat run "<intent>" [--capability <name>] [--payable-now] [--endpoint <url> [--method <verb>] [--allow-unlisted]] [--max-amount <usd>] [${ALLOW_HIGH_MAX_AMOUNT_FLAG}] [--dry-run] [--live-probe] [--param key=value ...] [--input '<json>' | --input-file <path>] [--auto-rebuy] [--json] [--verbose]`;

// Every flag `selat run` understands. Kept as data so the unknown-flag error
// can list them and tests can pin the set.
export const KNOWN_RUN_FLAGS = ["--dry-run", "--live-probe", "--param", "--input", "--input-file", "--auto-rebuy", "--endpoint", "--method", "--allow-unlisted", "--capability", "--max-amount", ALLOW_HIGH_MAX_AMOUNT_FLAG, "--json", "--verbose"];
export const KNOWN_RUN_FLAGS = ["--dry-run", "--live-probe", "--param", "--input", "--input-file", "--auto-rebuy", "--endpoint", "--method", "--allow-unlisted", "--capability", "--payable-now", "--max-amount", ALLOW_HIGH_MAX_AMOUNT_FLAG, "--json", "--verbose"];

// Conservative per-call ceiling. Catalog exec_hints are untrusted; a hint
// above this is clamped. An explicit --max-amount is ALSO clamped to this
Expand Down Expand Up @@ -77,12 +77,19 @@ export const DEFAULT_RUN_MAX_AMOUNT_USD = HARD_CLI_MAX_AMOUNT_USD;
* interpret the name — omitted, the flag is not forwarded, so unscoped ranking
* stays today's default.
*
* --payable-now is passthrough to rank.mjs too: rank only endpoints routable
* via selat-pay (gateway-batched on a Circle-supported chain, erc-3009, or
* tempo-native), so a pick cannot land on a listing whose indexed terms yield
* no runnable selat-pay command. The "no runnable selat-pay command" error
* recommends this flag, so `run` must accept and forward it (it used to be
* rejected as unknown, dead-ending the very retry the hint suggested).
*
* Unknown --flags are an ERROR, not intent tokens: `run` spends real money, and
* silently dropping a flag like --dry-run means paying when the user asked not
* to (tester feedback round 2).
*/
export function parseRunArgs(args) {
const opts = { inputInline: undefined, inputFile: undefined, autoRebuy: false, dryRun: false, liveProbe: false, jsonMode: false, verbose: false, rawParams: [], endpoint: undefined, method: undefined, capability: undefined, maxAmount: undefined, allowHighMaxAmount: false, allowUnlisted: false };
const opts = { inputInline: undefined, inputFile: undefined, autoRebuy: false, dryRun: false, liveProbe: false, jsonMode: false, verbose: false, rawParams: [], endpoint: undefined, method: undefined, capability: undefined, payableNow: false, maxAmount: undefined, allowHighMaxAmount: false, allowUnlisted: false };
const intentTokens = [];
for (let i = 0; i < args.length; i++) {
const a = args[i];
Expand Down Expand Up @@ -125,6 +132,7 @@ export function parseRunArgs(args) {
continue;
}
if (a === "--allow-unlisted") { opts.allowUnlisted = true; continue; }
if (a === "--payable-now") { opts.payableNow = true; continue; }
if (a === "--auto-rebuy") { opts.autoRebuy = true; continue; }
if (a === ALLOW_HIGH_MAX_AMOUNT_FLAG) { opts.allowHighMaxAmount = true; continue; }
if (a === "--dry-run") { opts.dryRun = true; continue; }
Expand Down Expand Up @@ -165,12 +173,23 @@ export function capabilityArgs({ capability } = {}) {
return ["--capability", capability];
}

/**
* Extra argv forwarding `--payable-now` to rank.mjs, which then ranks (or
* resolves a pin) against only the endpoints routable via selat-pay. Omitted
* when the flag is absent so the broad, rail-agnostic ranking stays today's
* default. Tests pin this alongside rankPickArgv so the flag the error hint
* recommends is the flag the spawn actually carries.
*/
export function payableNowArgs({ payableNow = false } = {}) {
return payableNow ? ["--payable-now"] : [];
}

/**
* rank.mjs argv after the script path for `selat run`'s pick step. The single
* seam tests pin so --live-probe / --endpoint / --capability cannot drift from
* the spawn.
* seam tests pin so --live-probe / --endpoint / --capability / --payable-now
* cannot drift from the spawn.
*/
export function rankPickArgv({ intent, liveProbe = false, endpoint, method, capability, allowUnlisted = false } = {}) {
export function rankPickArgv({ intent, liveProbe = false, endpoint, method, capability, allowUnlisted = false, payableNow = false } = {}) {
return [
intent,
"--pick",
Expand All @@ -181,6 +200,7 @@ export function rankPickArgv({ intent, liveProbe = false, endpoint, method, capa
// accidentally widen into unlisted territory.
...(allowUnlisted && endpoint ? ["--allow-unlisted"] : []),
...capabilityArgs({ capability }),
...payableNowArgs({ payableNow }),
];
}

Expand All @@ -197,6 +217,18 @@ export function withDocsCheck(selatPayArgs, { allowUnlisted = false } = {}) {
return [...selatPayArgs, "--docs-check"];
}

/**
* Retry advice for a pick with no runnable selat-pay command. Recommends
* --payable-now (which `run` accepts and forwards to rank.mjs) unless the
* caller already passed it — repeating advice they already followed would
* dead-end them, so then only the manual 402 inspection remains.
*/
export function noRunnableHintLine({ payableNow = false } = {}) {
return payableNow
? "--payable-now was already applied; inspect the live 402 manually."
: "Try --payable-now (rank only endpoints routable via selat-pay) or inspect the live 402 manually.";
}

/**
* Map rank.mjs's pin-specific exit codes to an honest error.
*
Expand Down Expand Up @@ -283,6 +315,10 @@ export async function run(args) {
console.log(" --capability <name> Rank only endpoints labeled with this capability");
console.log(" (Layer 0). Unknown names and empty labeled pools");
console.log(" are refused, never silently widened.");
console.log(" --payable-now Rank only endpoints routable via selat-pay today");
console.log(" (gateway-batched on a Circle-supported chain, erc-3009,");
console.log(" or tempo-native). Use it when the top pick has no");
console.log(" runnable selat-pay command from its indexed terms.");
console.log(" --max-amount <usd> Per-call spend ceiling, applied last-wins after the");
console.log(` catalog hint is validated. Hard CLI ceiling $${HARD_CLI_MAX_AMOUNT_USD}`);
console.log(" even when the flag is explicit. isTTY and agent harnesses");
Expand All @@ -296,7 +332,7 @@ export async function run(args) {
console.log(" -h, --help Show this help. Never ranks, never pays.");
return 0;
}
const { intent, inputInline, inputFile, autoRebuy, dryRun, liveProbe, jsonMode, verbose, rawParams, endpoint, method, capability, maxAmount, allowHighMaxAmount, allowUnlisted } = parsedArgs;
const { intent, inputInline, inputFile, autoRebuy, dryRun, liveProbe, jsonMode, verbose, rawParams, endpoint, method, capability, payableNow, maxAmount, allowHighMaxAmount, allowUnlisted } = parsedArgs;
if (!intent) {
return emitRunError({ jsonMode, error: "an intent is required", hints: [fmt.dim(RUN_USAGE)] });
}
Expand Down Expand Up @@ -345,7 +381,7 @@ export async function run(args) {
// Step 1: pick
const pick = await sh(
"node",
[join(skill.path, "scripts", "rank.mjs"), ...rankPickArgv({ intent, liveProbe, endpoint, method, capability, allowUnlisted })]
[join(skill.path, "scripts", "rank.mjs"), ...rankPickArgv({ intent, liveProbe, endpoint, method, capability, allowUnlisted, payableNow })]
);
if (pick.code !== 0) {
// A pin refusal is not a rank failure — it is rank.mjs doing its job, and a
Expand Down Expand Up @@ -420,11 +456,26 @@ export async function run(args) {
const hint = plan?.exec_hints?.[0];
const parsed = parseSelatPayHint(hint);
if (!parsed.ok) {
console.error(fmt.error("no runnable selat-pay command in pick output"));
if (parsed.reason) console.error(fmt.dim(parsed.reason));
console.error(fmt.dim("This usually means the top match has no routable payment terms."));
console.error(fmt.dim("Try --payable-now or inspect the live 402 manually."));
return 1;
// Nothing is charged here. Under --json the machine caller needs the
// same {ok:false, error} on stdout as every other refusal, with the
// retry advice as data (`hint`) rather than prose it cannot see.
const hintLine = noRunnableHintLine({ payableNow });
return emitRunError({
jsonMode,
error: "no runnable selat-pay command in pick output",
extra: {
reason: "no-runnable-command",
...(parsed.reason ? { detail: parsed.reason } : {}),
hint: hintLine,
payableNow,
...(plan?.note ? { note: plan.note } : {}),
},
hints: [
...(parsed.reason ? [fmt.dim(parsed.reason)] : []),
fmt.dim("This usually means the top match has no routable payment terms."),
fmt.dim(hintLine),
],
});
}

let { args: selatPayArgs, display } = parsed;
Expand Down
Loading
Loading