runspec-linux 0.1.3__tar.gz → 0.2.0__tar.gz

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.
@@ -56,3 +56,6 @@ chainlit-docs/
56
56
 
57
57
  # Claude Code local config (machine-specific)
58
58
  .claude/launch.json
59
+
60
+ # Stray committed test venv (removed from tracking)
61
+ .venv-test/
@@ -1,5 +1,23 @@
1
1
  # runspec-linux Changelog
2
2
 
3
+ ## [0.2.0] — 2026-06-09
4
+
5
+ Split the pure logic out into a new dependency-free package,
6
+ **`runspec-linux-core`**, and make `runspec-linux` depend on it. Every runnable
7
+ module is now a thin wrapper: it parses args via `runspec`, calls the matching
8
+ `runspec_linux_core` function, and renders the JSON/exit behaviour. The helper
9
+ functions are re-exported from `runspec_linux` for back-compat
10
+ (`from runspec_linux import nc_send` and friends still work), and
11
+ `runspec_linux.nc_command.nc_send` is preserved.
12
+
13
+ Why: importing a helper (e.g. `nc_send`) previously required installing
14
+ `runspec-linux`, which put all 21 console scripts on PATH and surfaced all 21
15
+ runnables in `runspec local` / `runspec serve`. Code that only wants the
16
+ importable helpers can now depend on `runspec-linux-core` instead — it has no
17
+ `runspec` dependency, no `runspec.toml`, and no entry points, so it surfaces
18
+ nothing. No behaviour change for the runnables themselves; JSON output and exit
19
+ codes are unchanged.
20
+
3
21
  ## [0.1.3] — 2026-06-04
4
22
 
5
23
  Switch `[config.logging]` to `store = "per-run"` so every runnable writes one
@@ -1,8 +1,9 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: runspec-linux
3
- Version: 0.1.3
3
+ Version: 0.2.0
4
4
  Summary: Linux system admin runnables for runspec
5
5
  Requires-Python: >=3.10
6
+ Requires-Dist: runspec-linux-core>=0.1.0
6
7
  Requires-Dist: runspec>=0.27.0
7
8
  Provides-Extra: dev
8
9
  Requires-Dist: mypy; extra == 'dev'
@@ -4,11 +4,12 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "runspec-linux"
7
- version = "0.1.3"
7
+ version = "0.2.0"
8
8
  requires-python = ">=3.10"
9
9
  description = "Linux system admin runnables for runspec"
10
10
  dependencies = [
11
11
  "runspec>=0.27.0",
12
+ "runspec-linux-core>=0.1.0",
12
13
  ]
13
14
 
14
15
  [project.scripts]
