bundleclaw 0.1.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.
- bundleclaw-0.1.0/PKG-INFO +91 -0
- bundleclaw-0.1.0/README.md +82 -0
- bundleclaw-0.1.0/bundleclaw/__init__.py +2 -0
- bundleclaw-0.1.0/bundleclaw/cli.py +295 -0
- bundleclaw-0.1.0/bundleclaw.egg-info/PKG-INFO +91 -0
- bundleclaw-0.1.0/bundleclaw.egg-info/SOURCES.txt +10 -0
- bundleclaw-0.1.0/bundleclaw.egg-info/dependency_links.txt +1 -0
- bundleclaw-0.1.0/bundleclaw.egg-info/entry_points.txt +2 -0
- bundleclaw-0.1.0/bundleclaw.egg-info/requires.txt +2 -0
- bundleclaw-0.1.0/bundleclaw.egg-info/top_level.txt +1 -0
- bundleclaw-0.1.0/pyproject.toml +17 -0
- bundleclaw-0.1.0/setup.cfg +4 -0
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: bundleclaw
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Migrate OpenClaw agent state between machines
|
|
5
|
+
Requires-Python: >=3.10
|
|
6
|
+
Description-Content-Type: text/markdown
|
|
7
|
+
Requires-Dist: typer>=0.12.0
|
|
8
|
+
Requires-Dist: cryptography>=43.0.0
|
|
9
|
+
|
|
10
|
+
<div align="center">
|
|
11
|
+
|
|
12
|
+
# BundleClaw — Python CLI
|
|
13
|
+
|
|
14
|
+
[](https://pypi.org/project/bundleclaw/)
|
|
15
|
+
[](https://www.python.org/)
|
|
16
|
+
[](../LICENSE)
|
|
17
|
+
|
|
18
|
+
Python implementation of the BundleClaw migration CLI.
|
|
19
|
+
|
|
20
|
+
</div>
|
|
21
|
+
|
|
22
|
+
---
|
|
23
|
+
|
|
24
|
+
## Install
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
# pip
|
|
28
|
+
pip install bundleclaw
|
|
29
|
+
|
|
30
|
+
# uv
|
|
31
|
+
uv pip install bundleclaw
|
|
32
|
+
|
|
33
|
+
# Or run directly
|
|
34
|
+
uvx bundleclaw --help
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Usage
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
# Export agent state
|
|
41
|
+
bundleclaw export \
|
|
42
|
+
--source ~/.openclaw \
|
|
43
|
+
--workspace ~/.openclaw/workspace \
|
|
44
|
+
--profile full \
|
|
45
|
+
--encrypt-pass 'strong-passphrase' \
|
|
46
|
+
--out agent-state.bcz
|
|
47
|
+
|
|
48
|
+
# Import on target machine
|
|
49
|
+
bundleclaw import \
|
|
50
|
+
--bundle agent-state.bcz \
|
|
51
|
+
--target ~/.openclaw \
|
|
52
|
+
--encrypt-pass 'strong-passphrase'
|
|
53
|
+
|
|
54
|
+
# Verify integrity
|
|
55
|
+
bundleclaw verify --target ~/.openclaw
|
|
56
|
+
|
|
57
|
+
# Transfer via SCP
|
|
58
|
+
bundleclaw transfer \
|
|
59
|
+
--bundle agent-state.bcz \
|
|
60
|
+
--to user@host:/tmp/agent-state.bcz
|
|
61
|
+
|
|
62
|
+
# Full bootstrap (import + verify + restart + health check)
|
|
63
|
+
bundleclaw bootstrap \
|
|
64
|
+
--bundle agent-state.bcz \
|
|
65
|
+
--encrypt-pass 'strong-passphrase' \
|
|
66
|
+
--target ~/.openclaw
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
## Development
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
python -m venv .venv
|
|
73
|
+
source .venv/bin/activate
|
|
74
|
+
pip install -e .
|
|
75
|
+
bundleclaw --help
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
### Tech Stack
|
|
79
|
+
|
|
80
|
+
- **Typer** for CLI argument parsing (Click-based)
|
|
81
|
+
- **cryptography** for AES-256-GCM encryption
|
|
82
|
+
- **zipfile** (stdlib) for ZIP archive handling
|
|
83
|
+
- **hashlib** (stdlib) for SHA-256 checksums
|
|
84
|
+
|
|
85
|
+
## Interoperability
|
|
86
|
+
|
|
87
|
+
Bundles created with this CLI are fully compatible with the [Node CLI](../node-cli/). The shared `.bcz` format is documented in [`spec/FORMAT.md`](../spec/FORMAT.md).
|
|
88
|
+
|
|
89
|
+
## License
|
|
90
|
+
|
|
91
|
+
[MIT](../LICENSE)
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
<div align="center">
|
|
2
|
+
|
|
3
|
+
# BundleClaw — Python CLI
|
|
4
|
+
|
|
5
|
+
[](https://pypi.org/project/bundleclaw/)
|
|
6
|
+
[](https://www.python.org/)
|
|
7
|
+
[](../LICENSE)
|
|
8
|
+
|
|
9
|
+
Python implementation of the BundleClaw migration CLI.
|
|
10
|
+
|
|
11
|
+
</div>
|
|
12
|
+
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
## Install
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
# pip
|
|
19
|
+
pip install bundleclaw
|
|
20
|
+
|
|
21
|
+
# uv
|
|
22
|
+
uv pip install bundleclaw
|
|
23
|
+
|
|
24
|
+
# Or run directly
|
|
25
|
+
uvx bundleclaw --help
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## Usage
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
# Export agent state
|
|
32
|
+
bundleclaw export \
|
|
33
|
+
--source ~/.openclaw \
|
|
34
|
+
--workspace ~/.openclaw/workspace \
|
|
35
|
+
--profile full \
|
|
36
|
+
--encrypt-pass 'strong-passphrase' \
|
|
37
|
+
--out agent-state.bcz
|
|
38
|
+
|
|
39
|
+
# Import on target machine
|
|
40
|
+
bundleclaw import \
|
|
41
|
+
--bundle agent-state.bcz \
|
|
42
|
+
--target ~/.openclaw \
|
|
43
|
+
--encrypt-pass 'strong-passphrase'
|
|
44
|
+
|
|
45
|
+
# Verify integrity
|
|
46
|
+
bundleclaw verify --target ~/.openclaw
|
|
47
|
+
|
|
48
|
+
# Transfer via SCP
|
|
49
|
+
bundleclaw transfer \
|
|
50
|
+
--bundle agent-state.bcz \
|
|
51
|
+
--to user@host:/tmp/agent-state.bcz
|
|
52
|
+
|
|
53
|
+
# Full bootstrap (import + verify + restart + health check)
|
|
54
|
+
bundleclaw bootstrap \
|
|
55
|
+
--bundle agent-state.bcz \
|
|
56
|
+
--encrypt-pass 'strong-passphrase' \
|
|
57
|
+
--target ~/.openclaw
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## Development
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
python -m venv .venv
|
|
64
|
+
source .venv/bin/activate
|
|
65
|
+
pip install -e .
|
|
66
|
+
bundleclaw --help
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
### Tech Stack
|
|
70
|
+
|
|
71
|
+
- **Typer** for CLI argument parsing (Click-based)
|
|
72
|
+
- **cryptography** for AES-256-GCM encryption
|
|
73
|
+
- **zipfile** (stdlib) for ZIP archive handling
|
|
74
|
+
- **hashlib** (stdlib) for SHA-256 checksums
|
|
75
|
+
|
|
76
|
+
## Interoperability
|
|
77
|
+
|
|
78
|
+
Bundles created with this CLI are fully compatible with the [Node CLI](../node-cli/). The shared `.bcz` format is documented in [`spec/FORMAT.md`](../spec/FORMAT.md).
|
|
79
|
+
|
|
80
|
+
## License
|
|
81
|
+
|
|
82
|
+
[MIT](../LICENSE)
|
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import shutil
|
|
7
|
+
import subprocess
|
|
8
|
+
import time
|
|
9
|
+
import zipfile
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
import typer
|
|
13
|
+
|
|
14
|
+
app = typer.Typer(help="OpenClaw state migration tool")
|
|
15
|
+
ENC_MAGIC = b"BCLAWENC1"
|
|
16
|
+
|
|
17
|
+
CORE_WORKSPACE_FILES = [
|
|
18
|
+
"AGENTS.md",
|
|
19
|
+
"SOUL.md",
|
|
20
|
+
"USER.md",
|
|
21
|
+
"TOOLS.md",
|
|
22
|
+
"IDENTITY.md",
|
|
23
|
+
"MEMORY.md",
|
|
24
|
+
"HEARTBEAT.md",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def copy_if_exists(src: Path, dst: Path) -> None:
|
|
29
|
+
if not src.exists():
|
|
30
|
+
return
|
|
31
|
+
dst.parent.mkdir(parents=True, exist_ok=True)
|
|
32
|
+
if src.is_dir():
|
|
33
|
+
shutil.copytree(src, dst, dirs_exist_ok=True)
|
|
34
|
+
else:
|
|
35
|
+
shutil.copy2(src, dst)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def file_sha256(path: Path) -> str:
|
|
39
|
+
h = hashlib.sha256()
|
|
40
|
+
h.update(path.read_bytes())
|
|
41
|
+
return f"sha256:{h.hexdigest()}"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _crypto():
|
|
45
|
+
try:
|
|
46
|
+
from cryptography.hazmat.primitives.ciphers.aead import AESGCM # type: ignore
|
|
47
|
+
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC # type: ignore
|
|
48
|
+
from cryptography.hazmat.primitives import hashes # type: ignore
|
|
49
|
+
except Exception as e: # pragma: no cover
|
|
50
|
+
raise RuntimeError("Encryption requested, install dependency: pip install cryptography") from e
|
|
51
|
+
return AESGCM, PBKDF2HMAC, hashes
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def encrypt_bytes(data: bytes, passphrase: str) -> bytes:
|
|
55
|
+
AESGCM, PBKDF2HMAC, hashes = _crypto()
|
|
56
|
+
salt = os.urandom(16)
|
|
57
|
+
iv = os.urandom(12)
|
|
58
|
+
kdf = PBKDF2HMAC(algorithm=hashes.SHA256(), length=32, salt=salt, iterations=200_000)
|
|
59
|
+
key = kdf.derive(passphrase.encode("utf-8"))
|
|
60
|
+
cipher = AESGCM(key)
|
|
61
|
+
encrypted = cipher.encrypt(iv, data, associated_data=None)
|
|
62
|
+
# cryptography AESGCM output = ciphertext || 16-byte tag
|
|
63
|
+
ciphertext, tag = encrypted[:-16], encrypted[-16:]
|
|
64
|
+
return ENC_MAGIC + salt + iv + tag + ciphertext
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def decrypt_bytes(data: bytes, passphrase: str) -> bytes:
|
|
68
|
+
if not data.startswith(ENC_MAGIC):
|
|
69
|
+
return data
|
|
70
|
+
AESGCM, PBKDF2HMAC, hashes = _crypto()
|
|
71
|
+
salt = data[len(ENC_MAGIC): len(ENC_MAGIC) + 16]
|
|
72
|
+
iv = data[len(ENC_MAGIC) + 16: len(ENC_MAGIC) + 28]
|
|
73
|
+
tag = data[len(ENC_MAGIC) + 28: len(ENC_MAGIC) + 44]
|
|
74
|
+
ciphertext = data[len(ENC_MAGIC) + 44:]
|
|
75
|
+
kdf = PBKDF2HMAC(algorithm=hashes.SHA256(), length=32, salt=salt, iterations=200_000)
|
|
76
|
+
key = kdf.derive(passphrase.encode("utf-8"))
|
|
77
|
+
cipher = AESGCM(key)
|
|
78
|
+
return cipher.decrypt(iv, ciphertext + tag, associated_data=None)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@app.command("export")
|
|
82
|
+
def export_cmd(
|
|
83
|
+
source: Path = typer.Option(..., help="OpenClaw home path"),
|
|
84
|
+
workspace: Path = typer.Option(..., help="Workspace path"),
|
|
85
|
+
out: Path = typer.Option(..., help="Output .bcz path"),
|
|
86
|
+
profile: str = typer.Option("full", help="full|memory-only|no-credentials"),
|
|
87
|
+
encrypt_pass: str | None = typer.Option(None, "--encrypt-pass", help="Encrypt output bundle with passphrase"),
|
|
88
|
+
):
|
|
89
|
+
source = source.expanduser().resolve()
|
|
90
|
+
workspace = workspace.expanduser().resolve()
|
|
91
|
+
out = out.expanduser().resolve()
|
|
92
|
+
|
|
93
|
+
tmp = Path.cwd() / f"bundleclaw-export-{int(time.time())}"
|
|
94
|
+
payload = tmp / "payload"
|
|
95
|
+
payload.mkdir(parents=True, exist_ok=True)
|
|
96
|
+
|
|
97
|
+
if profile not in {"full", "memory-only", "no-credentials"}:
|
|
98
|
+
raise typer.BadParameter("profile must be one of: full, memory-only, no-credentials")
|
|
99
|
+
|
|
100
|
+
include_openclaw = profile != "memory-only"
|
|
101
|
+
include_credentials = profile == "full"
|
|
102
|
+
include_identity = profile == "full"
|
|
103
|
+
include_workspace_core = True
|
|
104
|
+
include_workspace_memory = True
|
|
105
|
+
include_workspace_config = profile != "memory-only"
|
|
106
|
+
|
|
107
|
+
if include_openclaw:
|
|
108
|
+
copy_if_exists(source / "openclaw.json", payload / "openclaw.json")
|
|
109
|
+
if include_credentials:
|
|
110
|
+
copy_if_exists(source / "credentials", payload / "credentials")
|
|
111
|
+
if include_identity:
|
|
112
|
+
copy_if_exists(source / "identity", payload / "identity")
|
|
113
|
+
|
|
114
|
+
ws_dst = payload / "workspace"
|
|
115
|
+
ws_dst.mkdir(parents=True, exist_ok=True)
|
|
116
|
+
if include_workspace_core:
|
|
117
|
+
for f in CORE_WORKSPACE_FILES:
|
|
118
|
+
copy_if_exists(workspace / f, ws_dst / f)
|
|
119
|
+
if include_workspace_memory:
|
|
120
|
+
copy_if_exists(workspace / "memory", ws_dst / "memory")
|
|
121
|
+
if include_workspace_config:
|
|
122
|
+
copy_if_exists(workspace / "config", ws_dst / "config")
|
|
123
|
+
|
|
124
|
+
checksums: dict[str, str] = {}
|
|
125
|
+
openclaw_json = payload / "openclaw.json"
|
|
126
|
+
if openclaw_json.exists():
|
|
127
|
+
checksums["payload/openclaw.json"] = file_sha256(openclaw_json)
|
|
128
|
+
|
|
129
|
+
manifest = {
|
|
130
|
+
"format": "bundleclaw.v1",
|
|
131
|
+
"createdAt": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
|
132
|
+
"encrypted": bool(encrypt_pass),
|
|
133
|
+
"source": {"openclawHome": str(source), "workspace": str(workspace)},
|
|
134
|
+
"includes": {
|
|
135
|
+
"openclawJson": openclaw_json.exists(),
|
|
136
|
+
"credentials": (payload / "credentials").exists(),
|
|
137
|
+
"identity": (payload / "identity").exists(),
|
|
138
|
+
"workspaceCore": include_workspace_core,
|
|
139
|
+
"workspaceMemory": (ws_dst / "memory").exists(),
|
|
140
|
+
"workspaceConfig": (ws_dst / "config").exists(),
|
|
141
|
+
},
|
|
142
|
+
"checksums": checksums,
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
(tmp / "manifest.json").write_text(json.dumps(manifest, indent=2), encoding="utf-8")
|
|
146
|
+
out.parent.mkdir(parents=True, exist_ok=True)
|
|
147
|
+
|
|
148
|
+
tmp_zip = out.with_suffix(out.suffix + ".tmpzip")
|
|
149
|
+
with zipfile.ZipFile(tmp_zip, "w", compression=zipfile.ZIP_DEFLATED) as zf:
|
|
150
|
+
zf.write(tmp / "manifest.json", arcname="manifest.json")
|
|
151
|
+
for p in payload.rglob("*"):
|
|
152
|
+
if p.is_file():
|
|
153
|
+
zf.write(p, arcname=str(p.relative_to(tmp)))
|
|
154
|
+
|
|
155
|
+
data = tmp_zip.read_bytes()
|
|
156
|
+
tmp_zip.unlink(missing_ok=True)
|
|
157
|
+
if encrypt_pass:
|
|
158
|
+
data = encrypt_bytes(data, encrypt_pass)
|
|
159
|
+
out.write_bytes(data)
|
|
160
|
+
|
|
161
|
+
shutil.rmtree(tmp, ignore_errors=True)
|
|
162
|
+
typer.echo(f"Created {out}")
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
@app.command("import")
|
|
166
|
+
def import_cmd(
|
|
167
|
+
bundle: Path = typer.Option(..., help="Input .bcz bundle"),
|
|
168
|
+
target: Path = typer.Option(..., help="Target ~/.openclaw path"),
|
|
169
|
+
encrypt_pass: str | None = typer.Option(None, "--encrypt-pass", help="Passphrase for encrypted bundles"),
|
|
170
|
+
):
|
|
171
|
+
bundle = bundle.expanduser().resolve()
|
|
172
|
+
target = target.expanduser().resolve()
|
|
173
|
+
|
|
174
|
+
tmp = Path.cwd() / f"bundleclaw-import-{int(time.time())}"
|
|
175
|
+
tmp.mkdir(parents=True, exist_ok=True)
|
|
176
|
+
|
|
177
|
+
raw = bundle.read_bytes()
|
|
178
|
+
if raw.startswith(ENC_MAGIC):
|
|
179
|
+
if not encrypt_pass:
|
|
180
|
+
raise typer.BadParameter("Bundle is encrypted; provide --encrypt-pass")
|
|
181
|
+
raw = decrypt_bytes(raw, encrypt_pass)
|
|
182
|
+
|
|
183
|
+
zip_path = tmp / "bundle.zip"
|
|
184
|
+
zip_path.write_bytes(raw)
|
|
185
|
+
with zipfile.ZipFile(zip_path, "r") as zf:
|
|
186
|
+
zf.extractall(tmp)
|
|
187
|
+
|
|
188
|
+
payload = tmp / "payload"
|
|
189
|
+
if target.exists():
|
|
190
|
+
backup = target.parent / f"{target.name}.bundleclaw-backup-{int(time.time())}"
|
|
191
|
+
shutil.copytree(target, backup, dirs_exist_ok=True)
|
|
192
|
+
typer.echo(f"Backup created: {backup}")
|
|
193
|
+
|
|
194
|
+
target.mkdir(parents=True, exist_ok=True)
|
|
195
|
+
copy_if_exists(payload / "openclaw.json", target / "openclaw.json")
|
|
196
|
+
copy_if_exists(payload / "credentials", target / "credentials")
|
|
197
|
+
copy_if_exists(payload / "identity", target / "identity")
|
|
198
|
+
copy_if_exists(payload / "workspace", target / "workspace")
|
|
199
|
+
|
|
200
|
+
shutil.rmtree(tmp, ignore_errors=True)
|
|
201
|
+
typer.echo(f"Imported into {target}")
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
@app.command("verify")
|
|
205
|
+
def verify_cmd(target: Path = typer.Option(..., help="Target ~/.openclaw path")):
|
|
206
|
+
target = target.expanduser().resolve()
|
|
207
|
+
checks = [
|
|
208
|
+
("openclaw.json", (target / "openclaw.json").exists()),
|
|
209
|
+
("workspace/SOUL.md", (target / "workspace" / "SOUL.md").exists()),
|
|
210
|
+
("workspace/memory", (target / "workspace" / "memory").exists()),
|
|
211
|
+
]
|
|
212
|
+
failed = False
|
|
213
|
+
for name, ok in checks:
|
|
214
|
+
typer.echo(f"{'OK' if ok else 'MISSING'} {name}")
|
|
215
|
+
if not ok:
|
|
216
|
+
failed = True
|
|
217
|
+
raise typer.Exit(code=1 if failed else 0)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
@app.command("transfer")
|
|
221
|
+
def transfer_cmd(
|
|
222
|
+
bundle: Path = typer.Option(..., help="Bundle file to transfer"),
|
|
223
|
+
to: str = typer.Option(..., help="scp destination, e.g. user@host:/tmp/agent-state.bcz"),
|
|
224
|
+
scp_bin: str = typer.Option("scp", help="scp binary"),
|
|
225
|
+
):
|
|
226
|
+
bundle = bundle.expanduser().resolve()
|
|
227
|
+
subprocess.run([scp_bin, str(bundle), to], check=True)
|
|
228
|
+
typer.echo("Transfer complete")
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
@app.command("bootstrap")
|
|
232
|
+
def bootstrap_cmd(
|
|
233
|
+
bundle: Path = typer.Option(..., help="Input .bcz bundle"),
|
|
234
|
+
target: Path = typer.Option(..., help="Target ~/.openclaw path"),
|
|
235
|
+
encrypt_pass: str | None = typer.Option(None, "--encrypt-pass", help="Passphrase for encrypted bundles"),
|
|
236
|
+
restart_cmd: str = typer.Option("openclaw gateway restart", help="Restart command"),
|
|
237
|
+
skip_restart: bool = typer.Option(False, help="Skip gateway restart"),
|
|
238
|
+
):
|
|
239
|
+
bundle = bundle.expanduser().resolve()
|
|
240
|
+
target = target.expanduser().resolve()
|
|
241
|
+
|
|
242
|
+
tmp = Path.cwd() / f"bundleclaw-bootstrap-{int(time.time())}"
|
|
243
|
+
tmp.mkdir(parents=True, exist_ok=True)
|
|
244
|
+
|
|
245
|
+
raw = bundle.read_bytes()
|
|
246
|
+
if raw.startswith(ENC_MAGIC):
|
|
247
|
+
if not encrypt_pass:
|
|
248
|
+
raise typer.BadParameter("Bundle is encrypted; provide --encrypt-pass")
|
|
249
|
+
raw = decrypt_bytes(raw, encrypt_pass)
|
|
250
|
+
|
|
251
|
+
zip_path = tmp / "bundle.zip"
|
|
252
|
+
zip_path.write_bytes(raw)
|
|
253
|
+
with zipfile.ZipFile(zip_path, "r") as zf:
|
|
254
|
+
zf.extractall(tmp)
|
|
255
|
+
|
|
256
|
+
payload = tmp / "payload"
|
|
257
|
+
if target.exists():
|
|
258
|
+
backup = target.parent / f"{target.name}.bundleclaw-backup-{int(time.time())}"
|
|
259
|
+
shutil.copytree(target, backup, dirs_exist_ok=True)
|
|
260
|
+
typer.echo(f"Backup created: {backup}")
|
|
261
|
+
|
|
262
|
+
target.mkdir(parents=True, exist_ok=True)
|
|
263
|
+
copy_if_exists(payload / "openclaw.json", target / "openclaw.json")
|
|
264
|
+
copy_if_exists(payload / "credentials", target / "credentials")
|
|
265
|
+
copy_if_exists(payload / "identity", target / "identity")
|
|
266
|
+
copy_if_exists(payload / "workspace", target / "workspace")
|
|
267
|
+
|
|
268
|
+
shutil.rmtree(tmp, ignore_errors=True)
|
|
269
|
+
typer.echo(f"Imported into {target}")
|
|
270
|
+
|
|
271
|
+
checks = [
|
|
272
|
+
("openclaw.json", (target / "openclaw.json").exists()),
|
|
273
|
+
("workspace/SOUL.md", (target / "workspace" / "SOUL.md").exists()),
|
|
274
|
+
("workspace/memory", (target / "workspace" / "memory").exists()),
|
|
275
|
+
]
|
|
276
|
+
for name, ok in checks:
|
|
277
|
+
typer.echo(f"{'OK' if ok else 'MISSING'} {name}")
|
|
278
|
+
|
|
279
|
+
if not skip_restart:
|
|
280
|
+
try:
|
|
281
|
+
subprocess.run(restart_cmd, shell=True, check=True)
|
|
282
|
+
except Exception:
|
|
283
|
+
typer.echo(f"WARN restart failed; run manually: {restart_cmd}")
|
|
284
|
+
|
|
285
|
+
for cmd in ["openclaw doctor --non-interactive", "openclaw status"]:
|
|
286
|
+
try:
|
|
287
|
+
subprocess.run(cmd, shell=True, check=True)
|
|
288
|
+
except Exception:
|
|
289
|
+
typer.echo(f"WARN command failed; run manually: {cmd}")
|
|
290
|
+
|
|
291
|
+
typer.echo("Bootstrap complete")
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
if __name__ == "__main__":
|
|
295
|
+
app()
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: bundleclaw
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Migrate OpenClaw agent state between machines
|
|
5
|
+
Requires-Python: >=3.10
|
|
6
|
+
Description-Content-Type: text/markdown
|
|
7
|
+
Requires-Dist: typer>=0.12.0
|
|
8
|
+
Requires-Dist: cryptography>=43.0.0
|
|
9
|
+
|
|
10
|
+
<div align="center">
|
|
11
|
+
|
|
12
|
+
# BundleClaw — Python CLI
|
|
13
|
+
|
|
14
|
+
[](https://pypi.org/project/bundleclaw/)
|
|
15
|
+
[](https://www.python.org/)
|
|
16
|
+
[](../LICENSE)
|
|
17
|
+
|
|
18
|
+
Python implementation of the BundleClaw migration CLI.
|
|
19
|
+
|
|
20
|
+
</div>
|
|
21
|
+
|
|
22
|
+
---
|
|
23
|
+
|
|
24
|
+
## Install
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
# pip
|
|
28
|
+
pip install bundleclaw
|
|
29
|
+
|
|
30
|
+
# uv
|
|
31
|
+
uv pip install bundleclaw
|
|
32
|
+
|
|
33
|
+
# Or run directly
|
|
34
|
+
uvx bundleclaw --help
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Usage
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
# Export agent state
|
|
41
|
+
bundleclaw export \
|
|
42
|
+
--source ~/.openclaw \
|
|
43
|
+
--workspace ~/.openclaw/workspace \
|
|
44
|
+
--profile full \
|
|
45
|
+
--encrypt-pass 'strong-passphrase' \
|
|
46
|
+
--out agent-state.bcz
|
|
47
|
+
|
|
48
|
+
# Import on target machine
|
|
49
|
+
bundleclaw import \
|
|
50
|
+
--bundle agent-state.bcz \
|
|
51
|
+
--target ~/.openclaw \
|
|
52
|
+
--encrypt-pass 'strong-passphrase'
|
|
53
|
+
|
|
54
|
+
# Verify integrity
|
|
55
|
+
bundleclaw verify --target ~/.openclaw
|
|
56
|
+
|
|
57
|
+
# Transfer via SCP
|
|
58
|
+
bundleclaw transfer \
|
|
59
|
+
--bundle agent-state.bcz \
|
|
60
|
+
--to user@host:/tmp/agent-state.bcz
|
|
61
|
+
|
|
62
|
+
# Full bootstrap (import + verify + restart + health check)
|
|
63
|
+
bundleclaw bootstrap \
|
|
64
|
+
--bundle agent-state.bcz \
|
|
65
|
+
--encrypt-pass 'strong-passphrase' \
|
|
66
|
+
--target ~/.openclaw
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
## Development
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
python -m venv .venv
|
|
73
|
+
source .venv/bin/activate
|
|
74
|
+
pip install -e .
|
|
75
|
+
bundleclaw --help
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
### Tech Stack
|
|
79
|
+
|
|
80
|
+
- **Typer** for CLI argument parsing (Click-based)
|
|
81
|
+
- **cryptography** for AES-256-GCM encryption
|
|
82
|
+
- **zipfile** (stdlib) for ZIP archive handling
|
|
83
|
+
- **hashlib** (stdlib) for SHA-256 checksums
|
|
84
|
+
|
|
85
|
+
## Interoperability
|
|
86
|
+
|
|
87
|
+
Bundles created with this CLI are fully compatible with the [Node CLI](../node-cli/). The shared `.bcz` format is documented in [`spec/FORMAT.md`](../spec/FORMAT.md).
|
|
88
|
+
|
|
89
|
+
## License
|
|
90
|
+
|
|
91
|
+
[MIT](../LICENSE)
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
bundleclaw/__init__.py
|
|
4
|
+
bundleclaw/cli.py
|
|
5
|
+
bundleclaw.egg-info/PKG-INFO
|
|
6
|
+
bundleclaw.egg-info/SOURCES.txt
|
|
7
|
+
bundleclaw.egg-info/dependency_links.txt
|
|
8
|
+
bundleclaw.egg-info/entry_points.txt
|
|
9
|
+
bundleclaw.egg-info/requires.txt
|
|
10
|
+
bundleclaw.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
bundleclaw
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "bundleclaw"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Migrate OpenClaw agent state between machines"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.10"
|
|
7
|
+
dependencies = [
|
|
8
|
+
"typer>=0.12.0",
|
|
9
|
+
"cryptography>=43.0.0"
|
|
10
|
+
]
|
|
11
|
+
|
|
12
|
+
[project.scripts]
|
|
13
|
+
bundleclaw = "bundleclaw.cli:app"
|
|
14
|
+
|
|
15
|
+
[build-system]
|
|
16
|
+
requires = ["setuptools>=68", "wheel"]
|
|
17
|
+
build-backend = "setuptools.build_meta"
|