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 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. Only the human can pay. Every dollar leaves only after
13
- the person acts themselves: a tap in their Link app, a payment page they
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
- token,
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('anyone with this link can act for you until you stop it. ctrl-c stops it.');
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.');
@@ -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] "<direction>" [--dry-run] [--json]');
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
- const asJson = hasFlag(rawArgs, '--json');
10735
- const dryRun = hasFlag(rawArgs, '--dry-run');
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(rawArgs, '--needs-check');
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 < rawArgs.length; i++) {
10743
- const token = String(rawArgs[i] || '');
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 < rawArgs.length) {
10749
- flagArgs.push(rawArgs[i + 1]);
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 = {
@@ -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
+ };