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
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,23 @@ All notable changes to `sustech-cli` are documented in this file.

## [Unreleased]

## [0.12.0] - 2026-09-12

### Added

- Add `tis courses detail CODE` with teaching-task selection, course content,
hours, readings, prerequisite descriptions, syllabus links and aggregate
enrollment statistics. Optional `--round` enriches selection-period metadata,
course notes and conflict hints; partial read failures retain the core detail.
- Keep course-library and semester-specific task attributes separate, preserve
zero/unknown counts, and omit raw student records and internal configuration.

### Validation

- Type checks and all 460 tests passed. Read-only checks for BMEB316 and a
BIO102B teaching task verified course content, population counts and selection
period metadata; course notes are displayed when returned by TIS.

## [0.11.1] - 2026-09-12

### Fixed
Expand Down
24 changes: 23 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,28 @@ SUSTech Online source. The new official commands are CLI-only at present.

## Output contract

Course details supplement the existing TIS course search and selection commands:

```bash
sustech tis courses detail BMEB316
sustech tis courses detail BIO102B --rwh 2026-2027-1-BIO102B-001 --round bxxk --json
```

Use an exact course code; if multiple teaching tasks match, the command lists
their identifiers and requires `--rwh`. `--semester` selects the catalog term.
Details include course content, learning outcomes, readings, prerequisite
descriptions and referenced courses, syllabus links, and aggregate enrollment
counts. `--round` adds the relevant selection type's live counts, period, notes
and conflicts. Without it, matching enrolled/cart records can supply counts;
missing counts remain unknown. Optional read failures are reported in `sources`
while the course details remain available.

Teaching-task and course-library attributes stay separate. Syllabus documents
may describe an older offering; links require TIS authentication and are not
downloaded automatically. Population groups and quota-related counts retain
their original meanings, and are not converted into guaranteed remaining seats.
See [TIS course detail](docs/TIS_COURSE_DETAIL.md) for output fields and examples.

