nlsh-cli 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- nlsh/__init__.py +8 -0
- nlsh/_version.py +24 -0
- nlsh/cli.py +349 -0
- nlsh/config.py +114 -0
- nlsh/llm.py +131 -0
- nlsh_cli-0.1.0.dist-info/METADATA +144 -0
- nlsh_cli-0.1.0.dist-info/RECORD +9 -0
- nlsh_cli-0.1.0.dist-info/WHEEL +4 -0
- nlsh_cli-0.1.0.dist-info/entry_points.txt +2 -0
nlsh/__init__.py
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""nlsh — natural language to shell command CLI."""
|
|
2
|
+
|
|
3
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
4
|
+
|
|
5
|
+
try:
|
|
6
|
+
__version__ = version("nlsh-cli") # PyPI distribution name (command is `nlsh`)
|
|
7
|
+
except PackageNotFoundError: # not installed (e.g. running from a source tree)
|
|
8
|
+
__version__ = "0+unknown"
|
nlsh/_version.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# file generated by vcs-versioning
|
|
2
|
+
# don't change, don't track in version control
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
"__version__",
|
|
7
|
+
"__version_tuple__",
|
|
8
|
+
"version",
|
|
9
|
+
"version_tuple",
|
|
10
|
+
"__commit_id__",
|
|
11
|
+
"commit_id",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
version: str
|
|
15
|
+
__version__: str
|
|
16
|
+
__version_tuple__: tuple[int | str, ...]
|
|
17
|
+
version_tuple: tuple[int | str, ...]
|
|
18
|
+
commit_id: str | None
|
|
19
|
+
__commit_id__: str | None
|
|
20
|
+
|
|
21
|
+
__version__ = version = '0.1.0'
|
|
22
|
+
__version_tuple__ = version_tuple = (0, 1, 0)
|
|
23
|
+
|
|
24
|
+
__commit_id__ = commit_id = None
|
nlsh/cli.py
ADDED
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
"""Command-line interface for nlsh."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import platform
|
|
7
|
+
import re
|
|
8
|
+
import subprocess
|
|
9
|
+
import sys
|
|
10
|
+
from typing import List, Optional
|
|
11
|
+
|
|
12
|
+
import typer
|
|
13
|
+
from rich.console import Console
|
|
14
|
+
from rich.panel import Panel
|
|
15
|
+
from rich.prompt import Prompt
|
|
16
|
+
from rich.text import Text
|
|
17
|
+
|
|
18
|
+
from . import __version__
|
|
19
|
+
from .config import (
|
|
20
|
+
PRO_MODEL,
|
|
21
|
+
config_path,
|
|
22
|
+
load_config,
|
|
23
|
+
save_config,
|
|
24
|
+
)
|
|
25
|
+
from .llm import LLMError, Suggestion, suggest_command
|
|
26
|
+
|
|
27
|
+
run_app = typer.Typer(
|
|
28
|
+
add_completion=False,
|
|
29
|
+
help="Describe what you want in natural language; nlsh proposes a shell "
|
|
30
|
+
"command and asks before running it. Run `nlsh config set-key` to store "
|
|
31
|
+
"your API key.",
|
|
32
|
+
)
|
|
33
|
+
config_app = typer.Typer(
|
|
34
|
+
add_completion=False,
|
|
35
|
+
no_args_is_help=True,
|
|
36
|
+
help="Manage nlsh configuration (~/.config/nlsh/config.toml).",
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
console = Console()
|
|
40
|
+
err_console = Console(stderr=True)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
# --------------------------------------------------------------------------- #
|
|
44
|
+
# Dangerous-command detection
|
|
45
|
+
# --------------------------------------------------------------------------- #
|
|
46
|
+
# Each entry: (compiled pattern, human-readable reason). A match means the
|
|
47
|
+
# command needs an explicit typed "yes" before it runs.
|
|
48
|
+
_RISK_PATTERNS = [
|
|
49
|
+
(re.compile(r"\brm\s+-\w*[rf]\w*\s+(/|/\*|~|\$HOME)(\s|/|$)"),
|
|
50
|
+
"递归删除根目录或家目录 (rm -rf /)"),
|
|
51
|
+
(re.compile(r"\brm\s+-\w*[rf]"),
|
|
52
|
+
"强制/递归删除文件,可能不可恢复 (rm -rf)"),
|
|
53
|
+
(re.compile(r"\bdd\b[^|;&]*\bof=/dev/"),
|
|
54
|
+
"用 dd 直接写入磁盘设备,会覆盖数据"),
|
|
55
|
+
(re.compile(r"\bmkfs\b"),
|
|
56
|
+
"格式化文件系统 (mkfs),会清空目标"),
|
|
57
|
+
(re.compile(r">\s*/dev/(sd|nvme|hd|vd|disk|mmcblk)"),
|
|
58
|
+
"重定向写入块设备,会破坏磁盘"),
|
|
59
|
+
(re.compile(r":\s*\(\s*\)\s*\{"),
|
|
60
|
+
"疑似 fork 炸弹,会耗尽系统资源"),
|
|
61
|
+
(re.compile(r"\bchmod\s+-R\b"),
|
|
62
|
+
"递归修改权限 (chmod -R),影响面大"),
|
|
63
|
+
(re.compile(r"\bchown\s+-R\b"),
|
|
64
|
+
"递归修改属主 (chown -R),影响面大"),
|
|
65
|
+
(re.compile(r"\b(curl|wget)\b[^|]*\|\s*(sudo\s+)?(ba)?sh\b"),
|
|
66
|
+
"下载脚本直接执行 (curl | sh),来源不可信"),
|
|
67
|
+
(re.compile(r"\b(shutdown|reboot|halt|poweroff)\b|\binit\s+0\b"),
|
|
68
|
+
"关机/重启系统"),
|
|
69
|
+
(re.compile(r"\bsudo\b"),
|
|
70
|
+
"以 root 权限执行"),
|
|
71
|
+
]
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def risk_check(command: str) -> List[str]:
|
|
75
|
+
"""Return a list of reasons the command looks dangerous (empty if safe)."""
|
|
76
|
+
return [reason for pattern, reason in _RISK_PATTERNS if pattern.search(command)]
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
# --------------------------------------------------------------------------- #
|
|
80
|
+
# Environment detection
|
|
81
|
+
# --------------------------------------------------------------------------- #
|
|
82
|
+
def _detect_shell() -> str:
|
|
83
|
+
shell = os.environ.get("SHELL", "")
|
|
84
|
+
return os.path.basename(shell) if shell else "sh"
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _detect_os() -> str:
|
|
88
|
+
system = platform.system()
|
|
89
|
+
if system == "Linux":
|
|
90
|
+
# Add distro hint when available for better command targeting.
|
|
91
|
+
try:
|
|
92
|
+
with open("/etc/os-release", encoding="utf-8") as fh:
|
|
93
|
+
for line in fh:
|
|
94
|
+
if line.startswith("PRETTY_NAME="):
|
|
95
|
+
name = line.split("=", 1)[1].strip().strip('"')
|
|
96
|
+
return f"Linux ({name})"
|
|
97
|
+
except OSError:
|
|
98
|
+
pass
|
|
99
|
+
return "Linux"
|
|
100
|
+
if system == "Darwin":
|
|
101
|
+
return f"macOS {platform.mac_ver()[0]}".strip()
|
|
102
|
+
return system or "unknown"
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _version_callback(value: bool) -> None:
|
|
106
|
+
if value:
|
|
107
|
+
console.print(f"nlsh {__version__}")
|
|
108
|
+
raise typer.Exit()
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _run_command(command: str) -> int:
|
|
112
|
+
"""Run the command in the user's shell, inheriting the terminal."""
|
|
113
|
+
shell_path = os.environ.get("SHELL") or "/bin/sh"
|
|
114
|
+
try:
|
|
115
|
+
completed = subprocess.run([shell_path, "-c", command])
|
|
116
|
+
return completed.returncode
|
|
117
|
+
except KeyboardInterrupt:
|
|
118
|
+
return 130
|
|
119
|
+
except OSError as exc:
|
|
120
|
+
err_console.print(f"[red]Failed to run command:[/red] {exc}")
|
|
121
|
+
return 1
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
# --------------------------------------------------------------------------- #
|
|
125
|
+
# Rendering
|
|
126
|
+
# --------------------------------------------------------------------------- #
|
|
127
|
+
def _render_suggestion(suggestion: Suggestion, command: str) -> None:
|
|
128
|
+
body = Text()
|
|
129
|
+
body.append("$ ", style="dim")
|
|
130
|
+
body.append(command, style="bold cyan")
|
|
131
|
+
if suggestion.explanation:
|
|
132
|
+
body.append("\n\n")
|
|
133
|
+
body.append(suggestion.explanation, style="dim")
|
|
134
|
+
console.print(
|
|
135
|
+
Panel(body, title="proposed command", border_style="cyan", expand=False)
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
if len(suggestion.steps) > 1:
|
|
139
|
+
steps = Text()
|
|
140
|
+
for i, (part, explain) in enumerate(suggestion.steps, 1):
|
|
141
|
+
steps.append(f"{i}. ", style="bold")
|
|
142
|
+
steps.append(part, style="cyan")
|
|
143
|
+
if explain:
|
|
144
|
+
steps.append(f"\n {explain}", style="dim")
|
|
145
|
+
if i < len(suggestion.steps):
|
|
146
|
+
steps.append("\n")
|
|
147
|
+
console.print(Panel(steps, title="步骤分解", border_style="dim", expand=False))
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _render_risks(risks: List[str]) -> None:
|
|
151
|
+
warn = Text()
|
|
152
|
+
warn.append("⚠ 危险操作检测\n", style="bold red")
|
|
153
|
+
for reason in risks:
|
|
154
|
+
warn.append(f" • {reason}\n", style="red")
|
|
155
|
+
console.print(Panel(warn, border_style="red", expand=False))
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _confirm_dangerous() -> bool:
|
|
159
|
+
"""Require the user to type the full word 'yes'."""
|
|
160
|
+
typed = Prompt.ask(
|
|
161
|
+
"[bold red]此命令被标记为危险,输入完整的 'yes' 才执行[/bold red]",
|
|
162
|
+
default="",
|
|
163
|
+
show_default=False,
|
|
164
|
+
)
|
|
165
|
+
return typed.strip() == "yes"
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
# --------------------------------------------------------------------------- #
|
|
169
|
+
# Main run command (default, no subcommand name)
|
|
170
|
+
# --------------------------------------------------------------------------- #
|
|
171
|
+
@run_app.command()
|
|
172
|
+
def run(
|
|
173
|
+
query: Optional[List[str]] = typer.Argument(
|
|
174
|
+
None, help="Natural language description of what you want to do."
|
|
175
|
+
),
|
|
176
|
+
pro: bool = typer.Option(
|
|
177
|
+
False, "--pro", help=f"Use the stronger model ({PRO_MODEL}) for this request."
|
|
178
|
+
),
|
|
179
|
+
model: Optional[str] = typer.Option(
|
|
180
|
+
None, "--model", "-m", help="Override the model id for this request."
|
|
181
|
+
),
|
|
182
|
+
yes: bool = typer.Option(
|
|
183
|
+
False, "--yes", "-y", help="Run without confirmation (dangerous commands still prompt)."
|
|
184
|
+
),
|
|
185
|
+
dry_run: bool = typer.Option(
|
|
186
|
+
False, "--dry-run", "-n", help="Only print the command; never run it."
|
|
187
|
+
),
|
|
188
|
+
version: bool = typer.Option(
|
|
189
|
+
False, "--version", callback=_version_callback, is_eager=True,
|
|
190
|
+
help="Show version and exit."
|
|
191
|
+
),
|
|
192
|
+
) -> None:
|
|
193
|
+
"""Translate QUERY into a shell command and confirm before running."""
|
|
194
|
+
if not query:
|
|
195
|
+
err_console.print(
|
|
196
|
+
"[yellow]Usage:[/yellow] nlsh <what you want to do>\n"
|
|
197
|
+
"Example: nlsh find the 5 largest files under this directory\n"
|
|
198
|
+
"First time? Run: nlsh config set-key"
|
|
199
|
+
)
|
|
200
|
+
raise typer.Exit(code=2)
|
|
201
|
+
|
|
202
|
+
request = " ".join(query).strip()
|
|
203
|
+
cfg = load_config()
|
|
204
|
+
if not cfg.has_key:
|
|
205
|
+
err_console.print(
|
|
206
|
+
"[red]No DeepSeek API key found.[/red]\n"
|
|
207
|
+
"Set one with: [bold]nlsh config set-key[/bold]\n"
|
|
208
|
+
"or: [bold]export DEEPSEEK_API_KEY=sk-...[/bold]"
|
|
209
|
+
)
|
|
210
|
+
raise typer.Exit(code=1)
|
|
211
|
+
|
|
212
|
+
# Model override precedence: --model > --pro > configured default.
|
|
213
|
+
if model:
|
|
214
|
+
cfg.model = model
|
|
215
|
+
elif pro:
|
|
216
|
+
cfg.model = PRO_MODEL
|
|
217
|
+
|
|
218
|
+
shell = _detect_shell()
|
|
219
|
+
os_name = _detect_os()
|
|
220
|
+
cwd = os.getcwd()
|
|
221
|
+
|
|
222
|
+
try:
|
|
223
|
+
with console.status(
|
|
224
|
+
f"[dim]Asking DeepSeek ({cfg.model})…[/dim]", spinner="dots"
|
|
225
|
+
):
|
|
226
|
+
suggestion = suggest_command(
|
|
227
|
+
request, cfg, os_name=os_name, shell=shell, cwd=cwd
|
|
228
|
+
)
|
|
229
|
+
except LLMError as exc:
|
|
230
|
+
err_console.print(f"[red]Error:[/red] {exc}")
|
|
231
|
+
raise typer.Exit(code=1)
|
|
232
|
+
|
|
233
|
+
if not suggestion.command:
|
|
234
|
+
msg = suggestion.explanation or "无法为该请求生成命令。"
|
|
235
|
+
console.print(
|
|
236
|
+
Panel(Text(msg, style="yellow"), title="no command",
|
|
237
|
+
border_style="yellow", expand=False)
|
|
238
|
+
)
|
|
239
|
+
raise typer.Exit(code=1)
|
|
240
|
+
|
|
241
|
+
command = suggestion.command
|
|
242
|
+
_render_suggestion(suggestion, command)
|
|
243
|
+
|
|
244
|
+
if dry_run:
|
|
245
|
+
return
|
|
246
|
+
|
|
247
|
+
# --yes auto-runs only safe commands; dangerous ones still require a typed yes.
|
|
248
|
+
if yes:
|
|
249
|
+
risks = risk_check(command)
|
|
250
|
+
if risks:
|
|
251
|
+
_render_risks(risks)
|
|
252
|
+
if not _confirm_dangerous():
|
|
253
|
+
console.print("[dim]Cancelled.[/dim]")
|
|
254
|
+
raise typer.Exit(code=130)
|
|
255
|
+
raise typer.Exit(code=_run_command(command))
|
|
256
|
+
|
|
257
|
+
# Interactive confirm loop (re-checks risk after an edit).
|
|
258
|
+
while True:
|
|
259
|
+
risks = risk_check(command)
|
|
260
|
+
if risks:
|
|
261
|
+
_render_risks(risks)
|
|
262
|
+
|
|
263
|
+
choice = Prompt.ask(
|
|
264
|
+
"[bold]Run it?[/bold]", choices=["y", "n", "e"], default="n"
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
if choice == "n":
|
|
268
|
+
console.print("[dim]Cancelled.[/dim]")
|
|
269
|
+
raise typer.Exit(code=130)
|
|
270
|
+
|
|
271
|
+
if choice == "e":
|
|
272
|
+
edited = None
|
|
273
|
+
try:
|
|
274
|
+
edited = typer.edit(command + "\n")
|
|
275
|
+
except Exception: # editor not available
|
|
276
|
+
edited = None
|
|
277
|
+
if edited is None:
|
|
278
|
+
edited = Prompt.ask("Edit command", default=command)
|
|
279
|
+
command = edited.strip()
|
|
280
|
+
if not command:
|
|
281
|
+
console.print("[dim]Empty command, cancelled.[/dim]")
|
|
282
|
+
raise typer.Exit(code=130)
|
|
283
|
+
console.print(f"[dim]$ {command}[/dim]")
|
|
284
|
+
continue # re-loop: re-check risk and re-prompt on the edited command
|
|
285
|
+
|
|
286
|
+
# choice == "y"
|
|
287
|
+
if risks and not _confirm_dangerous():
|
|
288
|
+
console.print("[dim]Cancelled.[/dim]")
|
|
289
|
+
raise typer.Exit(code=130)
|
|
290
|
+
break
|
|
291
|
+
|
|
292
|
+
raise typer.Exit(code=_run_command(command))
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
# --------------------------------------------------------------------------- #
|
|
296
|
+
# `nlsh config ...` subcommands
|
|
297
|
+
# --------------------------------------------------------------------------- #
|
|
298
|
+
@config_app.command("set-key")
|
|
299
|
+
def config_set_key(
|
|
300
|
+
model: Optional[str] = typer.Option(
|
|
301
|
+
None, "--model", "-m", help="Also set the default model."
|
|
302
|
+
),
|
|
303
|
+
) -> None:
|
|
304
|
+
"""Store your DeepSeek API key in the config file (mode 0600)."""
|
|
305
|
+
key = Prompt.ask("DeepSeek API key", password=True)
|
|
306
|
+
key = (key or "").strip()
|
|
307
|
+
if not key:
|
|
308
|
+
err_console.print("[yellow]No key entered, nothing changed.[/yellow]")
|
|
309
|
+
raise typer.Exit(code=1)
|
|
310
|
+
path = save_config({"api_key": key, "model": model})
|
|
311
|
+
console.print(f"[green]Saved[/green] to {path}")
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
@config_app.command("set-model")
|
|
315
|
+
def config_set_model(
|
|
316
|
+
model: str = typer.Argument(..., help="Model id, e.g. deepseek-v4-pro."),
|
|
317
|
+
) -> None:
|
|
318
|
+
"""Set the default model in the config file."""
|
|
319
|
+
path = save_config({"model": model})
|
|
320
|
+
console.print(f"[green]Saved[/green] model = {model} ({path})")
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
@config_app.command("show")
|
|
324
|
+
def config_show() -> None:
|
|
325
|
+
"""Show the resolved configuration (the API key is masked)."""
|
|
326
|
+
cfg = load_config()
|
|
327
|
+
masked = "—"
|
|
328
|
+
if cfg.api_key:
|
|
329
|
+
k = cfg.api_key
|
|
330
|
+
masked = f"{k[:4]}…{k[-4:]}" if len(k) > 8 else "set"
|
|
331
|
+
console.print(f"config file : {config_path()}")
|
|
332
|
+
console.print(f"api_key : {masked}")
|
|
333
|
+
console.print(f"base_url : {cfg.base_url}")
|
|
334
|
+
console.print(f"model : {cfg.model}")
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
# --------------------------------------------------------------------------- #
|
|
338
|
+
# Entry point: route `nlsh config ...` to config_app, everything else to run.
|
|
339
|
+
# --------------------------------------------------------------------------- #
|
|
340
|
+
def app() -> None:
|
|
341
|
+
argv = sys.argv[1:]
|
|
342
|
+
if argv and argv[0] == "config":
|
|
343
|
+
config_app(args=argv[1:], prog_name="nlsh config")
|
|
344
|
+
else:
|
|
345
|
+
run_app(args=argv, prog_name="nlsh")
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
if __name__ == "__main__": # pragma: no cover
|
|
349
|
+
app()
|
nlsh/config.py
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""Configuration loading for nlsh.
|
|
2
|
+
|
|
3
|
+
Resolution order for every setting:
|
|
4
|
+
1. Environment variable
|
|
5
|
+
2. Config file (~/.config/nlsh/config.toml or $NLSH_CONFIG)
|
|
6
|
+
3. Built-in default
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
try: # Python 3.11+
|
|
16
|
+
import tomllib
|
|
17
|
+
except ModuleNotFoundError: # pragma: no cover - fallback for 3.9/3.10
|
|
18
|
+
tomllib = None
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
DEFAULT_BASE_URL = "https://api.deepseek.com"
|
|
22
|
+
# deepseek-chat / deepseek-reasoner are deprecated on 2026-07-24;
|
|
23
|
+
# v4 models are the current generation (…-flash is the cheaper/faster tier).
|
|
24
|
+
FLASH_MODEL = "deepseek-v4-flash"
|
|
25
|
+
PRO_MODEL = "deepseek-v4-pro"
|
|
26
|
+
DEFAULT_MODEL = FLASH_MODEL
|
|
27
|
+
|
|
28
|
+
# Keys we own in the config file and are allowed to (re)write.
|
|
29
|
+
WRITABLE_KEYS = ("api_key", "base_url", "model")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def config_path() -> Path:
|
|
33
|
+
override = os.environ.get("NLSH_CONFIG")
|
|
34
|
+
if override:
|
|
35
|
+
return Path(override).expanduser()
|
|
36
|
+
base = os.environ.get("XDG_CONFIG_HOME", "~/.config")
|
|
37
|
+
return Path(base).expanduser() / "nlsh" / "config.toml"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _load_file() -> dict:
|
|
41
|
+
path = config_path()
|
|
42
|
+
if not path.is_file() or tomllib is None:
|
|
43
|
+
return {}
|
|
44
|
+
try:
|
|
45
|
+
with path.open("rb") as fh:
|
|
46
|
+
data = tomllib.load(fh)
|
|
47
|
+
except (OSError, ValueError):
|
|
48
|
+
return {}
|
|
49
|
+
# Accept either top-level keys or a [deepseek] table.
|
|
50
|
+
section = data.get("deepseek", {})
|
|
51
|
+
merged = {**data, **section}
|
|
52
|
+
return merged
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass
|
|
56
|
+
class Config:
|
|
57
|
+
api_key: str | None
|
|
58
|
+
base_url: str
|
|
59
|
+
model: str
|
|
60
|
+
|
|
61
|
+
@property
|
|
62
|
+
def has_key(self) -> bool:
|
|
63
|
+
return bool(self.api_key)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def load_config() -> Config:
|
|
67
|
+
file_cfg = _load_file()
|
|
68
|
+
api_key = (
|
|
69
|
+
os.environ.get("DEEPSEEK_API_KEY")
|
|
70
|
+
or os.environ.get("NLSH_API_KEY")
|
|
71
|
+
or file_cfg.get("api_key")
|
|
72
|
+
)
|
|
73
|
+
base_url = (
|
|
74
|
+
os.environ.get("DEEPSEEK_BASE_URL")
|
|
75
|
+
or file_cfg.get("base_url")
|
|
76
|
+
or DEFAULT_BASE_URL
|
|
77
|
+
)
|
|
78
|
+
model = (
|
|
79
|
+
os.environ.get("NLSH_MODEL")
|
|
80
|
+
or file_cfg.get("model")
|
|
81
|
+
or DEFAULT_MODEL
|
|
82
|
+
)
|
|
83
|
+
return Config(api_key=api_key, base_url=base_url.rstrip("/"), model=model)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _toml_escape(value: str) -> str:
|
|
87
|
+
return value.replace("\\", "\\\\").replace('"', '\\"')
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def save_config(updates: dict) -> Path:
|
|
91
|
+
"""Merge `updates` into the config file and write it back (mode 0600).
|
|
92
|
+
|
|
93
|
+
Only WRITABLE_KEYS are persisted; values of None are ignored. The file is
|
|
94
|
+
rewritten as flat top-level keys, which the loader also accepts.
|
|
95
|
+
"""
|
|
96
|
+
path = config_path()
|
|
97
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
98
|
+
|
|
99
|
+
current = {k: v for k, v in _load_file().items() if k in WRITABLE_KEYS}
|
|
100
|
+
for key, value in updates.items():
|
|
101
|
+
if key in WRITABLE_KEYS and value is not None:
|
|
102
|
+
current[key] = value
|
|
103
|
+
|
|
104
|
+
lines = ["# nlsh configuration\n"]
|
|
105
|
+
for key in WRITABLE_KEYS:
|
|
106
|
+
value = current.get(key)
|
|
107
|
+
if value:
|
|
108
|
+
lines.append(f'{key} = "{_toml_escape(str(value))}"\n')
|
|
109
|
+
path.write_text("".join(lines), encoding="utf-8")
|
|
110
|
+
try:
|
|
111
|
+
path.chmod(0o600)
|
|
112
|
+
except OSError:
|
|
113
|
+
pass
|
|
114
|
+
return path
|
nlsh/llm.py
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""DeepSeek client: turn a natural language request into a shell command.
|
|
2
|
+
|
|
3
|
+
The DeepSeek API is OpenAI-compatible, so this is a single chat-completions
|
|
4
|
+
call constrained to JSON output.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from typing import List, Tuple
|
|
12
|
+
|
|
13
|
+
import httpx
|
|
14
|
+
|
|
15
|
+
from .config import Config
|
|
16
|
+
|
|
17
|
+
SYSTEM_PROMPT = """\
|
|
18
|
+
You are nlsh, a tool that translates a user's natural language request into a \
|
|
19
|
+
single shell command for their terminal.
|
|
20
|
+
|
|
21
|
+
Rules:
|
|
22
|
+
- ALWAYS return exactly ONE concrete, runnable command. Never reply with a
|
|
23
|
+
question and never refuse just because a detail (filename, path, pattern) is
|
|
24
|
+
missing.
|
|
25
|
+
- When a value is unknown, fill in a clear placeholder the user can edit, such
|
|
26
|
+
as path/to/folder, FILENAME, or "pattern". Do NOT use angle brackets <...> or
|
|
27
|
+
other shell metacharacters in placeholders (they would break the shell). Say
|
|
28
|
+
in the explanation what to replace.
|
|
29
|
+
- Map the intent faithfully: "强制"/"force" -> add -f; "递归"/"recursive" -> -r;
|
|
30
|
+
and so on. Chain stages with pipes, && or ; when needed.
|
|
31
|
+
- Target the user's OS and shell given in the context.
|
|
32
|
+
- Prefer safe, idiomatic, widely-available commands. Do not invent flags.
|
|
33
|
+
- Do NOT wrap the command in markdown fences or backticks.
|
|
34
|
+
- Keep "explanation" to one short sentence in the user's language.
|
|
35
|
+
- If the command has multiple stages (joined by pipes |, && or ;), break it
|
|
36
|
+
down: list each stage in "steps" with the exact segment and what it does.
|
|
37
|
+
For a single simple command, return an empty "steps" array.
|
|
38
|
+
- Only set "command" to an empty string if the request genuinely cannot be a
|
|
39
|
+
shell command at all (not merely under-specified); then explain why.
|
|
40
|
+
|
|
41
|
+
Respond ONLY with a JSON object of the form:
|
|
42
|
+
{
|
|
43
|
+
"command": "<the command>",
|
|
44
|
+
"explanation": "<one short sentence in the user's language>",
|
|
45
|
+
"steps": [{"part": "<command segment>", "explain": "<what this stage does>"}]
|
|
46
|
+
}
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass
|
|
51
|
+
class Suggestion:
|
|
52
|
+
command: str
|
|
53
|
+
explanation: str
|
|
54
|
+
steps: List[Tuple[str, str]] = field(default_factory=list)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class LLMError(RuntimeError):
|
|
58
|
+
pass
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _user_message(query: str, os_name: str, shell: str, cwd: str) -> str:
|
|
62
|
+
return (
|
|
63
|
+
f"OS: {os_name}\n"
|
|
64
|
+
f"Shell: {shell}\n"
|
|
65
|
+
f"Current directory: {cwd}\n\n"
|
|
66
|
+
f"Request: {query}"
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def suggest_command(
|
|
71
|
+
query: str,
|
|
72
|
+
cfg: Config,
|
|
73
|
+
*,
|
|
74
|
+
os_name: str,
|
|
75
|
+
shell: str,
|
|
76
|
+
cwd: str,
|
|
77
|
+
timeout: float = 30.0,
|
|
78
|
+
) -> Suggestion:
|
|
79
|
+
if not cfg.has_key:
|
|
80
|
+
raise LLMError("No API key configured.")
|
|
81
|
+
|
|
82
|
+
payload = {
|
|
83
|
+
"model": cfg.model,
|
|
84
|
+
"messages": [
|
|
85
|
+
{"role": "system", "content": SYSTEM_PROMPT},
|
|
86
|
+
{"role": "user", "content": _user_message(query, os_name, shell, cwd)},
|
|
87
|
+
],
|
|
88
|
+
"temperature": 0.0,
|
|
89
|
+
"response_format": {"type": "json_object"},
|
|
90
|
+
"stream": False,
|
|
91
|
+
}
|
|
92
|
+
headers = {
|
|
93
|
+
"Authorization": f"Bearer {cfg.api_key}",
|
|
94
|
+
"Content-Type": "application/json",
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
try:
|
|
98
|
+
resp = httpx.post(
|
|
99
|
+
f"{cfg.base_url}/chat/completions",
|
|
100
|
+
json=payload,
|
|
101
|
+
headers=headers,
|
|
102
|
+
timeout=timeout,
|
|
103
|
+
)
|
|
104
|
+
except httpx.HTTPError as exc:
|
|
105
|
+
raise LLMError(f"Request to DeepSeek failed: {exc}") from exc
|
|
106
|
+
|
|
107
|
+
if resp.status_code != 200:
|
|
108
|
+
detail = resp.text.strip()
|
|
109
|
+
raise LLMError(f"DeepSeek returned HTTP {resp.status_code}: {detail[:300]}")
|
|
110
|
+
|
|
111
|
+
try:
|
|
112
|
+
content = resp.json()["choices"][0]["message"]["content"]
|
|
113
|
+
data = json.loads(content)
|
|
114
|
+
except (KeyError, IndexError, ValueError) as exc:
|
|
115
|
+
raise LLMError(f"Could not parse DeepSeek response: {exc}") from exc
|
|
116
|
+
|
|
117
|
+
command = str(data.get("command", "")).strip()
|
|
118
|
+
explanation = str(data.get("explanation", "")).strip()
|
|
119
|
+
|
|
120
|
+
steps: List[Tuple[str, str]] = []
|
|
121
|
+
raw_steps = data.get("steps")
|
|
122
|
+
if isinstance(raw_steps, list):
|
|
123
|
+
for item in raw_steps:
|
|
124
|
+
if not isinstance(item, dict):
|
|
125
|
+
continue
|
|
126
|
+
part = str(item.get("part", "")).strip()
|
|
127
|
+
explain = str(item.get("explain", "")).strip()
|
|
128
|
+
if part:
|
|
129
|
+
steps.append((part, explain))
|
|
130
|
+
|
|
131
|
+
return Suggestion(command=command, explanation=explanation, steps=steps)
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: nlsh-cli
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Turn natural language into shell commands via the DeepSeek API, with a confirm-before-run step.
|
|
5
|
+
Project-URL: Homepage, https://github.com/decajoin/nlsh
|
|
6
|
+
Project-URL: Repository, https://github.com/decajoin/nlsh
|
|
7
|
+
Author: Yiqi Yang
|
|
8
|
+
License: MIT
|
|
9
|
+
Keywords: cli,deepseek,llm,natural-language,shell,terminal
|
|
10
|
+
Classifier: Environment :: Console
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Topic :: Utilities
|
|
15
|
+
Requires-Python: >=3.9
|
|
16
|
+
Requires-Dist: httpx>=0.27
|
|
17
|
+
Requires-Dist: rich>=13
|
|
18
|
+
Requires-Dist: typer>=0.12
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
|
|
21
|
+
# nlsh
|
|
22
|
+
|
|
23
|
+
Describe what you want to do in plain language; `nlsh` asks the DeepSeek API for
|
|
24
|
+
the matching shell command, shows it with a short explanation, and waits for your
|
|
25
|
+
confirmation before running it.
|
|
26
|
+
|
|
27
|
+
```
|
|
28
|
+
$ nlsh find the 5 largest files under this directory
|
|
29
|
+
╭─ proposed command ───────────────────────────────────────╮
|
|
30
|
+
│ $ du -ah . | sort -rh | head -n 5 │
|
|
31
|
+
│ │
|
|
32
|
+
│ Lists files by size and shows the five largest. │
|
|
33
|
+
╰───────────────────────────────────────────────────────────╯
|
|
34
|
+
Run it? [y/n/e] (n):
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
`y` runs it, `n` cancels, `e` opens the command in your `$EDITOR` first.
|
|
38
|
+
|
|
39
|
+
## Install
|
|
40
|
+
|
|
41
|
+
Once published to PyPI, install it as a standalone tool (the package is
|
|
42
|
+
`nlsh-cli`; the command it installs is `nlsh`):
|
|
43
|
+
|
|
44
|
+
```sh
|
|
45
|
+
uv tool install nlsh-cli # or: pipx install nlsh-cli
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
### From source
|
|
49
|
+
|
|
50
|
+
One command sets up the virtual environment and all dependencies from the
|
|
51
|
+
lockfile:
|
|
52
|
+
|
|
53
|
+
```sh
|
|
54
|
+
uv sync
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
This creates `.venv/` and installs `nlsh`. Run it with `uv run nlsh ...`, or
|
|
58
|
+
activate the env first (`source .venv/bin/activate`) and just call `nlsh`.
|
|
59
|
+
|
|
60
|
+
Plain pip (no uv) works too:
|
|
61
|
+
|
|
62
|
+
```sh
|
|
63
|
+
python -m venv .venv && . .venv/bin/activate
|
|
64
|
+
pip install -r requirements.txt
|
|
65
|
+
pip install -e .
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
## Configure
|
|
69
|
+
|
|
70
|
+
Easiest — store the key interactively (written to `~/.config/nlsh/config.toml`,
|
|
71
|
+
mode 0600):
|
|
72
|
+
|
|
73
|
+
```sh
|
|
74
|
+
nlsh config set-key
|
|
75
|
+
nlsh config set-model deepseek-v4-pro # optional, change default model
|
|
76
|
+
nlsh config show # show resolved config (key masked)
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Or via environment variable:
|
|
80
|
+
|
|
81
|
+
```sh
|
|
82
|
+
export DEEPSEEK_API_KEY=sk-...
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
or edit `~/.config/nlsh/config.toml` directly:
|
|
86
|
+
|
|
87
|
+
```toml
|
|
88
|
+
api_key = "sk-..."
|
|
89
|
+
model = "deepseek-v4-flash" # optional (also: deepseek-v4-pro)
|
|
90
|
+
base_url = "https://api.deepseek.com" # optional
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
Resolution order for each setting is: environment variable → config file → default.
|
|
94
|
+
|
|
95
|
+
## Usage
|
|
96
|
+
|
|
97
|
+
```sh
|
|
98
|
+
nlsh <natural language request>
|
|
99
|
+
|
|
100
|
+
-y, --yes run without confirmation (dangerous commands still prompt)
|
|
101
|
+
-n, --dry-run only print the command, never run it
|
|
102
|
+
--pro use the stronger model (deepseek-v4-pro) for this request
|
|
103
|
+
-m, --model ID override the model id for this request
|
|
104
|
+
--version show version
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
At the confirmation prompt: `y` runs, `n` cancels, `e` edits first. Commands
|
|
108
|
+
that match risky patterns (`rm -rf`, `dd of=/dev/…`, `mkfs`, fork bombs,
|
|
109
|
+
`curl … | sh`, `sudo`, …) are flagged in red and require typing the full word
|
|
110
|
+
`yes` before they run — even under `--yes`. Multi-stage commands (pipes, `&&`)
|
|
111
|
+
are shown with a per-step breakdown.
|
|
112
|
+
|
|
113
|
+
## Development
|
|
114
|
+
|
|
115
|
+
```sh
|
|
116
|
+
uv sync # installs deps + dev tools (pytest, ruff)
|
|
117
|
+
uv run pytest -q # run the test suite
|
|
118
|
+
uv run ruff check . # lint
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
CI (GitHub Actions) runs ruff + pytest across Python 3.9–3.13 and checks the
|
|
122
|
+
lockfile on every push and PR.
|
|
123
|
+
|
|
124
|
+
### Releasing
|
|
125
|
+
|
|
126
|
+
The version is derived from the git tag by `hatch-vcs`, so a release is just a
|
|
127
|
+
tag:
|
|
128
|
+
|
|
129
|
+
```sh
|
|
130
|
+
git tag v0.1.0
|
|
131
|
+
git push origin v0.1.0
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
The `Publish` workflow builds the sdist/wheel and uploads them to PyPI via
|
|
135
|
+
[Trusted Publishing](https://docs.pypi.org/trusted-publishers/) (configure a
|
|
136
|
+
`pypi` environment on the repo and register the workflow as a trusted publisher
|
|
137
|
+
— no API token stored).
|
|
138
|
+
|
|
139
|
+
## How it works
|
|
140
|
+
|
|
141
|
+
`nlsh` sends your request plus context (OS, shell, current directory) to the
|
|
142
|
+
DeepSeek chat API constrained to JSON output, parses `{command, explanation}`,
|
|
143
|
+
and runs the chosen command through your `$SHELL` so aliases and the working
|
|
144
|
+
directory behave as expected.
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
nlsh/__init__.py,sha256=bbgIL4bdvKleZdziso75uWneZy--qCiMdcLZR1Z9el4,316
|
|
2
|
+
nlsh/_version.py,sha256=n_5vdJsPNu7wZ57LGuRL585uvll-hiuvZUBWzdG0RQU,520
|
|
3
|
+
nlsh/cli.py,sha256=w1VOkxCBR2zfDhJ1AlCVEroLZYt5l9tN84ErjoXjBEU,12051
|
|
4
|
+
nlsh/config.py,sha256=j6ZhmorE-BXHik73XkawO90Oy47b502mkZQE6DHq7do,3140
|
|
5
|
+
nlsh/llm.py,sha256=WlQobVFp36oEBce9fg_5o3XDQSumSfyaa_eZ-kVLbgg,4253
|
|
6
|
+
nlsh_cli-0.1.0.dist-info/METADATA,sha256=jorfoPHrhplu2FCXAsBiuZW2vEoKOel8VOBv5CBsms4,4539
|
|
7
|
+
nlsh_cli-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
8
|
+
nlsh_cli-0.1.0.dist-info/entry_points.txt,sha256=LCi5FJkY2FKOmsqxbm3g_2qbzKZx2tJBPv1uN8-tL0k,38
|
|
9
|
+
nlsh_cli-0.1.0.dist-info/RECORD,,
|