feat: add /migrate-from-openclaw skill for guided OpenClaw migration

Conversational migration skill that reads an existing OpenClaw installation
and interactively guides users through importing identity, personality,
channel credentials, groups, scheduled tasks, MCP servers, skills, and
plugins into NanoClaw.

8-phase flow: discovery → groups/architecture → settings → identity/memory
→ channel credentials → scheduled tasks → MCP/webhooks/config → summary.

Includes:
- discover-openclaw.ts: finds OpenClaw state dir, parses JSON5 config,
  detects channels (both channels.* and legacy top-level format), groups
  (handles agent:main: prefixed session keys), workspace files (reads
  custom agent.workspace path), skills, config-registered plugins with
  API keys, cron jobs, MCP servers. Dumps raw config keys for robustness.
- extract-channel-credentials.ts: resolves SecretRef formats (plain,
  env template, object), writes credentials directly to .env via
  --write-env flag (never exposes raw values to stdout)
- MIGRATE_CRONS.md: extracted reference for cron job migration, loaded
  only when cron jobs exist
- migration-state.md: persistent state file for recovery after compaction
- Setup hook: detects ~/.openclaw during /setup and offers migration

Tested against real ~/.clawdbot and remote ~/.openclaw installations.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
gavrielc
2026-04-04 19:27:14 +03:00
parent 3608f05233
commit b2a5a58f8a
5 changed files with 1773 additions and 0 deletions

View File

