@stemtrooper/learningcode 0.1.0 → 0.2.1

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
@@ -47,6 +47,100 @@ Because Spark's limits are the design constraint, not the model:
47
47
  Stock Pi does not know `/v1/me/quota` or `/v1/queue` exist. That gap is the whole
48
48
  reason this wrapper exists.
49
49
 
50
+ ## Banner
51
+
52
+ The LEARNINGCODE block banner replaces Pi's header at startup:
53
+
54
+ ```
55
+ ██╗ ███████╗ █████╗ ██████╗ ███╗ ██╗██╗███╗ ██╗ ██████╗ ██████╗ ██████╗ ██████╗ ███████╗
56
+ ██║ ██╔════╝██╔══██╗██╔══██╗████╗ ██║██║████╗ ██║██╔════╝ ██╔════╝██╔═══██╗██╔══██╗██╔════╝
57
+ ██║ █████╗ ███████║██████╔╝██╔██╗ ██║██║██╔██╗ ██║██║ ███╗██║ ██║ ██║██║ ██║█████╗
58
+ ██║ ██╔══╝ ██╔══██║██╔══██╗██║╚██╗██║██║██║╚██╗██║██║ ██║██║ ██║ ██║██║ ██║██╔══╝
59
+ ███████╗███████╗██║ ██║██║ ██║██║ ╚████║██║██║ ╚████║╚██████╔╝╚██████╗╚██████╔╝██████╔╝███████╗
60
+ ╚══════╝╚══════╝╚═╝ ╚═╝╚═╝ ╚═╝╚═╝ ╚═══╝╚═╝╚═╝ ╚═══╝ ╚═════╝ ╚═════╝ ╚═════╝ ╚═════╝ ╚══════╝
61
+ ═════════════════════════════════════════════════════════════════════════════════════════════════
62
+ The Learning Curve · Sarawak
63
+ /help commands · /quota today's spend · /hotkeys keys
64
+ ```
65
+
66
+ The figlet "ANSI Shadow" face, 97 columns wide, kept verbatim because the
67
+ double-line box characters only align if every row keeps its exact offset.
68
+
69
+ Colour comes from the active theme, so it stays legible in light and dark.
70
+
71
+ **It needs a 99 column terminal.** Below that it collapses to a wordmark,
72
+ rather than drawing art that would be clipped into something that looks broken.
73
+ An 80 column terminal will show the compact form, so widen the window or reduce
74
+ the art.
75
+
76
+ It installs via `ctx.ui.setHeader`, the supported way to brand a fork.
77
+
78
+ ## Other providers
79
+
80
+ Any `--model` other than `tlc-spark/...` skips the Spark token and the Spark
81
+ health check, so you can work while Spark is down:
82
+
83
+ ```bash
84
+ export OPENCODE_API_KEY=...
85
+ learningcode --model opencode-go/glm-5.3-flash
86
+ ```
87
+
88
+ **OpenCode Zen is excluded.** Zen and Go share one `OPENCODE_API_KEY`, so leaving
89
+ that variable in place authenticates both and exposes Zen's 111 pay-per-use models
90
+ alongside the 29 a Go subscription covers, at up to $20/M output. learningcode
91
+ moves the key into Pi's `auth.json` under `opencode-go` alone, which leaves Zen
92
+ credential-less so it never registers. Set `LEARNINGCODE_ALLOW_ZEN=1` to opt in.
93
+
94
+ ## Theme
95
+
96
+ Two TLC themes ship with the package and are seeded into
97
+ `~/.learningcode/agent/themes/` on first run:
98
+
99
+ | Theme | Accent | Sampled from |
100
+ |---|---|---|
101
+ | `tlc-dark` | `#29c8f2` | `TLC_BLACKBGND.png` |
102
+ | `tlc-light` | `#0dacd6` | `TLC_WHITEBGND.png` |
103
+
104
+ The values are read out of the logo pixels, not eyeballed. The logo ships two
105
+ cyans because the darker one has to hold contrast on a white field, which maps
106
+ exactly onto Pi's light/dark split.
107
+
108
+ A seeded theme is **never overwritten**, so an edit survives upgrades. Override
109
+ the default with `--theme`, or `LEARNINGCODE_THEME=tlc-light`. To try Pi's own:
110
+
111
+ ```bash
112
+ learningcode --theme dark
113
+ ```
114
+
115
+ 28 of Pi's 56 colour tokens are re-tinted: the accent, borders, greys, markdown,
116
+ syntax highlighting, diff colours, selected backgrounds, and the thinking-level
117
+ ramp. Semantic colours (red, green, yellow) are left alone, because those mean
118
+ error, success and warning rather than anything about the brand. The thinking ramp
119
+ runs grey to cyan and keeps red at the top, where it still means "this is getting
120
+ expensive".
121
+
122
+ The banner does **not** follow the theme. It uses the brand cyan directly, the way
123
+ Pi's own logo does, because a wordmark that changes colour with whatever theme is
124
+ active stops being a wordmark. It picks the right one of the two cyans from the
125
+ terminal's colour mode.
126
+
127
+ ## Footer
128
+
129
+ While connected to Spark, a footer shows today's token spend:
130
+
131
+ ```
132
+ █████░░░░░ 50% 125k / 250k today
133
+ ```
134
+
135
+ Spark's quota endpoint is not part of the OpenAI API, so no stock harness shows
136
+ this. It polls at most every two minutes, since Spark allows one active
137
+ generation per student and a per-turn refresh would add latency to the thing the
138
+ student is waiting on. When AI is switched off for an account the footer says so
139
+ instead of showing a bar.
140
+
141
+ Off Spark, or with no token, it says so rather than drawing an empty bar that would
142
+ read as "you have spent nothing".
143
+
50
144
  ## Configuration
