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 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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ nlsh = nlsh.cli:app