@earntori/cli 0.2.2 → 0.2.4

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.
Files changed (3) hide show
  1. package/README.md +1 -1
  2. package/dist/index.js +123 -11
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -14,4 +14,4 @@ npx @earntori/cli logout
14
14
 
15
15
  **Earn AI credits by building securely in Claude Code and Cursor.** Tori checks the code your coding agent writes before you ship it, tells the agent how to fix what it finds, and rewards you in AI credits for building with it. Full guide: https://www.earntori.com/docs/mcp · issues: https://github.com/GAishZOEish/Tori-MCP/issues
16
16
 
17
- Sending rewards to your own users? That's the Partner API: https://github.com/GAishZOEish/tori-examples
17
+ Sending rewards to your own users? That's the Partner API: https://www.earntori.com/developers#quickstart
package/dist/index.js CHANGED
@@ -4,26 +4,58 @@ import { readFileSync, writeFileSync, mkdirSync, existsSync, appendFileSync, unl
4
4
  import { homedir } from "node:os";
5
5
  import { join, resolve, relative, isAbsolute, dirname } from "node:path";
6
6
  import { execSync } from "node:child_process";
7
- import { createHash } from "node:crypto";
7
+ import { createHash, createPublicKey, verify as edVerify } from "node:crypto";
8
8
  const CONFIG_DIR = join(homedir(), ".tori");
9
9
  const CONFIG = join(CONFIG_DIR, "config.json");
10
+ const SESSIONS_DIR = join(CONFIG_DIR, "sessions");
11
+ const hostOf = (base) => base.replace(/^https?:\/\//, "").replace(/[^a-z0-9.-]/gi, "_");
10
12
  const args = process.argv.slice(2);
11
13
  const cmd = args[0] ?? "help";
12
14
  const flag = (name) => { const i = args.indexOf(`--${name}`); return i >= 0 ? args[i + 1] : undefined; };
13
15
  const has = (name) => args.includes(`--${name}`);
14
16
  const json = has("json");
15
- function loadCfg() { try {
16
- return JSON.parse(readFileSync(CONFIG, "utf8"));
17
- }
18
- catch {
17
+ /** Effective config: the global login, unless this repo pins a base in .tori/project.json — then that base with its own saved session. */
18
+ function projectBase() {
19
+ let dir = process.cwd();
20
+ for (let i = 0; i < 8; i++) {
21
+ try {
22
+ const j = JSON.parse(readFileSync(join(dir, ".tori", "project.json"), "utf8"));
23
+ if (typeof j?.base === "string")
24
+ return j.base.replace(/\/$/, "");
25
+ }
26
+ catch { }
27
+ const up = dirname(dir);
28
+ if (up === dir)
29
+ break;
30
+ dir = up;
31
+ }
19
32
  return null;
20
- } }
33
+ }
34
+ function loadCfg() {
35
+ let global = null;
36
+ try {
37
+ global = JSON.parse(readFileSync(CONFIG, "utf8"));
38
+ }
39
+ catch { }
40
+ const pinned = projectBase();
41
+ if (!pinned)
42
+ return global;
43
+ if (global && global.base === pinned)
44
+ return global;
45
+ try {
46
+ const sess = JSON.parse(readFileSync(join(SESSIONS_DIR, hostOf(pinned) + ".json"), "utf8"));
47
+ return { ...sess, base: pinned };
48
+ }
49
+ catch { }
50
+ return null; // pinned to a base we have no session for
51
+ }
52
+ function saveSession(c) { mkdirSync(SESSIONS_DIR, { recursive: true }); writeFileSync(join(SESSIONS_DIR, hostOf(c.base) + ".json"), JSON.stringify(c, null, 2), { mode: 0o600 }); }
21
53
  function saveCfg(c) { mkdirSync(CONFIG_DIR, { recursive: true }); writeFileSync(CONFIG, JSON.stringify(c, null, 2), { mode: 0o600 }); }
22
54
  function out(o, human) { console.log(json ? JSON.stringify(o, null, 2) : human); }
23
55
  function die(msg) { console.error(json ? JSON.stringify({ error: msg }) : `\u2717 ${msg}`); process.exit(1); }
24
56
  const usd = (c) => `$${(c / 100).toFixed(2)}`;
25
57
  async function api(cfg, method, path, body) {
26
- const r = await fetch(`${cfg.base}${path}`, { method, headers: { Authorization: `Bearer ${cfg.token}`, "Content-Type": "application/json", "User-Agent": "@earntori/cli/0.2.2" }, body: body ? JSON.stringify(body) : undefined });
58
+ const r = await fetch(`${cfg.base}${path}`, { method, headers: { Authorization: `Bearer ${cfg.token}`, "Content-Type": "application/json", "User-Agent": "@earntori/cli/0.2.4" }, body: body ? JSON.stringify(body) : undefined });
27
59
  const t = await r.text();
28
60
  let j = null;
29
61
  try {
@@ -62,6 +94,7 @@ async function login() {
62
94
  if (!s.token)
63
95
  die(s.error ?? "Tori did not return a session");
64
96
  saveCfg({ base, token: s.token, email: s.email, githubLogin: s.githubLogin });
97
+ saveSession({ base, token: s.token, email: s.email, githubLogin: s.githubLogin });
65
98
  out({ ok: true, email: s.email, githubLogin: s.githubLogin, base }, `\u2713 Signed in as ${s.githubLogin} (${s.email}) on ${base}`);
66
99
  }
67
100
  async function status() {
@@ -124,9 +157,12 @@ catch {
124
157
  } }
125
158
  function writeJson(p, o) { mkdirSync(dirname(resolve(p)), { recursive: true }); writeFileSync(p, JSON.stringify(o, null, 2) + "\n"); }
126
159
  function init() {
160
+ const pin = flag("base");
161
+ if (pin)
162
+ writeJson(join(".tori", "project.json"), { base: pin.replace(/\/$/, "") });
127
163
  const cfg = loadCfg();
128
164
  if (!cfg)
129
- die("Not signed in \u2014 run: tori login");
165
+ die(pin ? `Not signed in for ${pin} \u2014 run: tori login --base ${pin}` : "Not signed in \u2014 run: tori login");
130
166
  const local = has("local");
131
167
  const cliBin = resolve(process.argv[1] ?? "");
132
168
  const mcpBin = resolve(dirname(cliBin), "..", "..", "mcp", "dist", "index.js");
@@ -150,7 +186,9 @@ function init() {
150
186
  ensure("SessionStart", `${cli} check --reminder`);
151
187
  writeJson(sfile, s);
152
188
  wrote.push(sfile);
153
- out({ ok: true, wrote, local }, `\u2713 Tori installed in this project${local ? " (local builds)" : ""}\n` +
189
+ if (pin)
190
+ wrote.push(join(".tori", "project.json"));
191
+ out({ ok: true, wrote, local }, `\u2713 Tori installed in this project${local ? " (local builds)" : ""}${pin ? ` \u2014 pinned to ${pin}` : ""}\n` +
154
192
  wrote.map((w) => ` ${w}`).join("\n") +
155
193
  `\n\nClaude Code: restart the session (hooks and .mcp.json load at start). Cursor: reload the window.\nTori's checks now run when the agent finishes a turn; findings are shown to the agent before it stops.`);
156
194
  }
@@ -199,11 +237,13 @@ function formatRun(r, n) {
199
237
  L.push("", `Waived (${r.waived.length}, not blocking): ` + r.waived.map((w) => `${w.path}${w.line ? ":" + w.line : ""} ${w.rule_id} \u2014 ${w.reason ?? "no reason"}`).join("; "));
200
238
  if (r.reward?.issued)
201
239
  L.push("", `Tori rewarded you (${r.reward.reward}) \u2014 details in your Tori ledger.`);
240
+ if (r.receipt)
241
+ L.push(`Receipt: ${r.receipt.short}`);
202
242
  return L.join("\n");
203
243
  }
204
244
  async function check() {
205
245
  if (has("reminder")) {
206
- console.log("Tori is installed in this project. Before finishing any work that touches webhooks, payments, banking, identity or third-party APIs, call the checks_run tool on the files you changed; after fixing a finding, call checks_resolve with the finding_id and path. Tori also runs automatically when you stop.");
246
+ console.log("Tori is installed in this project. Before finishing any work that touches webhooks, payments, banking, identity or third-party APIs, call the checks_run tool on the files you changed; after fixing a finding, call checks_resolve with the finding_id and path. Tori also runs automatically when you stop. Everything you do here is journaled — the ledger_query tool reads it back.");
207
247
  return;
208
248
  }
209
249
  const hook = has("hook");
@@ -256,15 +296,87 @@ async function check() {
256
296
  if (!r.clean)
257
297
  process.exitCode = 1;
258
298
  }
299
+ async function ledger() {
300
+ const cfg = loadCfg();
301
+ if (!cfg)
302
+ die("Not signed in \u2014 run: tori login");
303
+ const q = new URLSearchParams();
304
+ const proj = flag("project") ?? projectId();
305
+ if (proj !== "all")
306
+ q.set("project", proj);
307
+ if (flag("type"))
308
+ q.set("event_type", flag("type"));
309
+ if (flag("since"))
310
+ q.set("since", flag("since"));
311
+ if (flag("until"))
312
+ q.set("until", flag("until"));
313
+ if (flag("limit"))
314
+ q.set("limit", flag("limit"));
315
+ if (flag("before"))
316
+ q.set("before_seq", flag("before"));
317
+ const r = await api(cfg, "GET", `/api/v1/ledger/query?${q}`);
318
+ const line = (e) => {
319
+ const p = e.payload ?? {};
320
+ const bits = [];
321
+ if (p.rule_id)
322
+ bits.push(p.rule_id);
323
+ if (p.path)
324
+ bits.push(`${p.path}${p.line ? ":" + p.line : ""}`);
325
+ if (p.via)
326
+ bits.push(`via ${p.via}`);
327
+ if (p.reward)
328
+ bits.push(`${p.reward}${p.amount_cents ? " " + p.amount_cents + "\u00A2" : ""}`);
329
+ if (p.reason_code)
330
+ bits.push(`withheld: ${p.reason_code}`);
331
+ if (typeof p.clean === "boolean")
332
+ bits.push(`${p.open ?? 0} open, ${p.waived ?? 0} waived`);
333
+ if (p.harness)
334
+ bits.push(p.harness);
335
+ return `#${e.seq} ${String(e.ts).replace("T", " ").slice(0, 19)}Z ${e.event_type} [${e.actor_type}] ${bits.join(" \u00B7 ")} \u2014 ${e.receipt.short}`;
336
+ };
337
+ out(r, r.events.length ? r.events.map(line).join("\n") + (r.next_before_seq ? `\n(more: --before ${r.next_before_seq})` : "") : `No journal events for ${proj}.`);
338
+ }
339
+ async function receipt() {
340
+ const cfg = loadCfg();
341
+ if (!cfg)
342
+ die("Not signed in \u2014 run: tori login");
343
+ const id = args[1];
344
+ if (!id || id.startsWith("--"))
345
+ die("Usage: tori receipt <event_id|seq>");
346
+ const r = await api(cfg, "GET", `/api/v1/ledger/receipts/${encodeURIComponent(id)}`);
347
+ const k = await api(cfg, "GET", "/api/v1/ledger/receipt-key");
348
+ const recomputed = createHash("sha256").update(r.preimage, "utf8").digest("hex");
349
+ const hashOk = recomputed === r.hash;
350
+ let sigState = "UNSIGNED";
351
+ if (r.sig) {
352
+ if (!k.public_key_spki_b64)
353
+ sigState = "NO_KEY";
354
+ else {
355
+ try {
356
+ const pub = createPublicKey({ key: Buffer.from(k.public_key_spki_b64, "base64"), format: "der", type: "spki" });
357
+ sigState = edVerify(null, Buffer.from(r.signed, "utf8"), pub, Buffer.from(r.sig, "base64")) ? "VERIFIED" : "INVALID";
358
+ }
359
+ catch {
360
+ sigState = "INVALID";
361
+ }
362
+ }
363
+ }
364
+ const verdict = hashOk ? (sigState === "VERIFIED" ? "VERIFIED" : sigState) : "INVALID (hash mismatch)";
365
+ out({ ...r, recomputed_hash: recomputed, hash_ok: hashOk, signature: sigState, verdict }, `${verdict === "VERIFIED" ? "\u2713" : "\u2717"} ${verdict}\n#${r.seq} ${r.event_type} [${r.actor_type}] ${String(r.ts)}\nproject ${r.project_id}${r.ref_type ? ` \u00B7 ${r.ref_type} ${r.ref_id}` : ""}\nhash ${r.hash}\nprev_hash ${r.prev_hash}\nsig ${r.sig ?? "(unsigned)"}\nshort ${r.short}`);
366
+ if (verdict !== "VERIFIED")
367
+ process.exitCode = 1;
368
+ }
259
369
  function logout() { if (existsSync(CONFIG))
260
370
  unlinkSync(CONFIG); out({ ok: true }, "\u2713 Signed out"); }
261
- function help() { console.log(`tori \u2014 Tori CLI\n\n tori login [--base URL] sign in with GitHub (device flow)\n tori status [--json] balance + your AI keys\n tori key --limit 5 [--write env] [--file .env] [--json]\n tori stats [--json] your integration + developer program numbers\n tori init [--local] wire Tori into this project (MCP + Claude Code hooks)\n tori check [paths...] [--json] run Tori's checks on changed files\n tori logout\n`); }
371
+ function help() { console.log(`tori \u2014 Tori CLI\n\n tori login [--base URL] sign in with GitHub (device flow)\n tori status [--json] balance + your AI keys\n tori key --limit 5 [--write env] [--file .env] [--json]\n tori stats [--json] your integration + developer program numbers\n tori init [--local] wire Tori into this project (MCP + Claude Code hooks)\n tori check [paths...] [--json] run Tori's checks on changed files\n tori ledger [--project X|all] [--type a,b] [--since ISO] [--limit N] [--json]\n tori receipt <event_id|seq> fetch a receipt and verify it locally\n tori init --base https://sandbox.earntori.com pin this repo to a Tori environment (.tori/project.json)\n tori logout\n`); }
262
372
  (async () => {
263
373
  switch (cmd) {
264
374
  case "login": return login();
265
375
  case "status": return status();
266
376
  case "init": return init();
267
377
  case "check": return check();
378
+ case "ledger": return ledger();
379
+ case "receipt": return receipt();
268
380
  case "key": return key();
269
381
  case "stats": return stats();
270
382
  case "logout": return logout();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@earntori/cli",
3
- "version": "0.2.2",
3
+ "version": "0.2.4",
4
4
  "description": "Tori CLI \u2014 sign in with GitHub, check your credits, mint a capped AI key into your tools.",
5
5
  "license": "MIT",
6
6
  "type": "module",