51
145
 
52
146
  `~/.learningcode/agent/models.json` is created on first run and **never
@@ -0,0 +1,79 @@
1
+ {
2
+ "$schema": "https://raw.githubusercontent.com/earendil-works/pi/main/packages/coding-agent/src/modes/interactive/theme/theme-schema.json",
3
+ "name": "TLC dark",
4
+ "appearance": "dark",
5
+ "vars": {
6
+ "text": "okhsl(0 0% 92%)",
7
+ "muted": "okhsl(205 8% 68%)",
8
+ "violet": "okhsl(295 50% 67%)",
9
+ "blue": "okhsl(205 14% 62%)",
10
+ "green": "okhsl(159 59% 67%)",
11
+ "red": "okhsl(20 72% 67%)",
12
+ "yellow": "okhsl(83 88% 67%)",
13
+ "blueBg": "okhsl(196 48% 18%)",
14
+ "cyan": "#29c8f2"
15
+ },
16
+ "colors": {
17
+ "accent": "#29c8f2",
18
+ "border": "okhsl(205 12% 50%)",
19
+ "borderAccent": "#29c8f2",
20
+ "borderMuted": "okhsl(205 10% 36%)",
21
+ "success": "green",
22
+ "error": "red",
23
+ "warning": "yellow",
24
+ "muted": "okhsl(205 8% 68%)",
25
+ "dim": "okhsl(205 8% 56%)",
26
+ "text": "okhsl(0 0% 92%)",
27
+ "thinkingText": "okhsl(196 30% 70%)",
28
+ "selectedBg": "okhsl(196 48% 18%)",
29
+ "scrollbarTrack": "okhsl(205 10% 20%)",
30
+ "scrollbarThumb": "okhsl(205 10% 40%)",
31
+ "searchMatchBg": "okhsl(53 51% 24%)",
32
+ "searchMatchText": "muted",
33
+ "userMessageBg": "okhsl(196 48% 18%)",
34
+ "userMessageText": "text",
35
+ "customMessageBg": "okhsl(196 38% 16%)",
36
+ "customMessageText": "muted",
37
+ "customMessageLabel": "#29c8f2",
38
+ "toolPendingBg": "okhsl(229 5% 24%)",
39
+ "toolSuccessBg": "okhsl(158 46% 25%)",
40
+ "toolErrorBg": "okhsl(19 54% 25%)",
41
+ "toolTitle": "text",
42
+ "toolOutput": "muted",
43
+ "mdHeading": "yellow",
44
+ "mdLink": "#29c8f2",
45
+ "mdLinkUrl": "muted",
46
+ "mdCode": "okhsl(196 65% 78%)",
47
+ "mdCodeBlock": "green",
48
+ "mdCodeBlockBorder": "muted",
49
+ "mdQuote": "muted",
50
+ "mdQuoteBorder": "muted",
51
+ "mdHr": "muted",
52
+ "mdListBullet": "#29c8f2",
53
+ "toolDiffAdded": "green",
54
+ "toolDiffRemoved": "red",
55
+ "toolDiffContext": "muted",
56
+ "syntaxComment": "okhsl(205 8% 48%)",
57
+ "syntaxKeyword": "#29c8f2",
58
+ "syntaxFunction": "yellow",
59
+ "syntaxVariable": "okhsl(202 58% 67%)",
60
+ "syntaxString": "okhsl(52 67% 67%)",
61
+ "syntaxNumber": "green",
62
+ "syntaxType": "okhsl(196 55% 72%)",
63
+ "syntaxOperator": "okhsl(205 8% 62%)",
64
+ "syntaxPunctuation": "muted",
65
+ "thinkingOff": "okhsl(205 6% 45%)",
66
+ "thinkingMinimal": "okhsl(196 25% 55%)",
67
+ "thinkingLow": "okhsl(196 55% 60%)",
68
+ "thinkingMedium": "#29c8f2",
69
+ "thinkingHigh": "okhsl(187 70% 66%)",
70
+ "thinkingXhigh": "okhsl(83 70% 66%)",
71
+ "thinkingMax": "okhsl(20 85% 66%)",
72
+ "bashMode": "okhsl(159 64% 65%)"
73
+ },
74
+ "export": {
75
+ "pageBg": "okhsl(205 12% 12%)",
76
+ "cardBg": "okhsl(205 12% 16%)",
77
+ "infoBg": "okhsl(196 40% 20%)"
78
+ }
79
+ }
@@ -0,0 +1,79 @@
1
+ {
2
+ "$schema": "https://raw.githubusercontent.com/earendil-works/pi/main/packages/coding-agent/src/modes/interactive/theme/theme-schema.json",
3
+ "name": "TLC light",
4
+ "appearance": "light",
5
+ "vars": {
6
+ "text": "#313131",
7
+ "muted": "#555555",
8
+ "violet": "okhsl(295 60% 46%)",
9
+ "blue": "okhsl(205 30% 48%)",
10
+ "green": "okhsl(150 55% 34%)",
11
+ "red": "okhsl(2 70% 46%)",
12
+ "yellow": "okhsl(40 80% 36%)",
13
+ "blueBg": "okhsl(190 55% 90%)",
14
+ "cyan": "#0dacd6"
15
+ },
16
+ "colors": {
17
+ "accent": "#0dacd6",
18
+ "border": "okhsl(0 0% 74%)",
19
+ "borderAccent": "#0dacd6",
20
+ "borderMuted": "okhsl(0 0% 84%)",
21
+ "success": "green",
22
+ "error": "red",
23
+ "warning": "yellow",
24
+ "muted": "#555555",
25
+ "dim": "okhsl(0 0% 55%)",
26
+ "text": "#313131",
27
+ "thinkingText": "okhsl(193 40% 42%)",
28
+ "selectedBg": "okhsl(190 55% 90%)",
29
+ "scrollbarTrack": "okhsl(0 0% 90%)",
30
+ "scrollbarThumb": "okhsl(0 0% 72%)",
31
+ "searchMatchBg": "okhsl(56 22% 91%)",
32
+ "searchMatchText": "muted",
33
+ "userMessageBg": "okhsl(190 55% 90%)",
34
+ "userMessageText": "text",
35
+ "customMessageBg": "okhsl(190 45% 93%)",
36
+ "customMessageText": "muted",
37
+ "customMessageLabel": "#0dacd6",
38
+ "toolPendingBg": "okhsl(248 3% 91%)",
39
+ "toolSuccessBg": "okhsl(156 21% 91%)",
40
+ "toolErrorBg": "okhsl(24 23% 91%)",
41
+ "toolTitle": "text",
42
+ "toolOutput": "muted",
43
+ "mdHeading": "yellow",
44
+ "mdLink": "#0dacd6",
45
+ "mdLinkUrl": "muted",
46
+ "mdCode": "okhsl(193 60% 34%)",
47
+ "mdCodeBlock": "green",
48
+ "mdCodeBlockBorder": "muted",
49
+ "mdQuote": "muted",
50
+ "mdQuoteBorder": "muted",
51
+ "mdHr": "muted",
52
+ "mdListBullet": "#0dacd6",
53
+ "toolDiffAdded": "green",
54
+ "toolDiffRemoved": "red",
55
+ "toolDiffContext": "muted",
56
+ "syntaxComment": "#6b7280",
57
+ "syntaxKeyword": "#0dacd6",
58
+ "syntaxFunction": "yellow",
59
+ "syntaxVariable": "okhsl(203 73% 46%)",
60
+ "syntaxString": "okhsl(52 84% 46%)",
61
+ "syntaxNumber": "green",
62
+ "syntaxType": "okhsl(193 55% 38%)",
63
+ "syntaxOperator": "#555555",
64
+ "syntaxPunctuation": "muted",
65
+ "thinkingOff": "okhsl(0 0% 62%)",
66
+ "thinkingMinimal": "okhsl(193 30% 52%)",
67
+ "thinkingLow": "okhsl(193 55% 44%)",
68
+ "thinkingMedium": "#0dacd6",
69
+ "thinkingHigh": "okhsl(178 60% 34%)",
70
+ "thinkingXhigh": "okhsl(83 65% 38%)",
71
+ "thinkingMax": "okhsl(20 78% 46%)",
72
+ "bashMode": "okhsl(159 74% 55%)"
73
+ },
74
+ "export": {
75
+ "pageBg": "okhsl(0 0% 97%)",
76
+ "cardBg": "okhsl(0 0% 100%)",
77
+ "infoBg": "okhsl(190 50% 92%)"
78
+ }
79
+ }
@@ -8,12 +8,16 @@ import {
8
8
  MODEL_ID,
9
9
  PI_AGENT_DIR_ENV,
10
10
  PROVIDER_ID,
11
+ REQUIRED_NODE,
11
12
  TOKEN_ENV,
12
13
  UserError,
13
14
  agentDir,
15
+ compareVersions,
14
16
  sparkBaseUrl,
15
17
  } from "../lib/config.mjs";
