openclaw-teleport 0.2.0

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.
package/package.json ADDED
@@ -0,0 +1,31 @@
1
+ {
2
+ "name": "openclaw-teleport",
3
+ "version": "0.2.0",
4
+ "description": "Agent soul migration — pack your identity, memory, and tools into one file, unpack on a new machine",
5
+ "type": "module",
6
+ "bin": {
7
+ "openclaw-teleport": "./dist/cli.mjs"
8
+ },
9
+ "scripts": {
10
+ "build": "esbuild src/cli.ts --bundle --platform=node --format=esm --outfile=dist/cli.mjs --banner:js=\"#!/usr/bin/env node\" --external:commander",
11
+ "dev": "tsx src/cli.ts",
12
+ "prepublishOnly": "npm run build"
13
+ },
14
+ "keywords": [
15
+ "kagura-project",
16
+ "ai-agent",
17
+ "migration",
18
+ "openclaw"
19
+ ],
20
+ "author": "kagura-agent",
21
+ "license": "MIT",
22
+ "dependencies": {
23
+ "commander": "^13.1.0"
24
+ },
25
+ "devDependencies": {
26
+ "esbuild": "^0.25.0",
27
+ "tsx": "^4.19.0",
28
+ "typescript": "^5.7.0",
29
+ "@types/node": "^22.0.0"
30
+ }
31
+ }
package/src/cli.ts ADDED
@@ -0,0 +1,53 @@
1
+ import { Command } from 'commander';
2
+ import { pack } from './pack.js';
3
+ import { unpack, inspect } from './commands.js';
4
+
5
+ const program = new Command();
6
+
7
+ program
8
+ .name('openclaw-teleport')
9
+ .description('🌸 Agent soul migration — pack your identity, memory, and tools into one file')
10
+ .version('0.2.0');
11
+
12
+ program
13
+ .command('pack')
14
+ .description('Pack an agent into a .soul archive')
15
+ .argument('[agent-id]', 'Agent ID to pack (defaults to first configured agent)')
16
+ .option('-o, --output <path>', 'Output file path (default: ./{agent}_{date}.soul)')
17
+ .action(async (agentId: string | undefined, opts: { output?: string }) => {
18
+ try {
19
+ await pack(agentId, opts.output);
20
+ } catch (err) {
21
+ console.error(`\n${err instanceof Error ? err.message : String(err)}\n`);
22
+ process.exit(1);
23
+ }
24
+ });
25
+
26
+ program
27
+ .command('unpack')
28
+ .description('Unpack a .soul archive and restore the agent')
29
+ .argument('<file>', 'Path to .soul file')
30
+ .option('-w, --workspace <path>', 'Target workspace directory')
31
+ .action(async (file: string, opts: { workspace?: string }) => {
32
+ try {
33
+ await unpack(file, opts.workspace);
34
+ } catch (err) {
35
+ console.error(`\n${err instanceof Error ? err.message : String(err)}\n`);
36
+ process.exit(1);
37
+ }
38
+ });
39
+
40
+ program
41
+ .command('inspect')
42
+ .description('Inspect a .soul archive without unpacking')
43
+ .argument('<file>', 'Path to .soul file')
44
+ .action(async (file: string) => {
45
+ try {
46
+ await inspect(file);
47
+ } catch (err) {
48
+ console.error(`\n${err instanceof Error ? err.message : String(err)}\n`);
49
+ process.exit(1);
50
+ }
51
+ });
52
+
53
+ program.parse();
@@ -0,0 +1,613 @@
1
+ import * as fs from 'node:fs';
2
+ import * as path from 'node:path';
3
+ import * as os from 'node:os';
4
+ import { execSync } from 'node:child_process';
5
+ import { loadConfig, commandExists, isGhAuthenticated, type Manifest, type OpenClawConfig, type CronJob } from './utils.js';
6
+
7
+ const OPENCLAW_DIR = path.join(os.homedir(), '.openclaw');
8
+ const CONFIG_PATH = path.join(OPENCLAW_DIR, 'openclaw.json');
9
+ const CRON_DIR = path.join(OPENCLAW_DIR, 'cron');
10
+
11
+ function extractManifest(soulFile: string): { tmpDir: string; manifest: Manifest } {
12
+ const tmpDir = path.join(os.tmpdir(), `soul-unpack-${Date.now()}`);
13
+ fs.mkdirSync(tmpDir, { recursive: true });
14
+
15
+ execSync(`tar -xzf "${path.resolve(soulFile)}" -C "${tmpDir}"`, { encoding: 'utf-8' });
16
+
17
+ const manifestPath = path.join(tmpDir, 'soul', 'manifest.json');
18
+ if (!fs.existsSync(manifestPath)) {
19
+ fs.rmSync(tmpDir, { recursive: true });
20
+ throw new Error('āŒ Invalid .soul file: manifest.json not found');
21
+ }
22
+
23
+ const manifest: Manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
24
+ return { tmpDir, manifest };
25
+ }
26
+
27
+ // ── Step 1: Install OpenClaw ───────────────────────────────────────
28
+
29
+ function ensureOpenClaw(): boolean {
30
+ console.log('šŸ”§ Checking OpenClaw installation...');
31
+
32
+ if (commandExists('openclaw')) {
33
+ try {
34
+ const version = execSync('openclaw --version', { encoding: 'utf-8', stdio: 'pipe' }).trim();
35
+ console.log(` āœ… OpenClaw found (${version})`);
36
+ } catch {
37
+ console.log(' āœ… OpenClaw found');
38
+ }
39
+ return true;
40
+ }
41
+
42
+ console.log(' ā¬‡ļø OpenClaw not found, installing...');
43
+ try {
44
+ execSync('npm install -g openclaw', {
45
+ encoding: 'utf-8',
46
+ stdio: 'pipe',
47
+ timeout: 120000,
48
+ });
49
+
50
+ // Verify installation
51
+ if (commandExists('openclaw')) {
52
+ console.log(' āœ… OpenClaw installed successfully');
53
+ return true;
54
+ } else {
55
+ console.log(' āš ļø Installation completed but openclaw command not found in PATH');
56
+ console.log(' Try: npm install -g openclaw');
57
+ return false;
58
+ }
59
+ } catch (err) {
60
+ console.log(' āš ļø Failed to install OpenClaw automatically');
61
+ console.log(' Run manually: npm install -g openclaw');
62
+ return false;
63
+ }
64
+ }
65
+
66
+ // ── Step 2: Write full config ──────────────────────────────────────
67
+
68
+ function writeAgentConfig(
69
+ manifest: Manifest,
70
+ stageDir: string,
71
+ targetWorkspace: string
72
+ ): void {
73
+ console.log('āš™ļø Writing agent configuration...');
74
+
75
+ fs.mkdirSync(OPENCLAW_DIR, { recursive: true });
76
+
77
+ const agentConfigPath = path.join(stageDir, 'config', 'agent-config.json');
78
+ if (!fs.existsSync(agentConfigPath)) {
79
+ console.log(' āš ļø No agent config in archive, skipping');
80
+ return;
81
+ }
82
+
83
+ const agentConfig = JSON.parse(fs.readFileSync(agentConfigPath, 'utf-8'));
84
+
85
+ // Build the new agent entry with dynamic paths
86
+ const agentDir = path.join(OPENCLAW_DIR, 'agents', manifest.agent_id, 'agent');
87
+ const savedAgent = agentConfig.agent ?? {};
88
+ // Remove old paths from saved config before merging
89
+ delete savedAgent.workspace;
90
+ delete savedAgent.agentDir;
91
+ const newAgent = {
92
+ id: manifest.agent_id,
93
+ name: manifest.agent_name,
94
+ ...savedAgent,
95
+ // Set paths dynamically for the new machine
96
+ workspace: targetWorkspace,
97
+ agentDir: agentDir,
98
+ };
99
+
100
+ if (fs.existsSync(CONFIG_PATH)) {
101
+ // Merge into existing config
102
+ const existingConfig: OpenClawConfig = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8'));
103
+
104
+ if (!existingConfig.agents) {
105
+ existingConfig.agents = { list: [] };
106
+ }
107
+ if (!existingConfig.agents.list) {
108
+ existingConfig.agents.list = [];
109
+ }
110
+
111
+ const existingIdx = existingConfig.agents.list.findIndex(
112
+ (a) => a.id === manifest.agent_id
113
+ );
114
+
115
+ if (existingIdx >= 0) {
116
+ existingConfig.agents.list[existingIdx] = newAgent;
117
+ console.log(' āœ… Agent config updated (merged into existing)');
118
+ } else {
119
+ existingConfig.agents.list.push(newAgent);
120
+ console.log(' āœ… Agent config added to existing openclaw.json');
121
+ }
122
+
123
+ // Merge agent defaults if present in manifest
124
+ if (manifest.agent_defaults && Object.keys(manifest.agent_defaults).length > 0) {
125
+ if (!existingConfig.agents.defaults) {
126
+ existingConfig.agents.defaults = {};
127
+ }
128
+ // Merge defaults, setting workspace dynamically
129
+ existingConfig.agents.defaults = {
130
+ ...existingConfig.agents.defaults,
131
+ ...manifest.agent_defaults,
132
+ workspace: targetWorkspace,
133
+ };
134
+ console.log(' āœ… Agent defaults merged');
135
+ }
136
+
137
+ // Merge channels config if present
138
+ if (manifest.channels && Object.keys(manifest.channels).length > 0) {
139
+ if (!existingConfig.channels) {
140
+ existingConfig.channels = {};
141
+ }
142
+ for (const [key, val] of Object.entries(manifest.channels)) {
143
+ if (!(key in existingConfig.channels)) {
144
+ (existingConfig.channels as Record<string, unknown>)[key] = val;
145
+ console.log(` āœ… Channel '${key}' config added`);
146
+ } else {
147
+ console.log(` ā­ļø Channel '${key}' already exists, skipping`);
148
+ }
149
+ }
150
+ }
151
+
152
+ // Merge models config if not present
153
+ if (manifest.models_config && Object.keys(manifest.models_config).length > 0) {
154
+ if (!existingConfig.models) {
155
+ existingConfig.models = manifest.models_config;
156
+ console.log(' āœ… Models config restored');
157
+ } else {
158
+ console.log(' ā­ļø Models config already exists, skipping');
159
+ }
160
+ }
161
+
162
+ // Merge bindings
163
+ if (manifest.bindings && manifest.bindings.length > 0) {
164
+ if (!existingConfig.bindings || (existingConfig.bindings as unknown[]).length === 0) {
165
+ existingConfig.bindings = manifest.bindings;
166
+ console.log(' āœ… Bindings restored');
167
+ } else {
168
+ console.log(' ā­ļø Bindings already exist, skipping');
169
+ }
170
+ }
171
+
172
+ fs.writeFileSync(CONFIG_PATH, JSON.stringify(existingConfig, null, 2));
173
+ } else {
174
+ // Create new config from scratch
175
+ const newConfig: OpenClawConfig = {
176
+ agents: {
177
+ defaults: {
178
+ ...(manifest.agent_defaults ?? {}),
179
+ workspace: targetWorkspace,
180
+ },
181
+ list: [newAgent],
182
+ },
183
+ };
184
+
185
+ // Add channels
186
+ if (manifest.channels && Object.keys(manifest.channels).length > 0) {
187
+ newConfig.channels = manifest.channels;
188
+ console.log(' āœ… Channel configs restored');
189
+ }
190
+
191
+ // Add models
192
+ if (manifest.models_config && Object.keys(manifest.models_config).length > 0) {
193
+ newConfig.models = manifest.models_config;
194
+ console.log(' āœ… Models config restored');
195
+ }
196
+
197
+ // Add bindings
198
+ if (manifest.bindings && manifest.bindings.length > 0) {
199
+ newConfig.bindings = manifest.bindings;
200
+ console.log(' āœ… Bindings restored');
201
+ }
202
+
203
+ fs.writeFileSync(CONFIG_PATH, JSON.stringify(newConfig, null, 2));
204
+ console.log(' āœ… New openclaw.json created');
205
+ }
206
+
207
+ // Ensure agent directory exists
208
+ fs.mkdirSync(agentDir, { recursive: true });
209
+ }
210
+
211
+ // ── Step 3: Restore cron jobs ──────────────────────────────────────
212
+
213
+ function restoreCronJobs(manifest: Manifest, stageDir: string): number {
214
+ console.log('ā° Restoring cron jobs...');
215
+
216
+ // Restore cron files from archive
217
+ const cronDir = path.join(stageDir, 'cron');
218
+ let cronFileCount = 0;
219
+ if (fs.existsSync(cronDir)) {
220
+ fs.mkdirSync(CRON_DIR, { recursive: true });
221
+ const files = fs.readdirSync(cronDir);
222
+ for (const f of files) {
223
+ fs.copyFileSync(path.join(cronDir, f), path.join(CRON_DIR, f));
224
+ cronFileCount++;
225
+ }
226
+ }
227
+
228
+ // If manifest has full cron_jobs content, merge them into jobs.json
229
+ if (manifest.cron_jobs && manifest.cron_jobs.length > 0) {
230
+ fs.mkdirSync(CRON_DIR, { recursive: true });
231
+ const jobsPath = path.join(CRON_DIR, 'jobs.json');
232
+
233
+ let existingJobs: CronJob[] = [];
234
+ if (fs.existsSync(jobsPath)) {
235
+ try {
236
+ const data = JSON.parse(fs.readFileSync(jobsPath, 'utf-8'));
237
+ existingJobs = data.jobs ?? [];
238
+ } catch {
239
+ existingJobs = [];
240
+ }
241
+ }
242
+
243
+ // Merge: replace jobs with same ID, add new ones
244
+ for (const job of manifest.cron_jobs) {
245
+ const idx = existingJobs.findIndex((j) => j.id === job.id);
246
+ if (idx >= 0) {
247
+ existingJobs[idx] = job;
248
+ } else {
249
+ existingJobs.push(job);
250
+ }
251
+ }
252
+
253
+ fs.writeFileSync(jobsPath, JSON.stringify({ version: 1, jobs: existingJobs }, null, 2));
254
+ console.log(` āœ… ${manifest.cron_jobs.length} cron job(s) restored`);
255
+ } else if (cronFileCount > 0) {
256
+ console.log(` āœ… ${cronFileCount} cron file(s) restored`);
257
+ } else {
258
+ console.log(' (none)');
259
+ }
260
+
261
+ return manifest.cron_jobs?.length ?? cronFileCount;
262
+ }
263
+
264
+ // ── Step 4 & 5: GitHub auth + clone repos ──────────────────────────
265
+
266
+ function cloneGitHubRepos(manifest: Manifest, targetWorkspace: string): { cloned: number; skipped: number; failed: number } {
267
+ const result = { cloned: 0, skipped: 0, failed: 0 };
268
+
269
+ if (!manifest.github_repos || manifest.github_repos.length === 0) {
270
+ return result;
271
+ }
272
+
273
+ console.log('\nšŸ™ Cloning GitHub repos...');
274
+
275
+ // Check if gh CLI is available
276
+ if (!commandExists('gh')) {
277
+ console.log(' āš ļø GitHub CLI (gh) not installed');
278
+ console.log(' Install it: https://cli.github.com/');
279
+ console.log(' Then run: gh auth login');
280
+ console.log(` Repos to clone manually (${manifest.github_repos.length}):`);
281
+ for (const repo of manifest.github_repos) {
282
+ console.log(` git clone ${repo.url}`);
283
+ }
284
+ result.failed = manifest.github_repos.length;
285
+ return result;
286
+ }
287
+
288
+ // Check GitHub auth
289
+ if (!isGhAuthenticated()) {
290
+ console.log(' āš ļø GitHub CLI not authenticated');
291
+ console.log('');
292
+ console.log(' ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”');
293
+ console.log(' │ Please run: gh auth login │');
294
+ console.log(' │ │');
295
+ console.log(' │ Then re-run unpack, or clone manually: │');
296
+ console.log(' ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜');
297
+ console.log('');
298
+ for (const repo of manifest.github_repos) {
299
+ const fork = repo.isFork ? ' (fork)' : '';
300
+ console.log(` • ${repo.name}${fork}: ${repo.url}`);
301
+ }
302
+ result.failed = manifest.github_repos.length;
303
+ return result;
304
+ }
305
+
306
+ // Clone repos
307
+ for (const repo of manifest.github_repos) {
308
+ // Forks go to workspace/forks/, others go directly to workspace/
309
+ const targetDir = repo.isFork
310
+ ? path.join(targetWorkspace, 'forks', repo.name)
311
+ : path.join(targetWorkspace, repo.name);
312
+
313
+ if (fs.existsSync(targetDir)) {
314
+ console.log(` ā­ļø ${repo.name} (already exists)`);
315
+ result.skipped++;
316
+ continue;
317
+ }
318
+
319
+ try {
320
+ fs.mkdirSync(path.dirname(targetDir), { recursive: true });
321
+ console.log(` šŸ“„ Cloning ${repo.name}${repo.isFork ? ' (fork)' : ''}...`);
322
+ execSync(`gh repo clone "${repo.url}" "${targetDir}"`, {
323
+ encoding: 'utf-8',
324
+ timeout: 120000,
325
+ stdio: 'pipe',
326
+ });
327
+ console.log(` āœ… ${repo.name}`);
328
+ result.cloned++;
329
+ } catch {
330
+ console.log(` āš ļø Failed to clone ${repo.name}`);
331
+ result.failed++;
332
+ }
333
+ }
334
+
335
+ return result;
336
+ }
337
+
338
+ // ── Step 6: Start Gateway ──────────────────────────────────────────
339
+
340
+ function startGateway(): boolean {
341
+ console.log('\nšŸš€ Starting OpenClaw Gateway...');
342
+
343
+ if (!commandExists('openclaw')) {
344
+ console.log(' āš ļø openclaw command not found, skipping gateway start');
345
+ return false;
346
+ }
347
+
348
+ try {
349
+ const output = execSync('openclaw gateway start', {
350
+ encoding: 'utf-8',
351
+ timeout: 30000,
352
+ stdio: 'pipe',
353
+ });
354
+ console.log(' āœ… Gateway started');
355
+ if (output.trim()) {
356
+ // Show first few lines of output
357
+ const lines = output.trim().split('\n').slice(0, 3);
358
+ for (const line of lines) {
359
+ console.log(` ${line}`);
360
+ }
361
+ }
362
+ return true;
363
+ } catch (err) {
364
+ console.log(' āš ļø Failed to start gateway');
365
+ if (err instanceof Error && 'stderr' in err) {
366
+ const stderr = (err as { stderr: string }).stderr?.trim();
367
+ if (stderr) {
368
+ console.log(` ${stderr.split('\n')[0]}`);
369
+ }
370
+ }
371
+ console.log(' Try manually: openclaw gateway start');
372
+ return false;
373
+ }
374
+ }
375
+
376
+ // ── Main unpack ────────────────────────────────────────────────────
377
+
378
+ export async function unpack(soulFile: string, workspacePath?: string): Promise<void> {
379
+ console.log('\n🌸 openclaw-teleport — unpacking agent soul...\n');
380
+
381
+ if (!fs.existsSync(soulFile)) {
382
+ throw new Error(`āŒ File not found: ${soulFile}`);
383
+ }
384
+
385
+ const { tmpDir, manifest } = extractManifest(soulFile);
386
+ const stageDir = path.join(tmpDir, 'soul');
387
+
388
+ console.log(`šŸ†” Agent: ${manifest.agent_name} (${manifest.agent_id})`);
389
+ console.log(`šŸ“… Packed: ${manifest.packed_at}`);
390
+ console.log(`šŸ“ Files: ${manifest.files.length}`);
391
+ console.log('');
392
+
393
+ // ── Step 1: Ensure OpenClaw is installed ─────────────────────────
394
+ const openclawInstalled = ensureOpenClaw();
395
+
396
+ // Determine workspace
397
+ const targetWorkspace = workspacePath
398
+ ? path.resolve(workspacePath)
399
+ : path.join(OPENCLAW_DIR, 'workspace');
400
+
401
+ fs.mkdirSync(targetWorkspace, { recursive: true });
402
+
403
+ // ── Step 2: Restore identity files ───────────────────────────────
404
+ console.log('\nšŸ“ Restoring identity files...');
405
+ let identityCount = 0;
406
+ const identityDir = path.join(stageDir, 'identity');
407
+ if (fs.existsSync(identityDir)) {
408
+ const files = fs.readdirSync(identityDir);
409
+ for (const f of files) {
410
+ const src = path.join(identityDir, f);
411
+ const dst = path.join(targetWorkspace, f);
412
+ fs.copyFileSync(src, dst);
413
+ console.log(` āœ… ${f}`);
414
+ identityCount++;
415
+ }
416
+ }
417
+
418
+ // ── Step 3: Restore memory ──────────────────────────────────────
419
+ console.log('🧠 Restoring memory...');
420
+ let memoryCount = 0;
421
+ const memoryDir = path.join(stageDir, 'memory');
422
+ if (fs.existsSync(memoryDir)) {
423
+ const copyRecursive = (src: string, dst: string) => {
424
+ fs.mkdirSync(dst, { recursive: true });
425
+ const entries = fs.readdirSync(src, { withFileTypes: true });
426
+ for (const entry of entries) {
427
+ const srcPath = path.join(src, entry.name);
428
+ const dstPath = path.join(dst, entry.name);
429
+ if (entry.isDirectory()) {
430
+ copyRecursive(srcPath, dstPath);
431
+ } else {
432
+ fs.copyFileSync(srcPath, dstPath);
433
+ memoryCount++;
434
+ }
435
+ }
436
+ };
437
+ copyRecursive(memoryDir, path.join(targetWorkspace, 'memory'));
438
+ console.log(` āœ… ${memoryCount} memory files restored`);
439
+ }
440
+
441
+ // ── Step 4: Restore tool data ───────────────────────────────────
442
+ console.log('šŸ—„ļø Restoring tool data...');
443
+ let dataCount = 0;
444
+ const dataDir = path.join(stageDir, 'data');
445
+ if (fs.existsSync(dataDir)) {
446
+ const copyRecursive = (src: string, dst: string) => {
447
+ fs.mkdirSync(dst, { recursive: true });
448
+ const entries = fs.readdirSync(src, { withFileTypes: true });
449
+ for (const entry of entries) {
450
+ const srcPath = path.join(src, entry.name);
451
+ const dstPath = path.join(dst, entry.name);
452
+ if (entry.isDirectory()) {
453
+ copyRecursive(srcPath, dstPath);
454
+ } else {
455
+ fs.copyFileSync(srcPath, dstPath);
456
+ console.log(` āœ… ${entry.name}`);
457
+ dataCount++;
458
+ }
459
+ }
460
+ };
461
+ copyRecursive(dataDir, targetWorkspace);
462
+ }
463
+
464
+ // ── Step 5: Write full agent config (with channels, credentials) ─
465
+ writeAgentConfig(manifest, stageDir, targetWorkspace);
466
+
467
+ // ── Step 6: Restore cron jobs ───────────────────────────────────
468
+ const cronCount = restoreCronJobs(manifest, stageDir);
469
+
470
+ // ── Step 7: Clone GitHub repos ──────────────────────────────────
471
+ const repoResult = cloneGitHubRepos(manifest, targetWorkspace);
472
+
473
+ // Clean up temp directory
474
+ fs.rmSync(tmpDir, { recursive: true });
475
+
476
+ // ── Step 8: Start Gateway ───────────────────────────────────────
477
+ let gatewayStarted = false;
478
+ if (openclawInstalled) {
479
+ gatewayStarted = startGateway();
480
+ }
481
+
482
+ // ── Step 9: Welcome summary ─────────────────────────────────────
483
+ const configuredServices: string[] = [];
484
+ if (manifest.channels) {
485
+ for (const [key, val] of Object.entries(manifest.channels)) {
486
+ if (val && typeof val === 'object' && (val as Record<string, unknown>).enabled !== false) {
487
+ configuredServices.push(key);
488
+ }
489
+ }
490
+ }
491
+
492
+ console.log('\n' + '═'.repeat(50));
493
+ console.log('🌸 Restoration Summary');
494
+ console.log('═'.repeat(50));
495
+ console.log(`šŸ†” Agent: ${manifest.agent_name} (${manifest.agent_id})`);
496
+ console.log(`šŸ“‚ Workspace: ${targetWorkspace}`);
497
+ console.log(`šŸ“ Files: ${identityCount} identity + ${memoryCount} memory + ${dataCount} data`);
498
+ console.log(`ā° Cron: ${cronCount} job(s)`);
499
+
500
+ if (manifest.github_repos && manifest.github_repos.length > 0) {
501
+ console.log(`šŸ™ Repos: ${repoResult.cloned} cloned, ${repoResult.skipped} skipped, ${repoResult.failed} failed`);
502
+ }
503
+
504
+ if (configuredServices.length > 0) {
505
+ console.log(`šŸ”— Services: ${configuredServices.join(', ')}`);
506
+ }
507
+
508
+ console.log(`šŸ”§ OpenClaw: ${openclawInstalled ? 'āœ…' : 'āš ļø needs install'}`);
509
+ console.log(`šŸš€ Gateway: ${gatewayStarted ? 'āœ… running' : 'āš ļø not started'}`);
510
+
511
+ // Services that may need attention
512
+ if (manifest.services_to_rebind && manifest.services_to_rebind.length > 0) {
513
+ const needsRebind = manifest.services_to_rebind.filter(
514
+ (s) => !configuredServices.includes(s)
515
+ );
516
+ if (needsRebind.length > 0) {
517
+ console.log('\nšŸ”— Services that may need attention:');
518
+ for (const svc of needsRebind) {
519
+ console.log(` ☐ ${svc}`);
520
+ }
521
+ }
522
+ }
523
+
524
+ console.log('\n' + '═'.repeat(50));
525
+ console.log(`Welcome back, ${manifest.agent_name} 🌸`);
526
+ console.log('═'.repeat(50) + '\n');
527
+ }
528
+
529
+ // ── Inspect ────────────────────────────────────────────────────────
530
+
531
+ export async function inspect(soulFile: string): Promise<void> {
532
+ if (!fs.existsSync(soulFile)) {
533
+ throw new Error(`āŒ File not found: ${soulFile}`);
534
+ }
535
+
536
+ // Extract just the manifest without full unpack
537
+ const tmpDir = path.join(os.tmpdir(), `soul-inspect-${Date.now()}`);
538
+ fs.mkdirSync(tmpDir, { recursive: true });
539
+
540
+ try {
541
+ // Extract only manifest.json
542
+ execSync(`tar -xzf "${path.resolve(soulFile)}" -C "${tmpDir}" soul/manifest.json`, {
543
+ encoding: 'utf-8',
544
+ });
545
+
546
+ const manifestPath = path.join(tmpDir, 'soul', 'manifest.json');
547
+ if (!fs.existsSync(manifestPath)) {
548
+ throw new Error('āŒ Invalid .soul file: manifest.json not found');
549
+ }
550
+
551
+ const manifest: Manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
552
+
553
+ const stats = fs.statSync(path.resolve(soulFile));
554
+ const sizeMB = (stats.size / 1024 / 1024).toFixed(2);
555
+
556
+ console.log('\n' + '═'.repeat(50));
557
+ console.log('🌸 Soul Archive Inspection');
558
+ console.log('═'.repeat(50));
559
+ console.log(`šŸ†” Agent: ${manifest.agent_name} (${manifest.agent_id})`);
560
+ console.log(`šŸ“… Packed: ${manifest.packed_at}`);
561
+ console.log(`šŸ“ Size: ${sizeMB} MB`);
562
+ console.log(`šŸ“ Files: ${manifest.files.length}`);
563
+
564
+ if (manifest.github_repos.length > 0) {
565
+ console.log(`\nšŸ™ GitHub Repos (${manifest.github_repos.length}):`);
566
+ for (const repo of manifest.github_repos) {
567
+ const fork = repo.isFork ? ' (fork)' : '';
568
+ console.log(` • ${repo.name}${fork}`);
569
+ console.log(` ${repo.url}`);
570
+ }
571
+ }
572
+
573
+ if (manifest.channels && Object.keys(manifest.channels).length > 0) {
574
+ console.log(`\nšŸ”‘ Channels (${Object.keys(manifest.channels).length}):`);
575
+ for (const key of Object.keys(manifest.channels)) {
576
+ console.log(` • ${key}`);
577
+ }
578
+ }
579
+
580
+ if (manifest.cron_jobs && manifest.cron_jobs.length > 0) {
581
+ console.log(`\nā° Cron Jobs (${manifest.cron_jobs.length}):`);
582
+ for (const job of manifest.cron_jobs) {
583
+ const status = job.enabled ? '🟢' : 'šŸ”“';
584
+ console.log(` ${status} ${job.name}`);
585
+ }
586
+ }
587
+
588
+ if (manifest.services_to_rebind.length > 0) {
589
+ console.log(`\nšŸ”— Services to rebind:`);
590
+ for (const svc of manifest.services_to_rebind) {
591
+ console.log(` • ${svc}`);
592
+ }
593
+ }
594
+
595
+ // Show file breakdown
596
+ const identityFiles = manifest.files.filter((f) => f.startsWith('identity/'));
597
+ const memoryFiles = manifest.files.filter((f) => f.startsWith('memory/'));
598
+ const dataFiles = manifest.files.filter((f) => f.startsWith('data/'));
599
+ const cronFiles = manifest.files.filter((f) => f.startsWith('cron/'));
600
+ const configFiles = manifest.files.filter((f) => f.startsWith('config/'));
601
+
602
+ console.log('\nšŸ“Š Contents breakdown:');
603
+ if (identityFiles.length > 0) console.log(` šŸ“ Identity: ${identityFiles.length} files`);
604
+ if (memoryFiles.length > 0) console.log(` 🧠 Memory: ${memoryFiles.length} files`);
605
+ if (dataFiles.length > 0) console.log(` šŸ—„ļø Data: ${dataFiles.length} files`);
606
+ if (cronFiles.length > 0) console.log(` ā° Cron: ${cronFiles.length} files`);
607
+ if (configFiles.length > 0) console.log(` āš™ļø Config: ${configFiles.length} files`);
608
+
609
+ console.log('═'.repeat(50) + '\n');
610
+ } finally {
611
+ fs.rmSync(tmpDir, { recursive: true });
612
+ }
613
+ }