Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
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
25 changes: 24 additions & 1 deletion cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,43 +25,66 @@ python -m cli.cli --help
## Commands

### Hello Command

```bash
linux-cli hello greet
linux-cli hello greet "Linux User"
linux-cli hello greet --verbose
```

### List Command

```bash
linux-cli list
linux-cli list --limit 5
linux-cli list --verbose
```

### Global Verbose Flag

```bash
linux-cli --verbose hello greet
linux-cli --verbose list --limit 5
```

## Development

### Running Tests

```bash
pytest
```

### Code Formatting

```bash
black cli/
```

### Import Sorting

```bash
isort cli/
```

### Linting

```bash
flake8 cli/
```

### Type Checking

```bash
mypy cli/
```

## GitHub Actions

The CLI has its own workflow that runs:

- Code formatting checks (Black)
- Import sorting checks (isort)
- Import sorting checks (isort)
- Linting (Flake8)
- Type checking (MyPy)
- Tests (pytest)
Expand Down
23 changes: 22 additions & 1 deletion cli/cli.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,33 @@
import typer

from commands import hello
from commands import hello, listing
from state import set_verbose

# Create the root CLI app
app = typer.Typer(help="101 Linux Commands CLI 🚀")


@app.callback()
def main(
ctx: typer.Context,
verbose: bool = typer.Option(
False,
"--verbose",
"-v",
is_flag=True,
help="Enable verbose debug output for all commands.",
),
) -> None:
"""Configure application-wide options before subcommands run."""

set_verbose(ctx, verbose)
if verbose:
typer.echo("[verbose] Verbose mode enabled", err=True)


# Register subcommands
app.add_typer(hello.app, name="hello")
app.add_typer(listing.app, name="list")

if __name__ == "__main__":
app()
22 changes: 21 additions & 1 deletion cli/commands/hello.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,29 @@
import typer

from state import set_verbose, verbose_active


app = typer.Typer(help="Hello command group")


@app.command()
def greet(name: str = "World"):
def greet(
ctx: typer.Context,
name: str = typer.Option("World", "--name", "-n", help="Name to greet."),
verbose: bool = typer.Option(
False,
"--verbose",
"-v",
is_flag=True,
help="Enable verbose output for this command.",
),
) -> None:
"""Say hello to someone."""

if verbose:
set_verbose(ctx, True)

if verbose_active(ctx):
typer.echo(f"[verbose] Preparing greeting for {name}", err=True)

typer.echo(f"Hello, {name}!")
74 changes: 74 additions & 0 deletions cli/commands/listing.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
"""Command utilities for listing available lessons."""

from __future__ import annotations

from pathlib import Path
from typing import Iterable, Optional

import typer

from state import set_verbose, verbose_active

app = typer.Typer(help="List available Linux command lessons.")

_CONTENT_DIR = Path(__file__).resolve().parents[2] / "ebook" / "en" / "content"


def _get_lessons() -> Iterable[Path]:
if not _CONTENT_DIR.exists():
return []
return sorted(_CONTENT_DIR.glob("*.md"))


def _format_title(path: Path) -> str:
stem = path.stem
prefix, _, slug = stem.partition("-")
title = slug.replace("-", " ").strip().title() if slug else prefix.replace("-", " ")
Comment on lines +23 to +26
Copy link

Copilot AI Oct 1, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This line contains duplicated logic for replacing dashes with spaces. Extract this transformation into a variable or helper function to avoid repetition.

Suggested change
def _format_title(path: Path) -> str:
stem = path.stem
prefix, _, slug = stem.partition("-")
title = slug.replace("-", " ").strip().title() if slug else prefix.replace("-", " ")
def _dashes_to_spaces(s: str) -> str:
return s.replace("-", " ")
def _format_title(path: Path) -> str:
stem = path.stem
prefix, _, slug = stem.partition("-")
title = _dashes_to_spaces(slug).strip().title() if slug else _dashes_to_spaces(prefix)

Copilot uses AI. Check for mistakes.
if prefix.isdigit():
return f"{prefix} {title}".strip()
return title


Comment on lines +23 to +31
Copy link

Copilot AI Oct 1, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nitpick] The title formatting logic is complex and handles multiple cases in a single function. Consider splitting this into separate functions for prefix extraction and title formatting to improve readability and maintainability.

Suggested change
def _format_title(path: Path) -> str:
stem = path.stem
prefix, _, slug = stem.partition("-")
title = slug.replace("-", " ").strip().title() if slug else prefix.replace("-", " ")
if prefix.isdigit():
return f"{prefix} {title}".strip()
return title
def _extract_prefix_and_slug(stem: str) -> tuple[str, str]:
prefix, _, slug = stem.partition("-")
return prefix, slug
def _format_title_from_parts(prefix: str, slug: str) -> str:
title = slug.replace("-", " ").strip().title() if slug else prefix.replace("-", " ")
if prefix.isdigit():
return f"{prefix} {title}".strip()
return title
def _format_title(path: Path) -> str:
stem = path.stem
prefix, slug = _extract_prefix_and_slug(stem)
return _format_title_from_parts(prefix, slug)

