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 +8 -2
- package/dist/cli.js +18 -1
- package/dist/flows/onboard.js +15 -12
- package/dist/llm.js +131 -6
- package/package.json +1 -1
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
|
-
|
|
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))
|
package/dist/flows/onboard.js
CHANGED
|
@@ -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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|