@@ -0,0 +1,56 @@
1
+ """runspec-linux — Linux system-admin runnables.
2
+
3
+ The pure logic lives in the dependency-free ``runspec-linux-core`` package; the
4
+ modules here wrap each helper in a runspec runnable. The helper functions are
5
+ re-exported for convenience and back-compat (e.g. ``from runspec_linux import
6
+ nc_send``). To import the helpers *without* surfacing any runnables in a venv,
7
+ depend on ``runspec-linux-core`` directly instead.
8
+ """
9
+
10
+ from runspec_linux_core import (
11
+ backup_files,
12
+ check_memory,
13
+ check_port,
14
+ check_service,
15
+ container_logs,
16
+ disk_usage,
17
+ find_large_files,
18
+ journalctl,
19
+ last_logins,
20
+ list_containers,
21
+ list_processes,
22
+ list_services,
23
+ nc_send,
24
+ ping_host,
25
+ restart_container,
26
+ restart_service,
27
+ search_log,
28
+ show_connections,
29
+ system_info,
30
+ tail_log,
31
+ who,
32
+ )
33
+
34
+ __all__ = [
35
+ "backup_files",
36
+ "check_memory",
37
+ "check_port",
38
+ "check_service",
39
+ "container_logs",
40
+ "disk_usage",
41
+ "find_large_files",
42
+ "journalctl",
43
+ "last_logins",
44
+ "list_containers",
45
+ "list_processes",
46
+ "list_services",
47
+ "nc_send",
48
+ "ping_host",
49
+ "restart_container",
50
+ "restart_service",
51
+ "search_log",
52
+ "show_connections",
53
+ "system_info",
54
+ "tail_log",
55
+ "who",
56
+ ]
@@ -0,0 +1,42 @@
1
+ import json
2
+ import sys
3
+
4
+ import runspec as rs
5
+ from runspec_linux_core import container_logs, list_containers, restart_container
6
+ from runspec_linux_core.errors import CommandError
7
+
8
+
9
+ def main_list_containers() -> None:
10
+ spec = rs.parse("list-containers")
11
+ include_all = bool(spec.all)
12
+ try:
13
+ print(json.dumps(list_containers(include_all)))
14
+ except Exception as e:
15
+ # Includes ToolNotFoundError (docker absent) and CommandError (docker ps failed).
16
+ print(json.dumps({"error": str(e)}))
17
+ sys.exit(1)
18
+
19
+
20
+ def main_container_logs() -> None:
21
+ spec = rs.parse("container-logs")
22
+ container = str(spec.container)
23
+ lines = int(spec.lines)
24
+ try:
25
+ print(json.dumps(container_logs(container, lines)))
26
+ except Exception as e:
27
+ print(json.dumps({"error": str(e)}))
28
+ sys.exit(1)
29
+
30
+
31
+ def main_restart_container() -> None:
32
+ spec = rs.parse("restart-container")
33
+ container = str(spec.container)
34
+ try:
35
+ print(json.dumps(restart_container(container)))
36
+ except CommandError as e:
37
+ print(json.dumps({"container": container, "restarted": False, "error": str(e)}))
38
+ sys.exit(1)
39
+ except Exception as e:
40
+ # Includes ToolNotFoundError (docker absent).
41
+ print(json.dumps({"error": str(e)}))
42
+ sys.exit(1)
@@ -0,0 +1,28 @@
1
+ import json
2
+ import sys
3
+
4
+ import runspec as rs
5
+ from runspec_linux_core import backup_files, find_large_files
6
+
7
+
8
+ def main_find_large_files() -> None:
9
+ spec = rs.parse("find-large-files")
10
+ search_path = str(spec.path)
11
+ min_mb = float(spec.min_mb)
12
+ limit = int(spec.limit)
13
+ try:
14
+ print(json.dumps(find_large_files(search_path, min_mb, limit)))
15
+ except Exception as e:
16
+ print(json.dumps({"error": str(e)}))
17
+ sys.exit(1)
18
+
19
+
20
+ def main_backup_files() -> None:
21
+ spec = rs.parse("backup-files")
22
+ source = str(spec.source)
23
+ destination = str(spec.destination)
24
+ try:
25
+ print(json.dumps(backup_files(source, destination)))
26
+ except Exception as e:
27
+ print(json.dumps({"error": str(e)}))
28
+ sys.exit(1)
@@ -0,0 +1,52 @@
1
+ import json
2
+ import re
3
+ import subprocess
4
+ import sys
5
+
6
+ import runspec as rs
7
+ from runspec_linux_core import journalctl, search_log, tail_log
8
+ from runspec_linux_core.errors import ToolNotFoundError
9
+
10
+
11
+ def main_tail_log() -> None:
12
+ spec = rs.parse("tail-log")
13
+ file_path = str(spec.file)
14
+ lines = int(spec.lines)
15
+ try:
16
+ print(json.dumps(tail_log(file_path, lines)))
17
+ except OSError as e:
18
+ print(json.dumps({"error": str(e), "file": file_path}))
19
+ sys.exit(1)
20
+
21
+
22
+ def main_search_log() -> None:
23
+ spec = rs.parse("search-log")
24
+ file_path = str(spec.file)
25
+ pattern = str(spec.pattern)
26
+ limit = int(spec.limit)
27
+ try:
28
+ print(json.dumps(search_log(file_path, pattern, limit)))
29
+ except OSError as e:
30
+ print(json.dumps({"error": str(e), "file": file_path}))
31
+ sys.exit(1)
32
+ except re.error as e:
33
+ print(json.dumps({"error": f"Invalid regex: {e}", "pattern": pattern}))
34
+ sys.exit(1)
35
+
36
+
37
+ def main_journalctl() -> None:
38
+ spec = rs.parse("journalctl")
39
+ unit = str(spec.unit)
40
+ lines = int(spec.lines)
41
+ since = str(spec.since) if spec.since is not None else None
42
+ try:
43
+ print(json.dumps(journalctl(unit, lines, since)))
44
+ except ToolNotFoundError as e:
45
+ print(json.dumps({"error": str(e)}))
46
+ sys.exit(1)
47
+ except subprocess.CalledProcessError as e:
48
+ print(json.dumps({"error": e.stderr.strip(), "unit": unit}))
49
+ sys.exit(1)
50
+ except Exception as e:
51
+ print(json.dumps({"error": str(e)}))
52
+ sys.exit(1)
@@ -0,0 +1,38 @@
1
+ import json
2
+ import sys
3
+
4
+ import runspec as rs
5
+
6
+ # Re-exported for back-compat: callers historically did
7
+ # `from runspec_linux.nc_command import nc_send`. The implementation now lives
8
+ # in the dependency-free runspec-linux-core package.
9
+ from runspec_linux_core import nc_send
10
+
11
+ __all__ = ["main", "nc_send"]
12
+
13
+
14
+ def main() -> None:
15
+ spec = rs.parse("nc-command")
16
+ host = str(spec.host)
17
+ port = int(spec.port)
18
+ command = str(spec.command)
19
+ wait = float(spec.wait)
20
+ read_timeout = float(spec.read_timeout)
21
+
22
+ try:
23
+ response = nc_send(host, port, command, wait=wait, read_timeout=read_timeout)
24
+ lines = response.splitlines()
25
+ print(
26
+ json.dumps(
27
+ {
28
+ "host": host,
29
+ "port": port,
30
+ "command": command,
31
+ "stdout": response,
32
+ "lines": lines,
33
+ }
34
+ )
35
+ )
36
+ except OSError as e:
37
+ print(json.dumps({"error": str(e), "host": host, "port": port}))
38
+ sys.exit(1)
@@ -0,0 +1,34 @@
1
+ import json
2
+ import sys
3
+
4
+ import runspec as rs
5
+ from runspec_linux_core import check_port, ping_host, show_connections
6
+
7
+
8
+ def main_ping_host() -> None:
9
+ spec = rs.parse("ping-host")
10
+ host = str(spec.host)
11
+ count = int(spec.count)
12
+ try:
13
+ print(json.dumps(ping_host(host, count)))
14
+ except Exception as e:
15
+ print(json.dumps({"error": str(e), "host": host}))
16
+ sys.exit(1)
17
+
18
+
19
+ def main_check_port() -> None:
20
+ spec = rs.parse("check-port")
21
+ host = str(spec.host)
22
+ port = int(spec.port)
23
+ timeout = float(spec.timeout)
24
+ print(json.dumps(check_port(host, port, timeout)))
25
+
26
+
27
+ def main_show_connections() -> None:
28
+ spec = rs.parse("show-connections")
29
+ state_filter = str(spec.state)
30
+ try:
31
+ print(json.dumps(show_connections(state_filter)))
32
+ except Exception as e:
33
+ print(json.dumps({"error": str(e)}))
34
+ sys.exit(1)
@@ -0,0 +1,24 @@
1
+ import json
2
+ import sys
3
+
4
+ import runspec as rs
5
+ from runspec_linux_core import last_logins, who
6
+
7
+
8
+ def main_last_logins() -> None:
9
+ spec = rs.parse("last-logins")
10
+ limit = int(spec.limit)
11
+ try:
12
+ print(json.dumps(last_logins(limit)))
13
+ except Exception as e:
14
+ print(json.dumps({"error": str(e)}))
15
+ sys.exit(1)
16
+
17
+
18
+ def main_who() -> None:
19
+ rs.parse("who")
20
+ try:
21
+ print(json.dumps(who()))
22
+ except Exception as e:
23
+ print(json.dumps({"error": str(e)}))
24
+ sys.exit(1)
@@ -0,0 +1,40 @@
1
+ import json
2
+ import sys
3
+
4
+ import runspec as rs
5
+ from runspec_linux_core import check_service, list_services, restart_service
6
+ from runspec_linux_core.errors import CommandError
7
+
8
+
9
+ def main_check_service() -> None:
10
+ spec = rs.parse("check-service")
11
+ service = str(spec.service)
12
+ try:
13
+ print(json.dumps(check_service(service)))
14
+ except Exception as e:
15
+ print(json.dumps({"error": str(e)}))
16
+ sys.exit(1)
17
+
18
+
19
+ def main_list_services() -> None:
20
+ spec = rs.parse("list-services")
21
+ state_filter = str(spec.state)
22
+ try:
23
+ print(json.dumps(list_services(state_filter)))
24
+ except Exception as e:
25
+ print(json.dumps({"error": str(e)}))
26
+ sys.exit(1)
27
+
28
+
29
+ def main_restart_service() -> None:
30
+ spec = rs.parse("restart-service")
31
+ service = str(spec.service)
32
+ try:
33
+ print(json.dumps(restart_service(service)))
34
+ except CommandError as e:
35
+ print(json.dumps({"service": service, "restarted": False, "error": str(e)}))
36
+ sys.exit(1)
37
+ except Exception as e:
38
+ # Includes ToolNotFoundError (systemctl absent).
39
+ print(json.dumps({"error": str(e)}))
40
+ sys.exit(1)
@@ -0,0 +1,43 @@
1
+ import json
2
+ import sys
3
+
4
+ import runspec as rs
5
+ from runspec_linux_core import check_memory, disk_usage, list_processes, system_info
6
+
7
+
8
+ def main_system_info() -> None:
9
+ rs.parse("system-info")
10
+ try:
11
+ print(json.dumps(system_info()))
12
+ except Exception as e:
13
+ print(json.dumps({"error": str(e)}))
14
+ sys.exit(1)
15
+
16
+
17
+ def main_disk_usage() -> None:
18
+ rs.parse("disk-usage")
19
+ try:
20
+ print(json.dumps(disk_usage()))
21
+ except Exception as e:
22
+ print(json.dumps({"error": str(e)}))
23
+ sys.exit(1)
24
+
25
+
26
+ def main_check_memory() -> None:
27
+ rs.parse("check-memory")
28
+ try:
29
+ print(json.dumps(check_memory()))
30
+ except Exception as e:
31
+ print(json.dumps({"error": str(e)}))
32
+ sys.exit(1)
33
+
34
+
35
+ def main_list_processes() -> None:
36
+ spec = rs.parse("list-processes")
37
+ name_filter = str(spec.filter) if spec.filter is not None else None
38
+ limit = int(spec.limit)
39
+ try:
40
+ print(json.dumps(list_processes(name_filter, limit)))
41
+ except Exception as e:
42
+ print(json.dumps({"error": str(e)}))
43
+ sys.exit(1)
@@ -1,3 +0,0 @@
1
- from runspec_linux.nc_command import nc_send
2
-
3
- __all__ = ["nc_send"]
@@ -1,117 +0,0 @@
1
- import json
2
- import shutil
3
- import subprocess
4
- import sys
5
-
6
- import runspec as rs
7
-
8
-
9
- def _docker_available() -> bool:
10
- return shutil.which("docker") is not None
11
-
12
-
13
- def main_list_containers() -> None:
14
- spec = rs.parse("list-containers")
15
- include_all = bool(spec.all)
16
-
17
- if not _docker_available():
18
- print(json.dumps({"error": "docker not installed or not in PATH"}))
19
- sys.exit(1)
20
-
21
- try:
22
- cmd = [
23
- "docker",
24
- "ps",
25
- "--format",
26
- "{{.ID}}\t{{.Image}}\t{{.Command}}\t{{.CreatedAt}}\t{{.Status}}\t{{.Names}}",
27
- ]
28
- if include_all:
29
- cmd.append("--all")
30
-
31
- result = subprocess.run(cmd, capture_output=True, text=True)
32
- if result.returncode != 0:
33
- print(json.dumps({"error": result.stderr.strip()}))
34
- sys.exit(1)
35
-
36
- rows = []
37
- for line in result.stdout.strip().splitlines():
38
- parts = line.split("\t")
39
- if len(parts) < 6:
40
- continue
41
- rows.append(
42
- {
43
- "id": parts[0],
44
- "image": parts[1],
45
- "command": parts[2].strip('"'),
46
- "created": parts[3],
47
- "status": parts[4],
48
- "name": parts[5],
49
- }
50
- )
51
- print(json.dumps(rows))
52
- except Exception as e:
53
- print(json.dumps({"error": str(e)}))
54
- sys.exit(1)
55
-
56
-
57
- def main_container_logs() -> None:
58
- spec = rs.parse("container-logs")
59
- container = str(spec.container)
60
- lines = int(spec.lines)
61
-
62
- if not _docker_available():
63
- print(json.dumps({"error": "docker not installed or not in PATH"}))
64
- sys.exit(1)
65
-
66
- try:
67
- result = subprocess.run(
68
- ["docker", "logs", "--tail", str(lines), container],
69
- capture_output=True,
70
- text=True,
71
- )
72
- # docker logs writes to stderr by default
73
- output = result.stderr if result.stderr else result.stdout
74
- output_lines = output.strip().splitlines()
75
- print(
76
- json.dumps(
77
- {
78
- "container": container,
79
- "lines": output_lines,
80
- "count": len(output_lines),
81
- }
82
- )
83
- )
84
- except Exception as e:
85
- print(json.dumps({"error": str(e)}))
86
- sys.exit(1)
87
-
88
-
89
- def main_restart_container() -> None:
90
- spec = rs.parse("restart-container")
91
- container = str(spec.container)
92
-
93
- if not _docker_available():
94
- print(json.dumps({"error": "docker not installed or not in PATH"}))
95
- sys.exit(1)
96
-
97
- try:
98
- result = subprocess.run(
99
- ["docker", "restart", container],
100
- capture_output=True,
101
- text=True,
102
- )
103
- if result.returncode != 0:
104
- print(
105
- json.dumps(
106
- {
107
- "container": container,
108
- "restarted": False,
109
- "error": result.stderr.strip(),
110
- }
111
- )
112
- )
113
- sys.exit(1)
114
- print(json.dumps({"container": container, "restarted": True}))
115
- except Exception as e:
116
- print(json.dumps({"error": str(e)}))
117
- sys.exit(1)
@@ -1,72 +0,0 @@
1
- import json
2
- import os
3
- import sys
4
- import tarfile
5
- from datetime import datetime
6
- from pathlib import Path
7
-
8
- import runspec as rs
9
-
10
-
11
- def main_find_large_files() -> None:
12
- spec = rs.parse("find-large-files")
13
- search_path = str(spec.path)
14
- min_mb = float(spec.min_mb)
15
- limit = int(spec.limit)
16
-
17
- min_bytes = int(min_mb * 1_048_576)
18
-
19
- try:
20
- results: list[dict] = []
21
- for dirpath, _dirs, filenames in os.walk(search_path):
22
- for name in filenames:
23
- fpath = os.path.join(dirpath, name)
24
- try:
25
- stat = os.stat(fpath, follow_symlinks=False)
26
- if stat.st_size >= min_bytes:
27
- results.append(
28
- {
29
- "path": fpath,
30
- "size_mb": round(stat.st_size / 1_048_576, 2),
31
- "modified": datetime.fromtimestamp(stat.st_mtime).strftime("%Y-%m-%d %H:%M:%S"),
32
- }
33
- )
34
- except OSError:
35
- continue
36
-
37
- results.sort(key=lambda r: r["size_mb"], reverse=True)
38
- print(json.dumps(results[:limit]))
39
- except Exception as e:
40
- print(json.dumps({"error": str(e)}))
41
- sys.exit(1)
42
-
43
-
44
- def main_backup_files() -> None:
45
- spec = rs.parse("backup-files")
46
- source = str(spec.source)
47
- destination = str(spec.destination)
48
-
49
- timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
50
- source_name = Path(source).name or "backup"
51
- archive_name = f"{source_name}_{timestamp}.tar.gz"
52
- archive_path = os.path.join(destination, archive_name)
53
-
54
- try:
55
- os.makedirs(destination, exist_ok=True)
56
- with tarfile.open(archive_path, "w:gz") as tar:
57
- tar.add(source, arcname=source_name)
58
-
59
- size_bytes = os.path.getsize(archive_path)
60
- print(
61
- json.dumps(
62
- {
63
- "source": source,
64
- "destination": archive_path,
65
- "size_bytes": size_bytes,
66
- "size_mb": round(size_bytes / 1_048_576, 2),
67
- }
68
- )
69
- )
70
- except Exception as e:
71
- print(json.dumps({"error": str(e)}))
72
- sys.exit(1)
@@ -1,81 +0,0 @@
1
- import json
2
- import re
3
- import shutil
4
- import subprocess
5
- import sys
6
-
7
- import runspec as rs
8
-
9
-
10
- def main_tail_log() -> None:
11
- spec = rs.parse("tail-log")
12
- file_path = str(spec.file)
13
- lines = int(spec.lines)
14
-
15
- try:
16
- with open(file_path, errors="replace") as f:
17
- all_lines = f.readlines()
18
- tail = [line.rstrip("\n") for line in all_lines[-lines:]]
19
- print(json.dumps({"file": file_path, "lines": tail, "count": len(tail)}))
20
- except OSError as e:
21
- print(json.dumps({"error": str(e), "file": file_path}))
22
- sys.exit(1)
23
-
24
-
25
- def main_search_log() -> None:
26
- spec = rs.parse("search-log")
27
- file_path = str(spec.file)
28
- pattern = str(spec.pattern)
29
- limit = int(spec.limit)
30
-
31
- try:
32
- regex = re.compile(pattern, re.IGNORECASE)
33
- matches: list[str] = []
34
- with open(file_path, errors="replace") as f:
35
- for line in f:
36
- if regex.search(line):
37
- matches.append(line.rstrip("\n"))
38
- trimmed = matches[-limit:] if len(matches) > limit else matches
39
- print(
40
- json.dumps(
41
- {
42
- "file": file_path,
43
- "pattern": pattern,
44
- "matches": trimmed,
45
- "count": len(trimmed),
46
- "total_matches": len(matches),
47
- }
48
- )
49
- )
50
- except OSError as e:
51
- print(json.dumps({"error": str(e), "file": file_path}))
52
- sys.exit(1)
53
- except re.error as e:
54
- print(json.dumps({"error": f"Invalid regex: {e}", "pattern": pattern}))
55
- sys.exit(1)
56
-
57
-
58
- def main_journalctl() -> None:
59
- spec = rs.parse("journalctl")
60
- unit = str(spec.unit)
61
- lines = int(spec.lines)
62
- since = str(spec.since) if spec.since is not None else None
63
-
64
- if not shutil.which("journalctl"):
65
- print(json.dumps({"error": "journalctl not available — not a systemd system"}))
66
- sys.exit(1)
67
-
68
- try:
69
- cmd = ["journalctl", f"-u{unit}", f"-n{lines}", "--no-pager", "--output=short"]
70
- if since:
71
- cmd += [f"--since={since}"]
72
-
73
- result = subprocess.run(cmd, capture_output=True, text=True, check=True)
74
- output_lines = result.stdout.strip().splitlines()
75
- print(json.dumps({"unit": unit, "lines": output_lines, "count": len(output_lines)}))
76
- except subprocess.CalledProcessError as e:
77
- print(json.dumps({"error": e.stderr.strip(), "unit": unit}))
78
- sys.exit(1)
79
- except Exception as e:
80
- print(json.dumps({"error": str(e)}))
81
- sys.exit(1)
@@ -1,65 +0,0 @@
1
- import json
2
- import socket
3
- import sys
4
- import time
5
-
6
- import runspec as rs
7
-
8
-
9
- def nc_send(
10
- host: str,
11
- port: int,
12
- command: str,
13
- wait: float = 0.3,
14
- read_timeout: float = 0.1,
15
- ) -> str:
16
- """Send a command over a plain TCP socket and return the response.
17
-
18
- Appends a newline to command if not present, waits `wait` seconds for the
19
- server to respond, then reads in chunks until the socket goes quiet.
20
- """
21
- with socket.create_connection((host, port), timeout=10) as sock:
22
- payload = command if command.endswith("\n") else command + "\n"
23
- sock.sendall(payload.encode())
24
-
25
- time.sleep(wait)
26
-
27
- sock.settimeout(read_timeout)
28
- chunks: list[bytes] = []
29
- while True:
30
- try:
31
- chunk = sock.recv(4096)
32
- if not chunk:
33
- break
34
- chunks.append(chunk)
35
- except TimeoutError:
36
- break
37
-
38
- return b"".join(chunks).decode(errors="replace")
39
-
40
-
41
- def main() -> None:
42
- spec = rs.parse("nc-command")
43
- host = str(spec.host)
44
- port = int(spec.port)
45
- command = str(spec.command)
46
- wait = float(spec.wait)
47
- read_timeout = float(spec.read_timeout)
48
-
49
- try:
50
- response = nc_send(host, port, command, wait=wait, read_timeout=read_timeout)
51
- lines = response.splitlines()
52
- print(
53
- json.dumps(
54
- {
55
- "host": host,
56
- "port": port,
57
- "command": command,
58
- "stdout": response,
59
- "lines": lines,
60
- }
61
- )
62
- )
63
- except OSError as e:
64
- print(json.dumps({"error": str(e), "host": host, "port": port}))
65
- sys.exit(1)
@@ -1,159 +0,0 @@
1
- import json
2
- import re
3
- import shutil
4
- import socket
5
- import subprocess
6
- import sys
7
- import time
8
-
9
- import runspec as rs
10
-
11
-
12
- def main_ping_host() -> None:
13
- spec = rs.parse("ping-host")
14
- host = str(spec.host)
15
- count = int(spec.count)
16
-
17
- try:
18
- result = subprocess.run(
19
- ["ping", "-c", str(count), host],
20
- capture_output=True,
21
- text=True,
22
- )
23
- output = result.stdout + result.stderr
24
-
25
- sent = received = 0
26
- m = re.search(r"(\d+) packets transmitted,\s*(\d+) received", output)
27
- if m:
28
- sent = int(m.group(1))
29
- received = int(m.group(2))
30
-
31
- loss_pct = 0.0
32
- m2 = re.search(r"([\d.]+)% packet loss", output)
33
- if m2:
34
- loss_pct = float(m2.group(1))
35
-
36
- print(
37
- json.dumps(
38
- {
39
- "host": host,
40
- "reachable": received > 0,
41
- "packets_sent": sent,
42
- "packets_received": received,
43
- "loss_pct": loss_pct,
44
- }
45
- )
46
- )
47
- except Exception as e:
48
- print(json.dumps({"error": str(e), "host": host}))
49
- sys.exit(1)
50
-
51
-
52
- def main_check_port() -> None:
53
- spec = rs.parse("check-port")
54
- host = str(spec.host)
55
- port = int(spec.port)
56
- timeout = float(spec.timeout)
57
-
58
- start = time.monotonic()
59
- try:
60
- with socket.create_connection((host, port), timeout=timeout):
61
- elapsed_ms = round((time.monotonic() - start) * 1000, 1)
62
- print(
63
- json.dumps(
64
- {
65
- "host": host,
66
- "port": port,
67
- "open": True,
68
- "response_ms": elapsed_ms,
69
- }
70
- )
71
- )
72
- except (OSError, TimeoutError):
73
- elapsed_ms = round((time.monotonic() - start) * 1000, 1)
74
- print(json.dumps({"host": host, "port": port, "open": False, "response_ms": elapsed_ms}))
75
-
76
-
77
- def main_show_connections() -> None:
78
- spec = rs.parse("show-connections")
79
- state_filter = str(spec.state)
80
-
81
- try:
82
- if shutil.which("ss"):
83
- cmd = ["ss", "-tupn"]
84
- result = subprocess.run(cmd, capture_output=True, text=True, check=True)
85
- rows = _parse_ss(result.stdout, state_filter)
86
- elif shutil.which("netstat"):
87
- cmd = ["netstat", "-tupn"]
88
- result = subprocess.run(cmd, capture_output=True, text=True)
89
- rows = _parse_netstat(result.stdout, state_filter)
90
- else:
91
- print(json.dumps({"error": "Neither ss nor netstat found in PATH"}))
92
- sys.exit(1)
93
-
94
- print(json.dumps(rows))
95
- except Exception as e:
96
- print(json.dumps({"error": str(e)}))
97
- sys.exit(1)
98
-
99
-
100
- def _parse_ss(output: str, state_filter: str) -> list[dict]:
101
- rows = []
102
- for line in output.strip().splitlines()[1:]:
103
- parts = line.split()
104
- if len(parts) < 5:
105
- continue
106
- proto, local, peer = parts[0], parts[3], parts[4]
107
- state = parts[5] if len(parts) > 5 and not parts[5].startswith("users:") else ""
108
- process = ""
109
- for p in parts:
110
- if p.startswith("users:"):
111
- m = re.search(r'"([^"]+)"', p)
112
- if m:
113
- process = m.group(1)
114
-
115
- row_state = state.lower() if state else ("listen" if peer == "*:*" else "established")
116
- if state_filter == "established" and "estab" not in row_state:
117
- continue
118
- if state_filter == "listening" and "listen" not in row_state:
119
- continue
120
-
121
- rows.append(
122
- {
123
- "proto": proto,
124
- "local": local,
125
- "peer": peer,
126
- "state": state,
127
- "process": process,
128
- }
129
- )
130
- return rows
131
-
132
-
133
- def _parse_netstat(output: str, state_filter: str) -> list[dict]:
134
- rows = []
135
- for line in output.strip().splitlines():
136
- parts = line.split()
137
- if not parts or parts[0] not in ("tcp", "tcp6", "udp", "udp6"):
138
- continue
139
- if len(parts) < 6:
140
- continue
141
- proto, local, foreign = parts[0], parts[3], parts[4]
142
- state = parts[5] if len(parts) > 5 else ""
143
- process = parts[-1] if len(parts) > 6 else ""
144
-
145
- if state_filter == "established" and state.upper() != "ESTABLISHED":
146
- continue
147
- if state_filter == "listening" and state.upper() != "LISTEN":
148
- continue
149
-
150
- rows.append(
151
- {
152
- "proto": proto,
153
- "local": local,
154
- "peer": foreign,
155
- "state": state,
156
- "process": process,
157
- }
158
- )
159
- return rows
@@ -1,70 +0,0 @@
1
- import json
2
- import shutil
3
- import subprocess
4
- import sys
5
-
6
- import runspec as rs
7
-
8
-
9
- def main_last_logins() -> None:
10
- spec = rs.parse("last-logins")
11
- limit = int(spec.limit)
12
-
13
- if not shutil.which("last"):
14
- print(json.dumps({"error": "last command not found"}))
15
- sys.exit(1)
16
-
17
- try:
18
- result = subprocess.run(
19
- ["last", "-n", str(limit), "--time-format", "iso"],
20
- capture_output=True,
21
- text=True,
22
- )
23
- rows = []
24
- for line in result.stdout.strip().splitlines():
25
- if not line or line.startswith("wtmp") or line.startswith("btmp"):
26
- continue
27
- parts = line.split()
28
- if len(parts) < 3:
29
- continue
30
- rows.append(
31
- {
32
- "user": parts[0],
33
- "terminal": parts[1],
34
- "from": parts[2] if len(parts) > 2 else "",
35
- "login_time": parts[3] if len(parts) > 3 else "",
36
- "logout_time": parts[5] if len(parts) > 5 else "",
37
- }
38
- )
39
- print(json.dumps(rows[:limit]))
40
- except Exception as e:
41
- print(json.dumps({"error": str(e)}))
42
- sys.exit(1)
43
-
44
-
45
- def main_who() -> None:
46
- rs.parse("who")
47
-
48
- if not shutil.which("who"):
49
- print(json.dumps({"error": "who command not found"}))
50
- sys.exit(1)
51
-
52
- try:
53
- result = subprocess.run(["who"], capture_output=True, text=True, check=True)
54
- rows = []
55
- for line in result.stdout.strip().splitlines():
56
- parts = line.split()
57
- if len(parts) < 3:
58
- continue
59
- rows.append(
60
- {
61
- "user": parts[0],
62
- "terminal": parts[1],
63
- "login_time": " ".join(parts[2:4]) if len(parts) > 3 else parts[2],
64
- "from": parts[4].strip("()") if len(parts) > 4 else "",
65
- }
66
- )
67
- print(json.dumps(rows))
68
- except Exception as e:
69
- print(json.dumps({"error": str(e)}))
70
- sys.exit(1)
@@ -1,119 +0,0 @@
1
- import json
2
- import shutil
3
- import subprocess
4
- import sys
5
-
6
- import runspec as rs
7
-
8
-
9
- def _systemctl_available() -> bool:
10
- return shutil.which("systemctl") is not None
11
-
12
-
13
- def main_check_service() -> None:
14
- spec = rs.parse("check-service")
15
- service = str(spec.service)
16
-
17
- if not _systemctl_available():
18
- print(json.dumps({"error": "systemctl not available — not a systemd system"}))
19
- sys.exit(1)
20
-
21
- try:
22
- result = subprocess.run(
23
- [
24
- "systemctl",
25
- "show",
26
- service,
27
- "--property=ActiveState,SubState,LoadState,Description,ActiveEnterTimestamp",
28
- ],
29
- capture_output=True,
30
- text=True,
31
- )
32
- props: dict[str, str] = {}
33
- for line in result.stdout.strip().splitlines():
34
- if "=" in line:
35
- k, v = line.split("=", 1)
36
- props[k] = v
37
-
38
- active = props.get("ActiveState", "unknown")
39
- print(
40
- json.dumps(
41
- {
42
- "service": service,
43
- "active": active == "active",
44
- "active_state": active,
45
- "sub_state": props.get("SubState", "unknown"),
46
- "load_state": props.get("LoadState", "unknown"),
47
- "description": props.get("Description", ""),
48
- "since": props.get("ActiveEnterTimestamp", ""),
49
- }
50
- )
51
- )
52
- except Exception as e:
53
- print(json.dumps({"error": str(e)}))
54
- sys.exit(1)
55
-
56
-
57
- def main_list_services() -> None:
58
- spec = rs.parse("list-services")
59
- state_filter = str(spec.state)
60
-
61
- if not _systemctl_available():
62
- print(json.dumps({"error": "systemctl not available — not a systemd system"}))
63
- sys.exit(1)
64
-
65
- try:
66
- cmd = ["systemctl", "list-units", "--type=service", "--no-pager", "--no-legend"]
67
- if state_filter != "all":
68
- cmd += [f"--state={state_filter}"]
69
-
70
- result = subprocess.run(cmd, capture_output=True, text=True, check=True)
71
- rows = []
72
- for line in result.stdout.strip().splitlines():
73
- parts = line.split(None, 4)
74
- if len(parts) < 4:
75
- continue
76
- rows.append(
77
- {
78
- "unit": parts[0],
79
- "load": parts[1],
80
- "active": parts[2],
81
- "sub": parts[3],
82
- "description": parts[4] if len(parts) > 4 else "",
83
- }
84
- )
85
- print(json.dumps(rows))
86
- except Exception as e:
87
- print(json.dumps({"error": str(e)}))
88
- sys.exit(1)
89
-
90
-
91
- def main_restart_service() -> None:
92
- spec = rs.parse("restart-service")
93
- service = str(spec.service)
94
-
95
- if not _systemctl_available():
96
- print(json.dumps({"error": "systemctl not available — not a systemd system"}))
97
- sys.exit(1)
98
-
99
- try:
100
- result = subprocess.run(
101
- ["systemctl", "restart", service],
102
- capture_output=True,
103
- text=True,
104
- )
105
- if result.returncode != 0:
106
- print(
107
- json.dumps(
108
- {
109
- "service": service,
110
- "restarted": False,
111
- "error": result.stderr.strip(),
112
- }
113
- )
114
- )
115
- sys.exit(1)
116
- print(json.dumps({"service": service, "restarted": True}))
117
- except Exception as e:
118
- print(json.dumps({"error": str(e)}))
119
- sys.exit(1)
@@ -1,140 +0,0 @@
1
- import json
2
- import os
3
- import platform
4
- import sys
5
-
6
- import runspec as rs
7
-
8
-
9
- def main_system_info() -> None:
10
- rs.parse("system-info")
11
-
12
- try:
13
- load1, load5, load15 = os.getloadavg()
14
- with open("/proc/uptime") as f:
15
- uptime_seconds = float(f.read().split()[0])
16
-
17
- print(
18
- json.dumps(
19
- {
20
- "hostname": platform.node(),
21
- "os": f"{platform.system()} {platform.release()}",
22
- "kernel": platform.version(),
23
- "machine": platform.machine(),
24
- "uptime_seconds": round(uptime_seconds),
25
- "load_1": round(load1, 2),
26
- "load_5": round(load5, 2),
27
- "load_15": round(load15, 2),
28
- "cpu_count": os.cpu_count(),
29
- }
30
- )
31
- )
32
- except Exception as e:
33
- print(json.dumps({"error": str(e)}))
34
- sys.exit(1)
35
-
36
-
37
- def main_disk_usage() -> None:
38
- rs.parse("disk-usage")
39
-
40
- try:
41
- import subprocess
42
-
43
- result = subprocess.run(
44
- ["df", "-P", "-B1"],
45
- capture_output=True,
46
- text=True,
47
- check=True,
48
- )
49
- rows = []
50
- for line in result.stdout.strip().splitlines()[1:]:
51
- parts = line.split()
52
- if len(parts) < 6:
53
- continue
54
- size_bytes = int(parts[1])
55
- used_bytes = int(parts[2])
56
- avail_bytes = int(parts[3])
57
- rows.append(
58
- {
59
- "filesystem": parts[0],
60
- "size_mb": round(size_bytes / 1_048_576, 1),
61
- "used_mb": round(used_bytes / 1_048_576, 1),
62
- "available_mb": round(avail_bytes / 1_048_576, 1),
63
- "use_pct": parts[4],
64
- "mounted_on": parts[5],
65
- }
66
- )
67
- print(json.dumps(rows))
68
- except Exception as e:
69
- print(json.dumps({"error": str(e)}))
70
- sys.exit(1)
71
-
72
-
73
- def main_check_memory() -> None:
74
- rs.parse("check-memory")
75
-
76
- try:
77
- mem: dict[str, int] = {}
78
- with open("/proc/meminfo") as f:
79
- for line in f:
80
- key, val_kb = line.split(":")
81
- kb = int(val_kb.strip().split()[0])
82
- mem[key.strip()] = kb
83
-
84
- print(
85
- json.dumps(
86
- {
87
- "total_mb": round(mem.get("MemTotal", 0) / 1024, 1),
88
- "used_mb": round((mem.get("MemTotal", 0) - mem.get("MemAvailable", 0)) / 1024, 1),
89
- "free_mb": round(mem.get("MemFree", 0) / 1024, 1),
90
- "available_mb": round(mem.get("MemAvailable", 0) / 1024, 1),
91
- "cached_mb": round(mem.get("Cached", 0) / 1024, 1),
92
- "swap_total_mb": round(mem.get("SwapTotal", 0) / 1024, 1),
93
- "swap_used_mb": round((mem.get("SwapTotal", 0) - mem.get("SwapFree", 0)) / 1024, 1),
94
- }
95
- )
96
- )
97
- except Exception as e:
98
- print(json.dumps({"error": str(e)}))
99
- sys.exit(1)
100
-
101
-
102
- def main_list_processes() -> None:
103
- spec = rs.parse("list-processes")
104
- name_filter = str(spec.filter) if spec.filter is not None else None
105
- limit = int(spec.limit)
106
-
107
- try:
108
- import subprocess
109
-
110
- result = subprocess.run(
111
- ["ps", "aux", "--no-headers"],
112
- capture_output=True,
113
- text=True,
114
- check=True,
115
- )
116
- rows = []
117
- for line in result.stdout.strip().splitlines():
118
- parts = line.split(None, 10)
119
- if len(parts) < 11:
120
- continue
121
- command = parts[10]
122
- if name_filter and name_filter.lower() not in command.lower():
123
- continue
124
- rows.append(
125
- {
126
- "user": parts[0],
127
- "pid": int(parts[1]),
128
- "cpu_pct": float(parts[2]),
129
- "mem_pct": float(parts[3]),
130
- "vsz_kb": int(parts[4]),
131
- "rss_kb": int(parts[5]),
132
- "stat": parts[7],
133
- "command": command,
134
- }
135
- )
136
- rows.sort(key=lambda r: r["cpu_pct"], reverse=True)
137
- print(json.dumps(rows[:limit]))
138
- except Exception as e:
139
- print(json.dumps({"error": str(e)}))
140
- sys.exit(1)