learn-loops 0.4.2 → 0.4.3

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
@@ -7,7 +7,7 @@ npm i -g learn-loops
7
7
  loops
8
8
  ```
9
9
 
10
- Requires Node 20+ and either the `claude` or `codex` CLI installed and logged in. If `loops` isn't found after install, add npm's global bin folder to your `PATH` (`npm prefix -g`, then `<that>/bin`).
10
+ Requires Node 20+ and either the `claude` or `codex` CLI installed and logged in (`npm i -g @anthropic-ai/claude-code` or `npm i -g @openai/codex`). If neither is found, `loops` says so and shows how to install one; if your saved tutor is missing but the other is present, it falls back to that one. If `loops` isn't found after install, add npm's global bin folder to your `PATH` (`npm prefix -g`, then `<that>/bin`).
11
11
 
12
12
  ## How it teaches
13
13
 
@@ -61,4 +61,10 @@ loops profile edit your learner profile
61
61
  ```
62
62
 
63
63
  Your profile and streak live in `~/.config/loops/`.
64
- Env: `LOOPS_HOME`, `LOOPS_PROVIDER=claude|codex`, `LOOPS_IMAGES`, `NO_COLOR`.
64
+ ### Billing safety
65
+
66
+ `loops` runs the CLIs under your subscription login and strips `ANTHROPIC_API_KEY`, `OPENAI_API_KEY` and similar variables from their environment, so you aren't billed per token by accident. A CLI signed in with an API key (or Bedrock/Vertex) is refused unless you set `LOOPS_ALLOW_API_BILLING=1`. If your tutor fails (usage limit, outage), `loops` automatically tries the other one.
67
+
68
+ It **can't** see your account's "extra usage" (Claude) or credits (ChatGPT/Codex) settings, because neither CLI exposes them. If you don't want overage charges, turn those off in your account settings.
69
+
70
+ Env: `LOOPS_HOME`, `LOOPS_PROVIDER=claude|codex`, `LOOPS_ALLOW_API_BILLING=1`, `LOOPS_IMAGES`, `NO_COLOR`.
package/dist/cli.js CHANGED
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import * as p from "@clack/prompts";
3
3
  import { listTopics, loadProfile, configDir } from "./store.js";
4
+ import { PROVIDER_HELP, allStatuses, noProviderMessage, setNotifier } from "./llm.js";
4
5
  import { isDue } from "./srs.js";
5
6
  import { currentStreak, doneToday, loadStats, recordActivity, saveCheckin } from "./stats.js";
6
7
  import { banner, bar, must, t } from "./ui.js";
