@stemtrooper/learningcode 0.3.1 → 0.4.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 CHANGED
@@ -114,12 +114,19 @@ Useful commands, typed inside the session:
114
114
  | `/help` | list everything available |
115
115
  | `/quota` | your token spend today |
116
116
  | `/seats` | how many seats are free |
117
+ | `/spark-login` | check your TLC-Spark token, or find out how to get a new one |
117
118
  | `/model` | switch model |
118
119
  | `/hotkeys` | keyboard shortcuts |
119
120
 
120
- `/quota` and `/seats` talk to Spark directly. They are the two commands a stock
121
- AI coding tool does not have, because those endpoints are not part of any public
122
- API.
121
+ `/quota`, `/seats` and `/spark-login` talk to Spark directly. They are the commands a
122
+ stock AI coding tool does not have, because those endpoints are not part of any
123
+ public API.
124
+
125
+ **Getting a new TLC-Spark token.** Use `/spark-login` to check the one you have;
126
+ it tells you whether Spark still accepts it. To actually replace it, exit and run
127
+ `learningcode --login`, which prompts without echoing. It is deliberately *not* a
128
+ `/` command: typing a token into the agent's own input box would leave it in your
129
+ scrollback, which is the wrong thing to do on a shared lab machine.
123
130
 
124
131
  One-off, non-interactive:
125
132
 
@@ -1,4 +1,6 @@
1
1
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { cachedTokenPrefix } from "../lib/token.mjs";
3
+ import { agentDir } from "../lib/config.mjs";
2
4
 
3
5
  /**
4
6
  * Spark-aware status for the learningcode client.
@@ -17,13 +19,13 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
17
19
  const BASE_URL = (process.env.SPARK_BASE_URL || "https://spark.learning.com.my/v1").replace(/\/+$/, "");
18
20
  const TOKEN = process.env.SPARK_API_KEY || "";
19
21
 
20
- async function call(path: string): Promise<{ ok: true; body: unknown } | { ok: false; error: string }> {
22
+ async function call(path: string): Promise<{ ok: true; body: unknown } | { ok: false; error: string; status?: number }> {
21
23
  if (!TOKEN) return { ok: false, error: "no Spark token in the environment" };
22
24
  try {
23
25
  const response = await fetch(`${BASE_URL}${path}`, {
24
26
  headers: { Authorization: `Bearer ${TOKEN}` },
25
27
  });
26
- if (!response.ok) return { ok: false, error: `HTTP ${response.status}` };
28
+ if (!response.ok) return { ok: false, error: `HTTP ${response.status}`, status: response.status };
27
29
  return { ok: true, body: await response.json() };
28
30
  } catch (error) {
29
31
  return { ok: false, error: error instanceof Error ? error.message : "unreachable" };
@@ -85,6 +87,71 @@ export default function sparkQuota(pi: ExtensionAPI) {
85
87
  },
86
88
  });
87
89
 
90
+ /**
91
+ * Check the TLC-Spark token and say what to do about it.
92
+ *
93
+ * This deliberately does not accept a pasted token. Two constraints make that
94
+ * the wrong place to type a secret:
95
+ *
96
+ * - Pi's input dialog has no mask option, so the value would sit in scrollback
97
+ * in plain text, which on a shared lab machine is worse than the masked
98
+ * prompt `learningcode --login` already uses.
99
+ * - Pi caches resolved environment values, so a token set mid-session would
100
+ * not reliably take effect until the next launch anyway.
101
+ *
102
+ * So this reports whether the token Spark is actually seeing is still good,
103
+ * which is the question a student has after rotating it, and points at the
104
+ * command that does the work.
105
+ */
106
+ pi.registerCommand("spark-login", {
107
+ description: "Check your TLC-Spark token, or get a new one",
108
+ handler: async (_args, ctx) => {
109
+ const prefix = await cachedTokenPrefix(agentDir()).catch(() => undefined);
110
+
111
+ if (!TOKEN) {
112
+ ctx.ui.notify(
113
+ "No TLC-Spark token loaded. Exit and run: learningcode --login",
114
+ "warning",
115
+ );
116
+ return;
117
+ }
118
+
119
+ const result = await call("/me/quota");
120
+
121
+ if (result.ok) {
122
+ const body = result.body as Record<string, unknown>;
123
+ const used = num(body.tokensUsed);
124
+ const limit = num(body.dailyTokenLimit);
125
+ ctx.ui.notify(
126
+ [
127
+ `Token OK${prefix ? ` (${prefix}…)` : ""}`,
128
+ `${used.toLocaleString()}/${limit.toLocaleString()} tokens today`,
129
+ "Rotating it? Exit and run: learningcode --login",
130
+ ].join(" | "),
131
+ "info",
132
+ );
133
+ return;
134
+ }
135
+
136
+ if (result.status === 401 || result.status === 403) {
137
+ ctx.ui.notify(
138
+ [
139
+ `Spark rejected your token${prefix ? ` (${prefix}…)` : ""}.`,
140
+ "It was probably rotated, which revokes the old one.",
141
+ "Exit and run: learningcode --login",
142
+ ].join(" "),
143
+ "warning",
144
+ );
145
+ return;
146
+ }
147
+
148
+ ctx.ui.notify(
149
+ `Cannot reach Spark at ${BASE_URL} (${result.error}). Check your network.`,
150
+ "warning",
151
+ );
152
+ },
153
+ });
154
+
88
155
  // One check at startup rather than after every turn: the quota moves by at
89
156
  // most one request per turn, and a fetch on each `agent_end` would add
90
157
  // latency to the thing students are waiting on.
package/lib/token.mjs CHANGED
@@ -121,4 +121,16 @@ export async function hasToken(agentDir, explicit) {
121
121
  return Boolean(await readCachedToken(agentDir));
122
122
  }
123
123
 
124
+ /**
125
+ * The leading fragment of the cached token, for display only.
126
+ *
127
+ * Spark keeps `raw.slice(0, 18)` as tokenPrefix for the same reason, so showing
128
+ * this much identifies a token without disclosing it. Nothing here ever returns
129
+ * the full value.
130
+ */
131
+ export async function cachedTokenPrefix(agentDir, length = 18) {
132
+ const token = await readCachedToken(agentDir);
133
+ return token ? token.slice(0, length) : undefined;
134
+ }
135
+
124
136
  export { writeCachedToken };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stemtrooper/learningcode",
3
- "version": "0.3.1",
3
+ "version": "0.4.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",