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 README.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,23 @@ move you back to the level's start directory):
shellgame reset
```

List all available levels (number and name):

```sh
shellgame levels
```

Resume from a specific level (becomes your saved position, so quitting and
launching `shellgame` again continues from there):

```sh
shellgame resume <section>.<level>
```

Example: `shellgame resume 1.4`

Running `shellgame resume` without a level lists the available levels.

Repeat assignment (without resetting):

```sh
Expand Down
3 changes: 3 additions & 0 deletions docs/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,9 @@ ShellGame is a local, single-binary, Linux terminal learning experience for firs
| `shellgame status` | Show progress, time per level, hints used |
| `shellgame hint --repeat` | Reprint already revealed hints without consuming a new one |
| `shellgame skip` | Advance past a bonus (optional or extension) level |
| `shellgame levels` | List every level with its number and name |
| `shellgame resume LEVEL` | Jump to `LEVEL` (e.g. `1.4`) and save it as the current position |
| `shellgame resume` | With no level, lists the levels to choose from |
| `shellgame repeat` | Re-show a level or a section intro |
| `shellgame show --level` / `--section` | Re-show the current level or the current section intro |
| `shellgame exit` | Leave the ShellGame subshell |
Expand Down
4 changes: 2 additions & 2 deletions src/shellgame/cli/boot.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ class BootResult:
exit_code: int = 0


def boot_if_needed(*, wrapped: bool, devmode: bool) -> BootResult:
def boot_if_needed(*, wrapped: bool, devmode: bool, pending_command: list[str] | None = None) -> BootResult:
if wrapped:
return BootResult(should_exit=False)

Expand All @@ -28,5 +28,5 @@ def boot_if_needed(*, wrapped: bool, devmode: bool) -> BootResult:
detected = detect_interactive_shell()
target_shell = detected if detected in ("fish", "bash") else "bash"

launch_subshell(target_shell, devmode)
launch_subshell(target_shell, devmode, pending_command=pending_command)
return BootResult(should_exit=True, exit_code=0)
79 changes: 77 additions & 2 deletions src/shellgame/cli/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
from shellgame.cli.boot import boot_if_needed
from shellgame.core.services import GameServices
from shellgame.core.session import GameSession
from shellgame.levels.registry import LevelRegistry
from shellgame.levels.registry import LevelRegistry, parse_level_id
from shellgame.messages import Messages
from shellgame.paths import current_directory
from shellgame.shell.client import ShellClient
Expand Down Expand Up @@ -100,6 +100,61 @@ def invoke(self, ctx: click.Context) -> object:
ctx.exit(1)


def _pending_command(ctx: click.Context) -> list[str]:
"""The subcommand this invocation asked for, as argv tokens.

Running `shellgame <command>` from an ordinary terminal has to launch the
game subshell first, which ends this process before the subcommand ever
runs. The tokens are handed to the subshell so it can run them once it is
up, instead of silently dropping what the player typed.
"""
if ctx.invoked_subcommand is None:
return []

argv = sys.argv[1:]
try:
start = argv.index(ctx.invoked_subcommand)
except ValueError:
# The name reaching the group differs from what was typed; forwarding a
# guess would be worse than the plain start the player already gets.
return []
return argv[start:]


#: Commands that only print something. They need no workspace, no `cd` hooks
#: and no game shell, so launching one would trap the player in a subshell they
#: never asked for and have to `exit` out of.
_NO_GAME_SHELL_COMMANDS = frozenset({"levels"})


def _resume_targets_a_real_level(args: list[str]) -> bool:
"""Whether `resume` will actually move the player into a level.

Anything else — no ID, a malformed one, an unknown one, `--help` — only
prints, so it must not drag a subshell up behind it.
"""
if len(args) != 1:
return False

level_id = args[0]
if parse_level_id(level_id) is None:
return False
return cast(LevelRegistry, _module_attr("level_registry")).get(level_id) is not None


def _runs_without_game_shell(ctx: click.Context) -> bool:
command = _pending_command(ctx)
if not command:
return False

name, *args = command
if name in _NO_GAME_SHELL_COMMANDS:
return True
if name == "resume":
return not _resume_targets_a_real_level(args)
return False


