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.
@@ -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)"
@@ -0,0 +1,6 @@
1
+ """Allow `python -m samai_openbox ...` as an alias for `samai-openbox ...`."""
2
+ import sys
3
+ from .cli import main
4
+
5
+ if __name__ == "__main__":
6
+ sys.exit(main())
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
+ [![PyPI version](https://img.shields.io/pypi/v/samai-openbox.svg)](https://pypi.org/project/samai-openbox/)
41
+ [![Python 3.8+](https://img.shields.io/pypi/pyversions/samai-openbox.svg)](https://pypi.org/project/samai-openbox/)
42
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ samai-openbox = samai_openbox.cli:main
@@ -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