@earntori/cli 0.2.5 → 0.2.9

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 (2) hide show
  1. package/dist/index.js +160 -27
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -55,7 +55,7 @@ function out(o, human) { console.log(json ? JSON.stringify(o, null, 2) : human);
55
55
  function die(msg) { console.error(json ? JSON.stringify({ error: msg }) : `\u2717 ${msg}`); process.exit(1); }
56
56
  const usd = (c) => `$${(c / 100).toFixed(2)}`;
57
57
  async function api(cfg, method, path, body) {
58
- const r = await fetch(`${cfg.base}${path}`, { method, headers: { Authorization: `Bearer ${cfg.token}`, "Content-Type": "application/json", "User-Agent": "@earntori/cli/0.2.5" }, 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.9" }, body: body ? JSON.stringify(body) : undefined });
59
59
  const t = await r.text();
60
60
  let j = null;
61
61
  try {
@@ -157,6 +157,63 @@ catch {
157
157
  return null;
158
158
  } }
159
159
  function writeJson(p, o) { mkdirSync(dirname(resolve(p)), { recursive: true }); writeFileSync(p, JSON.stringify(o, null, 2) + "\n"); }
160
+ const withHarness = (mcp, id) => ({ ...mcp, env: { TORI_HARNESS: id } });
161
+ function mergeMcpJson(file, mcp) { const j = readJson(file) ?? {}; j.mcpServers = { ...(j.mcpServers ?? {}), tori: mcp }; writeJson(file, j); return file; }
162
+ function hookPresent(h, c) { return typeof h?.command === "string" && (h.command.includes("@earntori/cli check") || (c.local && h.command.includes(c.cliBin))); }
163
+ function ensureHook(hooks, event, entry, c) {
164
+ const arr = Array.isArray(hooks[event]) ? hooks[event] : [];
165
+ if (!arr.some((g) => Array.isArray(g?.hooks) && g.hooks.some((h) => hookPresent(h, c))))
166
+ arr.push(entry);
167
+ hooks[event] = arr;
168
+ }
169
+ const HARNESSES = [
170
+ {
171
+ id: "claude-code", label: "Claude Code", detect: () => true,
172
+ install: (c) => {
173
+ const wrote = [mergeMcpJson(".mcp.json", withHarness(c.mcp, "claude-code"))];
174
+ const sfile = join(".claude", "settings.json");
175
+ const s = readJson(sfile) ?? {};
176
+ s.hooks = s.hooks && typeof s.hooks === "object" ? s.hooks : {};
177
+ ensureHook(s.hooks, "Stop", { hooks: [{ type: "command", command: `${c.cli} check --hook` }] }, c);
178
+ ensureHook(s.hooks, "SessionStart", { hooks: [{ type: "command", command: `${c.cli} check --reminder` }] }, c);
179
+ writeJson(sfile, s);
180
+ wrote.push(sfile);
181
+ return wrote;
182
+ },
183
+ after: "Claude Code: restart the session (hooks and .mcp.json load at start).",
184
+ },
185
+ {
186
+ id: "cursor", label: "Cursor", detect: () => true,
187
+ install: (c) => [mergeMcpJson(join(".cursor", "mcp.json"), withHarness(c.mcp, "cursor"))],
188
+ after: "Cursor: reload the window.",
189
+ },
190
+ {
191
+ id: "codex", label: "Codex",
192
+ detect: () => !has("no-codex") && (has("codex") || existsSync(".codex") || existsSync(join(homedir(), ".codex"))),
193
+ install: (c) => {
194
+ const wrote = [];
195
+ // .codex/config.toml — append the [mcp_servers.tori] table if it is not already there.
196
+ const tfile = join(".codex", "config.toml");
197
+ const toml = existsSync(tfile) ? readFileSync(tfile, "utf8") : "";
198
+ if (!/\[mcp_servers\.tori\]/.test(toml)) {
199
+ const block = `\n[mcp_servers.tori]\ncommand = ${JSON.stringify(c.mcp.command)}\nargs = ${JSON.stringify(c.mcp.args)}\n\n[mcp_servers.tori.env]\nTORI_HARNESS = "codex"\n`;
200
+ mkdirSync(".codex", { recursive: true });
201
+ writeFileSync(tfile, (toml.trimEnd() + "\n" + block).replace(/^\n/, ""));
202
+ }
203
+ wrote.push(tfile);
204
+ // .codex/hooks.json — same two hooks; Stop uses exit 2 + stderr to continue the turn, SessionStart prints to stdout.
205
+ const hfile = join(".codex", "hooks.json");
206
+ const h = readJson(hfile) ?? {};
207
+ h.hooks = h.hooks && typeof h.hooks === "object" ? h.hooks : {};
208
+ ensureHook(h.hooks, "SessionStart", { matcher: "startup|resume", hooks: [{ type: "command", command: `${c.cli} check --reminder`, timeout: 30 }] }, c);
209
+ ensureHook(h.hooks, "Stop", { hooks: [{ type: "command", command: `${c.cli} check --hook`, timeout: 120 }] }, c);
210
+ writeJson(hfile, h);
211
+ wrote.push(hfile);
212
+ return wrote;
213
+ },
214
+ after: "Codex: trust the project, then /mcp to confirm tori and /hooks once to trust the two hooks.",
215
+ },
216
+ ];
160
217
  function init() {
161
218
  const pin = flag("base");
162
219
  if (pin)
@@ -169,30 +226,31 @@ function init() {
169
226
  const mcpBin = resolve(dirname(cliBin), "..", "..", "mcp", "dist", "index.js");
170
227
  if (local && !existsSync(mcpBin))
171
228
  die(`--local: MCP build not found at ${mcpBin} (run: pnpm --filter @earntori/mcp build)`);
172
- const mcp = local ? { command: "node", args: [mcpBin] } : { command: "npx", args: ["-y", "@earntori/mcp"] };
173
- const cli = local ? `node ${JSON.stringify(cliBin)}` : "npx -y @earntori/cli";
174
- const mergeMcp = (file) => { const j = readJson(file) ?? {}; j.mcpServers = { ...(j.mcpServers ?? {}), tori: mcp }; writeJson(file, j); return file; };
175
- const wrote = [mergeMcp(".mcp.json"), mergeMcp(join(".cursor", "mcp.json"))];
176
- const sfile = join(".claude", "settings.json");
177
- const s = readJson(sfile) ?? {};
178
- s.hooks = s.hooks && typeof s.hooks === "object" ? s.hooks : {};
179
- const ensure = (event, command) => {
180
- const arr = Array.isArray(s.hooks[event]) ? s.hooks[event] : [];
181
- const present = arr.some((g) => Array.isArray(g?.hooks) && g.hooks.some((h) => typeof h?.command === "string" && h.command.includes("@earntori/cli check") || (local && typeof h?.command === "string" && h.command.includes(cliBin))));
182
- if (!present)
183
- arr.push({ hooks: [{ type: "command", command }] });
184
- s.hooks[event] = arr;
185
- };
186
- ensure("Stop", `${cli} check --hook`);
187
- ensure("SessionStart", `${cli} check --reminder`);
188
- writeJson(sfile, s);
189
- wrote.push(sfile);
229
+ const ctx = { mcp: local ? { command: "node", args: [mcpBin] } : { command: "npx", args: ["-y", "@earntori/mcp"] }, cli: local ? `node ${JSON.stringify(cliBin)}` : "npx -y @earntori/cli", local, cliBin };
230
+ const set = HARNESSES.filter((h) => h.detect());
231
+ const wrote = [];
232
+ for (const h of set)
233
+ wrote.push(...h.install(ctx));
190
234
  if (pin)
191
235
  wrote.push(join(".tori", "project.json"));
192
- out({ ok: true, wrote, local }, `\u2713 Tori installed in this project${local ? " (local builds)" : ""}${pin ? ` \u2014 pinned to ${pin}` : ""}\n` +
236
+ out({ ok: true, harnesses: set.map((h) => h.id), wrote, local }, `\u2713 Tori installed in this project for ${set.map((h) => h.label).join(", ")}${local ? " (local builds)" : ""}${pin ? ` \u2014 pinned to ${pin}` : ""}\n` +
193
237
  wrote.map((w) => ` ${w}`).join("\n") +
194
- `\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.`);
238
+ `\n\n${set.map((h) => h.after).join(" ")}\nTori's checks now run when the agent finishes a turn; findings are shown to the agent before it stops.` +
239
+ (set.some((h) => h.id === "codex") ? "" : "\n(Codex not detected \u2014 run `tori init --codex` to set it up too.)"));
240
+ }
241
+ function repoIndex() { try {
242
+ const out = execSync("git ls-files", { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).split("\n").filter(Boolean);
243
+ return out.length ? out.slice(0, 5000) : undefined;
195
244
  }
245
+ catch {
246
+ return undefined;
247
+ } }
248
+ function envExample() { try {
249
+ return existsSync(".env.example") ? readFileSync(".env.example", "utf8").slice(0, 20000) : undefined;
250
+ }
251
+ catch {
252
+ return undefined;
253
+ } }
196
254
  const CODE_EXT = /\.(ts|tsx|js|jsx|mjs|cjs|py|go|rb|php|java|kt|cs)$/i;
197
255
  function projectId() {
198
256
  try {
@@ -206,7 +264,8 @@ function projectId() {
206
264
  function gitChanged() {
207
265
  try {
208
266
  const o = execSync("git status --porcelain --untracked-files=all", { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
209
- return o.split("\n").map((l) => l.slice(3).trim()).filter(Boolean).map((p) => (p.includes(" -> ") ? p.split(" -> ")[1] : p));
267
+ const SKIP = /(^|\/)(node_modules|dist|build|out|\.next|\.git|coverage|vendor|\.venv|venv|__pycache__|\.turbo|\.cache)(\/|$)/;
268
+ return o.split("\n").map((l) => l.slice(3).trim()).filter(Boolean).map((p) => (p.includes(" -> ") ? p.split(" -> ")[1] : p)).filter((p) => !SKIP.test(p)).slice(0, 200);
210
269
  }
211
270
  catch {
212
271
  return [];
@@ -242,9 +301,27 @@ function formatRun(r, n) {
242
301
  L.push(`Receipt: ${r.receipt.short}`);
243
302
  return L.join("\n");
244
303
  }
304
+ const dbg = (m) => { if (process.env.TORI_DEBUG)
305
+ console.error(`[tori ${new Date().toISOString().slice(11, 23)}] ${m}`); };
245
306
  async function check() {
307
+ dbg("check: start");
246
308
  if (has("reminder")) {
247
- 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. Call ledger_query first to see open and waived findings for this project — everything here is journaled.");
309
+ let extra = "";
310
+ try {
311
+ const cfg0 = loadCfg();
312
+ if (cfg0) {
313
+ const project = projectId();
314
+ const [st, ru] = await Promise.all([
315
+ api(cfg0, "GET", `/api/v1/standards?project=${encodeURIComponent(project)}`),
316
+ api(cfg0, "GET", `/api/v1/rules?project=${encodeURIComponent(project)}`),
317
+ ]);
318
+ const domains = st.standards.filter((s) => s.tier === "domain" && s.enabled).map((s) => s.id);
319
+ const enforced = ru.rules.filter((r) => r.enforced).length, recorded = ru.rules.length - enforced;
320
+ extra = ` This project is held to Tori's coding standards${domains.length ? ` plus ${domains.join(", ")}` : ""}${ru.rules.length ? `, and ${ru.rules.length} rule${ru.rules.length === 1 ? "" : "s"} of its own (${enforced} enforced, ${recorded} recorded)` : ""}.${recorded ? " Recorded rules are not checked automatically; read them with `tori rules` and follow them." : ""}`;
321
+ }
322
+ }
323
+ catch { }
324
+ 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. Call ledger_query first to see open and waived findings for this project — everything here is journaled." + extra);
248
325
  return;
249
326
  }
250
327
  const hook = has("hook");
@@ -255,6 +332,7 @@ async function check() {
255
332
  stopActive = !!JSON.parse(raw || "{}").stop_hook_active;
256
333
  }
257
334
  catch { }
335
+ dbg(`stdin read (stop_hook_active=${stopActive})`);
258
336
  }
259
337
  const cfg = loadCfg();
260
338
  if (!cfg) {
@@ -263,7 +341,10 @@ async function check() {
263
341
  die("Not signed in \u2014 run: tori login");
264
342
  }
265
343
  const explicit = args.slice(1).filter((a) => !a.startsWith("--"));
266
- const files = readCode(explicit.length ? explicit : gitChanged());
344
+ const changed = explicit.length ? explicit : gitChanged();
345
+ dbg(`changed files: ${changed.length}`);
346
+ const files = readCode(changed);
347
+ dbg(`code files: ${files.length} (${files.reduce((n, f) => n + f.content.length, 0)} chars)`);
267
348
  if (files.length === 0) {
268
349
  if (!hook)
269
350
  out({ clean: true, files: 0 }, "Nothing to check \u2014 no changed code files.");
@@ -271,7 +352,10 @@ async function check() {
271
352
  }
272
353
  let r;
273
354
  try {
274
- const res = await fetch(`${cfg.base}/api/v1/checks/run`, { method: "POST", headers: { Authorization: `Bearer ${cfg.token}`, "Content-Type": "application/json", "User-Agent": "@earntori/cli/check" }, body: JSON.stringify({ files, project: projectId() }) });
355
+ const body0 = { files, project: projectId(), repo_index: repoIndex(), env_example: envExample() };
356
+ dbg(`request: ${JSON.stringify(body0).length} bytes, index ${body0.repo_index?.length ?? 0} paths -> ${cfg.base}`);
357
+ const res = await fetch(`${cfg.base}/api/v1/checks/run`, { method: "POST", headers: { Authorization: `Bearer ${cfg.token}`, "Content-Type": "application/json", "User-Agent": "@earntori/cli/check" }, body: JSON.stringify(body0) });
358
+ dbg(`response: ${res.status}`);
275
359
  const t = await res.text();
276
360
  if (!res.ok)
277
361
  throw new Error(`Tori API ${res.status}${t ? ": " + t.slice(0, 200) : ""}`);
@@ -283,6 +367,7 @@ async function check() {
283
367
  die(e.message);
284
368
  }
285
369
  const text = formatRun(r, files.length);
370
+ const highs = r.findings.filter((f) => f.severity === "high").length, meds = r.findings.filter((f) => f.severity === "medium").length, lows = r.findings.length - highs - meds;
286
371
  if (hook) {
287
372
  if (r.clean)
288
373
  return;
@@ -290,7 +375,9 @@ async function check() {
290
375
  console.log(`Tori: ${r.findings.length} finding(s) still open \u2014 run \`tori check\` to see them.`);
291
376
  return;
292
377
  }
293
- console.error(text);
378
+ if (highs === 0)
379
+ return; // only high blocks; medium/low are recorded and shown in status and at session start
380
+ console.error(text + `\n\n${highs} high (blocking)${meds ? `, ${meds} medium` : ""}${lows ? `, ${lows} low` : ""} — medium and low are recorded and do not block.`);
294
381
  process.exit(2); // exit 2 = block the stop; stderr goes to the agent
295
382
  }
296
383
  out(r, text);
@@ -391,9 +478,53 @@ async function connector() {
391
478
  const r = await api(cfg, "GET", "/api/v1/connectors");
392
479
  out(r, r.connectors.length ? r.connectors.map((c) => `${c.revoked_at ? "revoked " : "active "} ${c.id} ${c.label} (${c.harness}) created ${String(c.created_at).slice(0, 10)}${c.last_used_at ? " last used " + String(c.last_used_at).slice(0, 16) : ""}`).join("\n") : "No connectors. Create one: tori connector new --label cowork");
393
480
  }
481
+ async function standards() {
482
+ const cfg = loadCfg();
483
+ if (!cfg)
484
+ die("Not signed in \u2014 run: tori login");
485
+ const project = projectId();
486
+ const sub = args[1];
487
+ if (sub === "enable" || sub === "disable") {
488
+ const ids = args.slice(2).filter((a) => !a.startsWith("--"));
489
+ if (!ids.length)
490
+ die(`Usage: tori standards ${sub} <standard…>`);
491
+ const r = await api(cfg, "POST", "/api/v1/standards", { project, enable: sub === "enable" ? ids : [], disable: sub === "disable" ? ids : [] });
492
+ out(r, `\u2713 ${sub === "enable" ? "Enabled" : "Disabled"} ${ids.join(", ")} for this project.\nOn for this project: coding standards (always) + ${r.enabled.length ? r.enabled.join(", ") : "no domain standards"}${r.receipts.length ? "\nReceipt: " + r.receipts.map((x) => x.short).join(", ") : ""}`);
493
+ return;
494
+ }
495
+ const r = await api(cfg, "GET", `/api/v1/standards?project=${encodeURIComponent(project)}`);
496
+ const line = (s) => ` ${s.enabled ? "on " : "off"} ${s.id.padEnd(20)} ${s.name}${s.rules ? ` (${s.rules} checks)` : " (no checks yet)"}`;
497
+ out(r, `Coding standards \u2014 always on:\n${r.standards.filter((s) => s.tier === "coding").map(line).join("\n")}\n\nDomain standards \u2014 choose per project:\n${r.standards.filter((s) => s.tier === "domain").map(line).join("\n")}\n\nEnable: tori standards enable <id…> Disable: tori standards disable <id…>`);
498
+ }
499
+ async function rules() {
500
+ const cfg = loadCfg();
501
+ if (!cfg)
502
+ die("Not signed in \u2014 run: tori login");
503
+ const project = projectId();
504
+ const sub = args[1];
505
+ if (sub === "add") {
506
+ const text = args.slice(2).filter((a) => !a.startsWith("--")).join(" ").trim();
507
+ if (!text)
508
+ die('Usage: tori rules add "<rule in plain English>"');
509
+ const r = await api(cfg, "POST", "/api/v1/rules", { project, text });
510
+ out(r, r.enforced ? `\u2713 Rule added and enforced: "${text}"\n id ${r.id} \u00b7 Tori will check it on every run. Receipt: ${r.receipt.short}`
511
+ : `\u2713 Rule recorded (not enforced): "${text}"\n id ${r.id} \u00b7 Tori can't turn this wording into a check yet. The agent still sees it as a rule it is held to. Enforceable shapes: "no X [in|outside <where>]", "<where> must contain X", "every <where> ships with X". Receipt: ${r.receipt.short}`);
512
+ return;
513
+ }
514
+ if (sub === "remove") {
515
+ const id = args[2];
516
+ if (!id || id.startsWith("--"))
517
+ die("Usage: tori rules remove <id>");
518
+ const r = await api(cfg, "DELETE", "/api/v1/rules", { project, id });
519
+ out(r, `\u2713 Rule ${r.id} removed. Receipt: ${r.receipt.short}`);
520
+ return;
521
+ }
522
+ const r = await api(cfg, "GET", `/api/v1/rules?project=${encodeURIComponent(project)}`);
523
+ out(r, r.rules.length ? r.rules.map((x) => ` ${x.enforced ? "enforced" : "recorded"} ${x.id} ${x.text}`).join("\n") : 'No rules for this project. Add one: tori rules add "no console.log outside dev paths"');
524
+ }
394
525
  function logout() { if (existsSync(CONFIG))
395
526
  unlinkSync(CONFIG); out({ ok: true }, "\u2713 Signed out"); }
396
- 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 connector new [--label X] | list | revoke <id> Tori as a remote MCP connector (Claude Cowork etc.)\n tori init --base https://sandbox.earntori.com pin this repo to a Tori environment (.tori/project.json)\n tori logout\n`); }
527
+ 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] [--codex|--no-codex] wire Tori into this project (Claude Code, Cursor, Codex)\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 connector new [--label X] | list | revoke <id> Tori as a remote MCP connector (Claude Cowork etc.)\n tori standards [enable|disable <id…>] standards that apply to this project (coding standards are always on)\n tori rules [add "<rule>"|list|remove <id>] your own rules for this project, in plain English\n tori init --base https://sandbox.earntori.com pin this repo to a Tori environment (.tori/project.json)\n tori logout\n`); }
397
528
  (async () => {
398
529
  switch (cmd) {
399
530
  case "login": return login();
@@ -403,6 +534,8 @@ function help() { console.log(`tori \u2014 Tori CLI\n\n tori login [--base URL]
403
534
  case "ledger": return ledger();
404
535
  case "receipt": return receipt();
405
536
  case "connector": return connector();
537
+ case "standards": return standards();
538
+ case "rules": return rules();
406
539
  case "key": return key();
407
540
  case "stats": return stats();
408
541
  case "logout": return logout();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@earntori/cli",
3
- "version": "0.2.5",
3
+ "version": "0.2.9",
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",