@stemtrooper/learningcode 0.1.0 → 0.2.0
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 +94 -0
- package/assets/themes/tlc-dark.json +79 -0
- package/assets/themes/tlc-light.json +79 -0
- package/bin/learningcode.mjs +60 -2
- package/extensions/banner.ts +105 -0
- package/extensions/footer.ts +128 -0
- package/lib/auth.mjs +57 -0
- package/lib/config.mjs +31 -0
- package/lib/themes.mjs +80 -0
- package/package.json +3 -2
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
|
+
}
|
package/bin/learningcode.mjs
CHANGED
|
@@ -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
|
-
...
|
|
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
|
|
346
|
+
// configured for some providers, which would shadow the Go credential.
|
|
289
347
|
...(token ? { [TOKEN_ENV]: token } : {}),
|
|
290
348
|
},
|
|
291
349
|
});
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* LEARNINGCODE startup banner.
|
|
5
|
+
*
|
|
6
|
+
* Pi lets an extension replace the whole header, which is the supported way to
|
|
7
|
+
* brand a fork without touching Pi's internals. The built-in header is Pi's own
|
|
8
|
+
* logo plus key hints; we trade that for the TLC banner and a line of slash
|
|
9
|
+
* commands, so nothing is claimed about keybindings we cannot read.
|
|
10
|
+
*
|
|
11
|
+
* The art is the figlet "ANSI Shadow" face, kept verbatim rather than rebuilt from
|
|
12
|
+
* a glyph map: the double-line box characters only line up if every row keeps
|
|
13
|
+
* its exact offset, and a one-space drift makes the whole thing look broken.
|
|
14
|
+
*
|
|
15
|
+
* Rendering uses the active theme's colour tokens (`accent`, `border`, `dim`,
|
|
16
|
+
* `muted`) so the banner stays legible in light and dark terminals instead of
|
|
17
|
+
* hard-coding colours that break on one of them.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/** Structural types, so this file needs no runtime import from Pi's packages. */
|
|
21
|
+
type ThemeLike = {
|
|
22
|
+
fg(token: string, text: string): string;
|
|
23
|
+
getColorMode?(): "light" | "dark";
|
|
24
|
+
};
|
|
25
|
+
type HeaderComponent = { render(width: number): string[] };
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* TLC brand cyan, sampled from the logo pixels in TLC_BLACKBGND.png and
|
|
29
|
+
* TLC_WHITEBGND.png rather than eyeballed.
|
|
30
|
+
*
|
|
31
|
+
* The banner uses these directly instead of the theme's `accent`, because a
|
|
32
|
+
* wordmark that changes colour with whatever theme is active stops being a
|
|
33
|
+
* wordmark. Pi's own logo does the same thing, keeping fixed brand colours and
|
|
34
|
+
* adapting only for light versus dark terminals.
|
|
35
|
+
*
|
|
36
|
+
* Two values because the logo ships two: the darker cyan holds contrast on a
|
|
37
|
+
* white field, the brighter one on black.
|
|
38
|
+
*/
|
|
39
|
+
const CYAN_DARK_BG = "#29c8f2";
|
|
40
|
+
const CYAN_LIGHT_BG = "#0dacd6";
|
|
41
|
+
|
|
42
|
+
const ART: readonly string[] = [
|
|
43
|
+
"██╗ ███████╗ █████╗ ██████╗ ███╗ ██╗██╗███╗ ██╗ ██████╗ ██████╗ ██████╗ ██████╗ ███████╗",
|
|
44
|
+
"██║ ██╔════╝██╔══██╗██╔══██╗████╗ ██║██║████╗ ██║██╔════╝ ██╔════╝██╔═══██╗██╔══██╗██╔════╝",
|
|
45
|
+
"██║ █████╗ ███████║██████╔╝██╔██╗ ██║██║██╔██╗ ██║██║ ███╗██║ ██║ ██║██║ ██║█████╗",
|
|
46
|
+
"██║ ██╔══╝ ██╔══██║██╔══██╗██║╚██╗██║██║██║╚██╗██║██║ ██║██║ ██║ ██║██║ ██║██╔══╝",
|
|
47
|
+
"███████╗███████╗██║ ██║██║ ██║██║ ╚████║██║██║ ╚████║╚██████╔╝╚██████╗╚██████╔╝██████╔╝███████╗",
|
|
48
|
+
"╚══════╝╚══════╝╚═╝ ╚═╝╚═╝ ╚═╝╚═╝ ╚═══╝╚═╝╚═╝ ╚═══╝ ╚═════╝ ╚═════╝ ╚═════╝ ╚═════╝ ╚══════╝",
|
|
49
|
+
];
|
|
50
|
+
|
|
51
|
+
const TAGLINE = "The Learning Curve · Sarawak";
|
|
52
|
+
const HINTS = "/help commands · /quota today's spend · /hotkeys keys";
|
|
53
|
+
|
|
54
|
+
const PAD = " ";
|
|
55
|
+
|
|
56
|
+
/** A copy, so a caller mutating the result cannot corrupt later renders. */
|
|
57
|
+
export const bannerArt = (): string[] => [...ART];
|
|
58
|
+
|
|
59
|
+
export const bannerWidth = (): number => Math.max(...ART.map((line) => line.length));
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The art is 97 columns, which does not fit the classic 80 column terminal.
|
|
63
|
+
* Below the art plus its indent we show a wordmark instead, because clipped box
|
|
64
|
+
* characters look like a rendering bug rather than a design.
|
|
65
|
+
*/
|
|
66
|
+
const MIN_FULL_WIDTH = bannerWidth() + PAD.length;
|
|
67
|
+
|
|
68
|
+
export function createBanner(theme: ThemeLike): HeaderComponent {
|
|
69
|
+
const art = bannerArt();
|
|
70
|
+
const artWidth = bannerWidth();
|
|
71
|
+
|
|
72
|
+
// Fall back to the theme's accent only if a caller cannot report its colour
|
|
73
|
+
// mode, which keeps the banner legible rather than dropping colour entirely.
|
|
74
|
+
const mode = typeof theme.getColorMode === "function" ? theme.getColorMode() : "dark";
|
|
75
|
+
const brand = mode === "light" ? CYAN_LIGHT_BG : CYAN_DARK_BG;
|
|
76
|
+
const ink = (text: string) => theme.fg(brand as never, text);
|
|
77
|
+
|
|
78
|
+
return {
|
|
79
|
+
render(width: number): string[] {
|
|
80
|
+
if (width < MIN_FULL_WIDTH) {
|
|
81
|
+
return ["", ink(PAD + "learningcode"), theme.fg("muted", PAD + TAGLINE)];
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const lines = ["", ...art.map((line) => ink(PAD + line))];
|
|
85
|
+
lines.push(theme.fg("border", PAD + "═".repeat(artWidth)));
|
|
86
|
+
lines.push(theme.fg("muted", PAD + TAGLINE));
|
|
87
|
+
lines.push(theme.fg("dim", PAD + HINTS));
|
|
88
|
+
lines.push("");
|
|
89
|
+
return lines;
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* `setHeader` lives on ExtensionUIContext, not on the ExtensionAPI the factory
|
|
96
|
+
* receives, so it has to be reached from an event's context. session_start is the
|
|
97
|
+
* right moment: extensions are loaded in print and JSON modes too, where there is
|
|
98
|
+
* no header to replace, and starting work in the factory would run for those.
|
|
99
|
+
*/
|
|
100
|
+
export default function learningcodeBanner(pi: ExtensionAPI) {
|
|
101
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
102
|
+
if (!ctx.hasUI || ctx.mode !== "tui") return;
|
|
103
|
+
ctx.ui.setHeader((_tui, theme) => createBanner(theme as ThemeLike));
|
|
104
|
+
});
|
|
105
|
+
}
|
|
@@ -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.
|
|
3
|
+
"version": "0.2.0",
|
|
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",
|
|
@@ -40,4 +41,4 @@
|
|
|
40
41
|
"type": "git",
|
|
41
42
|
"url": "git+https://github.com/stemtrooper/learningcode.git"
|
|
42
43
|
}
|
|
43
|
-
}
|
|
44
|
+
}
|