Skip to content

Adds new version of help50 - #210

Merged
rongxin-liu merged 99 commits into
mainfrom
help50
Sep 30, 2026
Merged

rongxin-liu merged 99 commits into
mainfrom
help50

Conversation

@dmalan

@dmalan dmalan commented May 9, 2024 •

Copy link
Copy Markdown
Member

To be squashed when merging.

This new version is implemented in Bash (instead of Python) as follows, wherein usage is inspired by systemctl, even though it doesn't run as a daemon but, rather, per login shell. It runs locally and automatically now, without any server. In a codespace, failures that no local helper recognizes can additionally be handed to the CS50 Duck (see Related repositories below); in plain cs50/cli, help is printed in the terminal.

  • help50 start sets $HELP50 to /tmp/help50.$PPID (the PID of the shell in which the command was run) and launches script, which logs standard I/O to that file. Started automatically by /etc/profile.d/cli.sh for interactive shells that have a terminal, if enabled; never for non-interactive shells like bash --login -c, where script would otherwise block.
  • help50 stop sends SIGTERM to script, which returns the user to the outer, unhelped shell.
  • help50 status checks for $HELP50, which is set only when help50 is started for a shell.
  • help50 disable writes /tmp/help50.lock.
  • help50 enable deletes /tmp/help50.lock.
  • help50 is-enabled checks for /tmp/help50.lock, and for $HELP50_DISABLED in the environment. The latter is a kill switch: any value other than 0, false, no, or off disables help50 at login and is reported as such, so that, delivered as a Codespaces secret, help50 can be turned off fleet-wide at each student's next codespace start without rebuilding an image.
  • help50 COMMAND [ARGS...] runs COMMAND as though it had been typed directly, with the same exit status, so that students who remember the old help50 make foo get exactly the behavior of make foo. Within a help50 session this is a shell function that evals COMMAND in the shell itself, so aliases, functions, and cd behave as they would have; outside one, /opt/cs50/bin/help50 execs it. Bare help50 prints usage explaining that help is now automatic.

  • /etc/profile.d/help50.sh is a config that that's only sourced when $HELP50 is set.
    • It sets $PROMPT_COMMAND to _help50, which is a Bash function implemented therein that, if the most recent command exited with non-0 status (per $?, ignoring ctl-c and ctl-z), reads $HELP50, strips ANSI and control characters (via ansi2txt and col), and drops everything through the command line itself as echoed by the terminal (so that tab-completion listings, history recall, and redrawn prompts aren't mistaken for output). The read is bounded to the first 64K and last 1M of the file, and long output is capped to its first 64 and last 1,024 lines with a marker in between, so the prompt's cost doesn't grow with how much a program printed before failing. What remains is passed as standard input, with the command's own words as arguments, to each executable in /opt/cs50/lib/help50/ (implemented in any language), each under a 5-second timeout.
    • If any of those echo output, it passes it to a _helpful function that, by default, displays it in yellow to help the user (and, for one prompt, aliases y, yes, n, and no to a reminder that the question was rhetorical, since yes and n are real programs).
    • If none of those echo output, it passes the failed command's output (its last 8K) and command line to _helpless OUTPUT CMD instead, which doesn't do anything in cs50/cli but is overridden in cs50/codespace to relay them to ddb50.
    • Else if the most recent command exited successfully, _helped is called, which doesn't do anything in cs50/cli but is overridden in cs50/codespace to indicate to the user that help is (no longer) available.
  • /etc/profile.d/cli.sh starts help50 automatically, as above.
  • /opt/cs50/lib/cli contains several helper functions (written in Bash) that our own wrappers and help50 use: _alert, _ansi, _find, _fold, _sure. The make, sqlite3, http-server, and valgrind wrappers now use them, and the make wrapper lets make itself report make foo.c so that the make helper can explain it.
  • /opt/cs50/lib/help50/ contains helpers for bash (e.g., 1s, a capitalized command, check 50, ./foo.c, .\foo, a directory run as a command, code typed into the terminal), cd (e.g., cd.., or a directory that exists elsewhere in the workspace), clang (a file without main), make (e.g., make foo.c, or a target whose .c file is elsewhere), and python (e.g., a file shadowing cs50, re, or string, or a file that exists elsewhere). Each verifies its hypothesis against the filesystem before advising; "elsewhere" searches $WORKDIR.
  • Dockerfile installs bsdextrautils (for col), colorized-logs (for ansi2txt), file, expect, and fzf, and no longer installs the Python help50 package.
  • tests/smoke.sh (also make smoke) checks a built image under timeouts, and .github/workflows/main.yml runs it against each architecture's build before pushing to Docker Hub.

$ git fetch
$ git checkout help50
$ make build
$ make run

/mnt/ $ help50 start

/mnt/ $ ps f
  PID TTY      STAT   TIME COMMAND
    1 pts/0    Ss     0:00 bash --login
   24 pts/0    S      0:00 /bin/bash /opt/cs50/bin/help50 start
   26 pts/0    S+     0:00  \_ script --append --command bash --login ; exit 1 --flush --quiet --return /tmp/help50.1
   27 pts/1    Ss     0:00      \_ sh -c bash --login ; exit 1
   28 pts/1    S      0:00          \_ bash --login
  143 pts/1    R+     0:00              \_ ps f

/mnt/ $ help50 stop

Session terminated, killing shell... ...killed.

/mnt/ $ ps f
  PID TTY      STAT   TIME COMMAND
    1 pts/0    Ss     0:00 bash --login
  178 pts/0    R+     0:00 ps f

(In a codespace, script spawns bash -c rather than sh -c, per $SHELL, and the outermost shell is VS Code's bash --login -i.)


Related repositories

This PR is the foundation; the rest of the pipeline lives elsewhere. Everything below is merged to its integration branch unless noted.

Repository Role PRs
cs50/cli Shell detection, helpers, help50 controller (this PR) #244 bug fixes + smoke test, #245 ignore terminal echo before the command line, #246 keep the end of long output + pass the command line to _helpless, #247 help50 COMMAND passthrough, #248 bounded read, helper timeout, HELP50_DISABLED
cs50/codespace Overrides _helpful/_helpless/_helped to relay to the help50 extension via command50; sets WORKDIR to the workspace; installs help50.vsix; Sysadmins terminal profile; smoke test #196 integration, #197 clear a stale button after a quiet failure, #198 inline advice only, no button, #199 send the command line with the output (all on canary); #200 rollout to main
cs50/help50.vsix The help50 button in the terminal's title bar; on click, hands the failed command's transcript to ddb50 #1 hardening + engine/deps, #2 button as text, #3 hide the button before dispatching
cs50/ddb50.vsix The CS50 Duck; requestGptResponse posts the transcript to cs50.ai's /api/v1/help #23 wait for the webview to be ready, #24 reset on hide, #25 fix the cold-start wait
cs50/cs50.vsix command50, the shell-to-VS Code bridge (WebSocket) that the codespace overrides use #58 Python 3.14 compatibility + VS Code process detection, #59 audit fixes
cs50/cs50.dev Delivers the HELP50_DISABLED kill switch as a per-student Codespaces secret when set on the server #212
cs50/cs50.ai, cs50/cs50.ai-config /api/v1/help (added 2024-10-09) wraps the transcript in the help50_* prompts of configs/chat_cs50.yml no changes needed
cs50/cs50.readthedocs.io Documentation: cs50.readthedocs.io/help50 #184
cs50/harvard Course pages: help50 make hello advice replaced with what students now see #485 (2026/fall), #486 (2026/x)

Data flow in a codespace: failed command → _help50 → helpers → _helpless OUTPUT CMD → command50 help50.showButton ask "$ CMD\nOUTPUT" → help50.vsix shows the button → click → ddb50 requestGptResponse → POST https://cs50.ai/api/v1/help.

Progression

Rollout

  1. Merge this PR, which publishes cs50/cli:latest.
  2. Rebuild cs50/codespace (main, already merged via Help50 rollout codespace#200), which builds on it.
  3. Students pick it up on their next codespace rebuild.

Escape hatches, should help50 misbehave: ctl-c interrupts the prompt hook; help50 stop for the current shell; help50 disable for a codespace's new shells; a student's own HELP50_DISABLED Codespaces secret; and, fleet-wide without a rebuild, HELP50_DISABLED=1 on cs50.dev (0 deletes the secret again). Root shells (the codespace's Sysadmins profile) never start help50.

@dmalan
dmalan marked this pull request as ready for review October 5, 2024 23:51
@phuyalgaurav

This comment was marked as off-topic.

@rongxin-liu

Copy link
Copy Markdown
Contributor

All checks passed, still need 1 approving review by you @rongxin-liu . Looks like all checks passed.

Thanks for the reminder. How did you perform the test?

- cli.sh: only start help50 in interactive shells with a terminal, else
  non-interactive login shells (bash --login -c) hang on script
- lib/cli: _ansi and _fold take arguments by $#, not -t 0, so messages
  aren't dropped when stdin is redirected; _fold falls back to 80 columns
- valgrind: source lib and use _alert/_ansi instead of undefined _help
- help50.sh: fix _rhetocial typo; cap _helpless payload at 8 KiB
- help50/python: handle python dir/file.py, use realpath --canonicalize-missing
- Dockerfile: install bsdextrautils explicitly for col
tests/smoke.sh checks a built image under timeouts: non-interactive login
shells exit, help50 deps are installed, wrappers print with stdin redirected.
Run via make smoke, and in CI before pushing to Docker Hub.
Fix help50 bugs and add smoke test
script records everything on the pty, including tab-completion listings,
history recall, and redrawn prompts. _help50 dropped only the first line as
the command, so after tab-completing a command with no output, the leftover
echo was treated as its output and passed to _helpless. Now drop everything
through the first line that ends with the command as recorded in history,
joining backslash continuations and their PS2 prompts; fall back to dropping
the first logical line if the command isn't found.
Ignore terminal echo before the command line in typescripts
The typescript was capped with head -n 1024, keeping the start of the output.
Errors are usually at the end (tracebacks, make: *** Error, segfaults), so a
program that printed a lot and then failed lost its error before any helper or
_helpless saw it. Now keep the first 64 lines (where the command line is echoed
and found) plus the last 1024, with a marker for what was omitted.

_helpless now also receives the command line as a second argument, so whatever
explains the output (in cs50/codespace, the CS50 Duck via cs50.ai) can see what
was run. The default _helpless ignores it; output stays the first argument, so
the codespace's empty-output check is unaffected.
Nothing exercised _help50 itself: the head/tail cap and the new command-line
argument were only checked by hand under a pty. Drive _help50 directly in the
image instead, with a fabricated typescript and a fake _helpless, and assert
that a command printing 3000 lines then an error yields the command line as
the second argument and, as the first, output that starts at the program's
first line, carries the omission marker, drops the middle, and ends with the
error. Also check that a failed command with no output yields an empty first
argument, which is what the codespace's empty-output check depends on.

The check fails against the previous help50.sh.
Keep the end of long output, and pass the command line to _helpless
Students used to run `help50 make foo`. Help now arrives automatically after
any failed command, so run COMMAND with the same exit status and let the prompt
hook handle the rest: help50 make foo behaves exactly like make foo. Builtins go
through bash -c with the shell's error wording preserved; an unknown command
fails with the shell's own "command not found" message so helpers match it.
Bare help50 prints usage (exit 0) explaining the new behaviour. The subcommands
(start/stop/...) are unchanged; only they require non-root.
Within a help50 session, define help50 as a shell function that evals COMMAND
in the calling shell, so aliases (rm -i), functions, and cd behave exactly as
they would directly, and no stderr is rewritten through an unwaited sed.

In the script (outside a session), route path-shaped names (./foo.c, ./dir)
through bash -c so the shell's own Permission denied / Is a directory errors
reach the helpers instead of a synthesized "command not found"; wait for sed
before exiting; pass -- to type so option-like commands don't leak usage noise.

In the prompt hook, treat `help50 COMMAND` as COMMAND when deriving argv, so
helpers that look at positional words (e.g., `check 50`) and the ./ re-make
hint still fire.

Smoke-test the passthrough: exit statuses, exact error text for unknown,
option-like, builtin, and path-shaped commands, usage, sudo, the in-shell
function, and the hook's argv handling.
Run `help50 COMMAND` as though COMMAND were typed directly
- Read the typescript bounded (first 64K + last 1M) instead of the whole file
  into a variable, so the prompt after a failed command no longer scales with
  how much it printed: 35 MB took 2.4 s, now 0.1 s, and 350 MB would have
  taken 24 s.
- Run each helper under timeout (5 s, then SIGKILL), so a slow or stuck helper
  cannot stall the prompt; today's helpers can't block, but the framework
  accepts helpers in any language.
- HELP50_DISABLED in the environment disables help50 at login and is reported
  by help50 is-enabled/status. Set as an organization-wide Codespaces secret,
  it turns help50 off for everyone at their next login without rebuilding an
  image; set by one user, it's a persistent personal opt-out.

Smoke tests cover all three.
- HELP50_DISABLED=0 (or false, no, off, case-insensitively) now counts as
  unset, so that an admin who sets the org secret to 0 to turn help50 back
  on gets what they asked for, rather than every student staying disabled
  with no error. The is-enabled message now shows the value and says to
  unset it. Smoke tests cover the false-y values and that the lock file is
  still honored when the environment doesn't disable.
- Note in the prompt hook that timeout runs each helper in its own process
  group, so ctl-c no longer reaches a stuck helper; the timeout itself is
  the bound. --foreground would restore ctl-c but stop timeout from killing
  the helper's children, which would give back the hang this is meant to
  remove.
Bound the prompt hook's cost, and add a kill switch
@rongxin-liu
rongxin-liu merged commit 164bc54 into main Sep 30, 2026
3 checks passed
@rongxin-liu
rongxin-liu deleted the help50 branch September 30, 2026 21:11
rongxin-liu added a commit that referenced this pull request Sep 30, 2026
Reimplements help50 in Bash, running locally and automatically per login
shell, without a server. Usage is inspired by systemctl:

- help50 start/stop/status/enable/disable/is-enabled control a session
  that logs the shell's I/O via script to /tmp/help50.$PPID
- help50 COMMAND [ARGS...] runs COMMAND as though typed directly, with
  the same exit status
- HELP50_DISABLED in the environment is a kill switch, so that as a
  Codespaces secret help50 can be turned off fleet-wide without a rebuild

/etc/profile.d/help50.sh installs a PROMPT_COMMAND hook that, after a
failed command, strips the typescript of ANSI/control characters and
terminal echo, bounds the read (first 64K + last 1M) and the output
(first 64 + last 1,024 lines), and passes it to each executable helper in
/opt/cs50/lib/help50/ under a 5-second timeout. Helper output is shown via
_helpful; otherwise _helpless receives the output and command line (a
no-op here, overridden in cs50/codespace to relay to the CS50 Duck).

Also adds /opt/cs50/lib/cli helper functions (_alert, _ansi, _find,
_fold, _sure) used by the make, sqlite3, http-server, and valgrind
wrappers; helpers for bash, cd, clang, make, and python; tests/smoke.sh
(make smoke), run in CI against each architecture's build before pushing
to Docker Hub; and installs bsdextrautils, colorized-logs, file, expect,
and fzf, dropping the Python help50 package.

Squashed from 99 commits, including #244, #245, #246, #247, and #248.

Co-authored-by: Rongxin Liu <10591665+rongxin-liu@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants