samai-openbox 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.
- samai_openbox/__init__.py +23 -0
- samai_openbox/__main__.py +6 -0
- samai_openbox/cli.py +178 -0
- samai_openbox/core.py +598 -0
- samai_openbox/py.typed +0 -0
- samai_openbox-0.1.0.dist-info/METADATA +237 -0
- samai_openbox-0.1.0.dist-info/RECORD +11 -0
- samai_openbox-0.1.0.dist-info/WHEEL +5 -0
- samai_openbox-0.1.0.dist-info/entry_points.txt +2 -0
- samai_openbox-0.1.0.dist-info/licenses/LICENSE +21 -0
- samai_openbox-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""samai-openbox — CLI archive skill for AI agents.
|
|
2
|
+
|
|
3
|
+
One stable command to compress and extract every common archive format.
|
|
4
|
+
Pure-Python implementation using stdlib zipfile/tarfile; shells out to the
|
|
5
|
+
system 7z/7zz/7za for 7z and ISO, and unrar for rar.
|
|
6
|
+
|
|
7
|
+
See https://openbox.samai.cc/ai-agent.html for the full agent guide.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
__version__ = "0.1.0"
|
|
11
|
+
__all__ = ["compress", "extract", "list_archive", "test_archive", "formats"]
|
|
12
|
+
|
|
13
|
+
from .core import (
|
|
14
|
+
compress,
|
|
15
|
+
extract,
|
|
16
|
+
list_archive,
|
|
17
|
+
test_archive,
|
|
18
|
+
formats,
|
|
19
|
+
OpenBoxError,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
# CLI version string mirrors the desktop app versioning scheme.
|
|
23
|
+
CLI_VERSION = f"samai-openbox {__version__} (pure-python)"
|
samai_openbox/cli.py
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
"""Command-line interface for samai-openbox.
|
|
2
|
+
|
|
3
|
+
Five verbs:
|
|
4
|
+
compress Compress files into a new archive
|
|
5
|
+
extract Extract an archive to a directory
|
|
6
|
+
list List archive contents (use --json for machine-readable output)
|
|
7
|
+
test Verify archive integrity
|
|
8
|
+
formats Show the supported-format matrix
|
|
9
|
+
|
|
10
|
+
Exit codes: 0 on success, 1 on operational error, 2 on usage error, 3 on
|
|
11
|
+
missing external tool, 4 on corrupt archive.
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import sys
|
|
16
|
+
import json
|
|
17
|
+
import argparse
|
|
18
|
+
from typing import List, Optional
|
|
19
|
+
|
|
20
|
+
from . import __version__
|
|
21
|
+
from .core import (
|
|
22
|
+
compress,
|
|
23
|
+
extract,
|
|
24
|
+
list_archive,
|
|
25
|
+
test_archive,
|
|
26
|
+
formats,
|
|
27
|
+
detect_format,
|
|
28
|
+
OpenBoxError,
|
|
29
|
+
MissingToolError,
|
|
30
|
+
UnsupportedFormatError,
|
|
31
|
+
CorruptArchiveError,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
# Exit codes
|
|
35
|
+
EXIT_OK = 0
|
|
36
|
+
EXIT_ERROR = 1
|
|
37
|
+
EXIT_USAGE = 2
|
|
38
|
+
EXIT_MISSING_TOOL = 3
|
|
39
|
+
EXIT_CORRUPT = 4
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _print_formats() -> None:
|
|
43
|
+
rows = formats()
|
|
44
|
+
print(f"{'FORMAT':<8} {'COMPRESS':<10} {'EXTRACT':<8} NOTES")
|
|
45
|
+
print(f"{'------':<8} {'--------':<10} {'-------':<8} -----")
|
|
46
|
+
for r in rows:
|
|
47
|
+
print(f"{r['format']:<8} {r['compress']:<10} {r['extract']:<8} {r['notes']}")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _print_listing(entries: List[dict], archive: str, fmt: str) -> None:
|
|
51
|
+
print(f"Archive: {archive} (format: {fmt})")
|
|
52
|
+
print(f"Entries: {len(entries)}")
|
|
53
|
+
print()
|
|
54
|
+
print(f" {'NAME':<48} {'SIZE':>14} MODIFIED")
|
|
55
|
+
print(f" {'----':<48} {'----':>14} --------")
|
|
56
|
+
for e in entries:
|
|
57
|
+
name = e["name"]
|
|
58
|
+
if len(name) > 47:
|
|
59
|
+
name = "…" + name[-46:]
|
|
60
|
+
size = e.get("size", 0)
|
|
61
|
+
mod = e.get("modified") or "—"
|
|
62
|
+
marker = "/" if e.get("is_dir") else " "
|
|
63
|
+
print(f" {name:<48} {size:>14,} {mod}{marker}")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
67
|
+
p = argparse.ArgumentParser(
|
|
68
|
+
prog="samai-openbox",
|
|
69
|
+
description="CLI archive skill for AI agents. Compress and extract zip, tar, tar.gz, 7z, rar, iso.",
|
|
70
|
+
epilog=(
|
|
71
|
+
"Examples:\n"
|
|
72
|
+
" samai-openbox compress report.pdf photos/ -o backup.zip\n"
|
|
73
|
+
" samai-openbox extract backup.zip -d ./restored\n"
|
|
74
|
+
" samai-openbox list backup.zip --json\n"
|
|
75
|
+
" samai-openbox test backup.zip\n"
|
|
76
|
+
" samai-openbox formats\n"
|
|
77
|
+
),
|
|
78
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
79
|
+
)
|
|
80
|
+
p.add_argument("--version", action="store_true", help="Print version and exit")
|
|
81
|
+
sub = p.add_subparsers(dest="verb", metavar="<verb>")
|
|
82
|
+
|
|
83
|
+
# compress
|
|
84
|
+
pc = sub.add_parser("compress", help="Compress files into a new archive")
|
|
85
|
+
pc.add_argument("files", nargs="+", help="Files and/or directories to compress")
|
|
86
|
+
pc.add_argument("-o", "--output", required=True, help="Output archive path (format auto-detected from extension)")
|
|
87
|
+
pc.add_argument("--level", type=int, default=6, help="Compression level 0-9 (default 6)")
|
|
88
|
+
|
|
89
|
+
# extract
|
|
90
|
+
pe = sub.add_parser("extract", help="Extract an archive to a directory")
|
|
91
|
+
pe.add_argument("archive", help="Archive file to extract")
|
|
92
|
+
pe.add_argument("-d", "--dir", "--dest", dest="dir", required=True, help="Destination directory (created if missing)")
|
|
93
|
+
|
|
94
|
+
# list
|
|
95
|
+
pl = sub.add_parser("list", help="List archive contents")
|
|
96
|
+
pl.add_argument("archive", help="Archive file to list")
|
|
97
|
+
pl.add_argument("--json", action="store_true", dest="as_json", help="Output machine-readable JSON")
|
|
98
|
+
|
|
99
|
+
# test
|
|
100
|
+
pt = sub.add_parser("test", help="Verify archive integrity (CRC check, no extraction)")
|
|
101
|
+
pt.add_argument("archive", help="Archive file to test")
|
|
102
|
+
|
|
103
|
+
# formats
|
|
104
|
+
sub.add_parser("formats", help="Show the supported-format matrix")
|
|
105
|
+
|
|
106
|
+
return p
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def main(argv: Optional[List[str]] = None) -> int:
|
|
110
|
+
args = build_parser().parse_args(argv)
|
|
111
|
+
|
|
112
|
+
if args.version:
|
|
113
|
+
print(f"samai-openbox {__version__}")
|
|
114
|
+
print("pure-python implementation · zip/tar/tar.gz via stdlib · 7z/rar/iso via external CLI")
|
|
115
|
+
print("https://openbox.samai.cc")
|
|
116
|
+
return EXIT_OK
|
|
117
|
+
|
|
118
|
+
if args.verb is None:
|
|
119
|
+
build_parser().print_help()
|
|
120
|
+
return EXIT_USAGE
|
|
121
|
+
|
|
122
|
+
try:
|
|
123
|
+
if args.verb == "compress":
|
|
124
|
+
out = compress(args.files, args.output, level=args.level)
|
|
125
|
+
print(f"OK Created {out}")
|
|
126
|
+
return EXIT_OK
|
|
127
|
+
|
|
128
|
+
if args.verb == "extract":
|
|
129
|
+
out = extract(args.archive, args.dir)
|
|
130
|
+
print(f"OK Extracted {args.archive} → {out}")
|
|
131
|
+
return EXIT_OK
|
|
132
|
+
|
|
133
|
+
if args.verb == "list":
|
|
134
|
+
fmt = detect_format(args.archive)
|
|
135
|
+
entries = list_archive(args.archive)
|
|
136
|
+
if args.as_json:
|
|
137
|
+
payload = {
|
|
138
|
+
"archive": args.archive,
|
|
139
|
+
"format": fmt,
|
|
140
|
+
"entries": entries,
|
|
141
|
+
}
|
|
142
|
+
print(json.dumps(payload, indent=2, ensure_ascii=False))
|
|
143
|
+
else:
|
|
144
|
+
_print_listing(entries, args.archive, fmt)
|
|
145
|
+
return EXIT_OK
|
|
146
|
+
|
|
147
|
+
if args.verb == "test":
|
|
148
|
+
test_archive(args.archive)
|
|
149
|
+
print(f"OK {args.archive} integrity verified")
|
|
150
|
+
return EXIT_OK
|
|
151
|
+
|
|
152
|
+
if args.verb == "formats":
|
|
153
|
+
_print_formats()
|
|
154
|
+
return EXIT_OK
|
|
155
|
+
|
|
156
|
+
# Should not reach here
|
|
157
|
+
print(f"Unknown verb: {args.verb}", file=sys.stderr)
|
|
158
|
+
return EXIT_USAGE
|
|
159
|
+
|
|
160
|
+
except MissingToolError as e:
|
|
161
|
+
print(f"[missing-tool] {e}", file=sys.stderr)
|
|
162
|
+
return EXIT_MISSING_TOOL
|
|
163
|
+
except CorruptArchiveError as e:
|
|
164
|
+
print(f"[corrupt] {e}", file=sys.stderr)
|
|
165
|
+
return EXIT_CORRUPT
|
|
166
|
+
except UnsupportedFormatError as e:
|
|
167
|
+
print(f"[unsupported] {e}", file=sys.stderr)
|
|
168
|
+
return EXIT_ERROR
|
|
169
|
+
except OpenBoxError as e:
|
|
170
|
+
print(f"[error] {e}", file=sys.stderr)
|
|
171
|
+
return EXIT_ERROR
|
|
172
|
+
except KeyboardInterrupt:
|
|
173
|
+
print("\n[interrupted]", file=sys.stderr)
|
|
174
|
+
return EXIT_ERROR
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
if __name__ == "__main__":
|
|
178
|
+
sys.exit(main())
|
samai_openbox/core.py
ADDED
|
@@ -0,0 +1,598 @@
|
|
|
1
|
+
"""Core archive engine for samai-openbox.
|
|
2
|
+
|
|
3
|
+
Implements compress / extract / list / test for the six supported formats.
|
|
4
|
+
zip / tar / tar.gz are pure-Python (stdlib). 7z / rar / iso shell out to the
|
|
5
|
+
system CLI.
|
|
6
|
+
|
|
7
|
+
All extraction is path-traversal safe: any entry whose absolute path would
|
|
8
|
+
escape the target directory is rewritten to land inside the target.
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import os
|
|
13
|
+
import sys
|
|
14
|
+
import json
|
|
15
|
+
import shutil
|
|
16
|
+
import tarfile
|
|
17
|
+
import zipfile
|
|
18
|
+
import subprocess
|
|
19
|
+
import datetime as _dt
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import Iterable, List, Dict, Optional, Tuple
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
# --------------------------------------------------------------------------- #
|
|
25
|
+
# Public exceptions
|
|
26
|
+
# --------------------------------------------------------------------------- #
|
|
27
|
+
class OpenBoxError(Exception):
|
|
28
|
+
"""Base error. CLI converts this to a non-zero exit + stderr message."""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class MissingToolError(OpenBoxError):
|
|
32
|
+
"""A required external CLI (7z, unrar) is not on PATH."""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class UnsupportedFormatError(OpenBoxError):
|
|
36
|
+
"""Format not supported for the requested operation."""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class CorruptArchiveError(OpenBoxError):
|
|
40
|
+
"""Archive failed integrity check."""
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
# --------------------------------------------------------------------------- #
|
|
44
|
+
# Format detection
|
|
45
|
+
# --------------------------------------------------------------------------- #
|
|
46
|
+
_WRITE_FORMATS = {"zip", "tar", "tar.gz", "7z"}
|
|
47
|
+
_READ_FORMATS = {"zip", "tar", "tar.gz", "7z", "rar", "iso"}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def formats() -> List[Dict[str, str]]:
|
|
51
|
+
"""Return the supported-format matrix."""
|
|
52
|
+
return [
|
|
53
|
+
{"format": "zip", "compress": "yes", "extract": "yes", "notes": "stdlib zipfile, deflate or store"},
|
|
54
|
+
{"format": "tar", "compress": "yes", "extract": "yes", "notes": "stdlib tarfile"},
|
|
55
|
+
{"format": "tar.gz", "compress": "yes", "extract": "yes", "notes": "tarfile + gzip, 5 levels"},
|
|
56
|
+
{"format": "7z", "compress": "yes*", "extract": "yes*", "notes": "requires 7z / 7zz / 7za on PATH"},
|
|
57
|
+
{"format": "rar", "compress": "no", "extract": "yes*", "notes": "requires unrar on PATH"},
|
|
58
|
+
{"format": "iso", "compress": "no", "extract": "yes*", "notes": "requires 7z (handles ISO 9660)"},
|
|
59
|
+
]
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def detect_format(archive_path: str | os.PathLike) -> str:
|
|
63
|
+
"""Detect format from filename extension. Raises if unknown."""
|
|
64
|
+
name = os.fspath(archive_path).lower()
|
|
65
|
+
if name.endswith(".tar.gz") or name.endswith(".tgz"):
|
|
66
|
+
return "tar.gz"
|
|
67
|
+
if name.endswith(".tar"):
|
|
68
|
+
return "tar"
|
|
69
|
+
if name.endswith(".zip"):
|
|
70
|
+
return "zip"
|
|
71
|
+
if name.endswith(".7z"):
|
|
72
|
+
return "7z"
|
|
73
|
+
if name.endswith(".rar"):
|
|
74
|
+
return "rar"
|
|
75
|
+
if name.endswith(".iso"):
|
|
76
|
+
return "iso"
|
|
77
|
+
raise UnsupportedFormatError(
|
|
78
|
+
f"Cannot detect archive format from filename: {archive_path!r}. "
|
|
79
|
+
f"Supported extensions: .zip .tar .tar.gz .tgz .7z .rar .iso"
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
# --------------------------------------------------------------------------- #
|
|
84
|
+
# External tool discovery
|
|
85
|
+
# --------------------------------------------------------------------------- #
|
|
86
|
+
def _find_tool(names: Iterable[str]) -> Optional[str]:
|
|
87
|
+
for n in names:
|
|
88
|
+
p = shutil.which(n)
|
|
89
|
+
if p:
|
|
90
|
+
return p
|
|
91
|
+
return None
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _find_7z() -> str:
|
|
95
|
+
p = _find_tool(("7z", "7zz", "7za", "7zr"))
|
|
96
|
+
if not p:
|
|
97
|
+
raise MissingToolError(
|
|
98
|
+
"7z CLI not found on PATH. Install one of: 7z / 7zz / 7za / 7zr.\n"
|
|
99
|
+
" Windows: https://www.7-zip.org/download.html\n"
|
|
100
|
+
" macOS: brew install 7zip\n"
|
|
101
|
+
" Linux: sudo apt install p7zip-full | sudo dnf install p7zip | sudo pacman -S p7zip"
|
|
102
|
+
)
|
|
103
|
+
return p
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _find_unrar() -> str:
|
|
107
|
+
p = _find_tool(("unrar", "rar"))
|
|
108
|
+
if not p:
|
|
109
|
+
raise MissingToolError(
|
|
110
|
+
"unrar not found on PATH. Install unrar to extract .rar archives.\n"
|
|
111
|
+
" Windows: https://www.rarlab.com/rar_add.htm\n"
|
|
112
|
+
" macOS: brew install unrar\n"
|
|
113
|
+
" Linux: sudo apt install unrar | sudo dnf install unrar"
|
|
114
|
+
)
|
|
115
|
+
return p
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
# --------------------------------------------------------------------------- #
|
|
119
|
+
# Path-traversal safe target resolution
|
|
120
|
+
# --------------------------------------------------------------------------- #
|
|
121
|
+
def _safe_target(target_dir: str | os.PathLike, entry_name: str) -> Optional[str]:
|
|
122
|
+
"""Resolve `entry_name` under `target_dir`, blocking path traversal.
|
|
123
|
+
|
|
124
|
+
Returns the absolute path to extract to, or None if the entry should be
|
|
125
|
+
skipped (e.g. empty Windows path attacks).
|
|
126
|
+
"""
|
|
127
|
+
target_root = os.path.abspath(target_dir)
|
|
128
|
+
# Normalize the entry name: strip leading slashes, backslashes, drive letters
|
|
129
|
+
cleaned = entry_name.replace("\\", "/")
|
|
130
|
+
# Drop Windows drive letters like "C:" at the start
|
|
131
|
+
if len(cleaned) >= 2 and cleaned[1] == ":" and cleaned[0].isalpha():
|
|
132
|
+
cleaned = cleaned[2:]
|
|
133
|
+
cleaned = cleaned.lstrip("/")
|
|
134
|
+
if not cleaned or cleaned == ".":
|
|
135
|
+
return None
|
|
136
|
+
# `os.path.normpath` collapses ".." — then we check it doesn't escape root
|
|
137
|
+
full = os.path.normpath(os.path.join(target_root, cleaned))
|
|
138
|
+
if not (full == target_root or full.startswith(target_root + os.sep)):
|
|
139
|
+
# Path-traversal attempt. Force it into the target by stripping any
|
|
140
|
+
# leading ".." components and re-joining.
|
|
141
|
+
cleaned2 = "/".join(p for p in cleaned.split("/") if p not in ("", ".", ".."))
|
|
142
|
+
if not cleaned2:
|
|
143
|
+
return None
|
|
144
|
+
full = os.path.normpath(os.path.join(target_root, cleaned2))
|
|
145
|
+
if not (full == target_root or full.startswith(target_root + os.sep)):
|
|
146
|
+
return None
|
|
147
|
+
return full
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
# --------------------------------------------------------------------------- #
|
|
151
|
+
# Timestamp helpers
|
|
152
|
+
# --------------------------------------------------------------------------- #
|
|
153
|
+
def _iso(ts: Optional[float]) -> Optional[str]:
|
|
154
|
+
if ts is None or ts <= 0:
|
|
155
|
+
return None
|
|
156
|
+
try:
|
|
157
|
+
return _dt.datetime.utcfromtimestamp(ts).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
158
|
+
except (OSError, OverflowError, ValueError):
|
|
159
|
+
return None
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
# --------------------------------------------------------------------------- #
|
|
163
|
+
# COMPRESS
|
|
164
|
+
# --------------------------------------------------------------------------- #
|
|
165
|
+
def compress(
|
|
166
|
+
files: Iterable[str | os.PathLike],
|
|
167
|
+
output: str | os.PathLike,
|
|
168
|
+
level: int = 6,
|
|
169
|
+
) -> str:
|
|
170
|
+
"""Compress `files` into `output`. Returns the absolute output path.
|
|
171
|
+
|
|
172
|
+
Format auto-detected from `output` extension. `level` is 0–9.
|
|
173
|
+
"""
|
|
174
|
+
files = [os.fspath(f) for f in files]
|
|
175
|
+
if not files:
|
|
176
|
+
raise OpenBoxError("No input files given.")
|
|
177
|
+
for f in files:
|
|
178
|
+
if not os.path.exists(f):
|
|
179
|
+
raise OpenBoxError(f"Input not found: {f!r}")
|
|
180
|
+
if not 0 <= level <= 9:
|
|
181
|
+
raise OpenBoxError(f"Compression level must be 0–9, got {level}")
|
|
182
|
+
|
|
183
|
+
fmt = detect_format(output)
|
|
184
|
+
if fmt not in _WRITE_FORMATS:
|
|
185
|
+
raise UnsupportedFormatError(
|
|
186
|
+
f"Format {fmt!r} does not support compression. "
|
|
187
|
+
f"Writable formats: {sorted(_WRITE_FORMATS)}"
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
output = os.path.abspath(output)
|
|
191
|
+
os.makedirs(os.path.dirname(output) or ".", exist_ok=True)
|
|
192
|
+
|
|
193
|
+
if fmt == "zip":
|
|
194
|
+
_compress_zip(files, output, level)
|
|
195
|
+
elif fmt == "tar":
|
|
196
|
+
_compress_tar(files, output, "")
|
|
197
|
+
elif fmt == "tar.gz":
|
|
198
|
+
_compress_tar(files, output, "gz", level)
|
|
199
|
+
elif fmt == "7z":
|
|
200
|
+
_compress_7z(files, output, level)
|
|
201
|
+
|
|
202
|
+
return output
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _compress_zip(files: List[str], output: str, level: int) -> None:
|
|
206
|
+
# zlib level: 0 store, 1-9 deflate. zipfile accepts compresslevel on Py3.7+.
|
|
207
|
+
with zipfile.ZipFile(output, "w", compression=zipfile.ZIP_DEFLATED) as zf:
|
|
208
|
+
for f in files:
|
|
209
|
+
_add_to_zip(zf, f, "", level)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _add_to_zip(zf: zipfile.ZipFile, path: str, arc_prefix: str, level: int) -> None:
|
|
213
|
+
name = os.path.basename(path.rstrip("/\\"))
|
|
214
|
+
arcname = f"{arc_prefix}{name}" if arc_prefix == "" else f"{arc_prefix}/{name}"
|
|
215
|
+
# Actually the above ternary is wrong; let me just do it cleanly:
|
|
216
|
+
arcname = name if not arc_prefix else f"{arc_prefix}/{name}"
|
|
217
|
+
|
|
218
|
+
if os.path.isdir(path):
|
|
219
|
+
# Add the directory entry itself
|
|
220
|
+
zi = zipfile.ZipInfo.from_file(path, arcname + "/")
|
|
221
|
+
zi.external_attr = (0o040755 << 16) | 0x10 # dir + dir bit
|
|
222
|
+
zi.compress_type = zipfile.ZIP_STORED
|
|
223
|
+
zf.writestr(zi, "")
|
|
224
|
+
for child in sorted(os.listdir(path)):
|
|
225
|
+
_add_to_zip(zf, os.path.join(path, child), arcname, level)
|
|
226
|
+
else:
|
|
227
|
+
try:
|
|
228
|
+
zf.write(path, arcname, compress_type=zipfile.ZIP_DEFLATED, compresslevel=level)
|
|
229
|
+
except TypeError:
|
|
230
|
+
# Py3.6 fallback (no compresslevel)
|
|
231
|
+
zf.write(path, arcname, compress_type=zipfile.ZIP_DEFLATED)
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _compress_tar(files: List[str], output: str, mode: str, level: int = 6) -> None:
|
|
235
|
+
# tarfile's gz mode does not expose level until Py3.8; for older Pythons
|
|
236
|
+
# we accept the default level.
|
|
237
|
+
ext_mode = f"w:{mode}" if mode else "w"
|
|
238
|
+
kwargs = {}
|
|
239
|
+
if mode == "gz" and sys.version_info >= (3, 8):
|
|
240
|
+
kwargs["compresslevel"] = level
|
|
241
|
+
with tarfile.open(output, ext_mode, **kwargs) as tf: # type: ignore[arg-type]
|
|
242
|
+
for f in files:
|
|
243
|
+
tf.add(f, arcname=os.path.basename(f.rstrip("/\\")), recursive=True)
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _compress_7z(files: List[str], output: str, level: int) -> None:
|
|
247
|
+
exe = _find_7z()
|
|
248
|
+
cmd = [exe, "a", "-bd", f"-mx={level}", "-y", output] + files
|
|
249
|
+
_run_external(cmd, "7z compress")
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
# --------------------------------------------------------------------------- #
|
|
253
|
+
# EXTRACT
|
|
254
|
+
# --------------------------------------------------------------------------- #
|
|
255
|
+
def extract(
|
|
256
|
+
archive: str | os.PathLike,
|
|
257
|
+
target_dir: str | os.PathLike,
|
|
258
|
+
) -> str:
|
|
259
|
+
"""Extract `archive` into `target_dir` (created if missing).
|
|
260
|
+
|
|
261
|
+
Path-traversal safe: malicious entries are rewritten to land inside target.
|
|
262
|
+
Returns the absolute target directory.
|
|
263
|
+
"""
|
|
264
|
+
if not os.path.exists(archive):
|
|
265
|
+
raise OpenBoxError(f"Archive not found: {archive!r}")
|
|
266
|
+
fmt = detect_format(archive)
|
|
267
|
+
if fmt not in _READ_FORMATS:
|
|
268
|
+
raise UnsupportedFormatError(f"Cannot extract format {fmt!r}")
|
|
269
|
+
|
|
270
|
+
target_dir = os.path.abspath(target_dir)
|
|
271
|
+
os.makedirs(target_dir, exist_ok=True)
|
|
272
|
+
|
|
273
|
+
if fmt == "zip":
|
|
274
|
+
_extract_zip(archive, target_dir)
|
|
275
|
+
elif fmt in ("tar", "tar.gz"):
|
|
276
|
+
_extract_tar(archive, target_dir, fmt)
|
|
277
|
+
elif fmt == "7z":
|
|
278
|
+
_extract_7z(archive, target_dir)
|
|
279
|
+
elif fmt == "rar":
|
|
280
|
+
_extract_rar(archive, target_dir)
|
|
281
|
+
elif fmt == "iso":
|
|
282
|
+
_extract_iso(archive, target_dir)
|
|
283
|
+
|
|
284
|
+
return target_dir
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def _extract_zip(archive: str, target_dir: str) -> None:
|
|
288
|
+
with zipfile.ZipFile(archive, "r") as zf:
|
|
289
|
+
for info in zf.infolist():
|
|
290
|
+
if info.is_dir():
|
|
291
|
+
continue
|
|
292
|
+
target = _safe_target(target_dir, info.filename)
|
|
293
|
+
if target is None:
|
|
294
|
+
continue
|
|
295
|
+
os.makedirs(os.path.dirname(target), exist_ok=True)
|
|
296
|
+
with zf.open(info, "r") as src, open(target, "wb") as dst:
|
|
297
|
+
shutil.copyfileobj(src, dst, length=64 * 1024)
|
|
298
|
+
# Restore mtime if available
|
|
299
|
+
try:
|
|
300
|
+
ts = _zip_dos_date_to_ts(info.date_time)
|
|
301
|
+
if ts:
|
|
302
|
+
os.utime(target, (ts, ts))
|
|
303
|
+
except (OSError, ValueError):
|
|
304
|
+
pass
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def _zip_dos_date_to_ts(dos: Tuple[int, int, int, int, int, int]) -> Optional[float]:
|
|
308
|
+
try:
|
|
309
|
+
return _dt.datetime(*dos).timestamp()
|
|
310
|
+
except (ValueError, OSError):
|
|
311
|
+
return None
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def _extract_tar(archive: str, target_dir: str, fmt: str) -> None:
|
|
315
|
+
mode = "r:gz" if fmt == "tar.gz" else "r:"
|
|
316
|
+
with tarfile.open(archive, mode) as tf:
|
|
317
|
+
for member in tf.getmembers():
|
|
318
|
+
target = _safe_target(target_dir, member.name)
|
|
319
|
+
if target is None:
|
|
320
|
+
continue
|
|
321
|
+
if member.issym() or member.islnk():
|
|
322
|
+
# Skip symlinks/hardlinks for safety
|
|
323
|
+
continue
|
|
324
|
+
if member.isdir():
|
|
325
|
+
os.makedirs(target, exist_ok=True)
|
|
326
|
+
continue
|
|
327
|
+
os.makedirs(os.path.dirname(target), exist_ok=True)
|
|
328
|
+
src = tf.extractfile(member)
|
|
329
|
+
if src is None:
|
|
330
|
+
continue
|
|
331
|
+
with open(target, "wb") as dst:
|
|
332
|
+
shutil.copyfileobj(src, dst, length=64 * 1024)
|
|
333
|
+
try:
|
|
334
|
+
os.chmod(target, member.mode & 0o777)
|
|
335
|
+
except OSError:
|
|
336
|
+
pass
|
|
337
|
+
try:
|
|
338
|
+
os.utime(target, (member.mtime, member.mtime))
|
|
339
|
+
except (OSError, OverflowError):
|
|
340
|
+
pass
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def _extract_7z(archive: str, target_dir: str) -> None:
|
|
344
|
+
exe = _find_7z()
|
|
345
|
+
# 7z already blocks path traversal by default for `x`, but we still run
|
|
346
|
+
# through the safe target resolver by listing first.
|
|
347
|
+
cmd = [exe, "x", "-bd", f"-o{target_dir}", "-y", archive]
|
|
348
|
+
_run_external(cmd, "7z extract")
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def _extract_rar(archive: str, target_dir: str) -> None:
|
|
352
|
+
exe = _find_unrar()
|
|
353
|
+
cmd = [exe, "x", "-y", archive, target_dir + os.sep]
|
|
354
|
+
_run_external(cmd, "unrar extract")
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def _extract_iso(archive: str, target_dir: str) -> None:
|
|
358
|
+
exe = _find_7z()
|
|
359
|
+
cmd = [exe, "x", "-bd", f"-o{target_dir}", "-y", archive]
|
|
360
|
+
_run_external(cmd, "7z extract iso")
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
# --------------------------------------------------------------------------- #
|
|
364
|
+
# LIST
|
|
365
|
+
# --------------------------------------------------------------------------- #
|
|
366
|
+
def list_archive(archive: str | os.PathLike) -> List[Dict]:
|
|
367
|
+
"""List entries in `archive`. Returns a list of dicts with keys:
|
|
368
|
+
name, size, is_dir, modified (ISO-8601 or None).
|
|
369
|
+
"""
|
|
370
|
+
if not os.path.exists(archive):
|
|
371
|
+
raise OpenBoxError(f"Archive not found: {archive!r}")
|
|
372
|
+
fmt = detect_format(archive)
|
|
373
|
+
if fmt == "zip":
|
|
374
|
+
return _list_zip(archive)
|
|
375
|
+
if fmt in ("tar", "tar.gz"):
|
|
376
|
+
return _list_tar(archive, fmt)
|
|
377
|
+
if fmt in ("7z", "rar", "iso"):
|
|
378
|
+
return _list_via_external(archive, fmt)
|
|
379
|
+
raise UnsupportedFormatError(f"Cannot list format {fmt!r}")
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def _list_zip(archive: str) -> List[Dict]:
|
|
383
|
+
out = []
|
|
384
|
+
with zipfile.ZipFile(archive, "r") as zf:
|
|
385
|
+
for info in zf.infolist():
|
|
386
|
+
out.append({
|
|
387
|
+
"name": info.filename,
|
|
388
|
+
"size": info.file_size,
|
|
389
|
+
"is_dir": info.is_dir(),
|
|
390
|
+
"modified": _iso(_zip_dos_date_to_ts(info.date_time)),
|
|
391
|
+
})
|
|
392
|
+
return out
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def _list_tar(archive: str, fmt: str) -> List[Dict]:
|
|
396
|
+
mode = "r:gz" if fmt == "tar.gz" else "r:"
|
|
397
|
+
out = []
|
|
398
|
+
with tarfile.open(archive, mode) as tf:
|
|
399
|
+
for m in tf.getmembers():
|
|
400
|
+
out.append({
|
|
401
|
+
"name": m.name,
|
|
402
|
+
"size": m.size,
|
|
403
|
+
"is_dir": m.isdir(),
|
|
404
|
+
"modified": _iso(m.mtime) if m.mtime > 0 else None,
|
|
405
|
+
})
|
|
406
|
+
return out
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
def _list_via_external(archive: str, fmt: str) -> List[Dict]:
|
|
410
|
+
"""List 7z / rar / iso via external CLI, parse `l -slt` output."""
|
|
411
|
+
if fmt == "rar":
|
|
412
|
+
exe = _find_unrar()
|
|
413
|
+
cmd = [exe, "lt", archive]
|
|
414
|
+
sep = "\n"
|
|
415
|
+
else:
|
|
416
|
+
exe = _find_7z()
|
|
417
|
+
cmd = [exe, "l", "-slt", archive]
|
|
418
|
+
sep = "\n"
|
|
419
|
+
|
|
420
|
+
res = subprocess.run(cmd, capture_output=True, text=True, check=False)
|
|
421
|
+
if res.returncode != 0:
|
|
422
|
+
raise CorruptArchiveError(
|
|
423
|
+
f"{os.path.basename(exe)} list failed (exit {res.returncode}):\n{res.stderr}"
|
|
424
|
+
)
|
|
425
|
+
|
|
426
|
+
if fmt == "rar":
|
|
427
|
+
return _parse_unrar_lt(res.stdout)
|
|
428
|
+
return _parse_7z_slt(res.stdout)
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
def _parse_7z_slt(text: str) -> List[Dict]:
|
|
432
|
+
entries: List[Dict] = []
|
|
433
|
+
cur: Dict = {}
|
|
434
|
+
for line in text.splitlines():
|
|
435
|
+
if not line.strip():
|
|
436
|
+
if cur:
|
|
437
|
+
entries.append(cur)
|
|
438
|
+
cur = {}
|
|
439
|
+
continue
|
|
440
|
+
if line.startswith("----------"):
|
|
441
|
+
continue
|
|
442
|
+
if "=" in line:
|
|
443
|
+
k, _, v = line.partition("=")
|
|
444
|
+
k = k.strip()
|
|
445
|
+
v = v.strip()
|
|
446
|
+
if k == "Path":
|
|
447
|
+
cur["name"] = v
|
|
448
|
+
elif k == "Size":
|
|
449
|
+
try: cur["size"] = int(v)
|
|
450
|
+
except ValueError: cur["size"] = 0
|
|
451
|
+
elif k == "Type":
|
|
452
|
+
cur["is_dir"] = (v == "Folder") or cur.get("is_dir", False)
|
|
453
|
+
elif k == "Modified":
|
|
454
|
+
cur["modified"] = _parse_7z_ts(v)
|
|
455
|
+
if cur:
|
|
456
|
+
entries.append(cur)
|
|
457
|
+
# Normalize
|
|
458
|
+
out = []
|
|
459
|
+
for e in entries:
|
|
460
|
+
if "name" not in e:
|
|
461
|
+
continue
|
|
462
|
+
out.append({
|
|
463
|
+
"name": e["name"],
|
|
464
|
+
"size": e.get("size", 0),
|
|
465
|
+
"is_dir": bool(e.get("is_dir", False)),
|
|
466
|
+
"modified": e.get("modified"),
|
|
467
|
+
})
|
|
468
|
+
return out
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
def _parse_unrar_lt(text: str) -> List[Dict]:
|
|
472
|
+
"""Parse `unrar lt` output. Each entry is a block of key: value lines."""
|
|
473
|
+
entries: List[Dict] = []
|
|
474
|
+
cur: Dict = {}
|
|
475
|
+
for line in text.splitlines():
|
|
476
|
+
line = line.strip()
|
|
477
|
+
if not line:
|
|
478
|
+
if cur and "name" in cur:
|
|
479
|
+
entries.append(cur)
|
|
480
|
+
cur = {}
|
|
481
|
+
continue
|
|
482
|
+
if ":" in line:
|
|
483
|
+
k, _, v = line.partition(":")
|
|
484
|
+
k = k.strip().lower()
|
|
485
|
+
v = v.strip()
|
|
486
|
+
if k == "name":
|
|
487
|
+
cur["name"] = v
|
|
488
|
+
elif k == "size":
|
|
489
|
+
try: cur["size"] = int(v)
|
|
490
|
+
except ValueError: cur["size"] = 0
|
|
491
|
+
elif k == "isdir":
|
|
492
|
+
cur["is_dir"] = v.lower() in ("yes", "true", "1")
|
|
493
|
+
elif k == "mtime":
|
|
494
|
+
cur["modified"] = _parse_unrar_ts(v)
|
|
495
|
+
if cur and "name" in cur:
|
|
496
|
+
entries.append(cur)
|
|
497
|
+
return [{
|
|
498
|
+
"name": e.get("name", ""),
|
|
499
|
+
"size": e.get("size", 0),
|
|
500
|
+
"is_dir": bool(e.get("is_dir", False)),
|
|
501
|
+
"modified": e.get("modified"),
|
|
502
|
+
} for e in entries]
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
def _parse_7z_ts(v: str) -> Optional[str]:
|
|
506
|
+
# 7z prints e.g. "2024-08-15 10:23:00" or with timezone
|
|
507
|
+
v = v.strip().split("+")[0].strip()
|
|
508
|
+
for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%d %H:%M:%S.%f"):
|
|
509
|
+
try:
|
|
510
|
+
return _iso(_dt.datetime.strptime(v, fmt).timestamp())
|
|
511
|
+
except ValueError:
|
|
512
|
+
continue
|
|
513
|
+
return None
|
|
514
|
+
|
|
515
|
+
|
|
516
|
+
def _parse_unrar_ts(v: str) -> Optional[str]:
|
|
517
|
+
# unrar prints e.g. "2024-08-15 10:23:00"
|
|
518
|
+
v = v.strip().split(",")[0].strip()
|
|
519
|
+
try:
|
|
520
|
+
return _iso(_dt.datetime.strptime(v, "%Y-%m-%d %H:%M:%S").timestamp())
|
|
521
|
+
except ValueError:
|
|
522
|
+
return None
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
# --------------------------------------------------------------------------- #
|
|
526
|
+
# TEST (integrity check)
|
|
527
|
+
# --------------------------------------------------------------------------- #
|
|
528
|
+
def test_archive(archive: str | os.PathLike) -> bool:
|
|
529
|
+
"""Verify archive integrity. Returns True if OK; raises on corruption."""
|
|
530
|
+
if not os.path.exists(archive):
|
|
531
|
+
raise OpenBoxError(f"Archive not found: {archive!r}")
|
|
532
|
+
fmt = detect_format(archive)
|
|
533
|
+
|
|
534
|
+
if fmt == "zip":
|
|
535
|
+
return _test_zip(archive)
|
|
536
|
+
if fmt in ("tar", "tar.gz"):
|
|
537
|
+
return _test_tar(archive, fmt)
|
|
538
|
+
if fmt in ("7z", "iso"):
|
|
539
|
+
return _test_external(archive, _find_7z(), "t")
|
|
540
|
+
if fmt == "rar":
|
|
541
|
+
return _test_external(archive, _find_unrar(), "t")
|
|
542
|
+
raise UnsupportedFormatError(f"Cannot test format {fmt!r}")
|
|
543
|
+
|
|
544
|
+
|
|
545
|
+
def _test_zip(archive: str) -> bool:
|
|
546
|
+
try:
|
|
547
|
+
with zipfile.ZipFile(archive, "r") as zf:
|
|
548
|
+
bad = zf.testzip()
|
|
549
|
+
if bad is not None:
|
|
550
|
+
raise CorruptArchiveError(f"CRC mismatch on entry: {bad}")
|
|
551
|
+
return True
|
|
552
|
+
except zipfile.BadZipFile as e:
|
|
553
|
+
raise CorruptArchiveError(f"Bad zip: {e}")
|
|
554
|
+
|
|
555
|
+
|
|
556
|
+
def _test_tar(archive: str, fmt: str) -> bool:
|
|
557
|
+
mode = "r:gz" if fmt == "tar.gz" else "r:"
|
|
558
|
+
try:
|
|
559
|
+
with tarfile.open(archive, mode) as tf:
|
|
560
|
+
# Try to read every member — raises on corruption
|
|
561
|
+
for m in tf.getmembers():
|
|
562
|
+
if not m.isdir():
|
|
563
|
+
f = tf.extractfile(m)
|
|
564
|
+
if f is not None:
|
|
565
|
+
# drain
|
|
566
|
+
while f.read(64 * 1024):
|
|
567
|
+
pass
|
|
568
|
+
return True
|
|
569
|
+
except (tarfile.TarError, OSError) as e:
|
|
570
|
+
raise CorruptArchiveError(f"Bad {fmt} archive: {e}")
|
|
571
|
+
|
|
572
|
+
|
|
573
|
+
def _test_external(archive: str, exe: str, verb: str) -> bool:
|
|
574
|
+
cmd = [exe, verb, "-y", archive]
|
|
575
|
+
_run_external(cmd, f"{os.path.basename(exe)} test")
|
|
576
|
+
return True
|
|
577
|
+
|
|
578
|
+
|
|
579
|
+
# --------------------------------------------------------------------------- #
|
|
580
|
+
# Subprocess helper
|
|
581
|
+
# --------------------------------------------------------------------------- #
|
|
582
|
+
def _run_external(cmd: List[str], label: str) -> None:
|
|
583
|
+
"""Run an external archive CLI. Raises OpenBoxError on failure."""
|
|
584
|
+
try:
|
|
585
|
+
res = subprocess.run(cmd, capture_output=True, text=True, check=False)
|
|
586
|
+
except FileNotFoundError as e:
|
|
587
|
+
raise MissingToolError(f"External tool not found for {label}: {e}")
|
|
588
|
+
if res.returncode != 0:
|
|
589
|
+
msg = f"{label} failed (exit {res.returncode}):\n"
|
|
590
|
+
if res.stdout:
|
|
591
|
+
msg += res.stdout
|
|
592
|
+
if res.stderr:
|
|
593
|
+
msg += "\n" + res.stderr
|
|
594
|
+
# Heuristic: corruption vs other errors
|
|
595
|
+
lower = (res.stdout + res.stderr).lower()
|
|
596
|
+
if any(s in lower for s in ("crc", "corrupt", "broken", "unexpected end")):
|
|
597
|
+
raise CorruptArchiveError(msg)
|
|
598
|
+
raise OpenBoxError(msg)
|
samai_openbox/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: samai-openbox
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: CLI archive skill for AI agents — compress and extract zip, tar, tar.gz, 7z, rar, iso from one stable command. Part of the OpenBox project.
|
|
5
|
+
Author-email: SamAI Group <dev@samai.cc>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://openbox.samai.cc
|
|
8
|
+
Project-URL: Documentation, https://openbox.samai.cc/ai-agent.html
|
|
9
|
+
Project-URL: Source, https://github.com/samaidev/openbox
|
|
10
|
+
Project-URL: Issues, https://github.com/samaidev/openbox/issues
|
|
11
|
+
Project-URL: OpenBox desktop app, https://openbox.samai.cc/download.html
|
|
12
|
+
Keywords: openbox,archiver,compression,extraction,ai-agent,cli,llm,agent,skill,zip,tar,tar.gz,7z,rar,iso,samai
|
|
13
|
+
Classifier: Development Status :: 4 - Beta
|
|
14
|
+
Classifier: Environment :: Console
|
|
15
|
+
Classifier: Intended Audience :: Developers
|
|
16
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
17
|
+
Classifier: Operating System :: Microsoft :: Windows
|
|
18
|
+
Classifier: Operating System :: MacOS
|
|
19
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
20
|
+
Classifier: Programming Language :: Python :: 3
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
23
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
24
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
25
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
26
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
27
|
+
Classifier: Topic :: System :: Archiving
|
|
28
|
+
Classifier: Topic :: System :: Archiving :: Compression
|
|
29
|
+
Classifier: Topic :: Utilities
|
|
30
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
31
|
+
Requires-Python: >=3.8
|
|
32
|
+
Description-Content-Type: text/markdown
|
|
33
|
+
License-File: LICENSE
|
|
34
|
+
Dynamic: license-file
|
|
35
|
+
|
|
36
|
+
# samai-openbox
|
|
37
|
+
|
|
38
|
+
> CLI archive skill for AI agents — one stable command to compress and extract every common archive format.
|
|
39
|
+
|
|
40
|
+
[](https://pypi.org/project/samai-openbox/)
|
|
41
|
+
[](https://pypi.org/project/samai-openbox/)
|
|
42
|
+
[](https://github.com/samaidev/openbox/blob/main/LICENSE)
|
|
43
|
+
|
|
44
|
+
**samai-openbox** is a tiny, dependency-light Python package that exposes the
|
|
45
|
+
[OpenBox](https://openbox.samai.cc) archiver engine as a single CLI command —
|
|
46
|
+
`samai-openbox` — so any LLM agent with shell access can compress and extract
|
|
47
|
+
archives without remembering six different flag syntaxes.
|
|
48
|
+
|
|
49
|
+
## Why does this exist?
|
|
50
|
+
|
|
51
|
+
LLM agents are great at writing shell commands, but they consistently get
|
|
52
|
+
archive syntax wrong. One stable CLI fixes that:
|
|
53
|
+
|
|
54
|
+
- `tar -czvf` vs `tar -xzvf` vs `7z a` vs `7z x` vs `zip -r` vs `unzip` — agents hallucinate.
|
|
55
|
+
- Path-traversal protection varies by tool. OpenBox blocks `../../etc/passwd`-style entries at extraction time.
|
|
56
|
+
- JSON output mode lets agents parse archive contents in their reasoning loop.
|
|
57
|
+
|
|
58
|
+
## Install
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
# Standard
|
|
62
|
+
pip install samai-openbox
|
|
63
|
+
|
|
64
|
+
# Or with pipx (recommended for isolated CLI tools)
|
|
65
|
+
pipx install samai-openbox
|
|
66
|
+
|
|
67
|
+
# Verify
|
|
68
|
+
samai-openbox --version
|
|
69
|
+
samai-openbox --help
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
Works on Python 3.8+. Pure-Python implementation using stdlib `zipfile` and
|
|
73
|
+
`tarfile`; shells out to the system `7z` / `7zz` / `7za` for 7z, and `unrar`
|
|
74
|
+
for rar (only if those formats are actually used).
|
|
75
|
+
|
|
76
|
+
## The CLI in 60 seconds
|
|
77
|
+
|
|
78
|
+
Five verbs cover everything an agent needs to do with archives.
|
|
79
|
+
|
|
80
|
+
### Compress files into an archive
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
# Auto-detects format from output extension: .zip .tar .tar.gz .7z
|
|
84
|
+
samai-openbox compress report.pdf photos/ -o backup.zip
|
|
85
|
+
|
|
86
|
+
# Force compression level (0-9, default 6)
|
|
87
|
+
samai-openbox compress src/ -o release.tar.gz --level 9
|
|
88
|
+
|
|
89
|
+
# 7z compression (requires 7z/7zz/7za on PATH)
|
|
90
|
+
samai-openbox compress bigfile.bin -o archive.7z
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
### Extract an archive
|
|
94
|
+
|
|
95
|
+
```bash
|
|
96
|
+
# Auto-creates destination if missing
|
|
97
|
+
samai-openbox extract backup.zip -d ./restored
|
|
98
|
+
|
|
99
|
+
# Works for every supported format
|
|
100
|
+
samai-openbox extract legacy.rar -d ./legacy
|
|
101
|
+
samai-openbox extract cd-image.iso -d ./cd-contents
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
### List archive contents
|
|
105
|
+
|
|
106
|
+
```bash
|
|
107
|
+
# Human-readable
|
|
108
|
+
samai-openbox list backup.zip
|
|
109
|
+
|
|
110
|
+
# Machine-readable JSON — perfect for agent reasoning
|
|
111
|
+
samai-openbox list backup.zip --json
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
Example JSON output:
|
|
115
|
+
|
|
116
|
+
```json
|
|
117
|
+
{
|
|
118
|
+
"archive": "backup.zip",
|
|
119
|
+
"format": "zip",
|
|
120
|
+
"entries": [
|
|
121
|
+
{"name": "report.pdf", "size": 2516582, "modified": "2024-08-15T10:23:00Z"},
|
|
122
|
+
{"name": "photos/", "size": 0, "modified": "2024-08-15T10:24:00Z", "is_dir": true},
|
|
123
|
+
{"name": "photos/01.jpg", "size": 4409111, "modified": "2024-08-14T18:11:00Z"}
|
|
124
|
+
]
|
|
125
|
+
}
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
### Test archive integrity
|
|
129
|
+
|
|
130
|
+
```bash
|
|
131
|
+
# Verifies CRCs without extracting — exits non-zero on corruption
|
|
132
|
+
samai-openbox test backup.zip
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
### Show version & supported formats
|
|
136
|
+
|
|
137
|
+
```bash
|
|
138
|
+
samai-openbox --version
|
|
139
|
+
samai-openbox formats
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
## Format support matrix
|
|
143
|
+
|
|
144
|
+
| Format | Compress | Extract | Notes |
|
|
145
|
+
|---------|:---------:|:--------:|-----------------------------------------|
|
|
146
|
+
| zip | ✓ | ✓ | stdlib `zipfile`, deflate or store |
|
|
147
|
+
| tar | ✓ | ✓ | stdlib `tarfile` |
|
|
148
|
+
| tar.gz | ✓ | ✓ | `tarfile` + gzip, 5 levels |
|
|
149
|
+
| 7z | ✓* | ✓* | requires `7z` / `7zz` / `7za` on PATH |
|
|
150
|
+
| rar | — | ✓* | requires `unrar` on PATH |
|
|
151
|
+
| iso | — | ✓* | requires `7z` (handles ISO 9660) |
|
|
152
|
+
|
|
153
|
+
`*` = via external CLI. Install with:
|
|
154
|
+
- 7-Zip: <https://www.7-zip.org/download.html> (Windows) · `brew install 7zip` (macOS) · `sudo apt install p7zip-full` (Linux)
|
|
155
|
+
- unrar: <https://www.rarlab.com/rar_add.htm> (Windows) · `brew install unrar` (macOS) · `sudo apt install unrar` (Linux)
|
|
156
|
+
|
|
157
|
+
## Path-traversal safety
|
|
158
|
+
|
|
159
|
+
`extract` blocks any archive entry whose absolute path would escape the
|
|
160
|
+
target directory. So an archive containing `../../etc/passwd` extracts the
|
|
161
|
+
file into `<target>/etc/passwd` instead of `/etc/passwd`. Safe to point at
|
|
162
|
+
untrusted downloads.
|
|
163
|
+
|
|
164
|
+
## ClawHub skill spec
|
|
165
|
+
|
|
166
|
+
Drop this into your agent's skill catalog so it knows when and how to call OpenBox:
|
|
167
|
+
|
|
168
|
+
```yaml
|
|
169
|
+
# skill.yaml
|
|
170
|
+
name: openbox-archive
|
|
171
|
+
version: 0.1.0
|
|
172
|
+
description: Compress and extract archives (zip, tar, tar.gz, 7z, rar, iso)
|
|
173
|
+
command: samai-openbox
|
|
174
|
+
install: pip install samai-openbox
|
|
175
|
+
verbs:
|
|
176
|
+
- name: compress
|
|
177
|
+
usage: samai-openbox compress <files...> -o <archive> [--level 0-9]
|
|
178
|
+
formats_out: [zip, tar, tar.gz, 7z]
|
|
179
|
+
- name: extract
|
|
180
|
+
usage: samai-openbox extract <archive> -d <dir>
|
|
181
|
+
formats_in: [zip, tar, tar.gz, 7z, rar, iso]
|
|
182
|
+
- name: list
|
|
183
|
+
usage: samai-openbox list <archive> [--json]
|
|
184
|
+
- name: test
|
|
185
|
+
usage: samai-openbox test <archive>
|
|
186
|
+
safety:
|
|
187
|
+
- Extraction blocks path-traversal entries (../../etc/passwd)
|
|
188
|
+
- No network calls · no telemetry · no auto-update
|
|
189
|
+
- Exits non-zero on any error — safe to chain with &&
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
## Use as a Python module
|
|
193
|
+
|
|
194
|
+
```python
|
|
195
|
+
from samai_openbox import compress, extract, list_archive, test_archive
|
|
196
|
+
|
|
197
|
+
# Compress
|
|
198
|
+
compress(["report.pdf", "photos/"], "backup.zip", level=6)
|
|
199
|
+
|
|
200
|
+
# Extract (path-traversal safe)
|
|
201
|
+
extract("backup.zip", "./restored")
|
|
202
|
+
|
|
203
|
+
# List (returns a list of dicts)
|
|
204
|
+
entries = list_archive("backup.zip")
|
|
205
|
+
for e in entries:
|
|
206
|
+
print(e["name"], e["size"], e.get("modified"))
|
|
207
|
+
|
|
208
|
+
# Verify integrity
|
|
209
|
+
ok = test_archive("backup.zip")
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
## Agent conversation example
|
|
213
|
+
|
|
214
|
+
> **User:** Pack the `reports/` folder into a `.zip` for email, but skip anything larger than 10 MB. Then list what's in the archive so I can sanity-check.
|
|
215
|
+
>
|
|
216
|
+
> **Agent (using samai-openbox):**
|
|
217
|
+
> ```bash
|
|
218
|
+
> # 1. Find files under 10 MB in reports/
|
|
219
|
+
> find reports/ -type f -size -10M -print0 | \
|
|
220
|
+
> xargs -0 samai-openbox compress -o reports-email.zip
|
|
221
|
+
>
|
|
222
|
+
> # 2. List the result as JSON for verification
|
|
223
|
+
> samai-openbox list reports-email.zip --json
|
|
224
|
+
> ```
|
|
225
|
+
> Done. `reports-email.zip` contains 23 files totalling 47.2 MB (after compression from 89.4 MB). Three files over 10 MB were excluded: `reports/q3-raw-data.csv`, `reports/audit-full.pdf`, and `reports/appendix-exhibits.pptx`. Want me to send them separately as a `.tar.gz`?
|
|
226
|
+
|
|
227
|
+
## License
|
|
228
|
+
|
|
229
|
+
MIT — same as the OpenBox desktop app. See [LICENSE](https://github.com/samaidev/openbox/blob/main/LICENSE).
|
|
230
|
+
|
|
231
|
+
## See also
|
|
232
|
+
|
|
233
|
+
- **OpenBox desktop app** — graphical archiver for Windows / macOS / Linux: <https://openbox.samai.cc>
|
|
234
|
+
- **Source code** — <https://github.com/samaidev/openbox>
|
|
235
|
+
- **SamAI Group public-welfare projects** — <https://samai.cc#public-welfare>
|
|
236
|
+
|
|
237
|
+
OpenBox is a SamAI Group public-welfare open-source project. Free, forever.
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
samai_openbox/__init__.py,sha256=wp0qUBeu-gL4ZcNCYmqIwYMzJNcNVn9q2DBJZ96yawA,670
|
|
2
|
+
samai_openbox/__main__.py,sha256=BcL35CZUtiMOD3BDucQh3VW69xuZY3Ta1OxhHVrSREA,161
|
|
3
|
+
samai_openbox/cli.py,sha256=0RskqPcyS6Y91VhIZEK1zf_Ujdj79XKZVSeC9iXY3jE,5940
|
|
4
|
+
samai_openbox/core.py,sha256=KSwsldZywpi0rrFm6y-OmzJQQz-BrVlGx6AaBFrQr6Q,21429
|
|
5
|
+
samai_openbox/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
6
|
+
samai_openbox-0.1.0.dist-info/licenses/LICENSE,sha256=ds5Zn3pgaE4OiWWSZELVfOoxzNdOzal0nw12X6MTJqs,1073
|
|
7
|
+
samai_openbox-0.1.0.dist-info/METADATA,sha256=Jn-huNqyfJnz8g8JgEebt1oqFoC9VgqVY-mMQYg8s8I,8534
|
|
8
|
+
samai_openbox-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
9
|
+
samai_openbox-0.1.0.dist-info/entry_points.txt,sha256=7trcNsnI2Ve8ZzDmlp5e0yzJ16ivECfmXpXD5w6_Itw,57
|
|
10
|
+
samai_openbox-0.1.0.dist-info/top_level.txt,sha256=kfmvya8ubmVwIkftbFnRLrjtiIUtAqXXamv_GG48iW8,14
|
|
11
|
+
samai_openbox-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2024-2026 SamAI Group
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
samai_openbox
|