18
+ import { GO_PROVIDER, saveProviderKey } from "../lib/auth.mjs";
16
19
  import { ensureSparkProvider, modelsPath, retargetProvider } from "../lib/models.mjs";
20
+ import { ensureThemes, preferredTheme } from "../lib/themes.mjs";
17
21
  import { hasToken, looksLikeToken, resolveToken, writeCachedToken } from "../lib/token.mjs";
18
22
 
19
23
  const here = dirname(fileURLToPath(import.meta.url));
@@ -114,6 +118,27 @@ function readModel(toPi) {
114
118
  return undefined;
115
119
  }
116
120
 
121
+ /**
122
+ * Fail early and legibly on an unsupported Node.
123
+ *
124
+ * `engines` in package.json only makes npm warn, and a warning scrolls past on
125
+ * a lab machine. The install then succeeds and Pi dies somewhere deep in its own
126
+ * start-up with a stack trace, which tells a student nothing. Set
127
+ * LEARNINGCODE_SKIP_NODE_CHECK=1 to bypass if the machine genuinely works.
128
+ */
129
+ function checkNodeVersion() {
130
+ if (process.env.LEARNINGCODE_SKIP_NODE_CHECK === "1") return;
131
+ const running = process.versions.node;
132
+ if (compareVersions(running, REQUIRED_NODE) >= 0) return;
133
+
134
+ throw new UserError(
135
+ `Node ${REQUIRED_NODE} or newer is required; this is Node ${running}.\n` +
136
+ " winget upgrade --id OpenJS.NodeJS.22\n" +
137
+ " (or download the LTS build from nodejs.org)\n" +
138
+ " Then reinstall: npm i -g @stemtrooper/learningcode",
139
+ );
140
+ }
141
+
117
142
  function fail(message, code = 1) {
118
143
  process.stderr.write(`learningcode: ${message}\n`);
119
144
  process.exit(code);
@@ -188,6 +213,8 @@ async function main() {
188
213
  return;
189
214
  }
190
215
 
216
+ checkNodeVersion();
217
+
191
218
  const dir = agentDir();
192
219
  const piEntry = resolvePiEntry();
193
220
  if (!piEntry) {
@@ -202,17 +229,30 @@ async function main() {
202
229
  await retargetProvider(dir, baseUrl);
203
230
  }
204
231
  await ensureSparkProvider(dir);
232
+ await ensureThemes(dir);
205
233
 
206
234
  // `--login` forces a fresh paste even when a token is already cached.
207
235
  const forced = [];
208
236
  const requestedModel = readModel(toPi);
209
237
  const effectiveModel = requestedModel ?? `${PROVIDER_ID}/${MODEL_ID}`;
210
238
  if (!requestedModel) forced.push("--model", effectiveModel);
239
+
240
+ // Default to the TLC theme, but never override an explicit choice: --theme on
241
+ // the command line, or LEARNINGCODE_THEME in the environment.
242
+ const userPickedTheme = toPi.some(
243
+ (arg) => arg === "--theme" || arg.startsWith("--theme="),
244
+ );
245
+ if (!userPickedTheme && !process.env.LEARNINGCODE_THEME) {
246
+ forced.push("--theme", preferredTheme());
247
+ }
248
+
211
249
  // Spark enforces one active generation per student, so subagents and parallel
212
250
  // tool calls would only earn 429s. Pi is serial by default; skills and rules
213
251
  // are pure prompt-token spend against a 32K ceiling, so they stay off.
214
252
  forced.push("--no-skills");
215
253
  forced.push("--extension", join(here, "..", "extensions", "spark-quota.ts"));
254
+ forced.push("--extension", join(here, "..", "extensions", "footer.ts"));
255
+ forced.push("--extension", join(here, "..", "extensions", "banner.ts"));
216
256
  if (process.env.LEARNINGCODE_PI_FLAGS) {
217
257
  forced.push(...process.env.LEARNINGCODE_PI_FLAGS.split(/\s+/).filter(Boolean));
218
258
  }
@@ -278,14 +318,32 @@ async function main() {
278
318
  }
279
319
  }
280
320
 
321
+ const childEnv = { ...process.env };
322
+
323
+ // Pi reads provider credentials from auth.json before the environment, and
324
+ // auth.json is keyed per provider. Storing the key under opencode-go alone
325
+ // authenticates Go while leaving Zen credential-less, so its 111 pay-per-use
326
+ // models never register. Zen and Go otherwise share OPENCODE_API_KEY, so
327
+ // leaving the env var in place would expose both.
328
+ const goKey = process.env.LEARNINGCODE_GO_KEY || process.env.OPENCODE_API_KEY;
329
+ if (goKey && process.env.LEARNINGCODE_ALLOW_ZEN !== "1") {
330
+ await saveProviderKey(dir, GO_PROVIDER, goKey);
331
+ delete childEnv.OPENCODE_API_KEY;
332
+ delete childEnv.LEARNINGCODE_GO_KEY;
333
+ } else if (goKey) {
334
+ process.stderr.write(
335
+ "learningcode: LEARNINGCODE_ALLOW_ZEN=1 exposes OpenCode Zen, which bills per token.\n",
336
+ );
337
+ }
338
+
281
339
  const child = spawn(process.execPath, [piEntry, ...forced, ...toPi], {
282
340
  stdio: "inherit",
283
341
  env: {
284
- ...process.env,
342
+ ...childEnv,
285
343
  [PI_AGENT_DIR_ENV]: dir,
286
344
  SPARK_BASE_URL: sparkBaseUrl(),
287
345
  // Unset rather than empty when not on Spark: Pi treats an empty key as
288
- // configured for some providers, which would shadow OPENCODE_API_KEY.
346
+ // configured for some providers, which would shadow the Go credential.
289
347
  ...(token ? { [TOKEN_ENV]: token } : {}),
290
348
  },
291
349
  });
@@ -0,0 +1,123 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { foregroundAnsi, rgbColor } from "@earendil-works/pi-tui";
3
+
4
+ /**
5
+ * LEARNINGCODE startup banner.
6
+ *
7
+ * Pi lets an extension replace the whole header, which is the supported way to
8
+ * brand a fork without touching Pi's internals. The built-in header is Pi's own
9
+ * logo plus key hints; we trade that for the TLC banner and a line of slash
10
+ * commands, so nothing is claimed about keybindings we cannot read.
11
+ *
12
+ * The art is the figlet "ANSI Shadow" face, kept verbatim rather than rebuilt from
13
+ * a glyph map: the double-line box characters only line up if every row keeps
14
+ * its exact offset, and a one-space drift makes the whole thing look broken.
15
+ *
16
+ * Rendering uses the active theme's colour tokens (`accent`, `border`, `dim`,
17
+ * `muted`) so the banner stays legible in light and dark terminals instead of
18
+ * hard-coding colours that break on one of them.
19
+ */
20
+
21
+ /**
22
+ * Only the parts of Pi's Theme this file touches, to keep the runtime import
23
+ * down to the two colour helpers it actually needs.
24
+ */
25
+ type ThemeLike = {
26
+ fg(token: string, text: string): string;
27
+ /** "light" | "dark" - the background the active theme is designed for. */
28
+ appearance?: "light" | "dark";
29
+ /** The terminal's colour capability, e.g. "truecolor". Not light or dark. */
30
+ getColorMode?(): string;
31
+ };
32
+ type HeaderComponent = { render(width: number): string[] };
33
+
34
+ /**
35
+ * TLC brand cyan, sampled from the logo pixels in TLC_BLACKBGND.png and
36
+ * TLC_WHITEBGND.png rather than eyeballed.
37
+ *
38
+ * The banner uses these directly instead of the theme's `accent`, because a
39
+ * wordmark that changes colour with whatever theme is active stops being a
40
+ * wordmark. Pi's own logo does the same thing, keeping fixed brand colours and
41
+ * adapting only for light versus dark terminals.
42
+ *
43
+ * Two values because the logo ships two: the darker cyan holds contrast on a
44
+ * white field, the brighter one on black.
45
+ *
46
+ * Note this must not go through `theme.fg()`. That resolves theme *tokens*, and
47
+ * `Theme.tokenAnsi` throws `Unknown theme color: #29c8f2` for anything that is
48
+ * not one, so a raw hex there crashes the header at render time. `foregroundAnsi`
49
+ * takes a colour value directly, and unlike a hand-rolled escape it still
50
+ * degrades to 256 colour on terminals without truecolour support.
51
+ */
52
+ const CYAN_DARK_BG = rgbColor(0x29, 0xc8, 0xf2);
53
+ const CYAN_LIGHT_BG = rgbColor(0x0d, 0xac, 0xd6);
54
+ const RESET = "\x1b[0m";
55
+
56
+ const ART: readonly string[] = [
57
+ "██╗ ███████╗ █████╗ ██████╗ ███╗ ██╗██╗███╗ ██╗ ██████╗ ██████╗ ██████╗ ██████╗ ███████╗",
58
+ "██║ ██╔════╝██╔══██╗██╔══██╗████╗ ██║██║████╗ ██║██╔════╝ ██╔════╝██╔═══██╗██╔══██╗██╔════╝",
59
+ "██║ █████╗ ███████║██████╔╝██╔██╗ ██║██║██╔██╗ ██║██║ ███╗██║ ██║ ██║██║ ██║█████╗",
60
+ "██║ ██╔══╝ ██╔══██║██╔══██╗██║╚██╗██║██║██║╚██╗██║██║ ██║██║ ██║ ██║██║ ██║██╔══╝",
61
+ "███████╗███████╗██║ ██║██║ ██║██║ ╚████║██║██║ ╚████║╚██████╔╝╚██████╗╚██████╔╝██████╔╝███████╗",
62
+ "╚══════╝╚══════╝╚═╝ ╚═╝╚═╝ ╚═╝╚═╝ ╚═══╝╚═╝╚═╝ ╚═══╝ ╚═════╝ ╚═════╝ ╚═════╝ ╚═════╝ ╚══════╝",
63
+ ];
64
+
65
+ const TAGLINE = "The Learning Curve · Sarawak";
66
+ const HINTS = "/help commands · /quota today's spend · /hotkeys keys";
67
+
68
+ const PAD = " ";
69
+
70
+ /** A copy, so a caller mutating the result cannot corrupt later renders. */
71
+ export const bannerArt = (): string[] => [...ART];
72
+
73
+ export const bannerWidth = (): number => Math.max(...ART.map((line) => line.length));
74
+
75
+ /**
76
+ * The art is 97 columns, which does not fit the classic 80 column terminal.
77
+ * Below the art plus its indent we show a wordmark instead, because clipped box
78
+ * characters look like a rendering bug rather than a design.
79
+ */
80
+ const MIN_FULL_WIDTH = bannerWidth() + PAD.length;
81
+
82
+ export function createBanner(theme: ThemeLike): HeaderComponent {
83
+ const art = bannerArt();
84
+ const artWidth = bannerWidth();
85
+
86
+ // Two different things, easy to swap by accident:
87
+ // appearance light or dark, decides which brand cyan is readable
88
+ // getColorMode the terminal's colour capability, decides how to emit it
89
+ // Default to dark so the banner keeps its colour on a host that reports
90
+ // neither, rather than dropping the brand.
91
+ const isLight = theme.appearance === "light";
92
+ const mode = typeof theme.getColorMode === "function" ? theme.getColorMode() : "truecolor";
93
+ const brand = foregroundAnsi(isLight ? CYAN_LIGHT_BG : CYAN_DARK_BG, mode as never);
94
+ const ink = (text: string) => `${brand}${text}${RESET}`;
95
+
96
+ return {
97
+ render(width: number): string[] {
98
+ if (width < MIN_FULL_WIDTH) {
99
+ return ["", ink(PAD + "learningcode"), theme.fg("muted", PAD + TAGLINE)];
100
+ }
101
+
102
+ const lines = ["", ...art.map((line) => ink(PAD + line))];
103
+ lines.push(theme.fg("border", PAD + "═".repeat(artWidth)));
104
+ lines.push(theme.fg("muted", PAD + TAGLINE));
105
+ lines.push(theme.fg("dim", PAD + HINTS));
106
+ lines.push("");
107
+ return lines;
108
+ },
109
+ };
110
+ }
111
+
112
+ /**
113
+ * `setHeader` lives on ExtensionUIContext, not on the ExtensionAPI the factory
114
+ * receives, so it has to be reached from an event's context. session_start is the
115
+ * right moment: extensions are loaded in print and JSON modes too, where there is
116
+ * no header to replace, and starting work in the factory would run for those.
117
+ */
118
+ export default function learningcodeBanner(pi: ExtensionAPI) {
119
+ pi.on("session_start", async (_event, ctx) => {
120
+ if (!ctx.hasUI || ctx.mode !== "tui") return;
121
+ ctx.ui.setHeader((_tui, theme) => createBanner(theme as ThemeLike));
122
+ });
123
+ }
@@ -0,0 +1,128 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+
3
+ /**
4
+ * A persistent footer showing what a Spark student actually needs to know:
5
+ * how much of the day's token budget is gone, and whether their account is
6
+ * allowed to run at all.
7
+ *
8
+ * Spark's quota and seat endpoints are not part of the OpenAI API, so no stock
9
+ * harness surfaces them. `/v1/me/quota` is the reason this footer exists.
10
+ *
11
+ * The value is polled at most every POLL_MS. Agent turns are serial under Spark
12
+ * (one active generation per student), so a per-turn refresh would add latency
13
+ * to the thing the student is waiting on for no new information.
14
+ */
15
+
16
+ type ThemeLike = { fg(token: string, text: string): string };
17
+ type ComponentLike = { render(width: number): string[] };
18
+ /** Just enough of the TUI to ask for a repaint after the quota changes. */
19
+ type TuiLike = { requestRender?(): void; invalidate?(): void };
20
+
21
+ const BASE_URL = (process.env.SPARK_BASE_URL || "https://spark.learning.com.my/v1").replace(/\/+$/, "");
22
+ const TOKEN = process.env.SPARK_API_KEY || "";
23
+
24
+ /** Two minutes. Enough to stay current, rarely enough to be invisible. */
25
+ const POLL_MS = 120_000;
26
+
27
+ type Quota = { tokensUsed: number; dailyTokenLimit: number; aiEnabled: boolean };
28
+
29
+ const num = (value: unknown): number =>
30
+ typeof value === "number" && Number.isFinite(value) ? value : 0;
31
+
32
+ const pct = (used: number, limit: number): string =>
33
+ limit > 0 ? `${Math.min(100, Math.round((used / limit) * 100))}%` : "--";
34
+
35
+ /**
36
+ * A ten cell meter. Text alone ("82%") is easy to skim past; a bar is read at a
37
+ * glance, which is the entire reason this is a footer rather than a command.
38
+ */
39
+ function meter(used: number, limit: number, width = 10): string {
40
+ if (limit <= 0) return "";
41
+ const filled = Math.min(width, Math.round((used / limit) * width));
42
+ return "█".repeat(filled) + "░".repeat(width - filled);
43
+ }
44
+
45
+ function format(quota: Quota): string[] {
46
+ if (quota.aiEnabled === false) return ["AI off — ask your teacher"];
47
+ const { tokensUsed, dailyTokenLimit } = quota;
48
+ return [
49
+ `${meter(tokensUsed, dailyTokenLimit)} ${pct(tokensUsed, dailyTokenLimit)}`,
50
+ `${Math.round(tokensUsed / 1000)}k/${Math.round(dailyTokenLimit / 1000)}k today`,
51
+ ];
52
+ }
53
+
54
+ async function fetchQuota(): Promise<Quota | null> {
55
+ if (!TOKEN) return null;
56
+ try {
57
+ const response = await fetch(`${BASE_URL}/me/quota`, {
58
+ headers: { Authorization: `Bearer ${TOKEN}` },
59
+ });
60
+ if (!response.ok) return null;
61
+ return (await response.json()) as Quota;
62
+ } catch {
63
+ return null;
64
+ }
65
+ }
66
+
67
+ export default function sparkFooter(pi: ExtensionAPI) {
68
+ let quota: Quota | null = null;
69
+ let timer: ReturnType<typeof setInterval> | undefined;
70
+ let tui: TuiLike | undefined;
71
+
72
+ pi.on("session_start", async (_event, ctx) => {
73
+ if (!ctx.hasUI || ctx.mode !== "tui") return;
74
+
75
+ const refresh = async () => {
76
+ quota = await fetchQuota();
77
+ // Repaint in place. Notifying would be the wrong tool: an empty
78
+ // notification still appends a row to the transcript, and the transcript is
79
+ // what the student is reading.
80
+ tui?.requestRender?.();
81
+ };
82
+
83
+ ctx.ui.setFooter((footerTui: TuiLike, theme: ThemeLike) => {
84
+ tui = footerTui;
85
+ return {
86
+ render(width: number): string[] {
87
+ // Nothing to say off Spark, or no token: say so rather than draw a bar
88
+ // full of empties that reads as "you have spent nothing".
89
+ if (!TOKEN) {
90
+ return [theme.fg("dim", " not on Spark — /login, or use --model opencode-go/…")];
91
+ }
92
+ if (!quota) return [theme.fg("dim", " Spark quota unavailable")];
93
+
94
+ if (quota.aiEnabled === false) {
95
+ return [theme.fg("error", " AI disabled for your account — ask your teacher")];
96
+ }
97
+
98
+ const { tokensUsed, dailyTokenLimit } = quota;
99
+ const bar = theme.fg("accent", meter(tokensUsed, dailyTokenLimit));
100
+ const percent = theme.fg("muted", pct(tokensUsed, dailyTokenLimit));
101
+
102
+ // Narrow terminals keep the bar and the percentage only; the absolute
103
+ // token counts are the part that can be dropped.
104
+ if (width < 52) return [` ${bar} ${percent}`];
105
+
106
+ const numbers = theme.fg(
107
+ "dim",
108
+ `${Math.round(tokensUsed / 1000)}k / ${Math.round(dailyTokenLimit / 1000)}k today`,
109
+ );
110
+ return [` ${bar} ${percent} ${numbers}`];
111
+ },
112
+ };
113
+ });
114
+
115
+ await refresh();
116
+ timer = setInterval(refresh, POLL_MS);
117
+ timer.unref?.();
118
+ });
119
+
120
+ pi.on("session_shutdown", async () => {
121
+ if (timer) clearInterval(timer);
122
+ timer = undefined;
123
+ tui = undefined;
124
+ });
125
+ }
126
+
127
+ // Exported for tests.
128
+ export const _internals = { format, meter, pct };
package/lib/auth.mjs ADDED
@@ -0,0 +1,57 @@
1
+ import { chmod, mkdir, readFile, writeFile } from "node:fs/promises";
2
+ import { dirname, join } from "node:path";
3
+ import { UserError } from "./config.mjs";
4
+
5
+ /**
6
+ * Pi resolves provider credentials from auth.json first, then the environment.
7
+ * auth.json is keyed by provider id, so a key stored under one provider cannot
8
+ * satisfy another.
9
+ *
10
+ * That matters because OpenCode Zen and OpenCode Go share a single
11
+ * `OPENCODE_API_KEY` env var. Setting it authenticates *both*, which exposes 111
12
+ * pay-per-use Zen models alongside the 29 covered by a Go subscription, where
13
+ * per-turn cost runs to $20/M output on the expensive ones.
14
+ *
15
+ * Writing the key under `opencode-go` alone authenticates Go and leaves Zen with
16
+ * no credential, so Zen models never register. Verified against Pi: with the key
17
+ * scoped this way, `--list-models claude` reports no matches, while the same
18
+ * search with the env var set lists Zen's catalogue.
19
+ */
20
+ export const GO_PROVIDER = "opencode-go";
21
+
22
+ export function authPath(agentDir) {
23
+ return join(agentDir, "auth.json");
24
+ }
25
+
26
+ async function readAuth(agentDir) {
27
+ try {
28
+ const parsed = JSON.parse(await readFile(authPath(agentDir), "utf-8"));
29
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) return parsed;
30
+ throw new UserError(`${authPath(agentDir)} is not a JSON object. Fix or delete it.`);
31
+ } catch (error) {
32
+ if (error instanceof UserError) throw error;
33
+ if (error?.code !== "ENOENT") {
34
+ throw new UserError(`Could not parse ${authPath(agentDir)}: ${error.message}`);
35
+ }
36
+ return {};
37
+ }
38
+ }
39
+
40
+ /**
41
+ * Store an API key for one provider, preserving any credentials Pi or the user
42
+ * has already written. Mode 0600 because this file holds bearer tokens.
43
+ */
44
+ export async function saveProviderKey(agentDir, providerId, key) {
45
+ const auth = await readAuth(agentDir);
46
+ auth[providerId] = { type: "api_key", key };
47
+
48
+ const path = authPath(agentDir);
49
+ await mkdir(dirname(path), { recursive: true });
50
+ await writeFile(path, `${JSON.stringify(auth, null, 2)}\n`, {
51
+ encoding: "utf-8",
52
+ mode: 0o600,
53
+ });
54
+ // `mode` only applies when creating the file.
55
+ await chmod(path, 0o600).catch(() => {});
56
+ return path;
57
+ }
package/lib/config.mjs CHANGED
@@ -35,6 +35,37 @@ export const TOKEN_ENV = "SPARK_API_KEY";
35
35
  /** Where the personal `spark_live_` token is cached between runs. */
