learn-loops 0.4.2 → 0.4.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.
- package/README.md +8 -2
- package/dist/cli.js +38 -4
- package/dist/flows/onboard.js +15 -12
- package/dist/llm.js +131 -6
- package/dist/store.js +8 -1
- package/dist/table.js +50 -0
- package/dist/ui.js +15 -0
- package/package.json +2 -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
|
-
import { listTopics, loadProfile, configDir } from "./store.js";
|
|
3
|
+
import { listTopics, loadProfile, configDir, deleteTopic } 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";
|
|
@@ -18,10 +19,13 @@ const HELP = `${t.bold("loops")} — learn anything with Claude Code or Codex as
|
|
|
18
19
|
loops streak your streak
|
|
19
20
|
loops map show a topic's map and prerequisite diagram
|
|
20
21
|
loops topics list your topics and progress
|
|
22
|
+
loops delete delete a topic and all its notes and progress
|
|
21
23
|
loops profile edit how you like to learn
|
|
22
24
|
loops --help this help
|
|
23
25
|
|
|
24
|
-
|
|
26
|
+
To delete a course: run ${t.bold("loops delete")}, or choose "Delete a topic" from the menu.
|
|
27
|
+
|
|
28
|
+
Env: LOOPS_HOME (config dir), LOOPS_PROVIDER=claude|codex, LOOPS_ALLOW_API_BILLING=1 (allow pay-per-token logins), NO_COLOR`;
|
|
25
29
|
const dueCount = (topics) => topics.reduce((n, tp) => n + tp.state.cards.filter((c) => isDue(c)).length, 0);
|
|
26
30
|
function progressOf(tp) {
|
|
27
31
|
const areas = tp.state.map?.areas ?? [];
|
|
@@ -101,12 +105,15 @@ async function startScreen(profile, topics, rec) {
|
|
|
101
105
|
}),
|
|
102
106
|
{ value: "__new", label: t.accent("+ Start something new") },
|
|
103
107
|
{ value: "__menu", label: t.dim("Go to the main menu") },
|
|
108
|
+
{ value: "__delete", label: t.dim("Delete a topic"), hint: "or run `loops delete`" },
|
|
104
109
|
],
|
|
105
110
|
}));
|
|
106
111
|
if (choice === "__menu")
|
|
107
112
|
return;
|
|
108
113
|
if (choice === "__new")
|
|
109
114
|
return dispatch("new", profile);
|
|
115
|
+
if (choice === "__delete")
|
|
116
|
+
return dispatch("delete", profile);
|
|
110
117
|
const tp = recent[Number(choice)];
|
|
111
118
|
if (tp.state.map && tp.state.phase === "survey" && !tp.state.progress[tp.state.map.areas[0].id]?.surveyed)
|
|
112
119
|
printMap(tp);
|
|
@@ -133,7 +140,7 @@ async function menu(profile) {
|
|
|
133
140
|
...(due ? [{ value: "review", label: "Review flashcards", hint: `${due} due${star("review")}` }] : []),
|
|
134
141
|
...(cards >= 4 ? [{ value: "quiz", label: "Mixed quiz", hint: `interleaved, weakest first${star("quiz")}` }] : []),
|
|
135
142
|
...(topics.some((tp) => tp.state.mistakes?.length) ? [{ value: "mistakes", label: "Mistake journal" }] : []),
|
|
136
|
-
...(topics.length ? [{ value: "topics", label: "My topics" }] : []),
|
|
143
|
+
...(topics.length ? [{ value: "topics", label: "My topics" }, { value: "delete", label: "Delete a topic" }] : []),
|
|
137
144
|
];
|
|
138
145
|
// put the recommendation first
|
|
139
146
|
opts.sort((a, b) => Number(b.value === rec) - Number(a.value === rec));
|
|
@@ -215,6 +222,17 @@ async function dispatch(action, profile, arg) {
|
|
|
215
222
|
printMap(tp);
|
|
216
223
|
break;
|
|
217
224
|
}
|
|
225
|
+
case "delete": {
|
|
226
|
+
if (!topics.length)
|
|
227
|
+
return p.log.warn("No topics to delete.");
|
|
228
|
+
const tp = arg ? topics.find((x) => x.state.title.toLowerCase() === arg.toLowerCase()) ?? (await chooseTopic(topics, "Which topic?")) : await chooseTopic(topics, "Which topic do you want to delete?");
|
|
229
|
+
const ok = must(await p.confirm({ message: `Delete "${tp.state.title}" and all its notes, cards and progress? This can't be undone.`, initialValue: false }));
|
|
230
|
+
if (!ok)
|
|
231
|
+
return p.log.info("Kept it.");
|
|
232
|
+
await deleteTopic(tp);
|
|
233
|
+
p.log.success(t.ok(`Deleted "${tp.state.title}".`));
|
|
234
|
+
break;
|
|
235
|
+
}
|
|
218
236
|
case "profile":
|
|
219
237
|
await onboard(profile);
|
|
220
238
|
break;
|
|
@@ -231,14 +249,30 @@ async function main() {
|
|
|
231
249
|
process.exit(1);
|
|
232
250
|
}
|
|
233
251
|
banner();
|
|
252
|
+
setNotifier((m) => p.log.warn(m));
|
|
253
|
+
const statuses = await allStatuses();
|
|
254
|
+
const usable = statuses.filter((x) => x.usable);
|
|
255
|
+
if (!usable.length) {
|
|
256
|
+
const installed = statuses.filter((x) => x.installed);
|
|
257
|
+
p.log.error(t.bad(installed.length ? `No usable tutor:\n${installed.map((x) => ` - ${x.reason}`).join("\n")}` : noProviderMessage()));
|
|
258
|
+
process.exit(1);
|
|
259
|
+
}
|
|
260
|
+
for (const x of statuses)
|
|
261
|
+
if (x.installed && !x.usable)
|
|
262
|
+
p.log.warn(x.reason);
|
|
234
263
|
let profile = await loadProfile();
|
|
235
264
|
if (!profile)
|
|
236
265
|
profile = await onboard();
|
|
237
266
|
if (process.env.LOOPS_PROVIDER === "claude" || process.env.LOOPS_PROVIDER === "codex")
|
|
238
267
|
profile.provider = process.env.LOOPS_PROVIDER;
|
|
268
|
+
if (!usable.some((x) => x.name === profile.provider)) {
|
|
269
|
+
const fallback = usable[0].name;
|
|
270
|
+
p.log.warn(`Using ${PROVIDER_HELP[fallback].label} for this session instead of ${PROVIDER_HELP[profile.provider].label}.`);
|
|
271
|
+
profile.provider = fallback;
|
|
272
|
+
}
|
|
239
273
|
if (!cmd)
|
|
240
274
|
return menu(profile);
|
|
241
|
-
if (["new", "continue", "review", "quiz", "mistakes", "streak", "topics", "profile", "map"].includes(cmd))
|
|
275
|
+
if (["new", "continue", "review", "quiz", "mistakes", "streak", "topics", "profile", "map", "delete"].includes(cmd))
|
|
242
276
|
return dispatch(cmd, profile, rest.join(" ") || undefined);
|
|
243
277
|
console.log(HELP);
|
|
244
278
|
process.exit(1);
|
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
|
package/dist/store.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { mkdir, readFile, writeFile, readdir } from "node:fs/promises";
|
|
1
|
+
import { mkdir, readFile, writeFile, readdir, rm } from "node:fs/promises";
|
|
2
2
|
import { existsSync } from "node:fs";
|
|
3
3
|
import { homedir } from "node:os";
|
|
4
4
|
import { join, dirname } from "node:path";
|
|
@@ -54,6 +54,13 @@ export async function listTopics(profile) {
|
|
|
54
54
|
return topics.sort((a, b) => lastTouched(b).localeCompare(lastTouched(a)));
|
|
55
55
|
}
|
|
56
56
|
const lastTouched = (t) => t.state.cards.map((c) => c.due).sort().pop() ?? t.state.createdAt;
|
|
57
|
+
/** Removes a topic's folder (notes, cards, progress) and its category folder if that leaves it empty. */
|
|
58
|
+
export async function deleteTopic(topic) {
|
|
59
|
+
await rm(topic.dir, { recursive: true, force: true });
|
|
60
|
+
const cat = dirname(topic.dir);
|
|
61
|
+
if (!(await readdir(cat).catch(() => ["x"])).length)
|
|
62
|
+
await rm(cat, { recursive: true, force: true });
|
|
63
|
+
}
|
|
57
64
|
export async function listCategories(profile) {
|
|
58
65
|
const root = expandHome(profile.rootDir);
|
|
59
66
|
if (!existsSync(root))
|
package/dist/table.js
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import Table from "cli-table3";
|
|
2
|
+
export const isTableLine = (s) => /^\s*\|.*\|\s*$/.test(s);
|
|
3
|
+
const ANSI = /\x1b\[[0-9;]*m/g;
|
|
4
|
+
const visible = (s) => s.replace(ANSI, "").length;
|
|
5
|
+
function splitRow(line) {
|
|
6
|
+
const body = line.trim().replace(/^\|/, "").replace(/\|$/, "");
|
|
7
|
+
return body.split(/(?<!\\)\|/).map((c) => c.replace(/\\\|/g, "|").trim());
|
|
8
|
+
}
|
|
9
|
+
const alignOf = (c) => (/^:-+:$/.test(c) ? "center" : /^-+:$/.test(c) ? "right" : "left");
|
|
10
|
+
/**
|
|
11
|
+
* Draw a markdown table (consecutive `| a | b |` lines) as a bordered, aligned box that wraps to maxWidth.
|
|
12
|
+
* `cell` styles a cell's text (e.g. inline bold/code/math); `head` styles header cells.
|
|
13
|
+
*/
|
|
14
|
+
export function renderTable(lines, cell, head, border, maxWidth) {
|
|
15
|
+
const rows = lines.map(splitRow);
|
|
16
|
+
const hasHeader = rows.length > 1 && rows[1].every((c) => /^:?-+:?$/.test(c));
|
|
17
|
+
const aligns = hasHeader ? rows[1].map(alignOf) : [];
|
|
18
|
+
const data = hasHeader ? [rows[0], ...rows.slice(2)] : rows;
|
|
19
|
+
const n = Math.max(...data.map((r) => r.length));
|
|
20
|
+
const grid = data.map((r, i) => Array.from({ length: n }, (_, c) => (hasHeader && i === 0 ? head : cell)(r[c] ?? "")));
|
|
21
|
+
const widths = Array.from({ length: n }, (_, c) => Math.max(1, ...grid.map((r) => Math.max(...r[c].split("\n").map(visible)))));
|
|
22
|
+
const budget = maxWidth - (3 * n + 1);
|
|
23
|
+
if (budget < 5 * n)
|
|
24
|
+
return stacked(data, hasHeader, cell, head);
|
|
25
|
+
while (widths.reduce((a, b) => a + b, 0) > budget)
|
|
26
|
+
widths[widths.indexOf(Math.max(...widths))]--;
|
|
27
|
+
const table = new Table({
|
|
28
|
+
chars: { top: "─", "top-mid": "┬", "top-left": "╭", "top-right": "╮", bottom: "─", "bottom-mid": "┴", "bottom-left": "╰", "bottom-right": "╯", left: "│", "left-mid": "├", mid: "─", "mid-mid": "┼", right: "│", "right-mid": "┤", middle: "│" },
|
|
29
|
+
style: { head: [], border: [], "padding-left": 1, "padding-right": 1 },
|
|
30
|
+
colWidths: widths.map((w) => w + 2),
|
|
31
|
+
colAligns: Array.from({ length: n }, (_, c) => aligns[c] ?? "left"),
|
|
32
|
+
wordWrap: true,
|
|
33
|
+
});
|
|
34
|
+
grid.forEach((r) => table.push(r));
|
|
35
|
+
let drawn = table.toString().split("\n");
|
|
36
|
+
// keep the rule under the header; drop the ones between single-line rows (they only add noise)
|
|
37
|
+
const wrapped = drawn.length - (grid.length * 2 + 1) > 0;
|
|
38
|
+
if (!wrapped)
|
|
39
|
+
drawn = drawn.filter((l, i) => !l.startsWith("├") || i === drawn.findIndex((x) => x.startsWith("├")));
|
|
40
|
+
return drawn.map((l) => l.replace(/[─│╭╮╰╯├┤┬┴┼]+/g, border));
|
|
41
|
+
}
|
|
42
|
+
/** Fallback for terminals too narrow for a grid: one "header: value" block per row. */
|
|
43
|
+
function stacked(data, hasHeader, cell, head) {
|
|
44
|
+
const out = [];
|
|
45
|
+
const keys = hasHeader ? data[0] : [];
|
|
46
|
+
for (const r of hasHeader ? data.slice(1) : data) {
|
|
47
|
+
out.push(...r.map((v, c) => (keys[c] ? `${head(keys[c])}: ${cell(v)}` : cell(v))), "");
|
|
48
|
+
}
|
|
49
|
+
return out.slice(0, -1);
|
|
50
|
+
}
|
package/dist/ui.js
CHANGED
|
@@ -3,6 +3,7 @@ import pc from "picocolors";
|
|
|
3
3
|
import { renderDiagram } from "./diagram.js";
|
|
4
4
|
import { parsePlot, plotToBraille, renderPlot, sanitizeSvg, svgCaption } from "./figures.js";
|
|
5
5
|
import { mathToText, svgToPng, texToLines, texToPng } from "./math.js";
|
|
6
|
+
import { isTableLine, renderTable } from "./table.js";
|
|
6
7
|
import { drawImage, imageProtocol } from "./termimg.js";
|
|
7
8
|
const useColor = !!process.stdout.isTTY && !process.env.NO_COLOR;
|
|
8
9
|
const c256 = (n) => (s) => (useColor ? `\x1b[38;5;${n}m${s}\x1b[39m` : s);
|
|
@@ -112,7 +113,15 @@ export function renderMd(md) {
|
|
|
112
113
|
.replace(/\*\*(.+?)\*\*/g, (_, x) => t.bold(x))
|
|
113
114
|
.replace(/`([^`]+)`/g, (_, x) => t.cyan(x))
|
|
114
115
|
.replace(/(?<![*\w])\*([^*]+)\*(?!\w)/g, (_, x) => t.italic(x));
|
|
116
|
+
let tableBuf = [];
|
|
117
|
+
const flushTable = () => {
|
|
118
|
+
const cols = process.stdout.columns ?? 100;
|
|
119
|
+
out.push(...renderTable(tableBuf, inline, (x) => t.bold(t.accent(inline(x))), t.dim, cols - 4).map((l) => " " + l));
|
|
120
|
+
tableBuf = [];
|
|
121
|
+
};
|
|
115
122
|
for (const raw of md.split("\n")) {
|
|
123
|
+
if (tableBuf.length && !isTableLine(raw))
|
|
124
|
+
flushTable();
|
|
116
125
|
// display math: $$ … $$ (one or many lines) and \[ … \]
|
|
117
126
|
if (math) {
|
|
118
127
|
const close = raw.match(/^(.*?)(\$\$|\\\])\s*$/);
|
|
@@ -162,6 +171,10 @@ export function renderMd(md) {
|
|
|
162
171
|
out.push(" " + t.dim("│ ") + t.cyan(raw));
|
|
163
172
|
continue;
|
|
164
173
|
}
|
|
174
|
+
if (isTableLine(raw)) {
|
|
175
|
+
tableBuf.push(raw);
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
165
178
|
const h = raw.match(/^(#{1,4})\s+(.*)/);
|
|
166
179
|
if (h) {
|
|
167
180
|
out.push("", " " + (h[1].length <= 2 ? t.bold(t.accent(inline(h[2]))) : t.bold(inline(h[2]))));
|
|
@@ -179,6 +192,8 @@ export function renderMd(md) {
|
|
|
179
192
|
}
|
|
180
193
|
out.push(raw.trim() ? " " + inline(raw) : "");
|
|
181
194
|
}
|
|
195
|
+
if (tableBuf.length)
|
|
196
|
+
flushTable();
|
|
182
197
|
return out.join("\n");
|
|
183
198
|
}
|
|
184
199
|
/** Plain-text math for prompts, options and flashcards. */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "learn-loops",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.4",
|
|
4
4
|
"description": "A learning CLI that uses Claude Code / Codex as your tutor",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
"@clack/prompts": "^1.8.1",
|
|
22
22
|
"@resvg/resvg-js": "^2.6.2",
|
|
23
23
|
"beautiful-mermaid": "^1.1.3",
|
|
24
|
+
"cli-table3": "^0.6.5",
|
|
24
25
|
"mathjax-full": "^3.2.1",
|
|
25
26
|
"mathjs": "^15.2.0",
|
|
26
27
|
"picocolors": "^1.1.1",
|