Copilot uses AI. Check for mistakes.
@app.callback(invoke_without_command=True)
def list_commands(
ctx: typer.Context,
limit: Optional[int] = typer.Option(
None,
"--limit",
"-l",
min=1,
help="Limit the number of commands displayed. Shows all when omitted.",
),
verbose: bool = typer.Option(
False,
"--verbose",
"-v",
is_flag=True,
help="Enable verbose output for this command.",
),
) -> None:
"""Display the available Linux command lessons."""

if verbose:
set_verbose(ctx, True)

lessons = list(_get_lessons())
total = len(lessons)

if verbose_active(ctx):
typer.echo(f"[verbose] Located {total} command lessons", err=True)

if total == 0:
typer.echo("No command lessons found.")
return

limit_value = total if limit is None else min(limit, total)

if verbose_active(ctx) and limit is not None:
typer.echo(f"[verbose] Limiting output to {limit_value} entries", err=True)

for path in lessons[:limit_value]:
typer.echo(_format_title(path))


__all__ = ["app"]
29 changes: 29 additions & 0 deletions cli/state.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
"""Shared CLI state helpers for global flags."""

from __future__ import annotations

import typer

_VERBOSE_KEY = "verbose"


def set_verbose(ctx: typer.Context, value: bool) -> None:
"""Persist the verbose flag on this context and all parents."""
current = ctx
while current is not None:
current.ensure_object(dict)
current.obj[_VERBOSE_KEY] = value
current = current.parent


def verbose_active(ctx: typer.Context) -> bool:
"""Check whether verbose mode is enabled anywhere up the chain."""
current = ctx
while current is not None:
if current.obj and current.obj.get(_VERBOSE_KEY):
return True
current = current.parent
return False


__all__ = ["set_verbose", "verbose_active"]
87 changes: 64 additions & 23 deletions cli/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,59 +4,100 @@
import os
import subprocess
import sys
from pathlib import Path


def test_cli_help():
"""Test that the CLI shows help."""
result = subprocess.run(
[sys.executable, "cli.py", "--help"],
CLI_DIR = Path(__file__).parent
CLI_ENV = {**os.environ, "PYTHONIOENCODING": "utf-8"}


def run_cli(*args: str) -> subprocess.CompletedProcess[str]:
return subprocess.run(
[sys.executable, "cli.py", *args],
capture_output=True,
text=True,
cwd=os.path.dirname(__file__),
encoding="utf-8",
cwd=CLI_DIR,
env=CLI_ENV,
)


def test_cli_help():
"""Test that the CLI shows help."""
result = run_cli("--help")
assert result.returncode == 0
assert "101 Linux Commands CLI" in result.stdout


def test_hello_command():
"""Test the hello command."""
result = subprocess.run(
[sys.executable, "cli.py", "hello", "greet"],
capture_output=True,
text=True,
cwd=os.path.dirname(__file__),
)
result = run_cli("hello", "greet")
assert result.returncode == 0
assert "Hello, World!" in result.stdout


def test_hello_command_with_name():
"""Test the hello command with a custom name."""
result = subprocess.run(
[sys.executable, "cli.py", "hello", "greet", "--name", "Linux"],
capture_output=True,
text=True,
cwd=os.path.dirname(__file__),
)
result = run_cli("hello", "greet", "--name", "Linux")
assert result.returncode == 0
assert "Hello, Linux!" in result.stdout


def test_hello_command_verbose_option():
"""Command-level verbose flag should emit debug output."""
result = run_cli("hello", "greet", "--verbose")
assert result.returncode == 0
assert "Hello, World!" in result.stdout
assert "[verbose]" in result.stderr


def test_global_verbose_flag():
"""Global verbose flag should cascade to subcommands."""
result = run_cli("--verbose", "hello", "greet", "--name", "Tester")
assert result.returncode == 0
assert "Hello, Tester!" in result.stdout
assert "[verbose] Verbose mode enabled" in result.stderr
assert "[verbose] Preparing greeting for Tester" in result.stderr


def test_hello_help():
"""Test the hello command help."""
result = subprocess.run(
[sys.executable, "cli.py", "hello", "--help"],
capture_output=True,
text=True,
cwd=os.path.dirname(__file__),
)
result = run_cli("hello", "--help")
assert result.returncode == 0
assert "Hello command group" in result.stdout


def test_list_command_basic():
"""List command should show lesson titles."""
result = run_cli("list", "--limit", "3")
assert result.returncode == 0
assert "000" in result.stdout
assert "Introduction" in result.stdout


def test_list_command_verbose_option():
"""Command-level verbose flag should emit debug output."""
result = run_cli("list", "--limit", "2", "--verbose")
assert result.returncode == 0
assert "[verbose] Located" in result.stderr


def test_list_command_global_verbose():
"""Global verbose flag should cascade to list command."""
result = run_cli("--verbose", "list", "--limit", "1")
assert result.returncode == 0
assert "[verbose] Verbose mode enabled" in result.stderr
assert "[verbose] Located" in result.stderr


if __name__ == "__main__":
test_cli_help()
test_hello_command()
test_hello_command_with_name()
test_hello_command_verbose_option()
test_global_verbose_flag()
test_hello_help()
test_list_command_basic()
test_list_command_verbose_option()
test_list_command_global_verbose()
print("✅ All tests passed!")
Loading