36
36
  export const TOKEN_FILE = "spark-token";
37
37
 
38
+ /**
39
+ * Pi's floor from its own package.json engines field. npm only warns when a
40
+ * dependency wants a newer Node, then the install succeeds and the CLI fails
41
+ * later with something unhelpful, so learningcode checks up front.
42
+ */
43
+ export const REQUIRED_NODE = "22.19.0";
44
+
45
+ /**
46
+ * Compare dotted versions numerically, so "22.9.0" is below "22.19.0".
47
+ * A leading "v" is stripped because parseInt("v22") is NaN, which would silently
48
+ * score such a version as 0.
49
+ */
50
+ export function compareVersions(a, b) {
51
+ const parse = (value) =>
52
+ value
53
+ .trim()
54
+ .replace(/^v/i, "")
55
+ .split(".")
56
+ .map((part) => {
57
+ const n = Number.parseInt(part, 10);
58
+ return Number.isFinite(n) ? n : 0;
59
+ });
60
+ const left = parse(a);
61
+ const right = parse(b);
62
+ for (let i = 0; i < Math.max(left.length, right.length); i += 1) {
63
+ const diff = (left[i] ?? 0) - (right[i] ?? 0);
64
+ if (diff !== 0) return diff < 0 ? -1 : 1;
65
+ }
66
+ return 0;
67
+ }
68
+
38
69
  /**
39
70
  * Pi resolves its agent directory from PI_CODING_AGENT_DIR when set, so
40
71
  * learningcode keeps its own config instead of sharing ~/.pi with a stock Pi
package/lib/themes.mjs ADDED
@@ -0,0 +1,80 @@
1
+ import { mkdir, readFile, readdir, writeFile } from "node:fs/promises";
2
+ import { dirname, join } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import { UserError } from "./config.mjs";
5
+
6
+ /**
7
+ * TLC brand themes, shipped with the package and seeded into the agent
8
+ * directory on first run.
9
+ *
10
+ * Pi reads themes from `<agentDir>/themes`, which for learningcode is
11
+ * ~/.learningcode/agent/themes. Seeding rather than forcing keeps the same rule
12
+ * the provider config follows: an existing file belongs to the student, so it is
13
+ * never overwritten. Someone who edits tlc-dark.json keeps their edit across
14
+ * upgrades.
15
+ */
16
+
17
+ /** The theme Pi starts with when a terminal reports no colour support. */
18
+ const SYSTEM_THEME = "system";
19
+
20
+ export const THEME_NAMES = ["tlc-dark", "tlc-light"];
21
+
22
+ export function themesDir(agentDir) {
23
+ return join(agentDir, "themes");
24
+ }
25
+
26
+ function packageThemeDir() {
27
+ // fileURLToPath, not import.meta.url.pathname: on Windows the pathname is a
28
+ // /C:/... URL fragment that no path function will resolve.
29
+ return join(dirname(fileURLToPath(import.meta.url)), "..", "assets", "themes");
30
+ }
31
+
32
+ /**
33
+ * Pick which TLC theme suits the terminal. Pi's system theme already probes the
34
+ * terminal, but it cannot tell us its answer from here, so the choice is made
35
+ * from the platform default and can be overridden by the caller.
36
+ */
37
+ export function preferredTheme(env = process.env) {
38
+ const forced = env.LEARNINGCODE_THEME;
39
+ if (forced) return forced;
40
+ // Windows Terminal defaults to a dark scheme for most profiles; macOS and
41
+ // Linux terminals are more often light. This is a guess either way, which is
42
+ // why an explicit --theme still wins.
43
+ return env.LEARNINGCODE_LIGHT_THEME === "1" ? "tlc-light" : "tlc-dark";
44
+ }
45
+
46
+ /**
47
+ * Copy any missing theme into the agent directory. Returns the names that were
48
+ * actually written, so a caller can tell a fresh install from an existing one.
49
+ */
50
+ export async function ensureThemes(agentDir) {
51
+ const source = packageThemeDir();
52
+ const target = themesDir(agentDir);
53
+ const written = [];
54
+
55
+ let available;
56
+ try {
57
+ available = await readdir(source);
58
+ } catch {
59
+ return written; // themes are a nicety, never a hard failure
60
+ }
61
+
62
+ await mkdir(target, { recursive: true });
63
+
64
+ for (const name of THEME_NAMES) {
65
+ if (!available.includes(`${name}.json`)) continue;
66
+ const destination = join(target, `${name}.json`);
67
+ try {
68
+ await readFile(destination, "utf-8");
69
+ continue; // already present: the student's copy wins
70
+ } catch (error) {
71
+ if (error?.code !== "ENOENT") {
72
+ throw new UserError(`Could not read ${destination}: ${error.message}`);
73
+ }
74
+ }
75
+ await writeFile(destination, await readFile(join(source, `${name}.json`), "utf-8"), "utf-8");
76
+ written.push(name);
77
+ }
78
+
79
+ return written;
80
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stemtrooper/learningcode",
3
- "version": "0.1.0",
3
+ "version": "0.2.1",
4
4
  "description": "TLC Spark coding agent for students: Pi wired to the Spark OpenAI-compatible endpoint with per-student tokens, quota and seat-queue awareness.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -8,6 +8,7 @@
8
8
  "learningcode": "./bin/learningcode.mjs"
9
9
  },
10
10
  "files": [
11
+ "assets",
11
12
  "bin",
12
13
  "lib",
13
14
  "extensions",
@@ -16,10 +17,12 @@
16
17
  ],
17
18
  "scripts": {
18
19
  "start": "node ./bin/learningcode.mjs",
19
- "test": "node --test \"test/**/*.test.mjs\""
20
+ "test": "node --test \"test/**/*.test.mjs\"",
21
+ "preview": "node ./scripts/preview.mjs"
20
22
  },
21
23
  "dependencies": {
22
- "@earendil-works/pi-coding-agent": "1.0.4"
24
+ "@earendil-works/pi-coding-agent": "1.0.4",
25
+ "@earendil-works/pi-tui": "1.0.4"
23
26
  },
24
27
  "devDependencies": {
25
28
  "jiti": "2.7.0"
@@ -40,4 +43,4 @@
40
43
  "type": "git",
41
44
  "url": "git+https://github.com/stemtrooper/learningcode.git"
42
45
  }
43
- }
46
+ }