Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ edstem auth status

Create a token at [edstem.org/settings/api-tokens](https://edstem.org/settings/api-tokens). The CLI also reads `~/.config/edstem-cli/token` and `~/.config/edstem-cli/config.yaml`.

Run `edstem update --check` to compare the installed version against npm, or `edstem update --yes` to install the latest release.

## Command model

Commands follow `edstem <plural-noun> [verb] [scope] [id] [flags]`. The canonical enrolment noun is `units`; `courses` and `projects` are equivalent aliases.
Expand Down Expand Up @@ -93,6 +95,8 @@ Run `edstem commands --json` for the full machine-readable command tree, includi
| `EDSTEM_TOKEN` | Provide the Ed API token. |
| `EDSTEM_CONFIG` | Override the local config file path. |

`config.yaml` also tunes read retries: `rateLimit.maxRetries` (default `3`) caps how many times a rate-limited or temporarily failing GET is retried, and `rateLimit.retryBaseDelay` (seconds, default `1.0`) sets the exponential backoff base. A longer `Retry-After` header wins. Writes are never retried.

## MCP

The package also installs `edstem-mcp`, a local stdio MCP server using the same `EDSTEM_TOKEN`. A hosted Streamable HTTP server is available at `https://edstem.tuuhub.com/mcp` and uses OAuth.
Expand Down
1 change: 1 addition & 0 deletions SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ edstem units --fields id,code,name
| edstem files list | List Ed-hosted downloadable files in one lesson. | <lesson> | | no |
| edstem files get | Download Ed-hosted files from one lesson. | <lesson> | --dest <directory><br>--slide <slide><br>--force | no |
| edstem activity | List current-user activity. | [unit] | -n, --max <count><br>-f, --filter <type> | no |
| edstem update | Report or install the latest edstem-cli release. | | --check | yes |
| edstem commands | Describe the complete command tree. | | | no |
| edstem skills | Generate the agent skill. | | | no |
| edstem skills generate | Regenerate SKILL.md from command metadata. | | | no |
Expand Down
4 changes: 1 addition & 3 deletions config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,5 @@ fetch:
count: 30

rateLimit:
requestDelay: 1.0 # seconds between paginated requests
maxRetries: 3 # retry count on rate-limit errors
maxRetries: 3 # retry count for rate-limited or temporarily failing reads
retryBaseDelay: 3.0 # base delay for exponential backoff (seconds)
maxCount: 100 # hard cap for single fetch
41 changes: 34 additions & 7 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import type { Command } from "commander";
import { loadToken } from "./auth.js";
import { loadConfig } from "./config.js";
import { downloadLessonFiles } from "./download.js";
import { EdClient } from "./ed/client.js";
import { EdClient, type FetchLike } from "./ed/client.js";
import { listLessonFiles } from "./ed/files.js";
import {
listCurrentActivity,
Expand All @@ -43,6 +43,7 @@ import { normalizeEdError } from "./errors.js";
import { lessonToMarkdown, threadToMarkdown } from "./markdown.js";
import { isMainModule } from "./main.js";
import { writeGeneratedSkill } from "./skills.js";
import { applyUpdate, checkForUpdate } from "./update.js";
import { VERSION } from "./version.js";

const SORT_OPTIONS = ["new", "old", "top", "hot"] as const;
Expand Down Expand Up @@ -94,6 +95,7 @@ const NOUNS: readonly NounSpec[] = [
export interface CliRuntime {
createClient: () => Promise<EdClient>;
defaultFetchCount: () => Promise<number>;
fetch?: FetchLike;
interactive: boolean;
isTTY: boolean;
writeStderr: (text: string) => void;
Expand Down Expand Up @@ -237,8 +239,8 @@ export function createProgram(runtime: CliRuntime = createDefaultRuntime()): Com
: `Mark ALL lessons as read in unit ${unit}.`,
};
},
async (client, command, unit: string, queries: string[]) =>
readLessons(client, unit, queries, {
async (command, unit: string, queries: string[]) =>
readLessons(await runtime.createClient(), unit, queries, {
all: Boolean(command.opts().all),
delaySeconds: command.opts().delay,
})
Expand Down Expand Up @@ -283,7 +285,8 @@ export function createProgram(runtime: CliRuntime = createDefaultRuntime()): Com
: `Submit all saved answers for slide ${slide}.`,
};
},
async (client, command, slide: number) => {
async (command, slide: number) => {
const client = await runtime.createClient();
const options = command.opts();
if (options.question !== undefined) {
return client.submitSlideAnswer(
Expand Down Expand Up @@ -344,6 +347,25 @@ export function createProgram(runtime: CliRuntime = createDefaultRuntime()): Com
}));
}));

