Skip to content

Commit 3b8df3d

Browse files
feat(cli): add generator to build CLI command index from ebook markdown; prefer generated index in show/search; add tests and docs (#435)
1 parent 87aa2ef commit 3b8df3d

File tree

8 files changed

+1644
-67
lines changed

8 files changed

+1644
-67
lines changed

cli/README.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,21 @@ Here are some examples of how to use the CLI:
5858
linux-cli show ls
5959
```
6060

61+
## Generating the CLI command index
62+
63+
The CLI can consume a generated index of commands parsed from the ebook markdown. This keeps the CLI registry in sync with the eBook source.
64+
65+
To generate or refresh the index, run the generator from the `cli/` directory:
66+
67+
```powershell
68+
# from the repo root
69+
cd cli
70+
python scripts/generate_command_index.py
71+
```
72+
73+
That will create `cli/data/commands.json` which `show` and `search` will prefer when present. The test `tests/test_generate_index.py` exercises the generator.
74+
75+
6176
## Development
6277

6378
### Running Tests

cli/cli.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,15 +21,16 @@ def resolve_command(self, ctx: click.Context, args: List[str]):
2121

2222
if "No such command" in original:
2323
script_name = ctx.find_root().info_name or "cli"
24-
hint = f"💡 Hint: Run '{script_name} --help' to see available commands."
24+
# Keep hint ASCII-only to avoid UnicodeEncodeError on some consoles
25+
hint = f"Hint: Run '{script_name} --help' to see available commands."
2526

2627
new_message = f"{original}\n{hint}"
2728
raise click.exceptions.UsageError(new_message, ctx=ctx) from e
2829

2930
raise
3031

3132

32-
app = typer.Typer(help="101 Linux Commands CLI 🚀", cls=CustomTyper)
33+
app = typer.Typer(help="101 Linux Commands CLI", cls=CustomTyper)
3334
app.add_typer(hello.app, name="hello")
3435
app.add_typer(list.app, name="list")
3536
app.add_typer(version.app, name="version")
@@ -40,7 +41,7 @@ def resolve_command(self, ctx: click.Context, args: List[str]):
4041

4142
# Main callback to handle global options
4243
def main_callback(
43-
verbose: bool = typer.Option(False, "--verbose", help="Enable verbose output")
44+
verbose: bool = typer.Option(False, "--verbose", help="Enable verbose output"),
4445
) -> None:
4546
verbose_flag["enabled"] = verbose
4647
if verbose:

cli/commands/search.py

Lines changed: 24 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,36 @@
11
from typing import Dict, List
22

3+
import json
4+
from pathlib import Path
35
import typer
46

57
from states.global_state import debug, verbose_flag
68

7-
app = typer.Typer(
8-
help=("Search available commands by keyword " "(name or description).")
9-
)
9+
10+
app = typer.Typer(help=("Search available commands by keyword (name or description)."))
1011

1112

1213
def _get_available_commands() -> List[Dict[str, str]]:
1314
"""
14-
Mocked list of available commands.
15-
Replace with real registry/source when available.
15+
Return commands loaded from cli/data/commands.json if available, else fall back
16+
to the mocked in-code list.
1617
"""
18+
data_path = Path(__file__).parent.parent / "data" / "commands.json"
19+
if data_path.exists():
20+
try:
21+
data = json.loads(data_path.read_text(encoding="utf-8"))
22+
# normalize to list of dicts with name/description
23+
return [
24+
{
25+
"name": item.get("name", ""),
26+
"description": item.get("description", ""),
27+
}
28+
for item in data
29+
]
30+
except Exception:
31+
pass
32+
33+
# fallback mocked data
1734
return [
1835
{"name": "ls", "description": "List directory contents"},
1936
{"name": "grep", "description": "Search for PATTERN in files"},
@@ -27,13 +44,13 @@ def _get_available_commands() -> List[Dict[str, str]]:
2744
},
2845
{
2946
"name": "cat",
30-
"description": ("Concatenate files and print on the standard " "output"),
47+
"description": ("Concatenate files and print on the standard output"),
3148
},
3249
{"name": "head", "description": "Output the first part of files"},
3350
{"name": "tail", "description": "Output the last part of files"},
3451
{
3552
"name": "sed",
36-
"description": ("Stream editor for filtering and transforming " "text"),
53+
"description": ("Stream editor for filtering and transforming text"),
3754
},
3855
]
3956

cli/commands/show.py

Lines changed: 89 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
from typing import Dict, TypedDict
22

3+
import json
4+
from pathlib import Path
35
import typer
46

57
from states.global_state import debug, verbose_flag
@@ -12,63 +14,95 @@ class CommandInfo(TypedDict):
1214
notes: str
1315

1416

15-
COMMANDS: Dict[str, CommandInfo] = {
16-
"ls": {
17-
"description": (
18-
"List information about the files in the current directory (the default). "
19-
"Options can be used to modify the output format, sort order, and more."
20-
),
21-
"usage": "ls [OPTION]... [FILE]...",
22-
"example": "ls -la /var/log",
23-
"notes": (
24-
"- `-l` : use a long listing format\n"
25-
"- `-a` : show hidden files (starting with `.`)\n"
26-
"- `-h` : with -l, print sizes in human-readable format (e.g., 1K, 234M)"
27-
),
28-
},
29-
"grep": {
30-
"description": (
31-
"Search input files for lines containing a match to the given PATTERN. "
32-
"Often used for filtering log files or searching through code."
33-
),
34-
"usage": "grep [OPTION]... PATTERN [FILE]...",
35-
"example": 'grep -R "TODO" .',
36-
"notes": (
37-
"- `-i` : ignore case distinctions\n"
38-
"- `-R` : recursively search subdirectories\n"
39-
"- `-n` : show line numbers of matches"
40-
),
41-
},
42-
"cat": {
43-
"description": (
44-
"Concatenate files and print on the standard output. "
45-
"Often used to quickly view file contents."
46-
),
47-
"usage": "cat [OPTION]... [FILE]...",
48-
"example": "cat /etc/passwd",
49-
"notes": (
50-
"- `-n` : number all output lines\n"
51-
"- `-b` : number non-blank lines\n"
52-
"- Use with pipes: `cat file.txt | grep pattern`"
53-
),
54-
},
55-
"mkdir": {
56-
"description": (
57-
"Create directories if they do not already exist. "
58-
"By default, it creates a single directory."
59-
),
60-
"usage": "mkdir [OPTION]... DIRECTORY...",
61-
"example": "mkdir -p projects/python/app",
62-
"notes": (
63-
"- `-p` : create parent directories as needed\n"
64-
"- `-v` : print a message for each created directory"
65-
),
66-
},
67-
}
17+
def _load_commands_from_json() -> Dict[str, CommandInfo] | None:
18+
data_path = Path(__file__).parent.parent / "data" / "commands.json"
19+
if not data_path.exists():
20+
return None
21+
try:
22+
data = json.loads(data_path.read_text(encoding="utf-8"))
23+
result: Dict[str, CommandInfo] = {}
24+
for item in data:
25+
key = item.get("name")
26+
if not key:
27+
continue
28+
result[key] = {
29+
"description": item.get("description", ""),
30+
"usage": item.get("usage", ""),
31+
"example": item.get("example", ""),
32+
"notes": item.get("notes", ""),
33+
}
34+
return result
35+
except Exception:
36+
return None
37+
38+
39+
# Try to load a generated commands index, otherwise fall back to the baked-in dataset
40+
_LOADED_COMMANDS = _load_commands_from_json()
41+
42+
43+
COMMANDS: Dict[str, CommandInfo]
44+
45+
if _LOADED_COMMANDS is not None:
46+
COMMANDS = _LOADED_COMMANDS
47+
else:
48+
# fallback in-code registry
49+
COMMANDS = {
50+
"ls": {
51+
"description": (
52+
"List information about the files in the current directory (the default). "
53+
"Options can be used to modify the output format, sort order, and more."
54+
),
55+
"usage": "ls [OPTION]... [FILE]...",
56+
"example": "ls -la /var/log",
57+
"notes": (
58+
"- `-l` : use a long listing format\n"
59+
"- `-a` : show hidden files (starting with `.`)\n"
60+
"- `-h` : with -l, print sizes in human-readable format (e.g., 1K, 234M)"
61+
),
62+
},
63+
"grep": {
64+
"description": (
65+
"Search input files for lines containing a match to the given PATTERN. "
66+
"Often used for filtering log files or searching through code."
67+
),
68+
"usage": "grep [OPTION]... PATTERN [FILE]...",
69+
"example": 'grep -R "TODO" .',
70+
"notes": (
71+
"- `-i` : ignore case distinctions\n"
72+
"- `-R` : recursively search subdirectories\n"
73+
"- `-n` : show line numbers of matches"
74+
),
75+
},
76+
"cat": {
77+
"description": (
78+
"Concatenate files and print on the standard output. "
79+
"Often used to quickly view file contents."
80+
),
81+
"usage": "cat [OPTION]... [FILE]...",
82+
"example": "cat /etc/passwd",
83+
"notes": (
84+
"- `-n` : number all output lines\n"
85+
"- `-b` : number non-blank lines\n"
86+
"- Use with pipes: `cat file.txt | grep pattern`"
87+
),
88+
},
89+
"mkdir": {
90+
"description": (
91+
"Create directories if they do not already exist. "
92+
"By default, it creates a single directory."
93+
),
94+
"usage": "mkdir [OPTION]... DIRECTORY...",
95+
"example": "mkdir -p projects/python/app",
96+
"notes": (
97+
"- `-p` : create parent directories as needed\n"
98+
"- `-v` : print a message for each created directory"
99+
),
100+
},
101+
}
68102

69103

70104
def show(
71-
command: str = typer.Argument(..., help="Linux command to show details for")
105+
command: str = typer.Argument(..., help="Linux command to show details for"),
72106
) -> None:
73107
"""
74108
Display description, usage, examples, and notes for a given Linux command.
@@ -79,7 +113,7 @@ def show(
79113

80114
if not info:
81115
typer.secho(
82-
f"Unknown command '{command}'. Try one of: {', '.join(sorted(COMMANDS))}",
116+
f"Unknown command '{command}'. Try one of: {', '.join(sorted(COMMANDS))}",
83117
err=True,
84118
fg=typer.colors.RED,
85119
)

cli/commands/version.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,12 @@
55

66
import typer
77

8+
# Ensure parent package path is available before importing package-level metadata
9+
sys.path.insert(0, str(Path(__file__).parent.parent))
10+
811
from __version__ import __version__
912
from states.global_state import debug, verbose_flag
1013

11-
sys.path.insert(0, str(Path(__file__).parent.parent))
12-
1314
app = typer.Typer(help="version command")
1415

1516

0 commit comments

Comments
 (0)