atris 3.72.4 → 3.73.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 +3 -1
- package/atris/skills/commerce/SKILL.md +24 -3
- package/bin/atris.js +10 -0
- package/commands/gm.js +17 -6
- package/commands/member.js +59 -9
- package/commands/pay.js +329 -0
- package/commands/task.js +55 -18
- package/commands/trust.js +117 -0
- package/commands/zen.js +17 -0
- package/lib/gm-bet.js +1443 -162
- package/lib/gm-phone-page.js +460 -357
- package/lib/gm-phone.js +74 -13
- package/lib/known-commands.js +1 -1
- package/lib/site.js +9 -1
- package/lib/try-repo.js +442 -0
- package/lib/zen-apply.js +28 -4
- package/lib/zen-fleet.js +100 -0
- package/lib/zen.js +41 -6
- package/package.json +1 -1
- package/scripts/det/xsearch-bench.js +1 -0
package/README.md
CHANGED
|
@@ -343,6 +343,8 @@ atris business record atris/reports/2026-04-12-operator-recap.md --outcome mixed
|
|
|
343
343
|
| `atris loop` | Refresh wiki health, stale/orphan signals, and next ingest candidates |
|
|
344
344
|
| `atris wiki` | Full wiki namespace: ingest, query, lint, search, log, and loop |
|
|
345
345
|
| `atris receipt` | Save evidence from an agent run |
|
|
346
|
+
| `atris pay` | Pay another Atris account in credits (100 credits = $1); `history`, `show`, `accept`, `cancel` |
|
|
347
|
+
| `atris trust` | See an account's trust and the friends you share; `vouch` or `unvouch` for someone |
|
|
346
348
|
| `atris experiments` | Run small experiments and compare results |
|
|
347
349
|
| `atris computer card` | Show or write the local owner/computer card |
|
|
348
350
|
| `atris release` | Draft or publish a version bump, tag, GitHub release, and launch post |
|
|
@@ -367,7 +369,7 @@ atris business record atris/reports/2026-04-12-operator-recap.md --outcome mixed
|
|
|
367
369
|
- `atris activate` loads the current wiki status so the next session starts with project memory, not just tasks
|
|
368
370
|
- `atris member` keeps team-member identity and learning local-first: `MEMBER.md` is the role contract, `goals.json` is the machine-readable goal/experiment state, `goals.md` is the human readout, and `logs/YYYY-MM-DD.md` records what happened. Use `atris member goal`, `tick`, `status`, `block`, and `review <name> <experiment-id> --accept --proof "..." --value 1..5` to test whether a member is making useful progress or needs the operator/orchestrator.
|
|
369
371
|
- `atris codex-goal` is the read-only bridge for native Codex `/goal`: `status` inspects the live goal database, while the legacy `reset` command now refuses every mutation. Active tasks continue in place; completed tasks retain their final goal, and new work or recurring monitors use a new dedicated Codex task.
|
|
370
|
-
- `atris task` keeps durable local task state and append-only events for agents; `atris/TODO.md` is just a regenerated readable board. Run the loop with `atris task new`, `delegate "..." --to <owner>`, `next`, `say`, and `ready <id> --proof "..."`; human approval is `atris task accept <id>` (moves to Done, awards Career XP) or `revise <id> --note "..."`. Final task transitions also append the general daily log and, when a real `atris/team/<member>/MEMBER.md` matches the task owner, that member's daily log. Add `--json` for headless agents, `atris task serve` for the local board, and `atris task show <ref>` / `events --all` for the full ledger. Commands accept semantic refs (`OBL-18`), full IDs, or any unique prefix. In cloud business workspaces, Supabase `tasks` is the source of truth and Swarlo the live claim layer.
|
|
372
|
+
- `atris task` keeps durable local task state and append-only events for agents; `atris/TODO.md` is just a regenerated readable board. Run the loop with `atris task new`, `delegate "..." --to <owner>`, `next`, `say`, and `ready <id> --proof "..."`; human approval is `atris task accept <id>` (moves to Done, awards Career XP) or `revise <id> --note "..."`. Final task transitions also append the general daily log and, when a real `atris/team/<member>/MEMBER.md` matches the task owner, that member's daily log. Add `--json` for headless agents, `atris task serve` for the local board, and `atris task show <ref>` / `events --all` for the full ledger. The board prints a private link with a fresh token on each run and listens only on 127.0.0.1. Open that full link in the browser; API clients send its token in `x-atris-token` and use `application/json` for changes. Browser changes must come from the board's own origin. Commands accept semantic refs (`OBL-18`), full IDs, or any unique prefix. In cloud business workspaces, Supabase `tasks` is the source of truth and Swarlo the live claim layer.
|
|
371
373
|
- `atris mission` is the durable autonomy layer: start with an owner, verifier, runner, and stop condition; record bounded work with `mission tick`; close with `mission complete` only after proof. Runners include `manual`, `claude`, `atris2`, and `codex_goal`.
|
|
372
374
|
- `atris pulse` is the OS-cron heartbeat for overnight self-improvement. Use `atris pulse status`, `tick`, `run`, `install --cadence "<cron>" --days 7 --verify "npm test" --model claude-opus-5-5`, and `uninstall`.
|
|
373
375
|
- `atris experiments` runs small test packs in `atris/experiments/`
|
|
@@ -9,9 +9,11 @@ tags:
|
|
|
9
9
|
|
|
10
10
|
# Commerce: buy or trade through Atris
|
|
11
11
|
|
|
12
|
-
You can quote and ask.
|
|
13
|
-
|
|
14
|
-
fill in, or a text from their own phone.
|
|
12
|
+
You can quote and ask. For anything bought in the real world, only the human
|
|
13
|
+
pays: every dollar leaves only after the person acts themselves, a tap in
|
|
14
|
+
their Link app, a payment page they fill in, or a text from their own phone.
|
|
15
|
+
The one exception is credits moving between Atris accounts (see "Pay another Atris
|
|
16
|
+
account in credits" below), which an agent key with the `pay` scope may send.
|
|
15
17
|
|
|
16
18
|
## Get a key
|
|
17
19
|
|
|
@@ -118,3 +120,22 @@ curl -s "$API/transactions?since=2026-09-01&kinds=ticket,shop,trade&limit=50" -H
|
|
|
118
120
|
Each transaction: `id, kind, when, what, amount_cents, currency, state, counterparty, receipt_url`.
|
|
119
121
|
`sources` says which ledgers loaded; if one says `failed`, the list is partial. Say so.
|
|
120
122
|
The CLI prints that as "Partial list: could not load ...".
|
|
123
|
+
|
|
124
|
+
## Pay another Atris account in credits
|
|
125
|
+
|
|
126
|
+
Credits move between Atris accounts, not cards. 100 credits = $1.
|
|
127
|
+
|
|
128
|
+
```bash
|
|
129
|
+
atris trust @nora # trust score, friends you share
|
|
130
|
+
atris pay @nora 500 for the logo # -> Paid @nora 500 credits ($5.00). Settled.
|
|
131
|
+
atris pay history
|
|
132
|
+
atris pay accept <id> # take a payment someone sent you
|
|
133
|
+
atris pay cancel <id> # cancel one that is still waiting
|
|
134
|
+
atris trust vouch @nora # vouch = follow
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
- Some payments wait for the receiver to accept. The CLI prints the id; check it with `atris pay show <id>`.
|
|
138
|
+
- Every payment carries a request id, and the CLI retries once with the same id after a dropped connection, so it never pays twice.
|
|
139
|
+
- An agent key needs the `pay` scope to send, accept, or cancel.
|
|
140
|
+
- A refusal (not enough credits, over the daily limit, blocked) prints in plain words. Tell the person; do not retry.
|
|
141
|
+
- Add `--json` for the raw receipt.
|
package/bin/atris.js
CHANGED
|
@@ -758,6 +758,8 @@ function showHelpAll() {
|
|
|
758
758
|
console.log(' topup - Buy credits and print a Stripe checkout URL');
|
|
759
759
|
console.log(' buy - Quote a purchase or stock order; the person approves before anything is charged');
|
|
760
760
|
console.log(' transactions - List what you bought, paid in fees, traded, or sent');
|
|
761
|
+
console.log(' pay - Pay another Atris account in credits; history, accept, cancel');
|
|
762
|
+
console.log(' trust - See an account\'s trust; vouch or unvouch for someone');
|
|
761
763
|
console.log(' hire - Hire an agent in one step: name, job, inbox, allowance, nightly work loop');
|
|
762
764
|
console.log(' hires - Morning check-in: one line per hire (mail, spend, last run)');
|
|
763
765
|
console.log(' mail - Your agents\' own inboxes: list, read, send (not Gmail)');
|
|
@@ -3498,6 +3500,14 @@ if (command === 'guide') {
|
|
|
3498
3500
|
require('../commands/social').inviteCommand()
|
|
3499
3501
|
.then(() => process.exit(0))
|
|
3500
3502
|
.catch((err) => { console.error(`\n✗ Error: ${err.message || err}`); process.exit(1); });
|
|
3503
|
+
} else if (command === 'pay') {
|
|
3504
|
+
require('../commands/pay').payCommand()
|
|
3505
|
+
.then((code) => process.exit(typeof code === 'number' ? code : 0))
|
|
3506
|
+
.catch((err) => { console.error(`\n✗ Error: ${err.message || err}`); process.exit(1); });
|
|
3507
|
+
} else if (command === 'trust') {
|
|
3508
|
+
require('../commands/trust').trustCommand()
|
|
3509
|
+
.then((code) => process.exit(typeof code === 'number' ? code : 0))
|
|
3510
|
+
.catch((err) => { console.error(`\n✗ Error: ${err.message || err}`); process.exit(1); });
|
|
3501
3511
|
} else {
|
|
3502
3512
|
console.log(`Unknown command: ${command}`);
|
|
3503
3513
|
console.log('Run "atris help" to see available commands');
|
package/commands/gm.js
CHANGED
|
@@ -30,7 +30,7 @@ function showHelp() {
|
|
|
30
30
|
console.log(' atris gm replay [--since 12h|2d|last-seen] [--speed N] [--plain] [--league]');
|
|
31
31
|
console.log(' atris gm card <member> [--json] one member\'s profile page');
|
|
32
32
|
console.log(' atris gm target [--landed N] [--max-abandoned N] [--max-stuck N] [--clear]');
|
|
33
|
-
console.log(' atris gm phone [--port 7777] [--send] decision cards on your phone');
|
|
33
|
+
console.log(' atris gm phone [--port 7777] [--send] [--new-link] decision cards on your phone');
|
|
34
34
|
console.log(' atris gm --json machine-readable state');
|
|
35
35
|
console.log(' atris gm --watch [--interval 10]');
|
|
36
36
|
console.log('');
|
|
@@ -139,6 +139,7 @@ function showHelp() {
|
|
|
139
139
|
console.log(' --interval <sec> Watch polling interval. Defaults to 10.');
|
|
140
140
|
console.log(' --port <n> gm phone port. Defaults to 7777.');
|
|
141
141
|
console.log(' --send gm phone: text the link to your saved number.');
|
|
142
|
+
console.log(' --new-link gm phone: make a new link; the old one stops working.');
|
|
142
143
|
console.log(' --no-seed Do not create a starter player mission.');
|
|
143
144
|
console.log(' --json Print machine-readable mode state.');
|
|
144
145
|
console.log(' --help, -h Show this help.');
|
|
@@ -1476,7 +1477,6 @@ function gmTargetCommand(args = []) {
|
|
|
1476
1477
|
// Commands started from the phone keep running on their own after that.
|
|
1477
1478
|
async function gmPhone(args = []) {
|
|
1478
1479
|
const http = require('http');
|
|
1479
|
-
const crypto = require('crypto');
|
|
1480
1480
|
const phone = require('../lib/gm-phone');
|
|
1481
1481
|
const { phonePage } = require('../lib/gm-phone-page');
|
|
1482
1482
|
const gmTarget = require('../lib/gm-target');
|
|
@@ -1493,11 +1493,14 @@ async function gmPhone(args = []) {
|
|
|
1493
1493
|
const addresses = [...new Set([lan, '127.0.0.1'].filter(Boolean))];
|
|
1494
1494
|
// filled in once a port is open; the handler reads it on every request
|
|
1495
1495
|
const hosts = [];
|
|
1496
|
-
const token = crypto.randomBytes(32).toString('base64url');
|
|
1497
1496
|
const stateDir = path.join(atrisHome(), 'state');
|
|
1497
|
+
// the same link across restarts, until --new-link
|
|
1498
|
+
const { token, reused } = phone.phoneToken(workspaceRoot, { stateDir, rotate: hasExactFlag(args, '--new-link') });
|
|
1499
|
+
const rotated = hasExactFlag(args, '--new-link');
|
|
1498
1500
|
const handler = phone.createPhoneHandler({
|
|
1499
1501
|
workspaceRoot,
|
|
1500
|
-
|
|
1502
|
+
// read on every request, so --new-link kills the old link in every running server
|
|
1503
|
+
token: () => phone.readPhoneToken(workspaceRoot, { stateDir }),
|
|
1501
1504
|
hosts,
|
|
1502
1505
|
loadView: () => phone.loadPhoneView(workspaceRoot),
|
|
1503
1506
|
runStep: phone.makeStepRunner({ cliPath: path.join(__dirname, '..', 'bin', 'atris.js'), cwd: workspaceRoot, logDir: path.join(stateDir, 'gm-phone-runs') }),
|
|
@@ -1515,6 +1518,10 @@ async function gmPhone(args = []) {
|
|
|
1515
1518
|
log,
|
|
1516
1519
|
// read the started bets' missions every 25 seconds, read-only
|
|
1517
1520
|
pollMs: gmBet.JOB_POLL_MS,
|
|
1521
|
+
// ask for the owner's own number, and its value once a week
|
|
1522
|
+
scoreboard: true,
|
|
1523
|
+
// the numbers a question card stands on, when no bet is being made to read them
|
|
1524
|
+
readBundle: () => gmBet.readBundle(workspaceRoot, { ledgerFile: gmBet.ledgerPath(stateDir) }),
|
|
1518
1525
|
// the roster's reviewer gets up to 16 minutes to read the finished work
|
|
1519
1526
|
reviewCli: gmBet.makeCliRunner({ cliPath: path.join(__dirname, '..', 'bin', 'atris.js'), cwd: workspaceRoot, timeoutMs: 16 * 60 * 1000 }),
|
|
1520
1527
|
});
|
|
@@ -1525,7 +1532,7 @@ async function gmPhone(args = []) {
|
|
|
1525
1532
|
const opened = [];
|
|
1526
1533
|
try {
|
|
1527
1534
|
for (const address of addresses) {
|
|
1528
|
-
const server = http.createServer((req, res) => { handler(req, res); });
|
|
1535
|
+
const server = http.createServer((req, res) => { handler(req, res).catch(() => res.destroy()); });
|
|
1529
1536
|
await new Promise((resolve, reject) => {
|
|
1530
1537
|
server.once('error', reject);
|
|
1531
1538
|
server.listen(port, address, resolve);
|
|
@@ -1573,7 +1580,9 @@ async function gmPhone(args = []) {
|
|
|
1573
1580
|
}
|
|
1574
1581
|
console.log(` ${link}`);
|
|
1575
1582
|
console.log('');
|
|
1576
|
-
console.log('
|
|
1583
|
+
if (rotated) console.log('this is a new link. the old link is dead now, even in a phone page that is still open.');
|
|
1584
|
+
else console.log(reused ? 'this is the same link as last time.' : 'this is a new link.');
|
|
1585
|
+
console.log('anyone with this link can act for you while this runs. ctrl-c stops it. a new link: atris gm phone --new-link');
|
|
1577
1586
|
|
|
1578
1587
|
if (hasExactFlag(args, '--send')) {
|
|
1579
1588
|
const autoland = require('../lib/autoland');
|
|
@@ -1594,6 +1603,8 @@ async function gmPhone(args = []) {
|
|
|
1594
1603
|
server.close();
|
|
1595
1604
|
if (typeof server.closeAllConnections === 'function') server.closeAllConnections();
|
|
1596
1605
|
}
|
|
1606
|
+
// the idea and question writers stop with the page; missions started from the phone keep going
|
|
1607
|
+
require('../lib/gm-bet').stopWriters();
|
|
1597
1608
|
const running = handler.actions.filter(a => a.status === 'running').length;
|
|
1598
1609
|
console.log('');
|
|
1599
1610
|
console.log(running ? `phone page stopped. ${running} started from the phone still running on their own.` : 'phone page stopped.');
|
package/commands/member.js
CHANGED
|
@@ -10725,34 +10725,44 @@ async function memberSteerOrDispatch(...rawArgs) {
|
|
|
10725
10725
|
|
|
10726
10726
|
async function memberSteerOrDispatchWith({ pullTask = false } = {}, rawArgs = []) {
|
|
10727
10727
|
if (hasFlag(rawArgs, '--help') || hasFlag(rawArgs, '-h')) {
|
|
10728
|
-
console.log('Usage: atris member steer [member]
|
|
10728
|
+
console.log('Usage: atris member steer [member] [--no-ignite] [--detach] [--dry-run] [--json] [--] "<direction>"');
|
|
10729
10729
|
console.log(' atris member "<direction>" [--dry-run] [--json]');
|
|
10730
10730
|
console.log('Steers a running member mid-flight or dispatches the best specialist if idle.');
|
|
10731
|
+
console.log(' --no-ignite only reach a live run; with nothing running, start nothing and exit 3');
|
|
10732
|
+
console.log(' --detach when it starts the member, run it in the background and return its pid');
|
|
10733
|
+
console.log(' -- everything after it is the direction, never an option');
|
|
10731
10734
|
return;
|
|
10732
10735
|
}
|
|
10733
10736
|
const root = process.cwd();
|
|
10734
|
-
|
|
10735
|
-
const
|
|
10737
|
+
// everything after -- is the direction: words a person typed are never options
|
|
10738
|
+
const cut = rawArgs.indexOf('--');
|
|
10739
|
+
const optionArgs = cut >= 0 ? rawArgs.slice(0, cut) : rawArgs;
|
|
10740
|
+
const directionArgs = cut >= 0 ? rawArgs.slice(cut + 1) : [];
|
|
10741
|
+
const asJson = hasFlag(optionArgs, '--json');
|
|
10742
|
+
const dryRun = hasFlag(optionArgs, '--dry-run');
|
|
10743
|
+
const noIgnite = hasFlag(optionArgs, '--no-ignite');
|
|
10744
|
+
const detach = hasFlag(optionArgs, '--detach');
|
|
10736
10745
|
// Set by `member up`: run only a claimed task with a runnable check, never
|
|
10737
10746
|
// the member's own goal text.
|
|
10738
|
-
const needsCheck = pullTask && hasFlag(
|
|
10747
|
+
const needsCheck = pullTask && hasFlag(optionArgs, '--needs-check');
|
|
10739
10748
|
const valueFlags = new Set(['--engine', '--model', '--verify', '--referee-engine', '--minutes', '--hours', '--max-ticks', '--from']);
|
|
10740
10749
|
const positionals = [];
|
|
10741
10750
|
const flagArgs = [];
|
|
10742
|
-
for (let i = 0; i <
|
|
10743
|
-
const token = String(
|
|
10751
|
+
for (let i = 0; i < optionArgs.length; i++) {
|
|
10752
|
+
const token = String(optionArgs[i] || '');
|
|
10744
10753
|
if (!token) continue;
|
|
10745
10754
|
if (token.startsWith('-')) {
|
|
10746
|
-
if (token === '--needs-check') continue;
|
|
10755
|
+
if (token === '--needs-check' || token === '--no-ignite' || token === '--detach') continue;
|
|
10747
10756
|
flagArgs.push(token);
|
|
10748
|
-
if (valueFlags.has(token) && i + 1 <
|
|
10749
|
-
flagArgs.push(
|
|
10757
|
+
if (valueFlags.has(token) && i + 1 < optionArgs.length) {
|
|
10758
|
+
flagArgs.push(optionArgs[i + 1]);
|
|
10750
10759
|
i++;
|
|
10751
10760
|
}
|
|
10752
10761
|
continue;
|
|
10753
10762
|
}
|
|
10754
10763
|
positionals.push(token);
|
|
10755
10764
|
}
|
|
10765
|
+
for (const token of directionArgs) if (String(token || '')) positionals.push(String(token));
|
|
10756
10766
|
|
|
10757
10767
|
let targetMember = null;
|
|
10758
10768
|
let prompt = '';
|
|
@@ -10777,6 +10787,26 @@ async function memberSteerOrDispatchWith({ pullTask = false } = {}, rawArgs = []
|
|
|
10777
10787
|
const goalPlane = memberGoalPlaneStatus(owner, paths);
|
|
10778
10788
|
const missionMap = memberRunMissionMap(root);
|
|
10779
10789
|
const missionSummary = memberMissionSummary(owner, missionMap, root);
|
|
10790
|
+
// --no-ignite only reaches a live run: with nothing running it starts
|
|
10791
|
+
// nothing and touches nothing, and says so
|
|
10792
|
+
if (noIgnite && !missionSummary.live) {
|
|
10793
|
+
const payload = { ok: false, action: 'member_not_running', member: owner };
|
|
10794
|
+
printJsonOrText(payload, [`${owner} is not running, so nothing was sent or started.`], asJson);
|
|
10795
|
+
process.exitCode = 3;
|
|
10796
|
+
return payload;
|
|
10797
|
+
}
|
|
10798
|
+
// --detach answers before the background run starts, so the cheap checks
|
|
10799
|
+
// run here first: a member that cannot run is refused with the plain reason
|
|
10800
|
+
if (detach && !dryRun) {
|
|
10801
|
+
const refusal = memberRefusalFor(targetMember, paths.memberFile)
|
|
10802
|
+
|| (resolveMemberEngineSeat(owner, root).engine_id ? '' : `no engine is set for ${owner}`);
|
|
10803
|
+
if (refusal) {
|
|
10804
|
+
const payload = { ok: false, action: 'member_run_not_started', member: owner, reason: refusal };
|
|
10805
|
+
printJsonOrText(payload, [`${owner} did not start: ${refusal}`], asJson);
|
|
10806
|
+
process.exitCode = 1;
|
|
10807
|
+
return payload;
|
|
10808
|
+
}
|
|
10809
|
+
}
|
|
10780
10810
|
// A human or odd-typed member never takes a task: it would be claimed and
|
|
10781
10811
|
// then stranded when the run refuses the member.
|
|
10782
10812
|
const pulled = pullTask && !prompt && !missionSummary.live_mission && !memberRefusalFor(targetMember, paths.memberFile)
|
|
@@ -10856,6 +10886,26 @@ async function memberSteerOrDispatchWith({ pullTask = false } = {}, rawArgs = []
|
|
|
10856
10886
|
return payload;
|
|
10857
10887
|
}
|
|
10858
10888
|
|
|
10889
|
+
// --detach: the same steer runs in the background, so a caller with a
|
|
10890
|
+
// short timeout gets started or not-started at once
|
|
10891
|
+
if (detach && !dryRun) {
|
|
10892
|
+
const { spawn } = require('child_process');
|
|
10893
|
+
const childArgs = [...optionArgs.filter(a => a !== '--detach' && a !== '--json'), ...(cut >= 0 ? ['--', ...directionArgs] : [])];
|
|
10894
|
+
let child;
|
|
10895
|
+
try {
|
|
10896
|
+
child = spawn(process.execPath, [process.argv[1], 'member', 'steer', ...childArgs], { cwd: root, detached: true, stdio: 'ignore', env: { ...process.env, ATRIS_SKIP_UPDATE_CHECK: '1' } });
|
|
10897
|
+
child.unref();
|
|
10898
|
+
} catch (error) {
|
|
10899
|
+
const payload = { ok: false, action: 'member_run_not_started', member: owner, error: String(error.message || error) };
|
|
10900
|
+
printJsonOrText(payload, [`${owner} did not start: ${payload.error}`], asJson);
|
|
10901
|
+
process.exitCode = 1;
|
|
10902
|
+
return payload;
|
|
10903
|
+
}
|
|
10904
|
+
const payload = { ok: true, action: 'member_run_detached', member: owner, pid: child.pid };
|
|
10905
|
+
printJsonOrText(payload, [`${owner} started in the background (pid ${child.pid}).`], asJson);
|
|
10906
|
+
return payload;
|
|
10907
|
+
}
|
|
10908
|
+
|
|
10859
10909
|
const command = `atris member run ${owner} ${JSON.stringify(effectivePrompt)} --engine ${engineSeat.engine_id} --ignite`;
|
|
10860
10910
|
if (dryRun) {
|
|
10861
10911
|
const payload = {
|
package/commands/pay.js
ADDED
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pay commands for Atris CLI, send credits to another Atris account.
|
|
3
|
+
*
|
|
4
|
+
* Usage:
|
|
5
|
+
* atris pay <to> <amount> [memo...] Pay someone in credits (100 credits = $1)
|
|
6
|
+
* atris pay history Your payments, newest first
|
|
7
|
+
* atris pay show <id> One payment
|
|
8
|
+
* atris pay accept <id> Accept a payment someone sent you
|
|
9
|
+
* atris pay cancel <id> Cancel a payment that is still waiting
|
|
10
|
+
*
|
|
11
|
+
* <to> can be @handle, handle, a user id, or an email. A leading @ is
|
|
12
|
+
* stripped before it is sent. Every payment carries a request_id, so the one
|
|
13
|
+
* retry after a network error can never pay twice.
|
|
14
|
+
*
|
|
15
|
+
* Backend: POST /api/pay, GET /api/pay/history, GET /api/pay/{id},
|
|
16
|
+
* POST /api/pay/{id}/accept, POST /api/pay/{id}/cancel.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
const { randomUUID } = require('crypto');
|
|
20
|
+
const { loadCredentials } = require('../utils/auth');
|
|
21
|
+
const { apiRequestJson } = require('../utils/api');
|
|
22
|
+
const { isHelpToken, argsWantHelp } = require('../lib/noninteractive');
|
|
23
|
+
|
|
24
|
+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
25
|
+
const SUBCOMMANDS = new Set(['history', 'show', 'accept', 'cancel']);
|
|
26
|
+
// Status 0 is a dropped connection; 502 and 504 mean a gateway lost the reply.
|
|
27
|
+
// In all three the payment may or may not have landed, so retry once with the
|
|
28
|
+
// same request_id and let the server dedupe.
|
|
29
|
+
const RETRY_STATUS = new Set([0, 502, 504]);
|
|
30
|
+
|
|
31
|
+
// Plain words for each error code, used when the server sends no message.
|
|
32
|
+
const ERROR_TEXT = {
|
|
33
|
+
insufficient_credits: 'You do not have enough credits for this payment.',
|
|
34
|
+
over_payment_cap: 'That is more than one payment can be.',
|
|
35
|
+
over_daily_cap: 'You hit your daily payment limit. Try again tomorrow.',
|
|
36
|
+
blocked: 'You cannot pay this account.',
|
|
37
|
+
self_payment: 'You cannot pay yourself.',
|
|
38
|
+
payment_not_found: 'No payment with that id.',
|
|
39
|
+
not_pending: 'That payment is no longer waiting.',
|
|
40
|
+
already_settled: 'That payment already went through.',
|
|
41
|
+
request_id_reused: 'That request id was already used for a different payment.',
|
|
42
|
+
expired: 'That payment expired.',
|
|
43
|
+
payments_unavailable: 'Payments are down right now. Try again in a minute.',
|
|
44
|
+
ledger_unavailable: 'Payments are down right now. Try again in a minute.',
|
|
45
|
+
scope_required: 'This key cannot make payments.',
|
|
46
|
+
invalid_amount: 'The amount must be a whole number of credits above 0.',
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const HINTS = {
|
|
50
|
+
insufficient_credits: 'Check your credits: atris balance · Buy more: atris topup',
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
function getToken() {
|
|
54
|
+
const creds = loadCredentials();
|
|
55
|
+
if (!creds || !creds.token) return null;
|
|
56
|
+
return creds.token;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function errorCode(res) {
|
|
60
|
+
const detail = res && res.data && res.data.detail;
|
|
61
|
+
if (detail && typeof detail === 'object') return detail.code || detail.error || null;
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function apiErrorMessage(res, fallback) {
|
|
66
|
+
const data = res && res.data;
|
|
67
|
+
const detail = data && (data.detail || data.error);
|
|
68
|
+
if (typeof detail === 'string') return detail;
|
|
69
|
+
const code = errorCode(res);
|
|
70
|
+
if (detail && typeof detail.message === 'string' && detail.message) return detail.message;
|
|
71
|
+
if (detail && detail.error && typeof detail.error === 'object' && detail.error.message) return detail.error.message;
|
|
72
|
+
if (code && ERROR_TEXT[code]) return ERROR_TEXT[code];
|
|
73
|
+
if (res && res.status === 0) return 'Could not reach Atris. Check your connection and try again.';
|
|
74
|
+
return fallback;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function formatCredits(amount) {
|
|
78
|
+
const n = Number(amount) || 0;
|
|
79
|
+
return `${n} credits ($${(n / 100).toFixed(2)})`;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// "@nora" -> "nora". Emails and ids pass through untouched.
|
|
83
|
+
function cleanTarget(raw) {
|
|
84
|
+
return String(raw == null ? '' : raw).trim().replace(/^@+/, '');
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function targetLabel(raw) {
|
|
88
|
+
const clean = cleanTarget(raw);
|
|
89
|
+
if (!clean) return '';
|
|
90
|
+
if (clean.includes('@') || UUID_RE.test(clean)) return clean;
|
|
91
|
+
return `@${clean}`;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function parseAmount(raw) {
|
|
95
|
+
const s = String(raw == null ? '' : raw).trim();
|
|
96
|
+
if (!/^\d{1,9}$/.test(s)) return null;
|
|
97
|
+
const n = parseInt(s, 10);
|
|
98
|
+
return n > 0 ? n : null;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function whenText(value) {
|
|
102
|
+
const s = String(value || '');
|
|
103
|
+
const m = s.match(/^(\d{4}-\d{2}-\d{2})[T ](\d{2}:\d{2})/);
|
|
104
|
+
return m ? `${m[1]} ${m[2]}` : s;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function splitFlags(args) {
|
|
108
|
+
const list = Array.isArray(args) ? args : [];
|
|
109
|
+
return {
|
|
110
|
+
json: list.includes('--json'),
|
|
111
|
+
rest: list.filter((a) => a !== '--json'),
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function createIo(io = {}) {
|
|
116
|
+
return {
|
|
117
|
+
request: io.request || apiRequestJson,
|
|
118
|
+
token: io.token !== undefined ? io.token : undefined,
|
|
119
|
+
log: io.log || console.log,
|
|
120
|
+
error: io.error || console.error,
|
|
121
|
+
newId: io.newId || randomUUID,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async function call(io, pathname, options = {}) {
|
|
126
|
+
const token = io.token !== undefined ? io.token : getToken();
|
|
127
|
+
if (!token) {
|
|
128
|
+
return { ok: false, status: 401, data: null, notLoggedIn: true };
|
|
129
|
+
}
|
|
130
|
+
return io.request(pathname, { ...options, token });
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Print the error in plain words and return the exit code.
|
|
134
|
+
function reportError(io, res, fallback) {
|
|
135
|
+
if (res && res.notLoggedIn) {
|
|
136
|
+
io.error('Not logged in. Run: atris login');
|
|
137
|
+
return 1;
|
|
138
|
+
}
|
|
139
|
+
if (res && res.status === 401) {
|
|
140
|
+
io.error('Session expired. Run: atris login');
|
|
141
|
+
return 1;
|
|
142
|
+
}
|
|
143
|
+
io.error(apiErrorMessage(res, fallback));
|
|
144
|
+
const hint = HINTS[errorCode(res)];
|
|
145
|
+
if (hint) io.error(hint);
|
|
146
|
+
return 1;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function receiptLine(receipt, label) {
|
|
150
|
+
const r = receipt || {};
|
|
151
|
+
const amount = formatCredits(r.amount_credits);
|
|
152
|
+
const who = label || r.to || 'them';
|
|
153
|
+
switch (r.status) {
|
|
154
|
+
case 'settled':
|
|
155
|
+
return `Paid ${who} ${amount}. Settled.`;
|
|
156
|
+
case 'settling':
|
|
157
|
+
return `Paid ${who} ${amount}. Settling, it finishes on its own.`;
|
|
158
|
+
case 'pending':
|
|
159
|
+
return `Sent ${who} ${amount}. Waiting for them to accept. id ${r.id}`;
|
|
160
|
+
case 'refunded':
|
|
161
|
+
return `Payment to ${who} of ${amount} was refunded. id ${r.id}`;
|
|
162
|
+
case 'failed':
|
|
163
|
+
return `Payment to ${who} of ${amount} failed. id ${r.id}`;
|
|
164
|
+
default:
|
|
165
|
+
return `Payment to ${who} of ${amount}: ${r.status || 'unknown'}. id ${r.id}`;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
async function send(io, args, json) {
|
|
170
|
+
const [rawTo, rawAmount, ...memoParts] = args;
|
|
171
|
+
const to = cleanTarget(rawTo);
|
|
172
|
+
if (!to || rawAmount === undefined) {
|
|
173
|
+
io.error('Usage: atris pay <to> <amount> [memo]');
|
|
174
|
+
return 2;
|
|
175
|
+
}
|
|
176
|
+
const amount = parseAmount(rawAmount);
|
|
177
|
+
if (amount === null) {
|
|
178
|
+
io.error(`"${rawAmount}" is not an amount. Use a whole number of credits (100 credits = $1).`);
|
|
179
|
+
io.error('Example: atris pay @nora 500 for the logo');
|
|
180
|
+
return 2;
|
|
181
|
+
}
|
|
182
|
+
const memo = memoParts.join(' ').trim();
|
|
183
|
+
const body = { to, amount_credits: amount, request_id: io.newId() };
|
|
184
|
+
if (memo) body.memo = memo;
|
|
185
|
+
|
|
186
|
+
// The API helper's own retry is off so this one retry is the only one, and
|
|
187
|
+
// it carries the same request_id.
|
|
188
|
+
const options = { method: 'POST', body, retries: 0 };
|
|
189
|
+
let res = await call(io, '/pay', options);
|
|
190
|
+
if (!res.notLoggedIn && RETRY_STATUS.has(res.status)) {
|
|
191
|
+
res = await call(io, '/pay', options);
|
|
192
|
+
}
|
|
193
|
+
if (!res.ok) return reportError(io, res, `Payment failed (${res.status})`);
|
|
194
|
+
const receipt = res.data || {};
|
|
195
|
+
if (json) {
|
|
196
|
+
io.log(JSON.stringify(receipt, null, 2));
|
|
197
|
+
} else {
|
|
198
|
+
io.log(receiptLine(receipt, targetLabel(rawTo)));
|
|
199
|
+
}
|
|
200
|
+
return receipt.status === 'failed' ? 1 : 0;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function historyRows(data) {
|
|
204
|
+
if (Array.isArray(data)) return data;
|
|
205
|
+
if (data && Array.isArray(data.payments)) return data.payments;
|
|
206
|
+
return [];
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
async function history(io, json) {
|
|
210
|
+
const res = await call(io, '/pay/history');
|
|
211
|
+
if (!res.ok) return reportError(io, res, `Could not load payments (${res.status})`);
|
|
212
|
+
const rows = historyRows(res.data);
|
|
213
|
+
if (json) {
|
|
214
|
+
io.log(JSON.stringify(rows, null, 2));
|
|
215
|
+
return 0;
|
|
216
|
+
}
|
|
217
|
+
if (rows.length === 0) {
|
|
218
|
+
io.log('No payments yet. Pay someone: atris pay @handle 500');
|
|
219
|
+
return 0;
|
|
220
|
+
}
|
|
221
|
+
io.log(`Payments (${rows.length}):`);
|
|
222
|
+
for (const r of rows) {
|
|
223
|
+
const incoming = r.direction === 'in';
|
|
224
|
+
const verb = incoming ? 'got' : 'sent';
|
|
225
|
+
const who = incoming ? `from ${r.from}` : `to ${r.to}`;
|
|
226
|
+
const memo = r.memo ? ` "${r.memo}"` : '';
|
|
227
|
+
io.log(` ${whenText(r.created_at)} ${verb} ${formatCredits(r.amount_credits)} ${who} ${r.status}${memo}`);
|
|
228
|
+
io.log(` id ${r.id}`);
|
|
229
|
+
}
|
|
230
|
+
return 0;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function printReceipt(io, r) {
|
|
234
|
+
io.log(`Payment ${r.id}`);
|
|
235
|
+
io.log(` Status ${r.status}`);
|
|
236
|
+
io.log(` Amount ${formatCredits(r.amount_credits)}`);
|
|
237
|
+
io.log(` From ${r.from}`);
|
|
238
|
+
io.log(` To ${r.to}`);
|
|
239
|
+
if (r.memo) io.log(` Memo ${r.memo}`);
|
|
240
|
+
if (r.created_at) io.log(` Made ${whenText(r.created_at)}`);
|
|
241
|
+
if (r.settled_at) io.log(` Settled ${whenText(r.settled_at)}`);
|
|
242
|
+
if (r.expires_at) io.log(` Expires ${whenText(r.expires_at)}`);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
async function show(io, id, json) {
|
|
246
|
+
if (!id || isHelpToken(id)) {
|
|
247
|
+
io.error('Usage: atris pay show <id>');
|
|
248
|
+
return 2;
|
|
249
|
+
}
|
|
250
|
+
const res = await call(io, `/pay/${encodeURIComponent(id)}`);
|
|
251
|
+
if (!res.ok) return reportError(io, res, `Could not load that payment (${res.status})`);
|
|
252
|
+
const receipt = res.data || {};
|
|
253
|
+
if (json) io.log(JSON.stringify(receipt, null, 2));
|
|
254
|
+
else printReceipt(io, receipt);
|
|
255
|
+
return 0;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
async function act(io, action, id, json) {
|
|
259
|
+
if (!id || isHelpToken(id)) {
|
|
260
|
+
io.error(`Usage: atris pay ${action} <id>`);
|
|
261
|
+
return 2;
|
|
262
|
+
}
|
|
263
|
+
const res = await call(io, `/pay/${encodeURIComponent(id)}/${action}`, { method: 'POST', retries: 0 });
|
|
264
|
+
if (!res.ok) return reportError(io, res, `Could not ${action} that payment (${res.status})`);
|
|
265
|
+
const r = res.data || {};
|
|
266
|
+
if (json) {
|
|
267
|
+
io.log(JSON.stringify(r, null, 2));
|
|
268
|
+
return 0;
|
|
269
|
+
}
|
|
270
|
+
const amount = formatCredits(r.amount_credits);
|
|
271
|
+
if (action === 'accept') {
|
|
272
|
+
io.log(r.status === 'settled' || r.status === 'settling'
|
|
273
|
+
? `Accepted. ${amount} ${r.status === 'settling' ? 'are on the way' : 'are yours'}.`
|
|
274
|
+
: `Payment ${r.id} is ${r.status}.`);
|
|
275
|
+
} else {
|
|
276
|
+
io.log(r.status === 'refunded'
|
|
277
|
+
? `Canceled. ${amount} went back to the sender.`
|
|
278
|
+
: `Payment ${r.id} is ${r.status}.`);
|
|
279
|
+
}
|
|
280
|
+
return 0;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function printHelp(log = console.log) {
|
|
284
|
+
log(`atris pay, send credits to another Atris account (100 credits = $1)
|
|
285
|
+
|
|
286
|
+
atris pay <to> <amount> [memo] Pay someone, like: atris pay @nora 500 for the logo
|
|
287
|
+
atris pay history Your payments, newest first
|
|
288
|
+
atris pay show <id> One payment
|
|
289
|
+
atris pay accept <id> Take a payment someone sent you
|
|
290
|
+
atris pay cancel <id> Cancel a payment that is still waiting
|
|
291
|
+
|
|
292
|
+
<to> is @handle, a user id, or an email.
|
|
293
|
+
Some payments wait for the other person to accept. You get an id to check on it.
|
|
294
|
+
Add --json for the raw receipt.
|
|
295
|
+
See who you trust first: atris trust <handle>`);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
async function payCommand(argv, ioOverrides) {
|
|
299
|
+
const io = createIo(ioOverrides);
|
|
300
|
+
const { json, rest } = splitFlags(argv || process.argv.slice(3));
|
|
301
|
+
const sub = rest[0];
|
|
302
|
+
if (sub === undefined || isHelpToken(sub)) {
|
|
303
|
+
printHelp(sub === undefined ? io.error : io.log);
|
|
304
|
+
return sub === undefined ? 2 : 0;
|
|
305
|
+
}
|
|
306
|
+
if (argsWantHelp(rest)) {
|
|
307
|
+
printHelp(io.log);
|
|
308
|
+
return 0;
|
|
309
|
+
}
|
|
310
|
+
// Subcommand words always win, so a typo never sends money. To pay someone
|
|
311
|
+
// whose handle is one of these words, write it with the @: atris pay @history 500.
|
|
312
|
+
if (SUBCOMMANDS.has(sub)) {
|
|
313
|
+
if (sub === 'history') return history(io, json);
|
|
314
|
+
if (sub === 'show') return show(io, rest[1], json);
|
|
315
|
+
return act(io, sub, rest[1], json);
|
|
316
|
+
}
|
|
317
|
+
return send(io, rest, json);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
module.exports = {
|
|
321
|
+
payCommand,
|
|
322
|
+
apiErrorMessage,
|
|
323
|
+
formatCredits,
|
|
324
|
+
cleanTarget,
|
|
325
|
+
targetLabel,
|
|
326
|
+
createIo,
|
|
327
|
+
call,
|
|
328
|
+
reportError,
|
|
329
|
+
};
|