@@ -0,0 +1,476 @@
/**
* Extract a channel credential from an OpenClaw configuration and write it
* directly to the NanoClaw .env file.
*
* Usage: npx tsx .claude/skills/migrate-from-openclaw/scripts/extract-channel-credentials.ts \
* --channel telegram --state-dir ~/.openclaw --write-env .env
*
* Handles OpenClaw SecretRef formats:
* - Plain string: "bot-token-value"
* - Env template: "${TELEGRAM_BOT_TOKEN}"
* - SecretRef object: { source: "env", provider: "default", id: "TELEGRAM_BOT_TOKEN" }
*
* Also reads <state-dir>/.env for env-based secrets.
*
* Credential values are NEVER emitted to stdout — only masked versions.
* When --write-env is provided, the script writes credentials directly to
* the target .env file so the agent never sees raw secrets.
*
* Emits a status block on stdout:
* === NANOCLAW MIGRATE: CREDENTIAL ===
* ...
* === END ===
*/
import fs from 'fs';
import os from 'os';
import path from 'path';
// ---------------------------------------------------------------------------
// JSON5-tolerant parser (same as discover script)
// ---------------------------------------------------------------------------
function parseJson5(text: string): unknown {
let cleaned = text.replace(
/("(?:[^"\\]|\\.)*")|\/\/[^\n]*/g,
(match, str) => (str ? str : ''),
);
cleaned = cleaned.replace(
/("(?:[^"\\]|\\.)*")|\/\*[\s\S]*?\*\//g,
(match, str) => (str ? str : ''),
);
cleaned = cleaned.replace(/,\s*([}\]])/g, '$1');
return JSON.parse(cleaned);
}
// ---------------------------------------------------------------------------
// Inline dotenv parser (reads key=value, skips comments)
// ---------------------------------------------------------------------------
function parseDotenv(filePath: string): Record<string, string> {
const env: Record<string, string> = {};
if (!fs.existsSync(filePath)) return env;
const lines = fs.readFileSync(filePath, 'utf-8').split('\n');
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) continue;
const eqIdx = trimmed.indexOf('=');
if (eqIdx === -1) continue;
const key = trimmed.slice(0, eqIdx).trim();
let value = trimmed.slice(eqIdx + 1).trim();
// Strip surrounding quotes
if (
(value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))
) {
value = value.slice(1, -1);
}
env[key] = value;
}
return env;
}
// ---------------------------------------------------------------------------
// Status block emitter
// ---------------------------------------------------------------------------
function emitStatus(fields: Record<string, string | number | boolean>): void {
const lines = ['=== NANOCLAW MIGRATE: CREDENTIAL ==='];
for (const [key, value] of Object.entries(fields)) {
lines.push(`${key}: ${value}`);
}
lines.push('=== END ===');
console.log(lines.join('\n'));
}
// ---------------------------------------------------------------------------
// Credential masking
// ---------------------------------------------------------------------------
function maskCredential(value: string): string {
if (value.length < 10) return '****';
return `${value.slice(0, 4)}...${value.slice(-4)}`;
}
// ---------------------------------------------------------------------------
// SecretRef resolution
// ---------------------------------------------------------------------------
interface SecretRef {
source: string;
provider?: string;
id: string;
}
function resolveSecretInput(
value: unknown,
dotenvVars: Record<string, string>,
): { resolved: string | null; source: string; note?: string } {
if (!value) return { resolved: null, source: 'missing' };
// Plain string
if (typeof value === 'string') {
// Check for env template: "${VAR_NAME}"
const envMatch = value.match(/^\$\{([^}]+)\}$/);
if (envMatch) {
const envKey = envMatch[1];
const envVal =
dotenvVars[envKey] ?? process.env[envKey] ?? null;
if (envVal) {
return { resolved: envVal, source: 'env_template' };
}
return {
resolved: null,
source: 'env_template',
note: `Environment variable ${envKey} not found`,
};
}
// Plain literal value
return { resolved: value, source: 'plain' };
}
// SecretRef object
if (typeof value === 'object' && value !== null) {
const ref = value as SecretRef;
if (ref.source === 'env') {
const envVal =
dotenvVars[ref.id] ?? process.env[ref.id] ?? null;
if (envVal) {
return { resolved: envVal, source: 'env_ref' };
}
return {
resolved: null,
source: 'env_ref',
note: `Environment variable ${ref.id} not found`,
};
}
if (ref.source === 'file') {
return {
resolved: null,
source: 'file_ref',
note: `File-based secret (${ref.id}) — cannot auto-extract, add manually`,
};
}
if (ref.source === 'exec') {
return {
resolved: null,
source: 'exec_ref',
note: `Exec-based secret (${ref.id}) — cannot auto-extract, add manually`,
};
}
}
return { resolved: null, source: 'unknown' };
}
// ---------------------------------------------------------------------------
// Channel credential mapping
// ---------------------------------------------------------------------------
interface ChannelCredentialSpec {
// Fields to look for in the channel config
fields: string[];
// Corresponding NanoClaw env var names
envVars: string[];
}
const CHANNEL_SPECS: Record<string, ChannelCredentialSpec> = {
telegram: {
fields: ['botToken'],
envVars: ['TELEGRAM_BOT_TOKEN'],
},
discord: {
fields: ['token'],
envVars: ['DISCORD_BOT_TOKEN'],
},
slack: {
fields: ['botToken', 'appToken'],
envVars: ['SLACK_BOT_TOKEN', 'SLACK_APP_TOKEN'],
},
whatsapp: {
fields: [], // Auth-state based, no token field
envVars: [],
},
};
// ---------------------------------------------------------------------------
// CLI arg parsing
// ---------------------------------------------------------------------------
function parseArgs(): { channel: string; stateDir: string; writeEnv: string } {
const args = process.argv.slice(2);
let channel = '';
let stateDir = '';
let writeEnv = '';
for (let i = 0; i < args.length; i++) {
if (args[i] === '--channel' && args[i + 1]) {
channel = args[++i].toLowerCase();
}
if (args[i] === '--state-dir' && args[i + 1]) {
stateDir = args[++i];
}
if (args[i] === '--write-env' && args[i + 1]) {
writeEnv = args[++i];
}
}
if (!channel) {
console.error('Usage: --channel <name> --state-dir <path> [--write-env <path>]');
process.exit(1);
}
// Expand ~ prefix
if (stateDir.startsWith('~')) {
stateDir = path.join(os.homedir(), stateDir.slice(1));
}
// Default state dir
if (!stateDir) {
const home = os.homedir();
if (fs.existsSync(path.join(home, '.openclaw'))) {
stateDir = path.join(home, '.openclaw');
} else if (fs.existsSync(path.join(home, '.clawdbot'))) {
stateDir = path.join(home, '.clawdbot');
} else {
console.error(
'No OpenClaw directory found. Use --state-dir to specify.',
);
process.exit(1);
}
}
return { channel, stateDir, writeEnv };
}
// ---------------------------------------------------------------------------
// .env writer — appends or replaces a KEY=VALUE line
// ---------------------------------------------------------------------------
function writeEnvVar(envPath: string, key: string, value: string): void {
let content = '';
if (fs.existsSync(envPath)) {
content = fs.readFileSync(envPath, 'utf-8');
}
const pattern = new RegExp(`^${key}=.*$`, 'm');
const line = `${key}="${value}"`;
if (pattern.test(content)) {
content = content.replace(pattern, line);
} else {
content = content.trimEnd() + (content ? '\n' : '') + line + '\n';
}
fs.writeFileSync(envPath, content);
}
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
function main(): void {
const { channel, stateDir, writeEnv } = parseArgs();
const spec = CHANNEL_SPECS[channel];
// Load dotenv from state dir
const dotenvVars = parseDotenv(path.join(stateDir, '.env'));
// Also check auth-profiles.json for API keys
const authProfilesPath = path.join(stateDir, 'auth-profiles.json');
let authProfiles: Record<string, unknown> = {};
if (fs.existsSync(authProfilesPath)) {
try {
authProfiles = JSON.parse(
fs.readFileSync(authProfilesPath, 'utf-8'),
) as Record<string, unknown>;
} catch {
// Ignore parse errors
}
}
// WhatsApp special case: no token, auth-state based.
// OpenClaw stores Baileys auth at <stateDir>/credentials/whatsapp/<accountId>/
// using useMultiFileAuthState (same as NanoClaw). The files are directly compatible.
if (channel === 'whatsapp') {
const authPaths = [
path.join(stateDir, 'credentials', 'whatsapp', 'default'),
path.join(stateDir, 'credentials', 'whatsapp'),
path.join(stateDir, 'wa-auth'),
];
// Also scan credentials/whatsapp/ for any account subdirectory
const waCredsDir = path.join(stateDir, 'credentials', 'whatsapp');
if (fs.existsSync(waCredsDir)) {
try {
for (const entry of fs.readdirSync(waCredsDir)) {
const candidate = path.join(waCredsDir, entry);
if (fs.statSync(candidate).isDirectory()) {
authPaths.push(candidate);
}
}
} catch {
// ignore
}
}
let authStatePath = '';
for (const p of authPaths) {
// Look for creds.json inside the directory — that confirms valid Baileys auth state
if (fs.existsSync(path.join(p, 'creds.json'))) {
authStatePath = p;
break;
}
}
emitStatus({
CHANNEL: 'whatsapp',
HAS_CREDENTIAL: false,
CREDENTIAL_SOURCE: 'auth_state',
NOTE: authStatePath
? `Baileys auth state found at ${authStatePath}. May not be portable across versions — recommend re-authenticating.`
: 'No WhatsApp auth state found. Will need to authenticate during setup.',
AUTH_STATE_PATH: authStatePath || 'not_found',
});
return;
}
// Unknown channel
if (!spec) {
emitStatus({
CHANNEL: channel,
HAS_CREDENTIAL: false,
NOTE: `Channel "${channel}" is not supported by NanoClaw. Supported: telegram, discord, slack, whatsapp.`,
});
return;
}
// Load OpenClaw config
let config: Record<string, unknown> | null = null;
for (const name of ['openclaw.json', 'clawdbot.json']) {
const configPath = path.join(stateDir, name);
if (fs.existsSync(configPath)) {
try {
config = parseJson5(
fs.readFileSync(configPath, 'utf-8'),
) as Record<string, unknown>;
break;
} catch {
// Try next
}
}
}
if (!config) {
emitStatus({
CHANNEL: channel,
HAS_CREDENTIAL: false,
NOTE: 'Could not load openclaw.json',
});
return;
}
const channels =
(config.channels as Record<string, unknown> | undefined) ?? {};
const channelConfig =
(channels[channel] as Record<string, unknown> | undefined) ?? {};
// Try to resolve each credential field
const results: Array<{
envVar: string;
resolved: string | null;
masked: string;
source: string;
note?: string;
}> = [];
for (let i = 0; i < spec.fields.length; i++) {
const field = spec.fields[i];
const envVar = spec.envVars[i];
// Check top-level channel config first
let rawValue = channelConfig[field];
// If not found, check first account
if (!rawValue && channelConfig.accounts) {
const accounts = channelConfig.accounts as Record<string, unknown>;
const firstAccount = Object.values(accounts)[0] as
| Record<string, unknown>
| undefined;
if (firstAccount) {
rawValue = firstAccount[field];
}
}
const { resolved, source, note } = resolveSecretInput(
rawValue,
dotenvVars,
);
results.push({
envVar,
resolved,
masked: resolved ? maskCredential(resolved) : '',
source,
note,
});
}
// Emit results for the primary credential
const primary = results[0];
if (!primary) {
emitStatus({
CHANNEL: channel,
HAS_CREDENTIAL: false,
NOTE: `No credential fields defined for ${channel}`,
});
return;
}
// If --write-env is set and credentials were resolved, write directly to .env.
// Credential values never appear in stdout.
let written = 0;
if (writeEnv) {
for (const r of results) {
if (r.resolved) {
writeEnvVar(writeEnv, r.envVar, r.resolved);
written++;
}
}
}
const fields: Record<string, string | number | boolean> = {
CHANNEL: channel,
HAS_CREDENTIAL: !!primary.resolved,
CREDENTIAL_SOURCE: primary.source,
CREDENTIAL_MASKED: primary.masked || 'none',
NANOCLAW_ENV_VAR: primary.envVar,
};
if (writeEnv && written > 0) {
fields.WRITTEN_TO = writeEnv;
fields.WRITTEN_COUNT = written;
}
if (primary.note) {
fields.NOTE = primary.note;
}
// Additional credentials (e.g. Slack has botToken + appToken)
if (results.length > 1) {
for (let i = 1; i < results.length; i++) {
const extra = results[i];
const suffix = `_${i + 1}`;
fields[`HAS_CREDENTIAL${suffix}`] = !!extra.resolved;
fields[`CREDENTIAL_SOURCE${suffix}`] = extra.source;
fields[`CREDENTIAL_MASKED${suffix}`] = extra.masked || 'none';
fields[`NANOCLAW_ENV_VAR${suffix}`] = extra.envVar;
if (extra.note) {
fields[`NOTE${suffix}`] = extra.note;
}
}
}
emitStatus(fields);
}
main();