diff --git a/README.md b/README.md index 6e54845..3837c2c 100644 --- a/README.md +++ b/README.md @@ -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 ""` | 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 `** 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 ` 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 ''` or `--input-file `. 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 `** (with **`--method `** 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 ""` | 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 `** 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 ` 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 ''` or `--input-file `. 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 `** (with **`--method `** 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 [--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 [--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). | diff --git a/lib/commands/run.mjs b/lib/commands/run.mjs index e98bcd6..36f96aa 100644 --- a/lib/commands/run.mjs +++ b/lib/commands/run.mjs @@ -40,11 +40,11 @@ import { authorizeExplicitMaxAmount, } from "../spend-guard.mjs"; -const RUN_USAGE = `usage: selat run "" [--capability ] [--endpoint [--method ] [--allow-unlisted]] [--max-amount ] [${ALLOW_HIGH_MAX_AMOUNT_FLAG}] [--dry-run] [--live-probe] [--param key=value ...] [--input '' | --input-file ] [--auto-rebuy] [--json] [--verbose]`; +const RUN_USAGE = `usage: selat run "" [--capability ] [--payable-now] [--endpoint [--method ] [--allow-unlisted]] [--max-amount ] [${ALLOW_HIGH_MAX_AMOUNT_FLAG}] [--dry-run] [--live-probe] [--param key=value ...] [--input '' | --input-file ] [--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 @@ -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]; @@ -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; } @@ -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", @@ -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 }), ]; } @@ -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. * @@ -283,6 +315,10 @@ export async function run(args) { console.log(" --capability 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 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"); @@ -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)] }); } @@ -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 @@ -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; diff --git a/test/run-payable-now.test.mjs b/test/run-payable-now.test.mjs new file mode 100644 index 0000000..c70c9c7 --- /dev/null +++ b/test/run-payable-now.test.mjs @@ -0,0 +1,175 @@ +// --payable-now: `selat run`'s "no runnable selat-pay command" error told the +// user to "Try --payable-now", but the parser rejected that flag as unknown and +// the rank.mjs spawn never carried it — so following the advice dead-ended. +// Seam tests pin the parse, the rank.mjs argv, the help text, and the hint so +// the flag the error recommends is the flag the spawn actually forwards. +import test from "node:test"; +import assert from "node:assert/strict"; +import { execFile } from "node:child_process"; +import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { promisify } from "node:util"; +import { closedEnv } from "./helpers/closed-env.mjs"; + +import { + parseRunArgs, + rankPickArgv, + payableNowArgs, + noRunnableHintLine, + KNOWN_RUN_FLAGS, +} from "../lib/commands/run.mjs"; + +test("--payable-now is a known flag and parses as a boolean, not intent", () => { + assert.ok(KNOWN_RUN_FLAGS.includes("--payable-now")); + const parsed = parseRunArgs(["search recent papers", "--payable-now"]); + assert.equal(parsed.ok, true); + assert.equal(parsed.payableNow, true); + assert.equal(parsed.intent, "search recent papers"); + // Position-independent, and never swallows a following token as a value. + const leading = parseRunArgs(["--payable-now", "search", "recent", "papers", "--dry-run"]); + assert.equal(leading.payableNow, true); + assert.equal(leading.dryRun, true); + assert.equal(leading.intent, "search recent papers"); +}); + +test("--payable-now defaults off", () => { + const parsed = parseRunArgs(["search recent papers"]); + assert.equal(parsed.ok, true); + assert.equal(parsed.payableNow, false); +}); + +test("payableNowArgs forwards the flag only when set", () => { + assert.deepEqual(payableNowArgs({}), []); + assert.deepEqual(payableNowArgs({ payableNow: false }), []); + assert.deepEqual(payableNowArgs({ payableNow: true }), ["--payable-now"]); +}); + +test("rankPickArgv forwards --payable-now to rank.mjs", () => { + assert.deepEqual( + rankPickArgv({ intent: "search recent papers", payableNow: true }), + ["search recent papers", "--pick", "--payable-now"], + ); + // Absent by default: broad, rail-agnostic ranking stays the default argv. + assert.deepEqual(rankPickArgv({ intent: "search recent papers" }), ["search recent papers", "--pick"]); +}); + +test("rankPickArgv keeps --payable-now alongside a pin and --capability", () => { + assert.deepEqual( + rankPickArgv({ + intent: "x", + liveProbe: true, + endpoint: "https://a.example/x", + method: "POST", + capability: "web.search", + payableNow: true, + }), + ["x", "--pick", "--live-probe", "--endpoint", "https://a.example/x", "--method", "POST", "--capability", "web.search", "--payable-now"], + ); +}); + +test("the no-runnable-command hint recommends a flag run accepts, once", () => { + const first = noRunnableHintLine({ payableNow: false }); + assert.match(first, /--payable-now/); + // Whatever the hint names must survive parseRunArgs, or the advice dead-ends. + const flags = first.match(/--[a-z-]+/g) ?? []; + assert.ok(flags.length > 0); + for (const flag of flags) assert.ok(KNOWN_RUN_FLAGS.includes(flag), `hint names unknown flag ${flag}`); + // Already applied: don't tell the user to pass it again. + const again = noRunnableHintLine({ payableNow: true }); + assert.doesNotMatch(again, /Try --payable-now/); + assert.match(again, /live 402/); +}); + +test("selat run --help lists --payable-now", async () => { + const run = promisify(execFile); + const r = await run(process.execPath, ["bin/selat.mjs", "run", "--help"], { env: closedEnv() }); + assert.match(r.stdout, /--payable-now/); + assert.match(r.stdout, /routable via selat-pay/); +}); + +// End to end through bin/selat.mjs against a fake discovery skill whose +// rank.mjs records its argv: the retry the hint recommends must reach the +// ranker carrying --payable-now, and nothing is ever paid (--dry-run, fake +// selat-pay, no session budget). +function fakeSkill({ runnable }) { + const dir = mkdtempSync(join(tmpdir(), "selat-payable-now-")); + mkdirSync(join(dir, "skill", "scripts"), { recursive: true }); + const argvFile = join(dir, "argv.jsonl"); + writeFileSync(argvFile, ""); + const pick = runnable + ? { service: { name: "Example API" }, minAmountUsd: 0.01, exec_hints: [{ argv: ["selat-pay", "GET", "https://api.example/v1", "--max-amount", "0.05", "--chain", "base"] }] } + : { service: { name: "Unroutable API" }, minAmountUsd: 0.01, exec_hints: [] }; + writeFileSync(join(dir, "skill", "scripts", "rank.mjs"), [ + "import { appendFileSync } from 'node:fs';", + "appendFileSync(process.env.SELAT_TEST_ARGV_FILE, JSON.stringify(process.argv.slice(2)) + '\\n');", + `process.stdout.write(${JSON.stringify(JSON.stringify(pick))});`, + ].join("\n")); + const fakePay = join(dir, "fake-selat-pay.mjs"); + writeFileSync(fakePay, "process.stdout.write('{}');\n"); + const env = closedEnv({ + SELAT_SKILL_PATH: join(dir, "skill"), + SELAT_TEST_ARGV_FILE: argvFile, + SELAT_PAY_BIN: fakePay, + SELAT_PAY_SESSION_PATH: join(dir, "no-session.json"), + XDG_CONFIG_HOME: join(dir, "xdg"), + XDG_STATE_HOME: join(dir, "xdg-state"), + }); + const calls = () => readFileSync(argvFile, "utf8").trim().split("\n").filter(Boolean).map((l) => JSON.parse(l)); + return { env, calls }; +} + +test("selat run --payable-now --dry-run forwards the flag to rank.mjs and pays nothing", async () => { + const run = promisify(execFile); + const { env, calls } = fakeSkill({ runnable: true }); + const r = await run(process.execPath, ["bin/selat.mjs", "run", "--json", "--dry-run", "--payable-now", "search recent papers"], { env }).catch((e) => e); + assert.equal(r.code ?? 0, 0, r.stderr); + const out = JSON.parse(r.stdout.trim()); + assert.equal(out.ok, true, r.stdout); + assert.deepEqual(calls(), [["search recent papers", "--pick", "--payable-now"]]); +}); + +test("the no-runnable-command error names --payable-now, and that retry reaches rank.mjs", async () => { + const run = promisify(execFile); + const { env, calls } = fakeSkill({ runnable: false }); + const first = await run(process.execPath, ["bin/selat.mjs", "run", "--dry-run", "search recent papers"], { env }).catch((e) => e); + assert.equal(first.code, 1); + assert.match(first.stderr, /no runnable selat-pay command/); + assert.match(first.stderr, /Try --payable-now/); + assert.deepEqual(calls(), [["search recent papers", "--pick"]]); + // Following the advice must not hit "unknown flag": the flag is forwarded. + const retry = await run(process.execPath, ["bin/selat.mjs", "run", "--dry-run", "search recent papers", "--payable-now"], { env }).catch((e) => e); + assert.doesNotMatch(retry.stderr, /unknown flag/); + assert.deepEqual(calls()[1], ["search recent papers", "--pick", "--payable-now"]); + // The fake still yields nothing runnable, so the hint no longer repeats itself. + assert.doesNotMatch(retry.stderr, /Try --payable-now/); + assert.match(retry.stderr, /--payable-now was already applied/); +}); + +// --json contract: this refusal used to print prose to stderr with an empty +// stdout, which JSON.parse("") turns into a crash at a machine caller. It now +// goes through the same {ok:false, error} stdout path as every other failure, +// carrying the retry advice as data. +test("no-runnable-command under --json is a parseable refusal on stdout with the hint as data", async () => { + const run = promisify(execFile); + const { env, calls } = fakeSkill({ runnable: false }); + const first = await run(process.execPath, ["bin/selat.mjs", "run", "--json", "--dry-run", "search recent papers"], { env }).catch((e) => e); + assert.equal(first.code, 1); + const out = JSON.parse(first.stdout.trim()); + assert.equal(out.ok, false); + assert.match(out.error, /no runnable selat-pay command/); + assert.equal(out.reason, "no-runnable-command"); + assert.equal(out.payableNow, false); + assert.match(out.hint, /--payable-now/); + assert.equal(out.detail, "missing exec_hints[0]"); + assert.deepEqual(calls(), [["search recent papers", "--pick"]]); + + const retry = await run(process.execPath, ["bin/selat.mjs", "run", "--json", "--dry-run", "--payable-now", "search recent papers"], { env }).catch((e) => e); + assert.equal(retry.code, 1); + const again = JSON.parse(retry.stdout.trim()); + assert.equal(again.ok, false); + assert.equal(again.reason, "no-runnable-command"); + assert.equal(again.payableNow, true); + assert.doesNotMatch(again.hint, /Try --payable-now/); + assert.deepEqual(calls()[1], ["search recent papers", "--pick", "--payable-now"]); +});