@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.
- package/dist/index.js +160 -27
- 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.
|
|
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
|
|
173
|
-
const
|
|
174
|
-
const
|
|
175
|
-
const
|
|
176
|
-
|
|
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\
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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]
|
|
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();
|