@@ -21,7 +22,7 @@ const HELP = `${t.bold("loops")} — learn anything with Claude Code or Codex as
21
22
  loops profile edit how you like to learn
22
23
  loops --help this help
23
24
 
24
- Env: LOOPS_HOME (config dir), LOOPS_PROVIDER=claude|codex, NO_COLOR`;
25
+ Env: LOOPS_HOME (config dir), LOOPS_PROVIDER=claude|codex, LOOPS_ALLOW_API_BILLING=1 (allow pay-per-token logins), NO_COLOR`;
25
26
  const dueCount = (topics) => topics.reduce((n, tp) => n + tp.state.cards.filter((c) => isDue(c)).length, 0);
26
27
  function progressOf(tp) {
27
28
  const areas = tp.state.map?.areas ?? [];
@@ -231,11 +232,27 @@ async function main() {
231
232
  process.exit(1);
232
233
  }
233
234
  banner();
235
+ setNotifier((m) => p.log.warn(m));
236
+ const statuses = await allStatuses();
237
+ const usable = statuses.filter((x) => x.usable);
238
+ if (!usable.length) {
239
+ const installed = statuses.filter((x) => x.installed);
240
+ p.log.error(t.bad(installed.length ? `No usable tutor:\n${installed.map((x) => ` - ${x.reason}`).join("\n")}` : noProviderMessage()));
241
+ process.exit(1);
242
+ }
243
+ for (const x of statuses)
244
+ if (x.installed && !x.usable)
245
+ p.log.warn(x.reason);
234
246
  let profile = await loadProfile();
235
247
  if (!profile)
236
248
  profile = await onboard();
237
249
  if (process.env.LOOPS_PROVIDER === "claude" || process.env.LOOPS_PROVIDER === "codex")
238
250
  profile.provider = process.env.LOOPS_PROVIDER;
251
+ if (!usable.some((x) => x.name === profile.provider)) {
252
+ const fallback = usable[0].name;
253
+ p.log.warn(`Using ${PROVIDER_HELP[fallback].label} for this session instead of ${PROVIDER_HELP[profile.provider].label}.`);
254
+ profile.provider = fallback;
255
+ }
239
256
  if (!cmd)
240
257
  return menu(profile);
241
258
  if (["new", "continue", "review", "quiz", "mistakes", "streak", "topics", "profile", "map"].includes(cmd))
@@ -1,25 +1,28 @@
1
1
  import * as p from "@clack/prompts";
2
- import { execFileSync } from "node:child_process";
3
2
  import { homedir } from "node:os";
4
3
  import { join } from "node:path";
4
+ import { PROVIDER_HELP, hasBinary as has, installedProviders } from "../llm.js";
5
5
  import { saveProfile } from "../store.js";
6
6
  import { must, t } from "../ui.js";
7
- const has = (bin) => {
8
- try {
9
- execFileSync("which", [bin], { stdio: "ignore" });
10
- return true;
11
- }
12
- catch {
13
- return false;
14
- }
15
- };
16
7
  export async function onboard(existing) {
17
8
  p.intro(t.bold(t.accent(existing ? " Edit your learner profile " : " Let's set up how YOU learn ")));
18
- if (!existing)
9
+ if (!existing) {
19
10
  p.note("Answer a few questions so every lesson, quiz and diagram fits you.\nYou can change any of this later with `loops profile`.", "Welcome");
11
+ const found = ["claude", "codex"].map((n) => `${has(n) ? t.ok("✓") : t.dim("✗")} ${PROVIDER_HELP[n].label}`).join(" ");
12
+ p.note([
13
+ `loops has no AI of its own. It looks for Claude Code or Codex on your computer and uses it as your tutor: ${found}`,
14
+ "",
15
+ "To avoid surprise charges:",
16
+ " • It only uses your subscription login. API-key / pay-per-token logins are refused,",
17
+ " and API-key environment variables are hidden from the tutor.",
18
+ " • If one tutor fails (e.g. usage limit), it switches to the other.",
19
+ " • It can't see your account's \"extra usage\" / credits setting. Turn that off in your",
20
+ " Claude or ChatGPT account settings if you don't want to pay beyond your plan.",
21
+ ].join("\n"), "Your tutor & billing");
22
+ }
20
23
  const d = existing;
21
24
  const name = must(await p.text({ message: "What should I call you?", initialValue: d?.name, validate: (v) => (v?.trim() ? undefined : "Required") }));
22
- const installed = ["claude", "codex"].filter(has);
25
+ const installed = installedProviders();
23
26
  const provider = must(await p.select({
24
27
  message: "Which AI tutor should power your lessons?",
25
28
  initialValue: d?.provider ?? installed[0] ?? "claude",
package/dist/llm.js CHANGED
@@ -1,16 +1,59 @@
1
1
  import { spawn } from "node:child_process";
2
+ import { accessSync, constants } from "node:fs";
2
3
  import { mkdtemp, readFile, rm } from "node:fs/promises";
3
4
  import { tmpdir } from "node:os";
4
- import { join } from "node:path";
5
+ import { delimiter, join } from "node:path";
5
6
  export const TUTOR_SYSTEM = [
6
7
  "You are an exceptional tutor grounded in learning science: you activate prior knowledge,",
7
8
  "teach in small chunks, use concrete examples and diagrams, test retrieval rather than recognition,",
8
9
  "and correct misconceptions precisely. You never pad. You are accurate: if unsure, say so.",
9
10
  "You output exactly what is asked for, in the requested format, with no preamble.",
10
11
  ].join(" ");
11
- function run(cmd, args, input, timeoutMs) {
12
+ export const PROVIDER_HELP = {
13
+ claude: { label: "Claude Code", install: "npm install -g @anthropic-ai/claude-code", login: "claude" },
14
+ codex: { label: "Codex CLI", install: "npm install -g @openai/codex", login: "codex login" },
15
+ };
16
+ /** True if `bin` is an executable file in some PATH directory (honours PATHEXT on Windows). */
17
+ export function hasBinary(bin, env = process.env) {
18
+ const exts = process.platform === "win32" ? ["", ...(env.PATHEXT ?? ".EXE;.CMD;.BAT").split(";")] : [""];
19
+ for (const dir of (env.PATH ?? "").split(delimiter).filter(Boolean)) {
20
+ for (const ext of exts) {
21
+ try {
22
+ accessSync(join(dir, bin + ext), constants.X_OK);
23
+ return true;
24
+ }
25
+ catch { }
26
+ }
27
+ }
28
+ return false;
29
+ }
30
+ export const installedProviders = () => ["claude", "codex"].filter((n) => hasBinary(n));
31
+ export function noProviderMessage() {
32
+ return [
33
+ "loops needs Claude Code or Codex installed to act as your tutor, and found neither on your PATH.",
34
+ "",
35
+ "Install one, sign in, then run loops again:",
36
+ ...Object.keys(PROVIDER_HELP).map((n) => ` ${PROVIDER_HELP[n].label}: ${PROVIDER_HELP[n].install} (then: ${PROVIDER_HELP[n].login})`),
37
+ "",
38
+ "Already installed in a non-standard place? Add its folder to your PATH.",
39
+ "Sign in with a subscription account: loops refuses API-key / pay-per-token logins unless LOOPS_ALLOW_API_BILLING=1.",
40
+ ].join("\n");
41
+ }
42
+ /** Credentials that make a CLI bill per token instead of using the user's subscription login. */
43
+ const METERED_ENV = ["ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN", "CLAUDE_CODE_USE_BEDROCK", "CLAUDE_CODE_USE_VERTEX", "CLAUDE_CODE_USE_FOUNDRY", "OPENAI_API_KEY", "CODEX_API_KEY"];
44
+ export const allowMetered = (env = process.env) => env.LOOPS_ALLOW_API_BILLING === "1";
45
+ /** The environment child CLIs run in: pay-per-token credentials are removed unless the user opted in. */
46
+ export function childEnv(env = process.env) {
47
+ if (allowMetered(env))
48
+ return { ...env };
49
+ const out = { ...env };
50
+ for (const k of METERED_ENV)
51
+ delete out[k];
52
+ return out;
53
+ }
54
+ function run(cmd, args, input, timeoutMs, includeStderr = false) {
12
55
  return new Promise((resolve, reject) => {
13
- const child = spawn(cmd, args, { cwd: tmpdir(), stdio: ["pipe", "pipe", "pipe"] });
56
+ const child = spawn(cmd, args, { cwd: tmpdir(), env: childEnv(), stdio: ["pipe", "pipe", "pipe"] });
14
57
  let out = "";
15
58
  let err = "";
16
59
  const timer = setTimeout(() => {
@@ -21,19 +64,20 @@ function run(cmd, args, input, timeoutMs) {
21
64
  child.stderr.on("data", (d) => (err += d));
22
65
  child.on("error", (e) => {
23
66
  clearTimeout(timer);
24
- reject(new Error(`Could not run "${cmd}" — is it installed and on your PATH? (${e.message})`));
67
+ const help = PROVIDER_HELP[cmd];
68
+ reject(new Error(`Could not run "${cmd}" — is it installed and on your PATH?${help ? ` Install: ${help.install}` : ""} (${e.message})`));
25
69
  });
26
70
  child.on("close", (code) => {
27
71
  clearTimeout(timer);
28
72
  if (code === 0)
29
- resolve(out);
73
+ resolve(includeStderr ? out + err : out);
30
74
  else
31
75
  reject(new Error(`${cmd} exited with ${code}: ${err.trim().split("\n").slice(-4).join("\n")}`));
32
76
  });
33
77
  child.stdin.end(input);
34
78
  });
35
79
  }
36
- export async function complete(provider, prompt, timeoutMs = 240_000) {
80
+ async function completeWith(provider, prompt, timeoutMs) {
37
81
  if (provider === "claude") {
38
82
  return run("claude", ["-p", "--tools", "", "--no-session-persistence", "--output-format", "text", "--system-prompt", TUTOR_SYSTEM], prompt, timeoutMs);
39
83
  }
@@ -47,6 +91,87 @@ export async function complete(provider, prompt, timeoutMs = 240_000) {
47
91
  await rm(dir, { recursive: true, force: true });
48
92
  }
49
93
  }
94
+ /** Classify `claude auth status` JSON. Anything that isn't a first-party subscription login bills per token. */
95
+ export function classifyClaude(raw) {
96
+ let j;
97
+ try {
98
+ j = JSON.parse(raw);
99
+ }
100
+ catch {
101
+ return "unknown";
102
+ }
103
+ if (j.loggedIn === false)
104
+ return "logged-out";
105
+ const method = String(j.authMethod ?? "");
106
+ if ((j.apiProvider && j.apiProvider !== "firstParty") || /api.?key|console/i.test(method))
107
+ return "metered";
108
+ return /claude\.ai|oauth|subscription/i.test(method) ? "subscription" : "unknown";
109
+ }
110
+ /** Classify `codex login status` output. */
111
+ export function classifyCodex(text) {
112
+ if (/not logged in/i.test(text))
113
+ return "logged-out";
114
+ if (/api key/i.test(text))
115
+ return "metered";
116
+ return /chatgpt/i.test(text) ? "subscription" : "unknown";
117
+ }
118
+ async function probe(name) {
119
+ const label = PROVIDER_HELP[name].label;
120
+ const bad = (billing, reason) => ({ name, installed: hasBinary(name), billing, usable: false, reason });
121
+ if (!hasBinary(name))
122
+ return bad("unknown", `${label} isn't installed (${PROVIDER_HELP[name].install})`);
123
+ let billing;
124
+ try {
125
+ billing = name === "claude" ? classifyClaude(await run("claude", ["auth", "status"], "", 20_000)) : classifyCodex(await run("codex", ["login", "status"], "", 20_000, true));
126
+ }
127
+ catch {
128
+ billing = "logged-out";
129
+ }
130
+ if (billing === "logged-out")
131
+ return bad(billing, `${label} isn't signed in (run: ${PROVIDER_HELP[name].login})`);
132
+ if (billing === "metered" && !allowMetered()) {
133
+ return bad(billing, `${label} is signed in with an API key / pay-per-token account, which would charge you per request. Sign in with your subscription instead, or set LOOPS_ALLOW_API_BILLING=1 to allow it`);
134
+ }
135
+ return { name, installed: true, billing, usable: true, reason: "" };
136
+ }
137
+ const statusCache = new Map();
138
+ export const providerStatus = (name) => {
139
+ if (!statusCache.has(name))
140
+ statusCache.set(name, probe(name));
141
+ return statusCache.get(name);
142
+ };
143
+ export const allStatuses = () => Promise.all(["claude", "codex"].map(providerStatus));
144
+ let notify = (m) => console.error(m);
145
+ export const setNotifier = (fn) => (notify = fn);
146
+ let sticky;
147
+ /**
148
+ * Run the prompt on the preferred tutor; if it can't be used or fails (usage limit, outage, signed out), fall back
149
+ * to the other one — but never to a CLI that would bill per token. Once a fallback works it's kept for the session.
150
+ */
151
+ export async function complete(preferred, prompt, timeoutMs = 240_000) {
152
+ const first = sticky ?? preferred;
153
+ const order = [first, first === "claude" ? "codex" : "claude"];
154
+ const problems = [];
155
+ for (const name of order) {
156
+ const st = await providerStatus(name);
157
+ if (!st.usable) {
158
+ problems.push(st.reason);
159
+ continue;
160
+ }
161
+ if (name !== first)
162
+ notify(`${PROVIDER_HELP[order[0]].label} didn't work, so I'm trying ${PROVIDER_HELP[name].label} instead.`);
163
+ try {
164
+ const out = await completeWith(name, prompt, timeoutMs);
165
+ if (name !== preferred)
166
+ sticky = name;
167
+ return out;
168
+ }
169
+ catch (e) {
170
+ problems.push(`${PROVIDER_HELP[name].label}: ${e.message}`);
171
+ }
172
+ }
173
+ throw new Error(problems.join("\n"));
174
+ }
50
175
  /**
51
176
  * Models often forget to double LaTeX backslashes inside JSON strings. `\theta` would silently parse as a
52
177
  * tab + "heta", `\nabla` as newline + "abla", `\frac` as form feed + "rac". Double the backslash of any
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "learn-loops",
3
- "version": "0.4.2",
3
+ "version": "0.4.3",
4
4
  "description": "A learning CLI that uses Claude Code / Codex as your tutor",
5
5
  "type": "module",
6
6
  "bin": {