mutating(program.command("update")
.description("Report or install the latest edstem-cli release.")
.option("--check", "Only report the latest release.")
.action(async (_options: unknown, command: Command) => {
// The plan summary needs the registry result, so fetch it once and close over it.
const info = await checkForUpdate(runtime.fetch);
if (command.opts().check || !info.updateAvailable) {
await writeValue(runtime, command, info);
return;
}
await mutationAction(runtime,
() => ({
summary: `Upgrade edstem-cli from ${info.currentVersion} to ${info.latestVersion} ` +
`with \`${info.upgradeCommand}\`.`,
}),
async () => ({ ...info, ranCommand: applyUpdate() })
)(command);
}));

program.command("commands")
.description("Describe the complete command tree.")
.action(async (_options: unknown, command: Command) => {
Expand All @@ -367,7 +389,12 @@ function createDefaultRuntime(): CliRuntime {
return {
createClient: () => {
client ??= Promise.all([loadToken(), loadConfig()]).then(([token, config]) =>
new EdClient({ apiBaseUrl: config.apiBaseUrl, token })
new EdClient({
apiBaseUrl: config.apiBaseUrl,
maxRetries: config.maxRetries,
retryBaseDelayMs: config.retryBaseDelayMs,
token,
})
);
return client;
},
Expand Down Expand Up @@ -405,7 +432,7 @@ function textAction<Arguments extends unknown[]>(
function mutationAction<Arguments extends unknown[]>(
runtime: CliRuntime,
plan: (command: Command, ...args: Arguments) => { summary: string },
action: (client: EdClient, command: Command, ...args: Arguments) => Promise<unknown>
action: (command: Command, ...args: Arguments) => Promise<unknown>
): (...args: [...Arguments, Command]) => Promise<void> {
return async (...args): Promise<void> => {
const command = args.at(-1) as Command;
Expand All @@ -417,7 +444,7 @@ function mutationAction<Arguments extends unknown[]>(
interactive: runtime.interactive,
});
if (!accepted) return;
await writeValue(runtime, command, await action(await runtime.createClient(), command, ...actionArgs));
await writeValue(runtime, command, await action(command, ...actionArgs));
};
}

Expand Down
15 changes: 14 additions & 1 deletion src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,15 @@ import { parse } from "yaml";
export interface EdstemConfig {
apiBaseUrl: string;
fetchCount: number;
maxRetries: number;
retryBaseDelayMs: number;
}

const DEFAULT_CONFIG: EdstemConfig = {
apiBaseUrl: "https://edstem.org/api/",
fetchCount: 30,
maxRetries: 3,
retryBaseDelayMs: 1_000,
};

export async function loadConfig(
Expand All @@ -34,8 +38,17 @@ export async function loadConfig(
const fetchCount = Number.isInteger(configuredCount) && configuredCount > 0
? configuredCount
: DEFAULT_CONFIG.fetchCount;
const rateLimit = asRecord(config.rateLimit);
const configuredRetries = Number(rateLimit.maxRetries);
const maxRetries = Number.isInteger(configuredRetries) && configuredRetries >= 0
? configuredRetries
: DEFAULT_CONFIG.maxRetries;
const configuredBaseDelay = Number(rateLimit.retryBaseDelay);
const retryBaseDelayMs = Number.isFinite(configuredBaseDelay) && configuredBaseDelay > 0
? Math.round(configuredBaseDelay * 1000)
: DEFAULT_CONFIG.retryBaseDelayMs;
const apiBaseUrl = process.env.EDSTEM_BASE_URL?.trim() || DEFAULT_CONFIG.apiBaseUrl;
return { apiBaseUrl, fetchCount };
return { apiBaseUrl, fetchCount, maxRetries, retryBaseDelayMs };
}

function asRecord(value: unknown): Record<string, unknown> {
Expand Down
101 changes: 76 additions & 25 deletions src/ed/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,15 +49,25 @@ export interface SlideSubmitResult {
export interface EdClientOptions {
apiBaseUrl?: string;
fetch?: FetchLike;
maxRetries?: number;
retryBaseDelayMs?: number;
sleep?: (ms: number) => Promise<void>;
token: string;
timeoutMs?: number;
}

export type FetchLike = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;

const RETRYABLE_STATUS = new Set([429, 502, 503, 504]);
const USER_CACHE_TTL_MS = 60_000;

export class EdClient {
private cachedUser?: { expiresAt: number; value: Promise<UserWithCourses> };
private readonly apiBaseUrl: string;
private readonly fetch: FetchLike;
private readonly maxRetries: number;
private readonly retryBaseDelayMs: number;
private readonly sleep: (ms: number) => Promise<void>;
private readonly token: string;
private readonly timeoutMs: number;

Expand All @@ -66,6 +76,9 @@ export class EdClient {
options.apiBaseUrl ?? readEnvironmentValue("EDSTEM_BASE_URL") ?? "https://edstem.org/api/"
);
this.fetch = options.fetch ?? globalThis.fetch.bind(globalThis);
this.maxRetries = options.maxRetries ?? 3;
this.retryBaseDelayMs = options.retryBaseDelayMs ?? 1_000;
this.sleep = options.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
this.token = options.token;
this.timeoutMs = options.timeoutMs ?? 15_000;
}
Expand Down Expand Up @@ -173,16 +186,16 @@ export class EdClient {
}

async fetchUser(): Promise<UserWithCourses> {
const data = await this.get("user");
const userData = asRecord(data.user);
const user = parseUser(userData);
const courses = asArray(data.courses).map((enrollment) => {
const record = asRecord(enrollment);
const course = asRecord(record.course);
const role = asRecord(record.role);
return parseCourse(course, asString(role.role));
if (this.cachedUser && this.cachedUser.expiresAt > Date.now()) {
return this.cachedUser.value;
}
const value = this.requestUser();
this.cachedUser = { expiresAt: Date.now() + USER_CACHE_TTL_MS, value };
// A failed lookup must not be cached, but an in-flight one is shared.
value.catch(() => {
if (this.cachedUser?.value === value) this.cachedUser = undefined;
});
return { courses, user };
return value;
}

async fetchUserActivity(
Expand Down Expand Up @@ -237,6 +250,19 @@ export class EdClient {
};
}

private async requestUser(): Promise<UserWithCourses> {
const data = await this.get("user");
const userData = asRecord(data.user);
const user = parseUser(userData);
const courses = asArray(data.courses).map((enrollment) => {
const record = asRecord(enrollment);
const course = asRecord(record.course);
const role = asRecord(record.role);
return parseCourse(course, asString(role.role));
});
return { courses, user };
}

private async get(
path: string,
params?: Record<string, string>
Expand Down Expand Up @@ -270,22 +296,15 @@ export class EdClient {
url.searchParams.set(key, value);
}

let response: Response;
try {
response = await this.fetch(url, {
body: options.jsonBody === undefined ? undefined : JSON.stringify(options.jsonBody),
headers: {
Accept: "application/json",
Authorization: `Bearer ${this.token}`,
...(options.jsonBody === undefined ? {} : { "Content-Type": "application/json" })
},
method,
redirect: "manual",
signal: AbortSignal.timeout(this.timeoutMs)
});
} catch (error) {
const detail = error instanceof Error ? error.message : String(error);
throw new EdApiError("network", 0, `Failed to reach the Ed API: ${detail}`);
let response = await this.send(method, url, options.jsonBody);
// Only reads are safe to repeat; a retried write could duplicate the mutation.
for (
let attempt = 0;
method === "GET" && attempt < this.maxRetries && RETRYABLE_STATUS.has(response.status);
attempt += 1
) {
await this.sleep(retryDelayMs(response, attempt, this.retryBaseDelayMs));
response = await this.send(method, url, options.jsonBody);
}

if (response.status >= 300 && response.status < 400) {
Expand Down Expand Up @@ -342,6 +361,38 @@ export class EdClient {

return payload;
}

private async send(
method: "GET" | "POST" | "PUT",
url: URL,
jsonBody: unknown
): Promise<Response> {
try {
return await this.fetch(url, {
body: jsonBody === undefined ? undefined : JSON.stringify(jsonBody),
headers: {
Accept: "application/json",
Authorization: `Bearer ${this.token}`,
...(jsonBody === undefined ? {} : { "Content-Type": "application/json" })
},
method,
redirect: "manual",
signal: AbortSignal.timeout(this.timeoutMs)
});
} catch (error) {
const detail = error instanceof Error ? error.message : String(error);
throw new EdApiError("network", 0, `Failed to reach the Ed API: ${detail}`);
}
}
}

function retryDelayMs(response: Response, attempt: number, baseDelayMs: number): number {
const backoffMs = baseDelayMs * 2 ** attempt;
const retryAfterSeconds = Number(response.headers.get("retry-after"));
const retryAfterMs = Number.isFinite(retryAfterSeconds) && retryAfterSeconds > 0
? retryAfterSeconds * 1000
: 0;
return Math.max(backoffMs, retryAfterMs);
}

function readEnvironmentValue(name: string): string | undefined {
Expand Down
Loading
Loading