@click.group(cls=CzechGroup, invoke_without_command=True)
@click.option("--devmode", is_flag=True, hidden=True, help="Enable developer mode")
@click.option(
Expand All @@ -120,8 +175,13 @@ def cli(ctx: click.Context, devmode: bool, forced_shell: str | None) -> None:
if forced_shell:
os.environ["SHELLGAME_FORCE_SHELL"] = forced_shell.lower()

if not wrapped and _runs_without_game_shell(ctx):
# Runs here and the process ends, instead of booting a shell the player
# would then have to leave by hand.
return

try:
boot = boot_if_needed(wrapped=wrapped, devmode=devmode)
boot = boot_if_needed(wrapped=wrapped, devmode=devmode, pending_command=_pending_command(ctx))
except Exception as e:
cast(Console, _module_attr("console")).print(
f"[bold red]CHYBA: Nepodařilo se spustit herní shell ({e})[/bold red]"
Expand Down Expand Up @@ -181,6 +241,21 @@ def reset() -> None:
_get_session().reset()


@cli.command()
def levels() -> None:
"""Vypsat všechny dostupné levely."""
_get_session().levels()


@cli.command()
@click.argument("level_id", required=False)
@click.pass_context
def resume(ctx: click.Context, level_id: str | None) -> None:
"""Pokračovat ve hře od zvoleného levelu (např. 1.4)."""
if not _get_session().resume(level_id):
ctx.exit(1)


@cli.command()
@click.option(
"--section",
Expand Down
38 changes: 31 additions & 7 deletions src/shellgame/cli/subshell.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,17 +49,37 @@ def _load_template(relative_path: str) -> str:
return resources.files(package).joinpath(relative_path).read_text(encoding="utf-8")


def _quote_for_fish(argument: str) -> str:
"""Single-quote an argument for fish.

fish's single quotes are literal apart from `\\` and `'`, which is a
different rule from POSIX shells, so `shlex.quote` cannot be reused here.
"""
escaped = argument.replace("\\", "\\\\").replace("'", "\\'")
return f"'{escaped}'"


def _autostart_command(shell_name: str, pending_command: list[str] | None) -> str:
"""Render `pending_command` so the integration's autostart can run it."""
if not pending_command:
return ""
quote = _quote_for_fish if shell_name == "fish" else shlex.quote
return " ".join(quote(token) for token in pending_command)


def _render_template(
template_text: str,
*,
binary_path: str,
dev_shortcuts: str,
cd_hook: str,
autostart_args: str = "",
) -> str:
return Template(template_text).safe_substitute(
binary_path=binary_path,
dev_shortcuts=dev_shortcuts,
cd_hook=cd_hook,
autostart_args=autostart_args,
)


Expand Down Expand Up @@ -128,7 +148,7 @@ def get_parent_shell() -> str:
return "unknown"


def get_fish_integration(binary_path: str, devmode: bool = False) -> str:
def get_fish_integration(binary_path: str, devmode: bool = False, *, pending_command: list[str] | None = None) -> str:
template_text = _load_template("fish_integration.template")

dev_shortcuts = ""
Expand All @@ -142,10 +162,11 @@ def get_fish_integration(binary_path: str, devmode: bool = False) -> str:
binary_path=binary_path,
dev_shortcuts=dev_shortcuts,
cd_hook=cd_hook,
autostart_args=_autostart_command("fish", pending_command),
)


def get_bash_integration(binary_path: str, devmode: bool = False) -> str:
def get_bash_integration(binary_path: str, devmode: bool = False, *, pending_command: list[str] | None = None) -> str:
template_text = _load_template("bash_integration.template")

dev_shortcuts = ""
Expand All @@ -159,6 +180,7 @@ def get_bash_integration(binary_path: str, devmode: bool = False) -> str:
binary_path=binary_path,
dev_shortcuts=dev_shortcuts,
cd_hook=cd_hook,
autostart_args=_autostart_command("bash", pending_command),
)


Expand All @@ -181,12 +203,14 @@ def _get_launcher_argv(devmode: bool) -> list[str]:
return launcher_argv


def _create_integration_script(shell_name: str, binary_path: str, devmode: bool) -> str:
def _create_integration_script(
shell_name: str, binary_path: str, devmode: bool, pending_command: list[str] | None = None
) -> str:
with tempfile.NamedTemporaryFile(mode="w", delete=False, suffix=f".{shell_name}") as f:
if shell_name == "fish":
f.write(get_fish_integration(binary_path, devmode))
f.write(get_fish_integration(binary_path, devmode, pending_command=pending_command))
else:
f.write(get_bash_integration(binary_path, devmode))
f.write(get_bash_integration(binary_path, devmode, pending_command=pending_command))
return f.name


Expand Down Expand Up @@ -220,14 +244,14 @@ def _run_subshell(argv: list[str], env: dict[str, str], debug: bool, label: str)
raise RuntimeError(f"{label.capitalize()} subshell exited with code {proc.returncode}")


def launch_subshell(shell_name: str, devmode: bool = False) -> None:
def launch_subshell(shell_name: str, devmode: bool = False, *, pending_command: list[str] | None = None) -> None:
launcher_argv = _get_launcher_argv(devmode)

binary_path = " ".join(shlex.quote(p) for p in launcher_argv)

debug = (os.environ.get("SHELLGAME_SUBSHELL_DEBUG") or "").strip() == "1"

script_path = _create_integration_script(shell_name, binary_path, devmode)
script_path = _create_integration_script(shell_name, binary_path, devmode, pending_command)
fd_hook_path: str | None = None

try:
Expand Down
18 changes: 14 additions & 4 deletions src/shellgame/cli/templates/bash_integration.template
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,13 @@ shopt -s globasciiranges 2>/dev/null || true
declare -a SHELLGAME_BINARY_ARR
eval "SHELLGAME_BINARY_ARR=($binary_path)"

# Command forwarded from outside the game shell; empty for a plain launch.
# Python injects already-quoted tokens, which are valid bash source here, so
# this must NOT go through eval: the quoting would be parsed a second time and
# arguments containing quotes would lose them.
declare -a SHELLGAME_AUTOSTART_ARR
SHELLGAME_AUTOSTART_ARR=($autostart_args)

# Ensure SHELLGAME_FD_HOOK exists so scripts (e.g. buggy.sh) can invoke fd-hook directly
if [ -z "${SHELLGAME_FD_HOOK:-}" ] || [ ! -x "${SHELLGAME_FD_HOOK:-}" ]; then
_sg_fd_hook="$(mktemp "${TMPDIR:-/tmp}/sg-fd-hook-XXXXXX.sh" 2>/dev/null || mktemp /tmp/sg-fd-hook-XXXXXX.sh)"
Expand Down Expand Up @@ -162,7 +169,7 @@ _shellgame_completions()
COMPREPLY=()
cur="${COMP_WORDS[COMP_CWORD]}"

local commands="init hint submit skip status reset repeat show remove exit"
local commands="init hint submit skip status reset resume levels repeat show remove exit"

if [ "$COMP_CWORD" -eq 1 ]; then
if [[ "${cur}" == -* ]]; then
Expand Down Expand Up @@ -205,12 +212,15 @@ export -f shellgame
# This must be at the end so the shellgame function is already defined.
if [ -z "${SHELLGAME_AUTO_STARTED:-}" ]; then
export SHELLGAME_AUTO_STARTED=1
# A command typed outside the game shell (e.g. `shellgame resume 6.2`)
# cannot run before this shell exists, so Python injects it here as argv
# and it is replayed once the shell is up. Empty for a plain launch.
if [ "${SHELLGAME_SUBSHELL_DEBUG:-}" = "1" ]; then
echo "[shellgame][debug][bash] autostarting shellgame..." >&2
echo "[shellgame][debug][bash] autostarting shellgame ${SHELLGAME_AUTOSTART_ARR[*]:-}..." >&2
set -x
shellgame
shellgame ${SHELLGAME_AUTOSTART_ARR[@]+"${SHELLGAME_AUTOSTART_ARR[@]}"}
{ set +x; } 2>/dev/null || true
else
shellgame
shellgame ${SHELLGAME_AUTOSTART_ARR[@]+"${SHELLGAME_AUTOSTART_ARR[@]}"}
fi
fi
13 changes: 11 additions & 2 deletions src/shellgame/cli/templates/fish_integration.template
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@
# We set it without quotes so Fish parses it as a list.
set -gx SHELLGAME_BINARY $binary_path

# Command forwarded from outside the game shell; empty for a plain launch.
set -g SHELLGAME_AUTOSTART_LIST $autostart_args

# Ensure SHELLGAME_FD_HOOK exists so scripts (e.g. buggy.sh) can invoke fd-hook directly
if not set -q SHELLGAME_FD_HOOK; or not test -x "$SHELLGAME_FD_HOOK"
set -g _sg_fd_hook (mktemp -t sg-fd-hook-XXXXXX.sh 2>/dev/null; or mktemp /tmp/sg-fd-hook-XXXXXX.sh)
Expand Down Expand Up @@ -164,13 +167,16 @@ end
complete -c shellgame -f

# Root commands
complete -c shellgame -n "__fish_shellgame_needs_command" -a "init hint submit skip status reset repeat show remove exit"
complete -c shellgame -n "__fish_shellgame_needs_command" -a "init hint submit skip status reset resume levels repeat show remove exit"
complete -c shellgame -s h -l help -d "Zobrazit nápovědu"

# Options for hint
complete -c shellgame -n "__fish_seen_subcommand_from hint" -s r -l repeat -d "Zobrazit znovu všechny již zobrazené nápovědy"
complete -c shellgame -n "__fish_seen_subcommand_from hint" -s h -l help -d "Zobrazit nápovědu"

# Options for resume
complete -c shellgame -n "__fish_seen_subcommand_from resume" -s h -l help -d "Zobrazit nápovědu"

# Options for repeat
complete -c shellgame -n "__fish_seen_subcommand_from repeat" -l section -d "Číslo sekce ke zopakování"
complete -c shellgame -n "__fish_seen_subcommand_from repeat" -l level -d "ID levelu ke zopakování"
Expand All @@ -195,5 +201,8 @@ complete -c g -w shellgame
# This must be at the end so the shellgame function is already defined.
if test "$SHELLGAME_AUTO_STARTED" = "0"
set -gx SHELLGAME_AUTO_STARTED 1
shellgame
# A command typed outside the game shell (e.g. `shellgame resume 6.2`)
# cannot run before this shell exists, so Python injects it here as a list
# and it is replayed once the shell is up. Empty for a plain launch.
shellgame $SHELLGAME_AUTOSTART_LIST
end
Loading