```bash
# Human-readable text
sustech tis courses search "machine learning"
Expand All @@ -237,7 +259,7 @@ review. A successful envelope looks like this:
"ok": true,
"command": "version",
"data": {
"version": "0.11.1",
"version": "0.12.0",
"runtime": "node v22.19.0"
}
}
Expand Down
62 changes: 62 additions & 0 deletions docs/TIS_COURSE_DETAIL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
# TIS course detail

`tis courses detail` is a read-only addition to the course-selection workflow.
It retrieves one exact course and teaching task without changing enrollment.

```bash
sustech tis courses detail BMEB316 --semester 2026-2027-1
sustech tis courses detail BIO102B --rwh 2026-2027-1-BIO102B-001 --round bxxk --json
sustech tis courses detail AI203 --semester 2026-2027-1 --jsonl
```

The course code is matched exactly. When more than one teaching task exists,
`TIS_COURSE_AMBIGUOUS` lists candidates; specify `--rwh` to select one. The detail
request uses the catalog's `kcid`, which differs from the mutation `courseId`.
Catalog lookup is live and bounded to 100 keyword matches; incomplete catalog
results produce an error instead of silently selecting a task.

## Output

| Field | Content |
| --- | --- |
| `course` | Normalized semester teaching task, teachers, meetings and identifiers |
| `catalog` | Course-library language/category, hours, assessment and teaching method |
| `content` | Plain-text introductions, objectives, outcomes, outline and readings; Chinese and English when supplied |
| `prerequisites` | Upstream description and deduplicated referenced course codes |
| `enrollment` | Aggregate counts, quotas, counts paired with quotas, source and current-account selection status |
| `selection` | Selection type, period and local start/end times, when `--round` returns them |
| `attachments` | Named Chinese/English syllabus links requiring TIS login |
| `teachingTeam` | Course-library teaching-team names and roles when populated |
| `notices` | Selection remarks, conflicts, preference summary, source differences and partial failures |
| `sources` | Per-source `available`, `empty`, `unavailable` or `not-requested` status |
| `reportedAt` | Read timestamp; not the original publication date |

Text output prefers Chinese content when available, then English. JSON retains
both languages. JSONL emits one detail item and the normal summary record.
HTML is converted to plain text; embedded scripts/styles are removed.

## Interpretation

- `--round` is the existing selection-type code, such as `bxxk`; it is not the
same as the period name. Without this option, exact matching current-account
enrolled/cart records supply population data when available.
- Total, undergraduate, graduate, male, female, internal (`对内`) and external
(`对外`) counts are separate marginals. Their presence does not establish a
breakdown by department, major or year, or a cross-tabulation of those groups.
- Zero remains zero. Missing fields remain absent. Percentages are emitted only
when both component counts reconcile with a positive total. Quotas and counts
paired with quotas are separate from aggregate enrollment.
- A missing prerequisite list does not establish that a course has no
requirements. The upstream description preserves alternatives such as
“CS112 or CS109”; listed course references are not interpreted as an AND rule.
- A syllabus may be older than the selected term. Course-library language and
category can differ from the semester teaching task; both remain visible.
- Status `0` means pending, `1` effective, `A` waitlisted and `-1` void. Unresolved
codes, including `W`, retain the original code with an unknown label.
- Course-specific remarks and conflict information are displayed when returned.
This command does not claim to aggregate university-wide announcements.

The output is explicitly projected. It excludes raw student identities, grades,
SQL configuration, internal staff IDs and raw error bodies. Optional enrichment
failures retain the core detail with source status; course resolution and the
primary detail response must succeed. There are no automatic attachment writes.
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "sustech-cli",
"version": "0.11.1",
"version": "0.12.0",
"description": "Human-friendly and agent-ready command-line access to SUSTech services.",
"license": "PolyForm-Noncommercial-1.0.0",
"type": "module",
Expand Down
15 changes: 15 additions & 0 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ import {
type AuthService,
} from "./core/auth-check.js";
import { inferCommandName } from "./core/argv.js";
import { validateCourseDetailOptions } from "./tis/course-detail.js";
import { formatCourseDetail } from "./tis/course-detail-text.js";
import { formatBrandArt, shouldUseBrandColor } from "./core/branding.js";
import { CAPABILITIES, formatCapabilities } from "./core/capabilities.js";
import { CLI_PARSE_OPTIONS, COMMAND_OPTIONS, SHARED_OUTPUT_OPTION_NAMES, type CliOptionName } from "./core/command-metadata.js";
Expand Down Expand Up @@ -539,6 +541,7 @@ Usage:
sustech pms delete apply JOB_ID --confirm
sustech tis courses search [KEYWORD] [--semester YYYY-YYYY-N] [--limit N] [--refresh]
sustech tis courses available [KEYWORD] --round ROUND [--semester YYYY-YYYY-N] [--limit N]
sustech tis courses detail CODE [--rwh RWH] [--round ROUND] [--semester YYYY-YYYY-N]
sustech tis enrolled [--semester YYYY-YYYY-N]
sustech tis schedule [--semester YYYY-YYYY-N] [--week N|--all]
sustech tis grades [--semester YYYY-YYYY-N]
Expand Down Expand Up @@ -936,6 +939,18 @@ async function main(argv: string[]): Promise<void> {
}, output);
return;
}
if (command === "courses" && operation === "detail") {
if (parsed.positionals.length !== 4) throw usageError("Use: tis courses detail CODE [--rwh RWH] [--round ROUND]");
const options = validateCourseDetailOptions({ code: parsed.positionals[3], rwh: values.rwh, round: values.round });
const semester = parseSemester(values.semester);
const client = await tisClient(values);
const result = await client.courseDetail(semester, options);
writeSuccess({
command: "tis courses detail", data: result, text: formatCourseDetail(result), items: [result],
summary: { course: result.course.code, rwh: result.course.rwh, semester: semester.value },
}, output);
return;
}
if (command === "courses" && operation === "available") {
const semester = parseSemester(values.semester);
const round = required(values.round, "--round");
Expand Down
1 change: 1 addition & 0 deletions src/core/capabilities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,7 @@ export const CAPABILITIES: readonly Capability[] = [
capability("auth check", "Verify credentials against TIS, Blackboard, WS, booking, lib-booking (library-booking alias), or PMS.", "read", { authentication: "selected-service", status: "preview" }),
capability("tis courses search", "Search the campus-wide course catalog.", "read", { authentication: "tis", status: "preview" }),
capability("tis courses available", "Search courses available to the authenticated student.", "read", { authentication: "tis", status: "preview" }),
capability("tis courses detail", "Read course content, prerequisites, syllabus links, teaching-task notices and aggregate enrollment counts.", "read", { authentication: "tis", status: "preview" }),
capability("tis enrolled", "Read the normalized enrolled-course schedule.", "read", { authentication: "tis", status: "preview" }),
capability("tis schedule", "Read a week or full semester personal schedule.", "read", { authentication: "tis", status: "preview" }),
capability("tis grades", "Read normalized grades and calculate GPA.", "read", { authentication: "tis", status: "preview" }),
Expand Down
1 change: 1 addition & 0 deletions src/core/command-metadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,7 @@ export const COMMAND_OPTIONS: Readonly<Record<string, readonly CliOptionName[]>>
"pms delete apply": ["credentials-file", "confirm"],
"tis courses search": ["credentials-file", "semester", "limit", "refresh"],
"tis courses available": ["credentials-file", "semester", "limit", "round"],
"tis courses detail": ["credentials-file", "semester", "rwh", "round"],
"tis enrolled": ["credentials-file", "semester"],
"tis schedule": ["credentials-file", "semester", "week", "all"],
"tis grades": ["credentials-file", "semester"],
Expand Down
2 changes: 1 addition & 1 deletion src/core/version.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
export const CLI_VERSION = "0.11.1";
export const CLI_VERSION = "0.12.0";
export const USER_AGENT = `sustech-cli/${CLI_VERSION} (+https://github.com/wormforce/sustech-cli)`;
17 changes: 15 additions & 2 deletions src/test/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,24 @@ import {

const CLI_PATH = fileURLToPath(new URL("../cli.js", import.meta.url));

test("TIS detail is discoverable and validates course selectors before authentication", () => {
const described = run(["describe", "tis", "courses", "detail", "--json"]);
assert.equal(described.status, 0);
const detail = JSON.parse(described.stdout).data;
assert.equal(detail.command, "tis courses detail");
assert.ok(detail.options.some((option: { name: string }) => option.name === "--rwh"));
for (const args of [["../profile"], [], ["CS101", "extra"]]) {
const invalid = runWithoutCredentials(["tis", "courses", "detail", ...args, "--json"]);
assert.equal(invalid.status, 2);
assert.equal(JSON.parse(invalid.stdout).error.code, "USAGE");
}
});

test("compiled CLI serves human text and versioned JSON from the real entrypoint", () => {
const text = run(["version"]);
assert.equal(text.status, 0);
assert.match(text.stdout, /:\*##: :#######:/);
assert.match(text.stdout, /sustech-cli 0\.11\.1/);
assert.match(text.stdout, /sustech-cli 0\.12\.0/);
assert.doesNotMatch(text.stdout, /\u001b\[/);

const json = run(["version", "--json"]);
Expand All @@ -27,7 +40,7 @@ test("compiled CLI serves human text and versioned JSON from the real entrypoint
schemaVersion: "1",
ok: true,
command: "version",
data: { version: "0.11.1", runtime: `node ${process.version}` },
data: { version: "0.12.0", runtime: `node ${process.version}` },
});
});

Expand Down
Loading