Claude Skill

migrate-from-openclaw

Migrate from OpenClaw to NanoClaw v2. Detects an existing OpenClaw installation, extracts identity, channel credentials, scheduled tasks, and other config, then guides interactive migration. Triggers on "migrate from openclaw", "openclaw migration", "import from openclaw".

LLM Mart · 0 points · 17 views 0 listing impressions 0 install-command copies
Virus-scanned Reviewed automatically before listing.

Full trust report

Download nanocoai-nanoclaw-.claude_skills_migrate-from-openclaw-ad8837c.zip · 30 KB
Part of nanocoai/nanoclaw — 49 skills

Install

skills CLI npx skills add https://github.com/nanocoai/nanoclaw/tree/main/.claude/skills/migrate-from-openclaw
Claude Code claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install nanocoai-nanoclaw@llmmart
Git git clone https://github.com/nanocoai/nanoclaw.git

The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole nanocoai/nanoclaw collection as a plugin from our marketplace. Git is the plain clone.

Skill manifest

Migrate from OpenClaw

Guide the user through migrating their OpenClaw installation into NanoClaw v2. This is a conversation, not a batch job. Read OpenClaw state, discuss it with the user, decide together what to bring over and where it belongs in v2's entity model, and show proposed changes before applying.

Principle: Never silently copy data. Read it, explain it, place it, then apply. Credentials are masked when displayed (first 4 + ... + last 4). Make judgment calls about what's core vs. reference material.

UX: Use AskUserQuestion for multiple-choice only. Use plain text for free-form input. Don't dump raw data — summarize and explain conversationally.

What this skill changes (conformance)

This skill drives existing NanoClaw entry points (setup/index.ts --step register, scripts/init-first-agent.ts, the onecli CLI) and copies a few files in (workspace markdown, OpenClaw skills, and its own transform module + test). It makes no code-level reach-in into core. Its integration assumptions about v2 are guarded by scripts/transform.test.ts, which is copied into the project's scripts/ test tree on apply (Phase 8) so vitest runs it against the composed install. REMOVE.md reverses every file the skill copies.

v2 architecture the migration targets

OpenClaw and NanoClaw v2 differ structurally. Keep these in mind throughout:

  • Entity model. v2's central DB (data/v2.db) holds users, user_roles, agent_groups, messaging_groups, and the messaging_group_agents wiring between them. There is no store/messages.db and no scheduled_tasks table.
  • Container isolation. Each agent group runs in its own Linux container. An OpenClaw "agent" maps to a v2 agent group (workspace + memory + CLAUDE.md); an OpenClaw chat/group maps to a v2 messaging group; the wiring row connects them.
  • Standing instructions vs memory. Per-group role, personality, and behavior live in groups/<folder>/instructions.prepend.md. Durable facts live under groups/<folder>/memory/. The provider project document is composed at spawn and must not be edited.
  • Credentials. Container-facing API credentials (Anthropic, OpenAI, …) are held in the OneCLI Agent Vault and injected per request — never in container env vars. Host-side channel tokens (Telegram/Discord/Slack bot tokens) stay in .env; the NanoClaw host process reads them to connect to the platform.
  • Access control. Per messaging group unknown_sender_policy plus user_roles (owner/admin) and agent_group_members — not a JSON allowlist file.
  • Scheduled tasks. A task is a messages_in row (kind='task') in a session's inbound.db, carrying a cron recurrence and a process_after timestamp. The agent creates them via its schedule_task MCP tool.

Migration State File

Create migration-state.md in the project root at the start of Phase 0. Update it after each phase. It's the single source of truth — if context is lost, re-read it to recover decisions and progress. Re-read it before starting any phase.

Sections to maintain:

  • Progress — checkbox list of phases (Phase 0–8)
  • Discovery — STATE_DIR, IDENTITY_NAME, channels, groups (with v2 platform_id mappings), workspace files, cron count, MCP servers
  • Decisions — assistant_name, shared-vs-separate, primary owner agent
  • Owner & Primary Agent — user id, role, agent group folder
  • Registered Groups — table: folder, platform_id, channel, session_mode
  • Credentials — table: credential, destination (vault / .env), status
  • Settings Migrated — timezone, container timeout
  • Identity & Memory — prepend and memory paths created for each group
  • Scheduled Tasks — table: original_id, name, mapped schedule, status
  • Deferred / Not Applicable — unsupported channels, OpenClaw-only features

Keep it factual and terse. Delete it at the end of Phase 8 (or offer to keep it as a record).

Phase 0: Discovery

Run the discovery script to find and summarize the OpenClaw installation:

pnpm exec tsx ${CLAUDE_SKILL_DIR}/scripts/discover-openclaw.ts

If the user specifies a custom path, pass --state-dir <path>.

Parse the status block. Key fields: STATUS, STATE_DIR, CHANNELS, WORKSPACE_FILES, DAILY_MEMORY_FILES, SKILL_COUNT, SKILLS, CRON_JOBS, MCP_SERVERS, IDENTITY_NAME, AGENT_COUNT, AGENT_IDS, GROUPS (each formatted channel:id(name)=>v2_platform_id — the right-hand value is what to pass as --platform-id to register).

Sanity-check the output. The script detects known structures but can miss data if OpenClaw's format changed. Check CONFIG_TOP_KEYS and CONFIG_CHANNEL_KEYS — if you see keys it didn't report on, read that section of the config with the Read tool. Check STATE_DIR_CONTENTS for directories it doesn't scan.

If STATUS=not_found: Tell the user no OpenClaw install was detected at the standard locations (~/.openclaw, ~/.clawdbot). Ask for a custom path; if none, exit.

If STATUS=found: Present a human-readable summary (identity name, workspace files, channels and which v2 supports, daily memory count, skills, cron count, MCP servers, agent count). Then paraphrase the key architectural differences from the section above — don't dump it as a table.

AskUserQuestion: "Ready to start migrating? I'll go through each area one at a time."

  1. Yes, let's go — proceed to Phase 1
  2. Tell me more — explain any area they ask about
  3. Skip migration — exit

Phase 1: Agents, Groups, and Shared vs Separate

Decide this before identity/memory — it determines where files go.

OpenClaw model: all groups routed to one agent share a workspace (SOUL/MEMORY/IDENTITY) and personality; only the session is per-group.

v2 model: each agent group is a separate container with its own filesystem, standing instructions, and memory/ tree. Multiple messaging groups wired to the same agent group share that state. There is no groups/global/.

AskUserQuestion: "In OpenClaw your groups shared one personality and memory. In v2 each agent group is separate. How do you want to handle this?"

  1. Shared identity (recommended if it was one bot) — apply the same core identity to each selected group's instructions.prepend.md; keep group facts in each group's memory tree.
  2. Fully separate — each group gets independent memory and instructions; no shared base edit.
  3. Just the primary agent for now — set one agent up; add others later.

Remember this choice for Phase 3.

Confirm the assistant name

IDENTITY_NAME from discovery is the OpenClaw name. Ask: "Your OpenClaw assistant was named <IDENTITY_NAME>. Keep it in v2?" If empty, ask them to choose (default: "Andy"). The chosen name is passed as --assistant-name to register/init.

Seed the owner and the primary DM agent

The owner identity and the primary agent are created together by scripts/init-first-agent.ts. It upserts the user, grants the owner role, creates the agent group + filesystem, wires a DM messaging group, and queues a welcome DM over the running service's CLI socket — so the service must be running. If it isn't, tell the user to start it first.

Resolve the owner's channel identity and the DM platform id (use the channel's own terminology). Then:

pnpm exec tsx scripts/init-first-agent.ts \
  --channel <channel> \
  --user-id <channel>:<handle> \
  --platform-id <channel>:<dm-id> \
  --display-name "<Owner Name>" \
  --agent-name "<confirmed assistant name>" \
  [--role owner]      # default: owner

For direct-addressable channels (telegram, whatsapp) the --platform-id is usually the same handle as --user-id with the channel prefix. --role defaults to owner (global, cross-channel) — use admin (scoped to the agent group) or member only if intended.

Register the remaining groups

For each additional OpenClaw group the user wants to bring over, register a messaging group and wire it to an agent group:

pnpm exec tsx setup/index.ts --step register -- \
  --platform-id "<v2_platform_id from discovery>" \
  --name "<group name>" \
  --folder "<channel>_<name-slug>" \
  --channel "<channel>" \
  --session-mode "<shared|agent-shared|per-thread>" \
  [--trigger "@<assistant name>"] \
  [--no-trigger-required] \
  --assistant-name "<assistant name>"

Notes:

  • register namespaces the --platform-id the same way the adapter will at runtime, so pass the => value discovery emitted (or the raw OpenClaw id).
  • Reuse a --folder to put a group on an existing agent (shared base/separate conversations); use a new --folder for a fully separate agent.
  • Engage defaults come from the channel adapter's declaration (most group chats default to mention-based engagement; channels without a mention signal default to a name pattern). Pass --trigger to set an explicit regex, or --no-trigger-required for respond-to-everything.
  • Register groups from channels v2 doesn't support yet too — the messaging group and wiring persist and activate when that channel is installed.

Folder naming: <channel>_<name-slug> (e.g. telegram_dev-team). Confirm each name and folder with the user.

Phase 2: Settings from Config

Read the config (<STATE_DIR>/openclaw.json or clawdbot.json) for settings that map to v2 setup.

Timezone

Check agents.defaults.userTimezone. If it's a valid IANA zone, write it to .env as TZ=<timezone>. v2 reads TZ from .env (src/config.ts) and uses it for cron/recurrence evaluation, so this matters for scheduled tasks.

Container timeout

Check agents.defaults.timeoutSeconds. v2's equivalent is CONTAINER_TIMEOUT (env var, default 30 min) or per-group ncl groups config update. If the OpenClaw value differs notably, note it; the user can set CONTAINER_TIMEOUT=<ms> in .env.

Access control (sender policies)

OpenClaw per-channel allowFrom / dmPolicy / groupPolicy map onto v2's model, which is not a JSON file. Each messaging group has an unknown_sender_policy; access is granted via user_roles (owner/admin) and agent_group_members. Map:

  • dmPolicy/groupPolicy: "open" → leave the default; no extra grants.

  • allowFrom / groupAllowFrom lists → for each allowed sender, upsert the user and add them as a member of the relevant agent group via ncl:

    ncl users create --id "<channel>:<handle>" --kind <channel> --display-name "<name>"
    ncl members add --user "<channel>:<handle>" --group "<ag-id>"
    
  • dmPolicy: "disabled" → don't wire that chat (or leave it registered but unwired).

The messaging groups register / init-first-agent create default their unknown_sender_policy to whatever the channel adapter declares for that context (DM vs group) — strict when the channel has no declaration — so unknown senders are gated until you add them (or an admin approves the adapter-declared approval card). Pass --unknown-sender-policy to register to override. Show the user the OpenClaw allowlist and confirm who to grant before running the commands.

Phase 3: Identity and Memory

Fully conversational — read files directly and discuss. Placement depends on the Phase 1 choice:

  • Shared identity: merge the same core identity/personality into every selected group's instructions.prepend.md.
  • Fully separate / primary only: merge identity/personality only into the corresponding group's instructions.prepend.md.

Never edit a composed CLAUDE.md or AGENTS.md; it is regenerated each spawn. Put standing behavior in instructions.prepend.md and facts in memory/.

Find workspace files at <STATE_DIR>/workspace/. If AGENT_COUNT > 1, also check <STATE_DIR>/agents/*/workspace/ and ask which agent maps to which v2 agent group.

IDENTITY.md / SOUL.md

Read them. Distinguish always-loaded vs reference:

  • Standing behavior (core traits, communication style, key rules) → weave into the group's instructions.prepend.md.
  • Reference (backstory, extended guidelines) → a separate durable concept in an appropriate folder under groups/<folder>/memory/, linked from that folder's index.md and the root Map.

Choose each memory folder based on which related information will be easiest to find together; a folder may contain different concept types. Before writing the first concept into a new folder, create the folder and its index.md. Follow memory/system/definition.md, including its YAML frontmatter rules, for every new concept.

Show proposed edits before applying — this is a thoughtful merge, not a paste.

USER.md

Create a focused user-context concept in an appropriate memory folder and link it through that folder's index and the root Map. Put only facts relevant in nearly every conversation (for example name or timezone) into ## Core Memory; keep all other details in the linked file.

MEMORY.md and daily memory files

Show MEMORY.md; keep relevant items in focused concepts under the chosen memory folders, with links through each folder index and the root Map. For daily files (workspace/memory/*.md, count = DAILY_MEMORY_FILES):

AskUserQuestion: "You have N daily memory files. How to handle them?"

  1. Copy as-is — agree on a descriptive folder, create it and its index.md, then copy with cp <workspace>/memory/*.md <group_dir>/memory/<chosen-folder>/ and link the retained files through its index and the root Map.
  2. Consolidate — read, extract durable facts, and place them in focused linked memory files.
  3. Skip.

OpenClaw skills

If SKILL_COUNT > 0, the SKILL.md format is shared, so skills are portable. Present each (name + description from the front matter) and let the user pick. For each confirmed skill, copy the directory into the container skills tree:

cp -r <skill_source_dir> container/skills/<skill_name>

A container rebuild is needed afterward — note it for Phase 8.

Config-registered plugins (with API keys)

If CONFIG_PLUGINS is non-empty, OpenClaw had plugins/skills carrying keys. For each, read the config section and decide together:

  • Matching v2 skill → run that skill; route its credential per Phase 4.
  • An MCP server → install the exact configured package; wire via ncl groups config add-mcp-server. Don't guess at packages.
  • An API key → route to the OneCLI vault if container-facing (Phase 4).

Don't install unknown packages or search for replacements — supply-chain risk.

Phase 4: Credentials

Two destinations, decided per credential. Channel tokens → .env (host reads them). Container-facing API credentials → the OneCLI vault (injected per request, never in container env).

Channel tokens (telegram, discord, slack)

Preview, then write to .env. The script emits only masked values:

pnpm exec tsx ${CLAUDE_SKILL_DIR}/scripts/extract-channel-credentials.ts \
  --state-dir <STATE_DIR> --channel <name>

Parse the status block. DESTINATION: env confirms a host-side token. Show CREDENTIAL_MASKED (and CREDENTIAL_MASKED_2 for Slack's app token).

AskUserQuestion:

  1. Use this credential — re-run with --write-env .env to save it.
  2. Enter a new one — ask in plain text, write to .env yourself.
  3. Skip this channel.
pnpm exec tsx ${CLAUDE_SKILL_DIR}/scripts/extract-channel-credentials.ts \
  --state-dir <STATE_DIR> --channel <name> --write-env .env

Check WRITTEN_TO / WRITTEN_COUNT. Slack writes both SLACK_BOT_TOKEN and SLACK_APP_TOKEN in one run.

If HAS_CREDENTIAL=false but a credential is expected: the config shape may be unrecognized, or it uses a file/exec SecretRef (CREDENTIAL_SOURCE ends in _ref with a NOTE) that can't be auto-extracted. Read the channel section of the config directly and ask the user to confirm or paste the value.

WhatsApp: authenticates via QR/pairing code — there's no token. Don't copy Baileys auth state (stale encryption sessions break decryption). Re-authenticate during /setup via /add-whatsapp. The extraction script reports DESTINATION: none for it.

Anthropic and other container-facing credentials → OneCLI vault

Find the agent's model credentials in OpenClaw. Check, in order:

  1. <STATE_DIR>/auth-profiles.json (and <STATE_DIR>/agents/<id>/agent/auth-profiles.json) — a profiles map keyed provider:identifier. For an anthropic provider profile the value depends on type: api_key → key, token → token, oauth → access.
  2. <STATE_DIR>/.env — ANTHROPIC_API_KEY or CLAUDE_CODE_OAUTH_TOKEN.
  3. Config models.providers — Anthropic provider apiKey.

These are container-facing, so they go to the OneCLI vault. Do not write them to .env or thread them into a container. Register each in the vault:

onecli secrets create --name Anthropic --type anthropic \
  --value <key-or-token> --host-pattern api.anthropic.com

For other container-facing keys discovered in plugins (e.g. OpenAI):

onecli secrets create --name OpenAI --type api_key \
  --value <key> --host-pattern api.openai.com

Run the command on the user's behalf so the value never lands in the chat transcript; confirm with onecli secrets list.

Caveats: keyRef/tokenRef with source:"exec" or source:"file" can't be auto-extracted — ask the user to paste it. For an oauth profile with a past expiry, warn that the token may need refreshing; the user can run claude setup-token and register the fresh token.

If OneCLI isn't installed yet, defer this: tell the user that during /setup (or /init-onecli) they'll register the Anthropic credential, and note the discovered profile in migration-state.md so it isn't lost.

There is no supported .env-credentials opt-out anymore: the session spec's admission rules refuse credential values in container env on every lane, by design (the retired /use-native-credential-proxy skill would be denied at every spawn). Credentials go through the OneCLI vault; custom Anthropic endpoints use ANTHROPIC_BASE_URL plus the placeholder-token pattern from setup, with the gateway rewriting the header on the wire.

Phase 5: Scheduled Tasks

Read <STATE_DIR>/cron/jobs.json. If absent or empty, skip.

If jobs exist, read ${CLAUDE_SKILL_DIR}/MIGRATE_CRONS.md for the v2 task model, the mapCronToRecurrence transform, the full field mapping, and how tasks are created (the agent's schedule_task MCP tool, since tasks live in a per-session inbound.db the host owns). Follow it for each enabled job.

Phase 6: MCP, Webhooks, Other Config

Read the relevant config sections directly. Conversational.

MCP servers

If MCP_SERVERS is non-empty, v2 supports per-agent-group MCP servers via the container config. Read each server's command/args/env/url from mcp.servers. For each one the user wants:

ncl groups config add-mcp-server --id <agent-group-id> \
  --name <server-name> --command <cmd> \
  [--args '<json-array>'] [--env '<json-object>']

stdio servers must be runnable inside the container (Node/npx-based work; custom binaries need a Dockerfile addition). Secrets referenced by a server's env should go to the OneCLI vault (Phase 4), not be inlined. The config change takes effect on restart: ncl groups restart --id <agent-group-id> (add --rebuild only if a custom binary was added to the Dockerfile).

Webhooks

OpenClaw cron.webhook / failureDestination / channel webhooks don't map to a v2 primitive. For a notification webhook, fold it into a scheduled task's prompt or a pre-agent script that curls the endpoint. Discuss the use case.

Other config (mention and move on)

  • Exec approvals / command allowlist → v2 uses container isolation; the agent runs sandboxed.
  • Human delay / TTS / compaction / model config → not v2 task/group fields (per-group model is in the container config).

Phase 7: Welcome and First Run

init-first-agent (Phase 1) already queued a welcome DM for the primary owner agent. If the service was up, the owner should have received it. For groups registered via setup --step register, the wiring also queues a /welcome onboarding message on first wiring.

Tell the user which agents are live now and which await channel installation (unsupported channels registered for the future).

Phase 8: Validate and Summarize

Run the shipped test

Copy the transform module and its test into the project so vitest runs them against the composed install, then build and test:

cp ${CLAUDE_SKILL_DIR}/scripts/transform.ts        scripts/openclaw-transform.ts
cp ${CLAUDE_SKILL_DIR}/scripts/transform.test.ts   scripts/openclaw-transform.test.ts
# Point the copied test at the copied module name:
sed -i.bak "s#from './transform.js'#from './openclaw-transform.js'#" scripts/openclaw-transform.test.ts && rm -f scripts/openclaw-transform.test.ts.bak

pnpm run build
pnpm exec vitest run scripts/openclaw-transform.test.ts

The test guards the skill's two v2 integration assumptions: credential routing (container-facing → vault, channel tokens → .env) and the cron → v2 recurrence mapping. It imports the real cron-parser (the same parser the host recurrence sweep uses), so a missing/renamed dependency turns it red. build typechecks the transform module against the project.

These copied files are the only files the skill installs into the project tree; REMOVE.md deletes them.

If a container rebuild is needed

If OpenClaw skills were copied or MCP servers added: ./container/build.sh, then restart the service.

Summary

Print what was migrated:

  • Owner + primary agent → users / user_roles / agent group + welcome DM
  • Additional groups → messaging groups + wiring (folders + session modes)
  • Timezone → .env TZ; container timeout → noted
  • Access grants → members/roles for OpenClaw allowlist senders
  • Identity/personality → per-group instructions.prepend.md + linked memory concepts
  • User context / memories → Core Memory only for universal facts; otherwise linked concepts in content-based folders under memory/
  • OpenClaw skills → container/skills/
  • Channel tokens → .env (list channels)
  • Container-facing credentials → OneCLI vault (list)
  • Scheduled tasks → mapped and scheduled via the agent (or noted for first run)
  • MCP servers → wired into agent group container configs

Noted for later: channel installs during /setup; container rebuild if needed; tasks deferred until a session exists.

Not applicable: unsupported channels (registered for the future); OpenClaw-only features (exec approvals, human delay, TTS, model/thinking config).

Remind: "Run /setup next to finish your NanoClaw install. Channel tokens are in .env; container-facing credentials are in the OneCLI vault. Select the channels we configured when setup asks."

Then delete migration-state.md (or offer to keep it as a record), and remove the copied transform files if you don't want them lingering (see REMOVE.md).

Troubleshooting

  • Config parse error: the JSON5 parser may not handle unusual syntax. Read the file directly and work with it manually.
  • Credential not found: likely a file/exec SecretRef — ask the user to paste the value.
  • init-first-agent can't reach the CLI socket: the service isn't running. Start it, then re-run.
  • Multi-agent complexity: do the primary/default agent first; add others as separate agent groups later.
Files (nanoclaw)
  • scripts
    • discover-openclaw.ts 23.1 KB
      /**
       * Discover an existing OpenClaw installation and emit a structured summary.
       *
       * Usage: pnpm exec tsx .claude/skills/migrate-from-openclaw/scripts/discover-openclaw.ts [--state-dir <path>]
       *
       * Checks (in order): --state-dir arg, $OPENCLAW_STATE_DIR, ~/.openclaw, ~/.clawdbot
       * Parses openclaw.json (JSON5-tolerant), scans workspace for identity/memory files,
       * checks cron jobs, MCP servers, and channel credentials.
       *
       * Emits a status block on stdout:
       *   === NANOCLAW MIGRATE: DISCOVERY ===
       *   ...
       *   === END ===
       */
      
      import fs from 'fs';
      import os from 'os';
      import path from 'path';
      
      // ---------------------------------------------------------------------------
      // JSON5-tolerant parser (no dependency)
      // ---------------------------------------------------------------------------
      
      function parseJson5(text: string): unknown {
        // Strip single-line comments (// ...) that aren't inside strings
        let cleaned = text.replace(
          /("(?:[^"\\]|\\.)*")|\/\/[^\n]*/g,
          (match, str) => (str ? str : ''),
        );
        // Strip block comments (/* ... */)
        cleaned = cleaned.replace(
          /("(?:[^"\\]|\\.)*")|\/\*[\s\S]*?\*\//g,
          (match, str) => (str ? str : ''),
        );
        // Strip trailing commas before } or ]
        cleaned = cleaned.replace(/,\s*([}\]])/g, '$1');
        return JSON.parse(cleaned);
      }
      
      // ---------------------------------------------------------------------------
      // Status block emitter (mirrors setup/status.ts convention)
      // ---------------------------------------------------------------------------
      
      function emitStatus(fields: Record<string, string | number | boolean>): void {
        const lines = ['=== NANOCLAW MIGRATE: DISCOVERY ==='];
        for (const [key, value] of Object.entries(fields)) {
          lines.push(`${key}: ${value}`);
        }
        lines.push('=== END ===');
        console.log(lines.join('\n'));
      }
      
      // ---------------------------------------------------------------------------
      // CLI arg parsing
      // ---------------------------------------------------------------------------
      
      function parseArgs(): { stateDir?: string } {
        const args = process.argv.slice(2);
        for (let i = 0; i < args.length; i++) {
          if (args[i] === '--state-dir' && args[i + 1]) {
            return { stateDir: args[i + 1] };
          }
        }
        return {};
      }
      
      // ---------------------------------------------------------------------------
      // Path resolution
      // ---------------------------------------------------------------------------
      
      function resolveStateDir(explicit?: string): string | null {
        const home = os.homedir();
        const candidates: string[] = [];
      
        if (explicit) {
          // Expand ~ prefix
          const expanded = explicit.startsWith('~')
            ? path.join(home, explicit.slice(1))
            : explicit;
          candidates.push(expanded);
        }
      
        if (process.env.OPENCLAW_STATE_DIR) {
          candidates.push(process.env.OPENCLAW_STATE_DIR);
        }
      
        candidates.push(path.join(home, '.openclaw'));
        candidates.push(path.join(home, '.clawdbot'));
      
        for (const dir of candidates) {
          if (fs.existsSync(dir) && fs.statSync(dir).isDirectory()) {
            return dir;
          }
        }
        return null;
      }
      
      // ---------------------------------------------------------------------------
      // Config loading
      // ---------------------------------------------------------------------------
      
      function loadConfig(
        stateDir: string,
      ): Record<string, unknown> | null {
        for (const name of ['openclaw.json', 'clawdbot.json']) {
          const configPath = path.join(stateDir, name);
          if (fs.existsSync(configPath)) {
            try {
              const raw = fs.readFileSync(configPath, 'utf-8');
              return parseJson5(raw) as Record<string, unknown>;
            } catch {
              // Try next name
            }
          }
        }
        return null;
      }
      
      // ---------------------------------------------------------------------------
      // Channel detection
      // ---------------------------------------------------------------------------
      
      interface ChannelInfo {
        name: string;
        hasCreds: boolean;
      }
      
      const SUPPORTED_CHANNELS = new Set([
        'whatsapp',
        'telegram',
        'slack',
        'discord',
      ]);
      
      // Fields that indicate a credential is present for each channel
      const CREDENTIAL_FIELDS: Record<string, string[]> = {
        telegram: ['botToken'],
        discord: ['token'],
        slack: ['botToken', 'appToken'],
        whatsapp: [], // Auth-state based, no token
        signal: ['account'],
        imessage: [],
        matrix: ['homeserverUrl', 'accessToken'],
        irc: ['server'],
        msteams: ['appId'],
        feishu: ['appId'],
        googlechat: [],
        mattermost: ['token', 'url'],
        zalo: [],
        bluebubbles: ['url'],
      };
      
      const ALL_KNOWN_CHANNELS = new Set([
        'whatsapp', 'telegram', 'slack', 'discord', 'signal',
        'imessage', 'matrix', 'irc', 'msteams', 'feishu',
        'googlechat', 'mattermost', 'zalo', 'bluebubbles',
      ]);
      
      function detectChannels(
        config: Record<string, unknown>,
      ): ChannelInfo[] {
        // Check both config.channels.* (newer) and top-level config.* (older/legacy)
        const channelsSections: Record<string, unknown> = {};
      
        // Source 1: channels.* (standard location)
        const nested = config.channels as Record<string, unknown> | undefined;
        if (nested) {
          for (const [k, v] of Object.entries(nested)) {
            if (v && typeof v === 'object') channelsSections[k] = v;
          }
        }
      
        // Source 2: top-level keys matching known channel names (legacy format)
        for (const key of Object.keys(config)) {
          if (ALL_KNOWN_CHANNELS.has(key) && !channelsSections[key]) {
            const v = config[key];
            if (v && typeof v === 'object') channelsSections[key] = v;
          }
        }
      
        const results: ChannelInfo[] = [];
      
        for (const [name, section] of Object.entries(channelsSections)) {
          if (!section || typeof section !== 'object') continue;
          const ch = section as Record<string, unknown>;
      
          // Check if any credential field is present and non-empty
          const credFields = CREDENTIAL_FIELDS[name] ?? [];
          let hasCreds = false;
      
          for (const field of credFields) {
            const val = ch[field];
            if (val && (typeof val === 'string' || typeof val === 'object')) {
              hasCreds = true;
              break;
            }
          }
      
          // Also check accounts for multi-account setups
          if (!hasCreds && ch.accounts && typeof ch.accounts === 'object') {
            for (const acct of Object.values(
              ch.accounts as Record<string, unknown>,
            )) {
              if (!acct || typeof acct !== 'object') continue;
              const a = acct as Record<string, unknown>;
              for (const field of credFields) {
                if (
                  a[field] &&
                  (typeof a[field] === 'string' || typeof a[field] === 'object')
                ) {
                  hasCreds = true;
                  break;
                }
              }
              if (hasCreds) break;
            }
          }
      
          // WhatsApp: check for auth state directory instead of token
          if (name === 'whatsapp' && !hasCreds) {
            // Will be checked separately via agents directory
            hasCreds = false;
          }
      
          results.push({ name, hasCreds });
        }
      
        return results;
      }
      
      // ---------------------------------------------------------------------------
      // Workspace scanning
      // ---------------------------------------------------------------------------
      
      const WORKSPACE_FILES = [
        'SOUL.md',
        'USER.md',
        'MEMORY.md',
        'IDENTITY.md',
        'TOOLS.md',
        'HEARTBEAT.md',
        'BOOTSTRAP.md',
        'AGENTS.md',
      ];
      
      function findWorkspace(stateDir: string, config: Record<string, unknown> | null): {
        dir: string | null;
        files: string[];
      } {
        // Check config-specified workspace path first (agent.workspace or agents.defaults.workspace)
        const configPaths: string[] = [];
        if (config) {
          const agentWs = (config.agent as Record<string, unknown> | undefined)?.workspace as string | undefined;
          if (agentWs) configPaths.push(agentWs.startsWith('~') ? path.join(os.homedir(), agentWs.slice(1)) : agentWs);
          const defaultsWs = ((config.agents as Record<string, unknown> | undefined)?.defaults as Record<string, unknown> | undefined)?.workspace as string | undefined;
          if (defaultsWs) configPaths.push(defaultsWs.startsWith('~') ? path.join(os.homedir(), defaultsWs.slice(1)) : defaultsWs);
        }
      
        // Check config-specified paths, then default locations
        const candidates = [
          ...configPaths,
          ...['workspace', 'workspace.default'].map((n) => path.join(stateDir, n)),
        ];
      
        for (const ws of candidates) {
          if (fs.existsSync(ws) && fs.statSync(ws).isDirectory()) {
            const found = WORKSPACE_FILES.filter((f) =>
              fs.existsSync(path.join(ws, f)),
            );
            if (found.length > 0) {
              return { dir: ws, files: found };
            }
          }
        }
      
        // Check agent-specific workspaces
        const agentsDir = path.join(stateDir, 'agents');
        if (fs.existsSync(agentsDir)) {
          for (const agentId of fs.readdirSync(agentsDir)) {
            for (const wsName of ['workspace', 'workspace.default']) {
              const ws = path.join(agentsDir, agentId, wsName);
              if (fs.existsSync(ws) && fs.statSync(ws).isDirectory()) {
                const found = WORKSPACE_FILES.filter((f) =>
                  fs.existsSync(path.join(ws, f)),
                );
                if (found.length > 0) {
                  return { dir: ws, files: found };
                }
              }
            }
          }
        }
      
        return { dir: null, files: [] };
      }
      
      // ---------------------------------------------------------------------------
      // Daily memory file detection
      // ---------------------------------------------------------------------------
      
      function countDailyMemoryFiles(workspaceDir: string | null): number {
        if (!workspaceDir) return 0;
        const memoryDir = path.join(workspaceDir, 'memory');
        if (!fs.existsSync(memoryDir) || !fs.statSync(memoryDir).isDirectory()) {
          return 0;
        }
        try {
          return fs
            .readdirSync(memoryDir)
            .filter((f) => f.endsWith('.md'))
            .length;
        } catch {
          return 0;
        }
      }
      
      // ---------------------------------------------------------------------------
      // Skills detection
      // ---------------------------------------------------------------------------
      
      interface SkillInfo {
        name: string;
        source: string; // 'workspace' | 'shared' | 'personal' | 'project'
        path: string;
      }
      
      function detectSkills(
        stateDir: string,
        workspaceDir: string | null,
      ): SkillInfo[] {
        const skills: SkillInfo[] = [];
        const seen = new Set<string>();
      
        const scanDir = (dir: string, source: string) => {
          if (!fs.existsSync(dir) || !fs.statSync(dir).isDirectory()) return;
          try {
            for (const entry of fs.readdirSync(dir)) {
              const skillDir = path.join(dir, entry);
              if (!fs.statSync(skillDir).isDirectory()) continue;
              // A directory is a skill if it contains SKILL.md
              if (fs.existsSync(path.join(skillDir, 'SKILL.md'))) {
                if (seen.has(entry)) continue;
                seen.add(entry);
                skills.push({ name: entry, source, path: skillDir });
              }
            }
          } catch {
            // ignore read errors
          }
        };
      
        // 1. Workspace skills
        if (workspaceDir) {
          scanDir(path.join(workspaceDir, 'skills'), 'workspace');
          // 4. Project-level shared skills
          scanDir(path.join(workspaceDir, '.agents', 'skills'), 'project');
        }
      
        // 2. Managed/shared skills
        scanDir(path.join(stateDir, 'skills'), 'shared');
      
        // 3. Personal cross-project skills
        const personalSkills = path.join(os.homedir(), '.agents', 'skills');
        scanDir(personalSkills, 'personal');
      
        return skills;
      }
      
      // ---------------------------------------------------------------------------
      // Identity extraction
      // ---------------------------------------------------------------------------
      
      function extractIdentityName(stateDir: string, workspaceDir: string | null): string {
        if (!workspaceDir) return '';
      
        const identityPath = path.join(workspaceDir, 'IDENTITY.md');
        if (!fs.existsSync(identityPath)) return '';
      
        try {
          const content = fs.readFileSync(identityPath, 'utf-8');
          // IDENTITY.md uses key:value format, e.g. "name: Claw"
          const match = content.match(/^name:\s*(.+)/im);
          return match ? match[1].trim() : '';
        } catch {
          return '';
        }
      }
      
      // ---------------------------------------------------------------------------
      // Agent detection
      // ---------------------------------------------------------------------------
      
      function detectAgents(stateDir: string): string[] {
        const agentsDir = path.join(stateDir, 'agents');
        if (!fs.existsSync(agentsDir)) return [];
      
        try {
          return fs
            .readdirSync(agentsDir)
            .filter((f) => {
              const p = path.join(agentsDir, f);
              return fs.statSync(p).isDirectory() && !f.startsWith('.');
            });
        } catch {
          return [];
        }
      }
      
      // ---------------------------------------------------------------------------
      // Group detection — from session store and channel config
      // ---------------------------------------------------------------------------
      
      interface GroupInfo {
        channel: string;
        id: string; // Platform-specific ID (WhatsApp JID, Telegram chat ID, etc.)
        name: string;
        source: 'session' | 'config';
      }
      
      /**
       * Map an OpenClaw session key (channel:kind:id) to the v2 platform_id the
       * router stores. Mirrors src/platform-id.ts:namespacedPlatformId — Chat SDK
       * channels prefix with "<channel>:"; native channels (WhatsApp/iMessage with
       * an "@", Signal "+"/"group:") pass through unprefixed. setup/register.ts
       * applies the same normalization to whatever you pass as --platform-id, so the
       * value emitted here is what to feed register.
       *
       *   OpenClaw keys: "whatsapp:group:120...@g.us", "telegram:group:-10012345"
       *   v2 platform_id: "120...@g.us", "telegram:-10012345", "discord:12345"
       */
      function toV2PlatformId(channel: string, id: string): string {
        if (id.startsWith(`${channel}:`)) return id;
        if (id.includes('@')) return id; // WhatsApp / iMessage JIDs and emails
        if (id.startsWith('+') || id.startsWith('group:')) return id; // Signal
        if (channel === 'deltachat') return id;
        return `${channel}:${id}`;
      }
      
      function detectGroups(
        stateDir: string,
        config: Record<string, unknown> | null,
        agents: string[],
      ): GroupInfo[] {
        const groups: GroupInfo[] = [];
        const seen = new Set<string>();
      
        // Source 1: Session store — scan for group session keys
        for (const agentId of agents) {
          const sessionsPath = path.join(
            stateDir,
            'agents',
            agentId,
            'sessions',
            'sessions.json',
          );
          if (!fs.existsSync(sessionsPath)) continue;
      
          try {
            const raw = fs.readFileSync(sessionsPath, 'utf-8');
            const data = JSON.parse(raw) as Record<string, unknown>;
      
            // Sessions can be stored as an object with session keys, or as
            // { sessions: { key: entry } } or { entries: [...] }
            const entries =
              (data.sessions as Record<string, unknown>) ??
              (data.entries as Record<string, unknown>) ??
              data;
      
            for (const [key, value] of Object.entries(entries)) {
              // Match session keys like "whatsapp:group:120...@g.us"
              // or prefixed "agent:main:whatsapp:group:120...@g.us"
              // Also match DM sessions: "whatsapp:dm:number@s.whatsapp.net"
              const match = key.match(/(\w+):(group|dm|channel):(.+)$/i);
              if (!match) continue;
      
              const [, channel, kind, id] = match;
              // Skip DM sessions for group detection — they're individual chats
              if (kind === 'dm') continue;
              const dedupKey = `${channel}:${id}`;
              if (seen.has(dedupKey)) continue;
              seen.add(dedupKey);
      
              // Try to extract display name from session entry
              let name = '';
              if (value && typeof value === 'object') {
                const entry = value as Record<string, unknown>;
                name =
                  (entry.displayName as string) ??
                  (entry.label as string) ??
                  (entry.subject as string) ??
                  '';
              }
      
              groups.push({
                channel,
                id,
                name: name || id,
                source: 'session',
              });
            }
          } catch {
            // Ignore parse errors
          }
        }
      
        // Source 2: Channel config — groups explicitly configured
        if (config) {
          const channels =
            (config.channels as Record<string, unknown> | undefined) ?? {};
          for (const [channelName, channelSection] of Object.entries(channels)) {
            if (!channelSection || typeof channelSection !== 'object') continue;
            const ch = channelSection as Record<string, unknown>;
      
            // WhatsApp/Telegram: channels.<channel>.groups.<groupId>
            const configGroups = ch.groups as Record<string, unknown> | undefined;
            if (configGroups) {
              for (const groupId of Object.keys(configGroups)) {
                const dedupKey = `${channelName}:${groupId}`;
                if (seen.has(dedupKey)) continue;
                seen.add(dedupKey);
                groups.push({
                  channel: channelName,
                  id: groupId,
                  name: groupId,
                  source: 'config',
                });
              }
            }
      
            // Discord: channels.discord.guilds.<guildId>
            if (channelName === 'discord') {
              const guilds = ch.guilds as Record<string, unknown> | undefined;
              if (guilds) {
                for (const guildId of Object.keys(guilds)) {
                  const dedupKey = `discord:${guildId}`;
                  if (seen.has(dedupKey)) continue;
                  seen.add(dedupKey);
                  groups.push({
                    channel: 'discord',
                    id: guildId,
                    name: guildId,
                    source: 'config',
                  });
                }
              }
            }
          }
        }
      
        return groups;
      }
      
      // ---------------------------------------------------------------------------
      // Cron job counting
      // ---------------------------------------------------------------------------
      
      function countCronJobs(stateDir: string): {
        count: number;
        summaries: string[];
      } {
        const jobsPath = path.join(stateDir, 'cron', 'jobs.json');
        if (!fs.existsSync(jobsPath)) return { count: 0, summaries: [] };
      
        try {
          const raw = fs.readFileSync(jobsPath, 'utf-8');
          const data = JSON.parse(raw) as {
            jobs?: Array<{ name?: string; enabled?: boolean }>;
          };
          const jobs = data.jobs ?? [];
          const summaries = jobs
            .filter((j) => j.enabled !== false)
            .map((j) => j.name || 'unnamed')
            .slice(0, 10);
          return { count: jobs.length, summaries };
        } catch {
          return { count: 0, summaries: [] };
        }
      }
      
      // ---------------------------------------------------------------------------
      // Config-registered plugins and skills (with API keys)
      // ---------------------------------------------------------------------------
      
      interface ConfigPlugin {
        name: string;
        source: 'skills.entries' | 'plugins.entries';
        hasApiKey: boolean;
      }
      
      function detectConfigPlugins(
        config: Record<string, unknown>,
      ): ConfigPlugin[] {
        const results: ConfigPlugin[] = [];
      
        // Check skills.entries (e.g. openai-whisper-api with apiKey)
        const skills = config.skills as Record<string, unknown> | undefined;
        const skillEntries = skills?.entries as Record<string, unknown> | undefined;
        if (skillEntries) {
          for (const [name, entry] of Object.entries(skillEntries)) {
            if (!entry || typeof entry !== 'object') continue;
            const e = entry as Record<string, unknown>;
            const hasKey = !!(e.apiKey || e.token || e.key);
            results.push({ name, source: 'skills.entries', hasApiKey: hasKey });
          }
        }
      
        // Check plugins.entries (e.g. brave with config.webSearch.apiKey)
        const plugins = config.plugins as Record<string, unknown> | undefined;
        const pluginEntries = plugins?.entries as Record<string, unknown> | undefined;
        if (pluginEntries) {
          for (const [name, entry] of Object.entries(pluginEntries)) {
            if (!entry || typeof entry !== 'object') continue;
            // Deep-search for apiKey in nested config
            const hasKey = JSON.stringify(entry).includes('apiKey');
            results.push({ name, source: 'plugins.entries', hasApiKey: hasKey });
          }
        }
      
        return results;
      }
      
      // ---------------------------------------------------------------------------
      // MCP server detection
      // ---------------------------------------------------------------------------
      
      function detectMcpServers(
        config: Record<string, unknown>,
      ): string[] {
        const mcp = config.mcp as Record<string, unknown> | undefined;
        if (!mcp) return [];
        const servers = mcp.servers as Record<string, unknown> | undefined;
        if (!servers) return [];
        return Object.keys(servers);
      }
      
      // ---------------------------------------------------------------------------
      // Main
      // ---------------------------------------------------------------------------
      
      function main(): void {
        const { stateDir: explicitDir } = parseArgs();
        const stateDir = resolveStateDir(explicitDir);
      
        if (!stateDir) {
          emitStatus({ STATUS: 'not_found' });
          return;
        }
      
        const config = loadConfig(stateDir);
        const channels = config ? detectChannels(config) : [];
        const { dir: workspaceDir, files: workspaceFiles } =
          findWorkspace(stateDir, config);
        const identityName = extractIdentityName(stateDir, workspaceDir);
        const agents = detectAgents(stateDir);
        const groups = detectGroups(stateDir, config, agents);
        const { count: cronCount, summaries: cronSummaries } =
          countCronJobs(stateDir);
        const mcpServers = config ? detectMcpServers(config) : [];
        const dailyMemoryFiles = countDailyMemoryFiles(workspaceDir);
        const skills = detectSkills(stateDir, workspaceDir);
        const configPlugins = config ? detectConfigPlugins(config) : [];
      
        // Format channels as "name(has_creds)" or "name(no_creds)"
        const channelList = channels
          .map((c) => `${c.name}(${c.hasCreds ? 'has_creds' : 'no_creds'})`)
          .join(',');
      
        // Separate supported vs unsupported
        const unsupported = channels
          .filter((c) => !SUPPORTED_CHANNELS.has(c.name))
          .map((c) => c.name)
          .join(',');
      
        // Format groups as "channel:id(name)=>v2_platform_id". The right-hand value
        // is what to pass as --platform-id to setup/register.ts.
        const groupList = groups
          .map(
            (g) =>
              `${g.channel}:${g.id}(${g.name})=>${toV2PlatformId(g.channel, g.id)}`,
          )
          .join('|');
      
        // Format skills as "name(source)" list
        const skillList = skills
          .map((s) => `${s.name}(${s.source})`)
          .join(',');
      
        // Dump raw top-level config keys so Claude can see what exists
        // beyond what this script specifically detects
        const configTopKeys = config ? Object.keys(config).sort().join(',') : 'none';
        const configChannelKeys = config?.channels
          ? Object.keys(config.channels as Record<string, unknown>).sort().join(',')
          : 'none';
      
        // List files/dirs at the state dir root for manual inspection
        let stateDirContents = 'unknown';
        try {
          stateDirContents = fs
            .readdirSync(stateDir)
            .filter((f) => !f.startsWith('.'))
            .sort()
            .join(',');
        } catch {
          // ignore
        }
      
        emitStatus({
          STATUS: 'found',
          STATE_DIR: stateDir,
          CONFIG_FOUND: config !== null,
          CONFIG_TOP_KEYS: configTopKeys,
          CONFIG_CHANNEL_KEYS: configChannelKeys,
          STATE_DIR_CONTENTS: stateDirContents,
          CHANNELS: channelList || 'none',
          UNSUPPORTED_CHANNELS: unsupported || 'none',
          WORKSPACE_DIR: workspaceDir || 'not_found',
          WORKSPACE_FILES: workspaceFiles.join(',') || 'none',
          IDENTITY_NAME: identityName || 'unknown',
          AGENT_COUNT: agents.length,
          AGENT_IDS: agents.join(',') || 'none',
          GROUPS: groupList || 'none',
          GROUP_COUNT: groups.length,
          DAILY_MEMORY_FILES: dailyMemoryFiles,
          SKILL_COUNT: skills.length,
          SKILLS: skillList || 'none',
          CONFIG_PLUGINS: configPlugins.map((p) => `${p.name}(${p.source}${p.hasApiKey ? ',has_key' : ''})`).join(',') || 'none',
          CONFIG_PLUGIN_COUNT: configPlugins.length,
          CRON_JOBS: cronCount,
          CRON_SUMMARIES: cronSummaries.join('|') || 'none',
          MCP_SERVERS: mcpServers.join(',') || 'none',
        });
      }
      
      main();
      
    • extract-channel-credentials.ts 9.9 KB
      /**
       * Extract a channel credential from an OpenClaw configuration and route it to
       * its correct NanoClaw v2 destination.
       *
       * Two destinations, decided per credential by transform.ts:
       *   - Host-side channel tokens (Telegram/Discord/Slack bot tokens) → written
       *     to NanoClaw's `.env`. The NanoClaw *host* process reads these to connect
       *     to the messaging platform; they never enter a container.
       *   - Container-facing API credentials (Anthropic, OpenAI, …) → NOT written
       *     anywhere by this script. The script prints the `onecli secrets create`
       *     command the operator runs so the credential lands in the OneCLI Agent
       *     Vault, which injects it per-request. Raw credentials are never threaded
       *     into a container env var.
       *
       * Usage:
       *   pnpm exec tsx .claude/skills/migrate-from-openclaw/scripts/extract-channel-credentials.ts \
       *     --channel telegram --state-dir ~/.openclaw [--write-env .env]
       *
       * Credential VALUES are never emitted to stdout — only masked versions. For
       * channel tokens, `--write-env` writes the real value directly to `.env` so
       * the agent never sees it. For vault credentials the script emits the plan
       * (name/type/host-pattern) but not the value; the operator runs the printed
       * command, keeping the secret off the chat transcript.
       *
       * Emits a status block on stdout:
       *   === NANOCLAW MIGRATE: CREDENTIAL ===
       *   ...
       *   === END ===
       */
      
      import fs from 'fs';
      import os from 'os';
      import path from 'path';
      
      import {
        channelEnvVars,
        classifyCredential,
        maskCredential,
        resolveSecretInput,
      } from './transform.js';
      
      // ---------------------------------------------------------------------------
      // JSON5-tolerant parser (OpenClaw config may use comments / trailing commas)
      // ---------------------------------------------------------------------------
      
      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();
          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'));
      }
      
      // ---------------------------------------------------------------------------
      // Channel → OpenClaw config field mapping
      // ---------------------------------------------------------------------------
      
      // The OpenClaw config field(s) that hold each channel's token. The matching
      // NanoClaw .env destination is decided by classifyCredential() in transform.ts.
      const CHANNEL_FIELDS: Record<string, string[]> = {
        telegram: ['botToken'],
        discord: ['token'],
        slack: ['botToken', 'appToken'],
        whatsapp: [], // QR/pairing-code auth — no token to migrate
      };
      
      // ---------------------------------------------------------------------------
      // 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);
        }
      
        if (stateDir.startsWith('~')) {
          stateDir = path.join(os.homedir(), stateDir.slice(1));
        }
      
        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 (host-side tokens only)
      // ---------------------------------------------------------------------------
      
      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();
      
        // WhatsApp uses QR / pairing-code auth — there is no token to migrate.
        // Copying Baileys auth state across installs leaves stale encryption
        // sessions, so re-authenticate during setup instead.
        if (channel === 'whatsapp') {
          emitStatus({
            CHANNEL: 'whatsapp',
            HAS_CREDENTIAL: false,
            DESTINATION: 'none',
            NOTE: 'WhatsApp authenticates via QR / pairing code — no token to migrate. Authenticate during /setup with /add-whatsapp.',
          });
          return;
        }
      
        const fields = CHANNEL_FIELDS[channel];
        if (!fields) {
          emitStatus({
            CHANNEL: channel,
            HAS_CREDENTIAL: false,
            DESTINATION: 'none',
            NOTE: `Channel "${channel}" has no direct token mapping. Supported: telegram, discord, slack, whatsapp.`,
          });
          return;
        }
      
        const dotenvVars = parseDotenv(path.join(stateDir, '.env'));
      
        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) ?? {};
      
        // Resolve every token field for this channel.
        const results: Array<{
          resolved: string | null;
          masked: string;
          source: string;
          note?: string;
          envVar: string;
        }> = [];
      
        const envVars = channelEnvVars(channel);
      
        for (let i = 0; i < fields.length; i++) {
          const field = fields[i];
      
          let rawValue = channelConfig[field];
          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, process.env);
          const dest = classifyCredential(channel, i);
          results.push({
            resolved,
            masked: resolved ? maskCredential(resolved) : '',
            source,
            note,
            envVar: dest?.envVar ?? envVars[i] ?? '',
          });
        }
      
        // Channel tokens are host-side: write them to .env when --write-env is set.
        let written = 0;
        if (writeEnv) {
          for (const r of results) {
            if (r.resolved && r.envVar) {
              writeEnvVar(writeEnv, r.envVar, r.resolved);
              written++;
            }
          }
        }
      
        const primary = results[0];
        const out: Record<string, string | number | boolean> = {
          CHANNEL: channel,
          DESTINATION: 'env',
          HAS_CREDENTIAL: !!primary.resolved,
          CREDENTIAL_SOURCE: primary.source,
          CREDENTIAL_MASKED: primary.masked || 'none',
          NANOCLAW_ENV_VAR: primary.envVar,
        };
        if (writeEnv && written > 0) {
          out.WRITTEN_TO = writeEnv;
          out.WRITTEN_COUNT = written;
        }
        if (primary.note) out.NOTE = primary.note;
      
        // Additional tokens (Slack carries bot + app).
        for (let i = 1; i < results.length; i++) {
          const extra = results[i];
          const suffix = `_${i + 1}`;
          out[`HAS_CREDENTIAL${suffix}`] = !!extra.resolved;
          out[`CREDENTIAL_SOURCE${suffix}`] = extra.source;
          out[`CREDENTIAL_MASKED${suffix}`] = extra.masked || 'none';
          out[`NANOCLAW_ENV_VAR${suffix}`] = extra.envVar;
          if (extra.note) out[`NOTE${suffix}`] = extra.note;
        }
      
        emitStatus(out);
      }
      
      main();
      
    • transform.test.ts 7.3 KB
      /**
       * Tests for the OpenClaw → NanoClaw v2 transforms.
       *
       * These are the skill's riskiest shipped code: the credential-routing decision
       * (vault vs .env) and the cron → v2 recurrence mapping. They guard the skill's
       * two real integration assumptions about NanoClaw v2:
       *
       *   1. Container-facing credentials go to the OneCLI vault — never threaded
       *      into a container env var. Channel tokens stay in `.env` for the host.
       *   2. Recurring tasks are `messages_in` rows carrying a cron `recurrence`
       *      plus a `process_after` first-run timestamp — the exact shape the host
       *      recurrence sweep consumes (src/modules/scheduling/recurrence.ts).
       *
       * The cron test imports the real `cron-parser` (the same `CronExpressionParser`
       * the recurrence sweep uses) unmocked, so a missing/renamed dependency turns
       * the test red — that's the dependency integration guard.
       */
      import { describe, expect, it } from 'vitest';
      import { CronExpressionParser } from 'cron-parser';
      
      import {
        approximateIntervalAsCron,
        channelEnvVars,
        classifyCredential,
        mapCronToRecurrence,
        maskCredential,
        resolveSecretInput,
        vaultCreateCommand,
        type OpenClawSchedule,
      } from './transform.js';
      
      // The same cron-parser call the host recurrence sweep makes, used as the
      // injected `computeNextCron` so the mapping test mirrors production behavior.
      function computeNextCron(expr: string, tz?: string): string {
        return CronExpressionParser.parse(expr, { tz: tz ?? 'UTC' }).next().toISOString();
      }
      
      describe('resolveSecretInput', () => {
        it('returns a plain literal value', () => {
          const r = resolveSecretInput('123:ABC-token', {});
          expect(r).toEqual({ resolved: '123:ABC-token', source: 'plain' });
        });
      
        it('resolves a "${ENV}" template from the state-dir .env', () => {
          const r = resolveSecretInput('${TELEGRAM_BOT_TOKEN}', { TELEGRAM_BOT_TOKEN: 'tok-1' });
          expect(r.resolved).toBe('tok-1');
          expect(r.source).toBe('env_template');
        });
      
        it('resolves a SecretRef {source:"env"} from the process env fallback', () => {
          const r = resolveSecretInput({ source: 'env', id: 'X_KEY' }, {}, { X_KEY: 'from-proc' });
          expect(r.resolved).toBe('from-proc');
          expect(r.source).toBe('env_ref');
        });
      
        it('cannot auto-extract file/exec SecretRefs and explains why', () => {
          const file = resolveSecretInput({ source: 'file', id: '/secrets/tok' }, {});
          expect(file.resolved).toBeNull();
          expect(file.source).toBe('file_ref');
          expect(file.note).toContain('cannot auto-extract');
      
          const exec = resolveSecretInput({ source: 'exec', id: 'op read ...' }, {});
          expect(exec.resolved).toBeNull();
          expect(exec.source).toBe('exec_ref');
        });
      
        it('reports missing for empty/absent values', () => {
          expect(resolveSecretInput(undefined, {}).source).toBe('missing');
          expect(resolveSecretInput('', {}).source).toBe('missing');
        });
      });
      
      describe('maskCredential', () => {
        it('shows first 4 + ... + last 4 for long values', () => {
          expect(maskCredential('sk-ant-abcdefgh1234')).toBe('sk-a...1234');
        });
        it('fully masks short values', () => {
          expect(maskCredential('short')).toBe('****');
        });
      });
      
      describe('classifyCredential — vault vs .env routing', () => {
        it('routes container-facing Anthropic credentials to the OneCLI vault', () => {
          const d = classifyCredential('anthropic');
          expect(d).toEqual({
            destination: 'vault',
            plan: { name: 'Anthropic', type: 'anthropic', hostPattern: 'api.anthropic.com' },
          });
        });
      
        it('routes container-facing OpenAI credentials to the vault as api_key', () => {
          const d = classifyCredential('openai');
          expect(d?.destination).toBe('vault');
          expect(d?.plan?.type).toBe('api_key');
          expect(d?.plan?.hostPattern).toBe('api.openai.com');
        });
      
        it('keeps host-side channel tokens in .env, never the vault', () => {
          expect(classifyCredential('telegram')).toEqual({
            destination: 'env',
            envVar: 'TELEGRAM_BOT_TOKEN',
          });
          expect(classifyCredential('discord')).toEqual({
            destination: 'env',
            envVar: 'DISCORD_BOT_TOKEN',
          });
        });
      
        it('selects the right env var for multi-token channels (Slack bot + app)', () => {
          expect(classifyCredential('slack', 0)?.envVar).toBe('SLACK_BOT_TOKEN');
          expect(classifyCredential('slack', 1)?.envVar).toBe('SLACK_APP_TOKEN');
        });
      
        it('exposes the full channel env-var list', () => {
          expect(channelEnvVars('slack')).toEqual(['SLACK_BOT_TOKEN', 'SLACK_APP_TOKEN']);
          expect(channelEnvVars('telegram')).toEqual(['TELEGRAM_BOT_TOKEN']);
          expect(channelEnvVars('whatsapp')).toEqual([]);
        });
      
        it('returns null for unknown credential kinds', () => {
          expect(classifyCredential('nonexistent')).toBeNull();
        });
      });
      
      describe('vaultCreateCommand', () => {
        it('renders the onecli secrets create command for a vault plan', () => {
          const plan = { name: 'Anthropic', type: 'anthropic' as const, hostPattern: 'api.anthropic.com' };
          expect(vaultCreateCommand(plan, 'sk-ant-xyz')).toBe(
            'onecli secrets create --name Anthropic --type anthropic --value sk-ant-xyz --host-pattern api.anthropic.com',
          );
        });
      });
      
      describe('mapCronToRecurrence — v2 task shape', () => {
        it('maps a cron schedule to recurrence=expr + next-fire processAfter', () => {
          const schedule: OpenClawSchedule = { kind: 'cron', expr: '0 9 * * 1-5', tz: 'UTC' };
          const r = mapCronToRecurrence(schedule, { computeNextCron });
          expect(r.recurrence).toBe('0 9 * * 1-5');
          // processAfter must be the next 09:00 UTC the real cron-parser computes.
          expect(r.processAfter).toBe(computeNextCron('0 9 * * 1-5', 'UTC'));
          expect(r.notes).toEqual([]);
        });
      
        it('maps an "at" schedule to a one-shot task (null recurrence)', () => {
          const at = '2030-01-01T12:00:00.000Z';
          const r = mapCronToRecurrence({ kind: 'at', at }, { computeNextCron });
          expect(r.processAfter).toBe(at);
          expect(r.recurrence).toBeNull();
        });
      
        it('approximates a clean fixed interval as cron and flags it', () => {
          const now = Date.parse('2026-01-01T00:00:00.000Z');
          const r = mapCronToRecurrence({ kind: 'every', everyMs: 15 * 60 * 1000 }, { computeNextCron, now });
          expect(r.recurrence).toBe('*/15 * * * *');
          expect(r.processAfter).toBe('2026-01-01T00:15:00.000Z');
          expect(r.notes[0]).toContain('approximated as cron');
        });
      
        it('leaves a non-divisible interval one-shot and flags it for the user', () => {
          const r = mapCronToRecurrence({ kind: 'every', everyMs: 90 * 1000 }, { computeNextCron });
          expect(r.recurrence).toBeNull();
          expect(r.notes[0]).toContain('no clean cron equivalent');
        });
      });
      
      describe('approximateIntervalAsCron', () => {
        it('maps minute intervals that divide 60', () => {
          expect(approximateIntervalAsCron(5 * 60000)).toBe('*/5 * * * *');
          expect(approximateIntervalAsCron(30 * 60000)).toBe('*/30 * * * *');
        });
        it('maps hour intervals that divide 24', () => {
          expect(approximateIntervalAsCron(60 * 60000)).toBe('0 */1 * * *');
          expect(approximateIntervalAsCron(6 * 60 * 60000)).toBe('0 */6 * * *');
        });
        it('maps daily to midnight', () => {
          expect(approximateIntervalAsCron(24 * 60 * 60000)).toBe('0 0 * * *');
        });
        it('returns null for intervals with no clean cron form', () => {
          expect(approximateIntervalAsCron(90 * 1000)).toBeNull(); // 90s
          expect(approximateIntervalAsCron(7 * 60 * 60000)).toBeNull(); // 7h
          expect(approximateIntervalAsCron(0)).toBeNull();
        });
      });
      
    • transform.ts 11.3 KB
      /**
       * Pure transforms that map OpenClaw config into NanoClaw v2 shapes.
       *
       * Two transforms, both deterministic and side-effect free so they can be
       * unit-tested in isolation (see transform.test.ts):
       *
       *   1. resolveSecretInput + classifyCredential — turn an OpenClaw config
       *      credential (plain string, "${ENV}" template, or SecretRef object) into
       *      a resolved value plus a routing decision: container-facing credentials
       *      go to the OneCLI vault (a `secrets create` plan); host-side channel
       *      tokens stay in `.env` (the NanoClaw host process reads them to connect
       *      to the messaging platform). Credentials are never threaded into a
       *      container via env vars.
       *
       *   2. mapCronToRecurrence — turn an OpenClaw cron job's `schedule` into the
       *      v2 task representation: a `messages_in` row with `kind='task'`, a
       *      `process_after` ISO timestamp (first run), and a `recurrence` cron
       *      expression for repeating jobs. There is no `scheduled_tasks` table in
       *      v2 — tasks live in the per-session `inbound.db`.
       */
      
      // ---------------------------------------------------------------------------
      // Credential resolution
      // ---------------------------------------------------------------------------
      
      /** OpenClaw SecretRef — a credential stored indirectly. */
      export interface SecretRef {
        source: string; // "env" | "file" | "exec" | ...
        provider?: string;
        id: string;
      }
      
      export interface ResolvedSecret {
        /** The credential value, or null when it can't be auto-extracted. */
        resolved: string | null;
        /** How the value was sourced — for the status block / explanation. */
        source:
          | 'missing'
          | 'plain'
          | 'env_template'
          | 'env_ref'
          | 'file_ref'
          | 'exec_ref'
          | 'unknown';
        /** Human-readable note when a value couldn't be resolved. */
        note?: string;
      }
      
      /**
       * Resolve an OpenClaw credential input to a concrete string when possible.
       *
       * Accepts:
       *   - a plain literal:            "123:ABC-token"
       *   - an env template:            "${TELEGRAM_BOT_TOKEN}"
       *   - a SecretRef object:         { source: "env", id: "TELEGRAM_BOT_TOKEN" }
       *
       * `dotenvVars` is the parsed `<state-dir>/.env`; `processEnv` is consulted as
       * a fallback. `file`/`exec` SecretRefs can't be auto-extracted — they return
       * `resolved: null` with an explanatory note so the caller prompts the user.
       */
      export function resolveSecretInput(
        value: unknown,
        dotenvVars: Record<string, string>,
        processEnv: Record<string, string | undefined> = {},
      ): ResolvedSecret {
        if (value === undefined || value === null || value === '') {
          return { resolved: null, source: 'missing' };
        }
      
        if (typeof value === 'string') {
          const envMatch = value.match(/^\$\{([^}]+)\}$/);
          if (envMatch) {
            const envKey = envMatch[1];
            const envVal = dotenvVars[envKey] ?? processEnv[envKey] ?? null;
            if (envVal) return { resolved: envVal, source: 'env_template' };
            return {
              resolved: null,
              source: 'env_template',
              note: `Environment variable ${envKey} not found in <state-dir>/.env or the environment`,
            };
          }
          return { resolved: value, source: 'plain' };
        }
      
        if (typeof value === 'object') {
          const ref = value as SecretRef;
          if (ref.source === 'env') {
            const envVal = dotenvVars[ref.id] ?? processEnv[ref.id] ?? null;
            if (envVal) return { resolved: envVal, source: 'env_ref' };
            return {
              resolved: null,
              source: 'env_ref',
              note: `Environment variable ${ref.id} not found in <state-dir>/.env or the environment`,
            };
          }
          if (ref.source === 'file') {
            return {
              resolved: null,
              source: 'file_ref',
              note: `File-based secret (${ref.id}) — cannot auto-extract, enter it manually`,
            };
          }
          if (ref.source === 'exec') {
            return {
              resolved: null,
              source: 'exec_ref',
              note: `Exec-based secret (${ref.id}) — cannot auto-extract, enter it manually`,
            };
          }
        }
      
        return { resolved: null, source: 'unknown' };
      }
      
      /** Mask a credential for display: first 4 + "..." + last 4. */
      export function maskCredential(value: string): string {
        if (value.length < 10) return '****';
        return `${value.slice(0, 4)}...${value.slice(-4)}`;
      }
      
      /**
       * Where a resolved credential belongs in NanoClaw v2.
       *
       *   - 'vault'   → a container-facing credential the agent uses for outbound
       *                 HTTPS (Anthropic, OpenAI, etc.). It goes to the OneCLI Agent
       *                 Vault, which injects it per-request — never into the
       *                 container env. The plan field carries the `onecli secrets
       *                 create` argument set.
       *   - 'env'     → a host-side channel token (Telegram/Discord/Slack bot
       *                 tokens). The NanoClaw *host* process reads it from `.env` to
       *                 connect to the messaging platform; it never enters a
       *                 container, so the vault is not involved.
       */
      export interface VaultPlan {
        /** `onecli secrets create --name` */
        name: string;
        /** `onecli secrets create --type` — 'anthropic' or 'api_key'. */
        type: 'anthropic' | 'api_key';
        /** `onecli secrets create --host-pattern` — the API host the agent calls. */
        hostPattern: string;
      }
      
      export interface CredentialDestination {
        destination: 'vault' | 'env';
        /** For 'env' destinations: the NanoClaw .env variable name. */
        envVar?: string;
        /** For 'vault' destinations: the `onecli secrets create` plan. */
        plan?: VaultPlan;
      }
      
      /**
       * Container-facing credentials → OneCLI vault. Keyed by a stable credential
       * kind the caller derives from the OpenClaw provider/profile (e.g. an
       * Anthropic auth profile, an OpenAI plugin key).
       */
      const VAULT_CREDENTIALS: Record<string, VaultPlan> = {
        anthropic: { name: 'Anthropic', type: 'anthropic', hostPattern: 'api.anthropic.com' },
        openai: { name: 'OpenAI', type: 'api_key', hostPattern: 'api.openai.com' },
      };
      
      /**
       * Host-side channel tokens → NanoClaw `.env`. The host process (not the
       * container) reads these to connect to the messaging platform.
       */
      const CHANNEL_ENV_VARS: Record<string, string[]> = {
        telegram: ['TELEGRAM_BOT_TOKEN'],
        discord: ['DISCORD_BOT_TOKEN'],
        slack: ['SLACK_BOT_TOKEN', 'SLACK_APP_TOKEN'],
      };
      
      /**
       * Classify a credential by kind and decide its NanoClaw destination.
       *
       * `kind` is either a channel name (telegram/discord/slack) for a host-side
       * channel token, or a vault credential key (anthropic/openai) for a
       * container-facing API credential. `index` selects which env var for channels
       * that carry more than one token (Slack: bot + app).
       */
      export function classifyCredential(
        kind: string,
        index = 0,
      ): CredentialDestination | null {
        const vault = VAULT_CREDENTIALS[kind];
        if (vault) {
          return { destination: 'vault', plan: vault };
        }
        const envVars = CHANNEL_ENV_VARS[kind];
        if (envVars && envVars[index]) {
          return { destination: 'env', envVar: envVars[index] };
        }
        return null;
      }
      
      /** Channel names whose host token is read from `.env` by the host process. */
      export function channelEnvVars(channel: string): string[] {
        return CHANNEL_ENV_VARS[channel] ?? [];
      }
      
      /**
       * Render a OneCLI vault plan into the exact command the operator runs.
       * The credential value is passed through unmasked here because the caller
       * runs the command directly — it must never be echoed to a chat transcript.
       */
      export function vaultCreateCommand(plan: VaultPlan, value: string): string {
        return [
          'onecli secrets create',
          `--name ${plan.name}`,
          `--type ${plan.type}`,
          `--value ${value}`,
          `--host-pattern ${plan.hostPattern}`,
        ].join(' ');
      }
      
      // ---------------------------------------------------------------------------
      // Cron → v2 recurrence mapping
      // ---------------------------------------------------------------------------
      
      /** OpenClaw cron schedule shapes (from its `src/cron/types.ts`). */
      export type OpenClawSchedule =
        | { kind: 'cron'; expr: string; tz?: string }
        | { kind: 'every'; everyMs: number }
        | { kind: 'at'; at: string };
      
      /**
       * The v2 task shape. A task is a `messages_in` row with `kind='task'`:
       *
       *   - processAfter  → ISO 8601 timestamp for the first/next run.
       *   - recurrence    → cron expression for repeating tasks; null for one-shot.
       *
       * The host's recurrence sweep (src/modules/scheduling/recurrence.ts) reads the
       * cron in `recurrence`, computes the next run via cron-parser in the user's
       * timezone, and clones a fresh pending row forward. One-shot tasks have a null
       * recurrence and are marked completed after running.
       */
      export interface V2TaskSchedule {
        processAfter: string;
        recurrence: string | null;
        /** Notes about anything that didn't map cleanly. */
        notes: string[];
      }
      
      /**
       * Map an OpenClaw schedule to the v2 task representation.
       *
       *   - kind:"cron"  → recurrence = expr; processAfter = next fire of expr.
       *                    Needs `computeNextCron` (cron-parser) injected so this
       *                    function stays pure / synchronously testable.
       *   - kind:"every" → recurrence = null; v2 has no fixed-interval recurrence,
       *                    so an interval is approximated as the nearest cron when
       *                    it divides evenly into minutes/hours, else flagged for
       *                    the user. processAfter = now + everyMs.
       *   - kind:"at"    → one-shot; recurrence = null; processAfter = the ISO `at`.
       *
       * `now` is injected (defaults to current time) for deterministic tests.
       */
      export function mapCronToRecurrence(
        schedule: OpenClawSchedule,
        opts: {
          computeNextCron: (expr: string, tz?: string) => string;
          now?: number;
        },
      ): V2TaskSchedule {
        const now = opts.now ?? Date.now();
        const notes: string[] = [];
      
        if (schedule.kind === 'cron') {
          const processAfter = opts.computeNextCron(schedule.expr, schedule.tz);
          return { processAfter, recurrence: schedule.expr, notes };
        }
      
        if (schedule.kind === 'at') {
          return { processAfter: schedule.at, recurrence: null, notes };
        }
      
        // kind === 'every' — approximate a fixed interval as a cron expression.
        const everyMs = schedule.everyMs;
        const processAfter = new Date(now + everyMs).toISOString();
        const approx = approximateIntervalAsCron(everyMs);
        if (approx) {
          notes.push(
            `OpenClaw fixed interval (every ${everyMs}ms) approximated as cron "${approx}". v2 recurrence is cron-based; confirm this matches intent.`,
          );
          return { processAfter, recurrence: approx, notes };
        }
        notes.push(
          `OpenClaw fixed interval (every ${everyMs}ms) has no clean cron equivalent. Set a cron expression manually, or keep it one-shot.`,
        );
        return { processAfter, recurrence: null, notes };
      }
      
      /**
       * Approximate a millisecond interval as a cron expression when it lands on a
       * whole number of minutes or hours that divides evenly. Returns null when
       * there's no clean cron form (e.g. every 90 seconds).
       */
      export function approximateIntervalAsCron(everyMs: number): string | null {
        if (everyMs <= 0) return null;
        const minutes = everyMs / 60000;
        if (!Number.isInteger(minutes) || minutes < 1) return null;
      
        if (minutes < 60) {
          // Every N minutes — only clean when N divides 60.
          if (60 % minutes === 0) return `*/${minutes} * * * *`;
          return null;
        }
      
        const hours = minutes / 60;
        if (Number.isInteger(hours)) {
          if (hours === 24) return '0 0 * * *';
          if (24 % hours === 0) return `0 */${hours} * * *`;
          return null;
        }
      
        return null;
      }
      
  • MIGRATE_CRONS.md 6.1 KB
    # Migrating OpenClaw Cron Jobs to NanoClaw v2 Tasks
    
    This file is referenced by SKILL.md Phase 5 when cron jobs are detected.
    
    ## How tasks work in NanoClaw v2
    
    There is no `scheduled_tasks` table and no `store/messages.db`. A v2 task is a
    `messages_in` row with `kind='task'` living in a **session's** `inbound.db`
    (under `data/v2-sessions/<agent-group>/<session>/inbound.db`). The row carries:
    
    - `process_after` — ISO 8601 timestamp for the next run.
    - `recurrence` — a cron expression for repeating tasks; `NULL` for one-shot.
    - `content` — JSON `{ "prompt": "...", "script": "<optional pre-agent bash>" }`.
    - `series_id` — stable handle linking every occurrence of a recurring task.
    
    The host recurrence sweep (`src/modules/scheduling/recurrence.ts`, called each
    60s tick from `src/host-sweep.ts`) finds completed rows that still carry a
    `recurrence`, computes the next fire with `cron-parser` **in the user's
    timezone** (`TIMEZONE` from `src/config.ts`), and clones a fresh pending row
    forward. One-shot tasks are marked `completed` after running, never deleted.
    
    Because `inbound.db` is host-owned and per-session, you do **not** write task
    rows by hand. The supported path is to let the running agent create them
    through its `schedule_task` MCP tool — the agent writes a system message, the
    host's `schedule_task` delivery action (`src/modules/scheduling/actions.ts`)
    inserts the `messages_in` row. So migrating crons means **handing the agent a
    clear instruction per job and letting it call `schedule_task`**.
    
    ## OpenClaw Cron Job Format
    
    Source: `<STATE_DIR>/cron/jobs.json` (from OpenClaw's `src/cron/types.ts`). If
    the file format doesn't match what's described here, read the actual file and
    adapt — OpenClaw may have changed its schema.
    
    The jobs file is `{ version: 1, jobs: CronJob[] }`. Each job has:
    
    - `id`, `name`, `description`, `enabled`, `deleteAfterRun`
    - `schedule`: `{ kind: "cron", expr, tz? }` | `{ kind: "every", everyMs }` | `{ kind: "at", at }`
    - `payload`: `{ kind: "agentTurn", message, model?, thinking?, timeoutSeconds? }` | `{ kind: "systemEvent", text }`
    - `sessionTarget`: `"main"` | `"isolated"` | `"current"` | `"session:<id>"`
    - `wakeMode`: `"next-heartbeat"` | `"now"`
    - `delivery`: `{ mode: "none" | "announce" | "webhook", channel?, to?, threadId?, bestEffort? }`
    - `failureAlert`: `{ after?, channel?, to?, cooldownMs? }` | `false`
    - `state`: runtime state (nextRunAtMs, lastRunStatus, …)
    
    ## Schedule mapping (use the shipped transform)
    
    `scripts/transform.ts` exports `mapCronToRecurrence`, which converts an
    OpenClaw `schedule` into the v2 `{ processAfter, recurrence, notes }` shape:
    
    - `kind:"cron"` → `recurrence = expr`; `processAfter` = next fire of `expr`
      (computed with `cron-parser` in the job's `tz`, falling back to the user's TZ).
    - `kind:"at"` → one-shot; `recurrence = null`; `processAfter = at`.
    - `kind:"every"` → v2 recurrence is cron-based, so a fixed interval is
      approximated as the nearest cron when it divides cleanly into minutes/hours
      (e.g. `everyMs: 900000` → `*/15 * * * *`); otherwise it's flagged in `notes`
      and left one-shot for you to set a cron by hand.
    
    `payload.message` (agentTurn) or `payload.text` (systemEvent) becomes the task
    `prompt`.
    
    ## What doesn't map
    
    - `delivery.mode:"webhook"` — v2 has no webhook delivery. Fold the webhook into
      the task `prompt` ("…then POST the result to <url>") or a pre-agent `script`
      that `curl`s the endpoint.
    - `delivery.mode:"announce"` / `channel` / `to` — a v2 task runs inside the
      session it was scheduled in and replies through that session's normal
      delivery path. Cross-channel announce isn't a task field; if the job targeted
      a different chat, schedule the task from the agent in *that* group.
    - `failureAlert` — no failure-alert system. Note it to the user.
    - `wakeMode` — v2 wakes a container when a task's `process_after` is due; there
      is no next-heartbeat vs now distinction.
    - `payload.model` / `thinking` / `timeoutSeconds` — per-task model/thinking
      config isn't a task field. Per-group model lives in the container config
      (`ncl groups config update`).
    - `deleteAfterRun` — v2 one-shot tasks become `completed`, not deleted.
    - `sessionTarget` — `isolated` vs `main`/`current` selected a session in
      OpenClaw. In v2 the task lands in whichever session the agent schedules it
      from. Schedule from the group/DM whose session should own the task.
    
    ## For each enabled job
    
    1. Show what it does: name, schedule, prompt, original delivery mode.
    2. Run the schedule through `mapCronToRecurrence` and show the resulting
       `processAfter` / `recurrence`, plus any `notes` (interval approximation,
       webhook caveats).
    3. Explain the differences (no failure alerts, webhook folded into the prompt,
       announce → runs in the scheduling session).
    4. Ask whether to keep this task.
    
    ## Creating the task
    
    Tasks are created **by the agent** via its `schedule_task` MCP tool, which is
    why the agent group and its DM/group session must already exist (Phase 1) and
    the service must be running. Hand the agent one instruction per kept job, e.g.:
    
    > Schedule a recurring task: prompt = "Summarize my unread email and send me
    > the digest.", recurrence = "0 9 * * 1-5", first run = "2026-06-09T09:00:00"
    > (my local time).
    
    The agent calls `schedule_task` with `prompt`, `processAfter` (ISO; a naive
    local timestamp is interpreted in the user's timezone), `recurrence` (the cron
    expression, or omitted for one-shot), and an optional `script`. The host
    inserts the `messages_in` row and the recurrence sweep takes over.
    
    To confirm afterwards, ask the agent to run `list_tasks`, or inspect the
    session's inbound DB directly:
    
    ```bash
    pnpm exec tsx scripts/q.ts \
      data/v2-sessions/<agent-group>/<session>/inbound.db \
      "SELECT series_id, status, process_after, recurrence FROM messages_in WHERE kind='task'"
    ```
    
    If the agent group / session doesn't exist yet (Phase 1 deferred, or the
    channel isn't installed), record the mapped tasks in the group's
    `groups/<folder>/openclaw-migration-tasks.md` with prompt + processAfter +
    recurrence per job, and tell the user the agent will schedule them on first run
    once the channel is wired.
    
  • REMOVE.md 2.9 KB
    # Remove migrate-from-openclaw
    
    This skill copies a small, fixed set of files into the project tree. Removal
    deletes exactly those. It does **not** undo the migration itself — the agents,
    messaging groups, wirings, roles, `.env` channel tokens, and OneCLI vault
    secrets the migration created are your live NanoClaw install, not skill files.
    Undoing those is a separate decision (see the last section).
    
    Idempotent: every step skips cleanly if the file is already gone.
    
    ## 1. Remove the copied transform module and its test
    
    These are the only files the skill installs into the project's source tree (the
    validate step in Phase 8 copies them into `scripts/` so vitest runs them):
    
    ```bash
    rm -f scripts/openclaw-transform.ts scripts/openclaw-transform.test.ts
    ```
    
    ## 2. Remove the migration state file
    
    ```bash
    rm -f migration-state.md
    ```
    
    ## 3. Remove deferred-task notes (if Phase 5 deferred any)
    
    When a task couldn't be scheduled yet, the skill records it per group:
    
    ```bash
    rm -f groups/*/openclaw-migration-tasks.md
    ```
    
    ## 4. Migrated content files (review before deleting)
    
    These are content you chose to bring over, now part of your agent groups. Delete
    only the ones you no longer want — review each first.
    
    - Identity / personality: `groups/*/instructions.prepend.md` and the memory
      concepts recorded for identity reference material
    - User context and memories: the destination files recorded in the migration's
      source-to-destination summary under `groups/*/memory/`
    - Copied OpenClaw skills: directories you added under `container/skills/`
      (compare against the stock set before removing — do not delete
      `onecli-gateway`, `welcome`, `self-customize`, `agent-browser`,
      `slack-formatting`, or other shipped container skills).
    
    Per-group standing instructions live in `groups/<folder>/instructions.prepend.md`;
    durable facts live under `groups/<folder>/memory/`. Review and revert only the
    OpenClaw-derived entries by hand if desired.
    
    ## 5. Rebuild if you removed copied skills
    
    If step 4 deleted any `container/skills/` directories:
    
    ```bash
    ./container/build.sh
    ```
    
    Then restart the service from your NanoClaw project root:
    
    ```bash
    source setup/lib/install-slug.sh
    # macOS
    launchctl kickstart -k gui/$(id -u)/$(launchd_label)
    # Linux
    systemctl --user restart $(systemd_unit)
    ```
    
    ## 6. Undo the migration itself (optional, destructive)
    
    This reverses the live install state the migration produced — only do it to
    fully back out. Use `ncl` to inspect first:
    
    ```bash
    ncl wirings list
    ncl messaging-groups list
    ncl groups list
    ncl roles list
    ```
    
    Then delete what the migration added with the matching `ncl ... delete` /
    `ncl roles revoke` / `ncl members remove` verbs. Remove migrated channel tokens
    from `.env`, and remove vault secrets with `onecli secrets delete` (list them
    with `onecli secrets list`). There is no automatic rollback — delete only the
    entities you recognize as migration output.
    
  • SKILL.md 23.5 KB
    ---
    name: migrate-from-openclaw
    description: Migrate from OpenClaw to NanoClaw v2. Detects an existing OpenClaw installation, extracts identity, channel credentials, scheduled tasks, and other config, then guides interactive migration. Triggers on "migrate from openclaw", "openclaw migration", "import from openclaw".
    ---
    
    # Migrate from OpenClaw
    
    Guide the user through migrating their OpenClaw installation into NanoClaw v2.
    This is a conversation, not a batch job. Read OpenClaw state, discuss it with
    the user, decide together what to bring over and where it belongs in v2's
    entity model, and show proposed changes before applying.
    
    **Principle:** Never silently copy data. Read it, explain it, place it, then
    apply. Credentials are masked when displayed (first 4 + `...` + last 4). Make
    judgment calls about what's core vs. reference material.
    
    **UX:** Use `AskUserQuestion` for multiple-choice only. Use plain text for
    free-form input. Don't dump raw data — summarize and explain conversationally.
    
    ## What this skill changes (conformance)
    
    This skill drives existing NanoClaw entry points (`setup/index.ts --step
    register`, `scripts/init-first-agent.ts`, the `onecli` CLI) and copies a few
    files in (workspace markdown, OpenClaw skills, and its own transform module +
    test). It makes no code-level reach-in into core. Its integration assumptions
    about v2 are guarded by `scripts/transform.test.ts`, which is copied into the
    project's `scripts/` test tree on apply (Phase 8) so vitest runs it against the
    composed install. `REMOVE.md` reverses every file the skill copies.
    
    ## v2 architecture the migration targets
    
    OpenClaw and NanoClaw v2 differ structurally. Keep these in mind throughout:
    
    - **Entity model.** v2's central DB (`data/v2.db`) holds `users`,
      `user_roles`, `agent_groups`, `messaging_groups`, and the
      `messaging_group_agents` wiring between them. There is no `store/messages.db`
      and no `scheduled_tasks` table.
    - **Container isolation.** Each agent group runs in its own Linux container.
      An OpenClaw "agent" maps to a v2 *agent group* (workspace + memory +
      CLAUDE.md); an OpenClaw chat/group maps to a v2 *messaging group*; the wiring
      row connects them.
    - **Standing instructions vs memory.** Per-group role, personality, and
      behavior live in `groups/<folder>/instructions.prepend.md`. Durable facts
      live under `groups/<folder>/memory/`. The provider project document is
      composed at spawn and must not be edited.
    - **Credentials.** Container-facing API credentials (Anthropic, OpenAI, …) are
      held in the OneCLI Agent Vault and injected per request — never in container
      env vars. Host-side channel tokens (Telegram/Discord/Slack bot tokens) stay
      in `.env`; the NanoClaw host process reads them to connect to the platform.
    - **Access control.** Per messaging group `unknown_sender_policy` plus
      `user_roles` (owner/admin) and `agent_group_members` — not a JSON allowlist
      file.
    - **Scheduled tasks.** A task is a `messages_in` row (`kind='task'`) in a
      session's `inbound.db`, carrying a cron `recurrence` and a `process_after`
      timestamp. The agent creates them via its `schedule_task` MCP tool.
    
    ## Migration State File
    
    Create `migration-state.md` in the project root at the start of Phase 0. Update
    it after each phase. It's the single source of truth — if context is lost,
    re-read it to recover decisions and progress. Re-read it before starting any
    phase.
    
    Sections to maintain:
    
    - **Progress** — checkbox list of phases (Phase 0–8)
    - **Discovery** — STATE_DIR, IDENTITY_NAME, channels, groups (with v2
      platform_id mappings), workspace files, cron count, MCP servers
    - **Decisions** — assistant_name, shared-vs-separate, primary owner agent
    - **Owner & Primary Agent** — user id, role, agent group folder
    - **Registered Groups** — table: folder, platform_id, channel, session_mode
    - **Credentials** — table: credential, destination (vault / .env), status
    - **Settings Migrated** — timezone, container timeout
    - **Identity & Memory** — prepend and memory paths created for each group
    - **Scheduled Tasks** — table: original_id, name, mapped schedule, status
    - **Deferred / Not Applicable** — unsupported channels, OpenClaw-only features
    
    Keep it factual and terse. Delete it at the end of Phase 8 (or offer to keep it
    as a record).
    
    ## Phase 0: Discovery
    
    Run the discovery script to find and summarize the OpenClaw installation:
    
    ```bash
    pnpm exec tsx ${CLAUDE_SKILL_DIR}/scripts/discover-openclaw.ts
    ```
    
    If the user specifies a custom path, pass `--state-dir <path>`.
    
    Parse the status block. Key fields: STATUS, STATE_DIR, CHANNELS,
    WORKSPACE_FILES, DAILY_MEMORY_FILES, SKILL_COUNT, SKILLS, CRON_JOBS,
    MCP_SERVERS, IDENTITY_NAME, AGENT_COUNT, AGENT_IDS, GROUPS (each formatted
    `channel:id(name)=>v2_platform_id` — the right-hand value is what to pass as
    `--platform-id` to register).
    
    **Sanity-check the output.** The script detects known structures but can miss
    data if OpenClaw's format changed. Check `CONFIG_TOP_KEYS` and
    `CONFIG_CHANNEL_KEYS` — if you see keys it didn't report on, read that section
    of the config with the Read tool. Check `STATE_DIR_CONTENTS` for directories it
    doesn't scan.
    
    **If STATUS=not_found:** Tell the user no OpenClaw install was detected at the
    standard locations (`~/.openclaw`, `~/.clawdbot`). Ask for a custom path; if
    none, exit.
    
    **If STATUS=found:** Present a human-readable summary (identity name, workspace
    files, channels and which v2 supports, daily memory count, skills, cron count,
    MCP servers, agent count). Then paraphrase the key architectural differences
    from the section above — don't dump it as a table.
    
    AskUserQuestion: "Ready to start migrating? I'll go through each area one at a
    time."
    1. **Yes, let's go** — proceed to Phase 1
    2. **Tell me more** — explain any area they ask about
    3. **Skip migration** — exit
    
    ## Phase 1: Agents, Groups, and Shared vs Separate
    
    **Decide this before identity/memory** — it determines where files go.
    
    **OpenClaw model:** all groups routed to one agent share a workspace
    (SOUL/MEMORY/IDENTITY) and personality; only the session is per-group.
    
    **v2 model:** each agent group is a separate container with its own filesystem,
    standing instructions, and `memory/` tree. Multiple messaging groups wired to
    the same agent group share that state. There is no `groups/global/`.
    
    AskUserQuestion: "In OpenClaw your groups shared one personality and memory. In
    v2 each agent group is separate. How do you want to handle this?"
    
    1. **Shared identity (recommended if it was one bot)** — apply the same core
       identity to each selected group's `instructions.prepend.md`; keep group
       facts in each group's memory tree.
    2. **Fully separate** — each group gets independent memory and instructions; no
       shared base edit.
    3. **Just the primary agent for now** — set one agent up; add others later.
    
    Remember this choice for Phase 3.
    
    ### Confirm the assistant name
    
    `IDENTITY_NAME` from discovery is the OpenClaw name. Ask: "Your OpenClaw
    assistant was named `<IDENTITY_NAME>`. Keep it in v2?" If empty, ask them to
    choose (default: "Andy"). The chosen name is passed as `--assistant-name` to
    register/init.
    
    ### Seed the owner and the primary DM agent
    
    The owner identity and the primary agent are created together by
    `scripts/init-first-agent.ts`. It upserts the user, grants the owner role,
    creates the agent group + filesystem, wires a DM messaging group, and queues a
    welcome DM over the running service's CLI socket — **so the service must be
    running.** If it isn't, tell the user to start it first.
    
    Resolve the owner's channel identity and the DM platform id (use the channel's
    own terminology). Then:
    
    ```bash
    pnpm exec tsx scripts/init-first-agent.ts \
      --channel <channel> \
      --user-id <channel>:<handle> \
      --platform-id <channel>:<dm-id> \
      --display-name "<Owner Name>" \
      --agent-name "<confirmed assistant name>" \
      [--role owner]      # default: owner
    ```
    
    For direct-addressable channels (telegram, whatsapp) the `--platform-id` is
    usually the same handle as `--user-id` with the channel prefix. `--role`
    defaults to `owner` (global, cross-channel) — use `admin` (scoped to the agent
    group) or `member` only if intended.
    
    ### Register the remaining groups
    
    For each additional OpenClaw group the user wants to bring over, register a
    messaging group and wire it to an agent group:
    
    ```bash
    pnpm exec tsx setup/index.ts --step register -- \
      --platform-id "<v2_platform_id from discovery>" \
      --name "<group name>" \
      --folder "<channel>_<name-slug>" \
      --channel "<channel>" \
      --session-mode "<shared|agent-shared|per-thread>" \
      [--trigger "@<assistant name>"] \
      [--no-trigger-required] \
      --assistant-name "<assistant name>"
    ```
    
    Notes:
    - `register` namespaces the `--platform-id` the same way the adapter will at
      runtime, so pass the `=>` value discovery emitted (or the raw OpenClaw id).
    - Reuse a `--folder` to put a group on an existing agent (shared base/separate
      conversations); use a new `--folder` for a fully separate agent.
    - Engage defaults come from the channel adapter's declaration (most group
      chats default to mention-based engagement; channels without a mention
      signal default to a name pattern). Pass `--trigger` to set an explicit
      regex, or `--no-trigger-required` for respond-to-everything.
    - Register groups from channels v2 doesn't support yet too — the messaging
      group and wiring persist and activate when that channel is installed.
    
    Folder naming: `<channel>_<name-slug>` (e.g. `telegram_dev-team`). Confirm each
    name and folder with the user.
    
    ## Phase 2: Settings from Config
    
    Read the config (`<STATE_DIR>/openclaw.json` or `clawdbot.json`) for settings
    that map to v2 setup.
    
    ### Timezone
    
    Check `agents.defaults.userTimezone`. If it's a valid IANA zone, write it to
    `.env` as `TZ=<timezone>`. v2 reads `TZ` from `.env` (`src/config.ts`) and uses
    it for cron/recurrence evaluation, so this matters for scheduled tasks.
    
    ### Container timeout
    
    Check `agents.defaults.timeoutSeconds`. v2's equivalent is `CONTAINER_TIMEOUT`
    (env var, default 30 min) or per-group `ncl groups config update`. If the
    OpenClaw value differs notably, note it; the user can set
    `CONTAINER_TIMEOUT=<ms>` in `.env`.
    
    ### Access control (sender policies)
    
    OpenClaw per-channel `allowFrom` / `dmPolicy` / `groupPolicy` map onto v2's
    model, which is **not** a JSON file. Each messaging group has an
    `unknown_sender_policy`; access is granted via `user_roles` (owner/admin) and
    `agent_group_members`. Map:
    
    - `dmPolicy`/`groupPolicy: "open"` → leave the default; no extra grants.
    - `allowFrom` / `groupAllowFrom` lists → for each allowed sender, upsert the
      user and add them as a member of the relevant agent group via `ncl`:
    
      ```bash
      ncl users create --id "<channel>:<handle>" --kind <channel> --display-name "<name>"
      ncl members add --user "<channel>:<handle>" --group "<ag-id>"
      ```
    - `dmPolicy: "disabled"` → don't wire that chat (or leave it registered but
      unwired).
    
    The messaging groups `register` / `init-first-agent` create default their
    `unknown_sender_policy` to whatever the channel adapter declares for that
    context (DM vs group) — `strict` when the channel has no declaration — so
    unknown senders are gated until you add them (or an admin approves the
    adapter-declared approval card). Pass `--unknown-sender-policy` to `register`
    to override. Show the user the OpenClaw allowlist and confirm who to grant
    before running the commands.
    
    ## Phase 3: Identity and Memory
    
    Fully conversational — read files directly and discuss. **Placement depends on
    the Phase 1 choice:**
    
    - **Shared identity:** merge the same core identity/personality into every
      selected group's `instructions.prepend.md`.
    - **Fully separate / primary only:** merge identity/personality only into the
      corresponding group's `instructions.prepend.md`.
    
    Never edit a composed `CLAUDE.md` or `AGENTS.md`; it is regenerated each spawn.
    Put standing behavior in `instructions.prepend.md` and facts in `memory/`.
    
    Find workspace files at `<STATE_DIR>/workspace/`. If `AGENT_COUNT > 1`, also
    check `<STATE_DIR>/agents/*/workspace/` and ask which agent maps to which v2
    agent group.
    
    ### IDENTITY.md / SOUL.md
    
    Read them. Distinguish always-loaded vs reference:
    - **Standing behavior** (core traits, communication style, key rules) → weave
      into the group's `instructions.prepend.md`.
    - **Reference** (backstory, extended guidelines) → a separate durable concept
      in an appropriate folder under `groups/<folder>/memory/`, linked from that
      folder's `index.md` and the root Map.
    
    Choose each memory folder based on which related information will be easiest to
    find together; a folder may contain different concept types. Before writing the
    first concept into a new folder, create the folder and its `index.md`. Follow
    `memory/system/definition.md`, including its YAML frontmatter rules, for every
    new concept.
    
    Show proposed edits before applying — this is a thoughtful merge, not a paste.
    
    ### USER.md
    
    Create a focused user-context concept in an appropriate memory folder and link
    it through that folder's index and the root Map. Put only facts relevant in
    nearly every conversation (for example name or timezone) into `## Core Memory`;
    keep all other details in the linked file.
    
    ### MEMORY.md and daily memory files
    
    Show `MEMORY.md`; keep relevant items in focused concepts under the chosen
    memory folders, with links through each folder index and the root Map. For
    daily files (`workspace/memory/*.md`, count = DAILY_MEMORY_FILES):
    
    AskUserQuestion: "You have N daily memory files. How to handle them?"
    1. **Copy as-is** — agree on a descriptive folder, create it and its `index.md`,
       then copy with `cp <workspace>/memory/*.md <group_dir>/memory/<chosen-folder>/`
       and link the retained files through its index and the root Map.
    2. **Consolidate** — read, extract durable facts, and place them in focused
       linked memory files.
    3. **Skip.**
    
    ### OpenClaw skills
    
    If `SKILL_COUNT > 0`, the SKILL.md format is shared, so skills are portable.
    Present each (name + description from the front matter) and let the user pick.
    For each confirmed skill, copy the directory into the container skills tree:
    
    ```bash
    cp -r <skill_source_dir> container/skills/<skill_name>
    ```
    
    A container rebuild is needed afterward — note it for Phase 8.
    
    ### Config-registered plugins (with API keys)
    
    If `CONFIG_PLUGINS` is non-empty, OpenClaw had plugins/skills carrying keys.
    For each, read the config section and decide together:
    - **Matching v2 skill** → run that skill; route its credential per Phase 4.
    - **An MCP server** → install the exact configured package; wire via
      `ncl groups config add-mcp-server`. Don't guess at packages.
    - **An API key** → route to the OneCLI vault if container-facing (Phase 4).
    
    Don't install unknown packages or search for replacements — supply-chain risk.
    
    ## Phase 4: Credentials
    
    Two destinations, decided per credential. **Channel tokens → `.env`** (host
    reads them). **Container-facing API credentials → the OneCLI vault** (injected
    per request, never in container env).
    
    ### Channel tokens (telegram, discord, slack)
    
    Preview, then write to `.env`. The script emits only masked values:
    
    ```bash
    pnpm exec tsx ${CLAUDE_SKILL_DIR}/scripts/extract-channel-credentials.ts \
      --state-dir <STATE_DIR> --channel <name>
    ```
    
    Parse the status block. `DESTINATION: env` confirms a host-side token. Show
    `CREDENTIAL_MASKED` (and `CREDENTIAL_MASKED_2` for Slack's app token).
    
    AskUserQuestion:
    1. **Use this credential** — re-run with `--write-env .env` to save it.
    2. **Enter a new one** — ask in plain text, write to `.env` yourself.
    3. **Skip this channel.**
    
    ```bash
    pnpm exec tsx ${CLAUDE_SKILL_DIR}/scripts/extract-channel-credentials.ts \
      --state-dir <STATE_DIR> --channel <name> --write-env .env
    ```
    
    Check `WRITTEN_TO` / `WRITTEN_COUNT`. Slack writes both `SLACK_BOT_TOKEN` and
    `SLACK_APP_TOKEN` in one run.
    
    **If `HAS_CREDENTIAL=false` but a credential is expected:** the config shape may
    be unrecognized, or it uses a `file`/`exec` SecretRef (`CREDENTIAL_SOURCE`
    ends in `_ref` with a NOTE) that can't be auto-extracted. Read the channel
    section of the config directly and ask the user to confirm or paste the value.
    
    **WhatsApp:** authenticates via QR/pairing code — there's no token. Don't copy
    Baileys auth state (stale encryption sessions break decryption).
    Re-authenticate during `/setup` via `/add-whatsapp`. The extraction script
    reports `DESTINATION: none` for it.
    
    ### Anthropic and other container-facing credentials → OneCLI vault
    
    Find the agent's model credentials in OpenClaw. Check, in order:
    1. `<STATE_DIR>/auth-profiles.json` (and
       `<STATE_DIR>/agents/<id>/agent/auth-profiles.json`) — a `profiles` map keyed
       `provider:identifier`. For an `anthropic` provider profile the value depends
       on `type`: `api_key` → `key`, `token` → `token`, `oauth` → `access`.
    2. `<STATE_DIR>/.env` — `ANTHROPIC_API_KEY` or `CLAUDE_CODE_OAUTH_TOKEN`.
    3. Config `models.providers` — Anthropic provider `apiKey`.
    
    These are container-facing, so they go to the OneCLI vault. Do **not** write
    them to `.env` or thread them into a container. Register each in the vault:
    
    ```bash
    onecli secrets create --name Anthropic --type anthropic \
      --value <key-or-token> --host-pattern api.anthropic.com
    ```
    
    For other container-facing keys discovered in plugins (e.g. OpenAI):
    
    ```bash
    onecli secrets create --name OpenAI --type api_key \
      --value <key> --host-pattern api.openai.com
    ```
    
    Run the command on the user's behalf so the value never lands in the chat
    transcript; confirm with `onecli secrets list`.
    
    **Caveats:** `keyRef`/`tokenRef` with `source:"exec"` or `source:"file"` can't
    be auto-extracted — ask the user to paste it. For an `oauth` profile with a
    past expiry, warn that the token may need refreshing; the user can run
    `claude setup-token` and register the fresh token.
    
    If OneCLI isn't installed yet, defer this: tell the user that during `/setup`
    (or `/init-onecli`) they'll register the Anthropic credential, and note the
    discovered profile in `migration-state.md` so it isn't lost.
    
    > There is no supported `.env`-credentials opt-out anymore: the session spec's
    > admission rules refuse credential values in container env on every lane, by
    > design (the retired `/use-native-credential-proxy` skill would be denied at
    > every spawn). Credentials go through the OneCLI vault; custom Anthropic
    > endpoints use `ANTHROPIC_BASE_URL` plus the placeholder-token pattern from
    > setup, with the gateway rewriting the header on the wire.
    
    ## Phase 5: Scheduled Tasks
    
    Read `<STATE_DIR>/cron/jobs.json`. If absent or empty, skip.
    
    If jobs exist, read `${CLAUDE_SKILL_DIR}/MIGRATE_CRONS.md` for the v2 task
    model, the `mapCronToRecurrence` transform, the full field mapping, and how
    tasks are created (the agent's `schedule_task` MCP tool, since tasks live in a
    per-session `inbound.db` the host owns). Follow it for each enabled job.
    
    ## Phase 6: MCP, Webhooks, Other Config
    
    Read the relevant config sections directly. Conversational.
    
    ### MCP servers
    
    If `MCP_SERVERS` is non-empty, v2 supports per-agent-group MCP servers via the
    container config. Read each server's `command`/`args`/`env`/`url` from
    `mcp.servers`. For each one the user wants:
    
    ```bash
    ncl groups config add-mcp-server --id <agent-group-id> \
      --name <server-name> --command <cmd> \
      [--args '<json-array>'] [--env '<json-object>']
    ```
    
    stdio servers must be runnable inside the container (Node/npx-based work;
    custom binaries need a Dockerfile addition). Secrets referenced by a server's
    `env` should go to the OneCLI vault (Phase 4), not be inlined. The config
    change takes effect on restart: `ncl groups restart --id <agent-group-id>`
    (add `--rebuild` only if a custom binary was added to the Dockerfile).
    
    ### Webhooks
    
    OpenClaw `cron.webhook` / `failureDestination` / channel webhooks don't map to
    a v2 primitive. For a notification webhook, fold it into a scheduled task's
    prompt or a pre-agent `script` that `curl`s the endpoint. Discuss the use case.
    
    ### Other config (mention and move on)
    
    - **Exec approvals / command allowlist** → v2 uses container isolation; the
      agent runs sandboxed.
    - **Human delay / TTS / compaction / model config** → not v2 task/group fields
      (per-group model is in the container config).
    
    ## Phase 7: Welcome and First Run
    
    `init-first-agent` (Phase 1) already queued a welcome DM for the primary owner
    agent. If the service was up, the owner should have received it. For groups
    registered via `setup --step register`, the wiring also queues a `/welcome`
    onboarding message on first wiring.
    
    Tell the user which agents are live now and which await channel installation
    (unsupported channels registered for the future).
    
    ## Phase 8: Validate and Summarize
    
    ### Run the shipped test
    
    Copy the transform module and its test into the project so vitest runs them
    against the composed install, then build and test:
    
    ```bash
    cp ${CLAUDE_SKILL_DIR}/scripts/transform.ts        scripts/openclaw-transform.ts
    cp ${CLAUDE_SKILL_DIR}/scripts/transform.test.ts   scripts/openclaw-transform.test.ts
    # Point the copied test at the copied module name:
    sed -i.bak "s#from './transform.js'#from './openclaw-transform.js'#" scripts/openclaw-transform.test.ts && rm -f scripts/openclaw-transform.test.ts.bak
    
    pnpm run build
    pnpm exec vitest run scripts/openclaw-transform.test.ts
    ```
    
    The test guards the skill's two v2 integration assumptions: credential routing
    (container-facing → vault, channel tokens → `.env`) and the cron → v2
    recurrence mapping. It imports the real `cron-parser` (the same parser the host
    recurrence sweep uses), so a missing/renamed dependency turns it red. `build`
    typechecks the transform module against the project.
    
    These copied files are the only files the skill installs into the project tree;
    `REMOVE.md` deletes them.
    
    ### If a container rebuild is needed
    
    If OpenClaw skills were copied or MCP servers added: `./container/build.sh`,
    then restart the service.
    
    ### Summary
    
    Print what was migrated:
    - Owner + primary agent → `users` / `user_roles` / agent group + welcome DM
    - Additional groups → messaging groups + wiring (folders + session modes)
    - Timezone → `.env TZ`; container timeout → noted
    - Access grants → members/roles for OpenClaw allowlist senders
    - Identity/personality → per-group `instructions.prepend.md` + linked memory concepts
    - User context / memories → Core Memory only for universal facts; otherwise
      linked concepts in content-based folders under `memory/`
    - OpenClaw skills → `container/skills/`
    - Channel tokens → `.env` (list channels)
    - Container-facing credentials → OneCLI vault (list)
    - Scheduled tasks → mapped and scheduled via the agent (or noted for first run)
    - MCP servers → wired into agent group container configs
    
    Noted for later: channel installs during `/setup`; container rebuild if needed;
    tasks deferred until a session exists.
    
    Not applicable: unsupported channels (registered for the future); OpenClaw-only
    features (exec approvals, human delay, TTS, model/thinking config).
    
    Remind: "Run `/setup` next to finish your NanoClaw install. Channel tokens are
    in `.env`; container-facing credentials are in the OneCLI vault. Select the
    channels we configured when setup asks."
    
    Then delete `migration-state.md` (or offer to keep it as a record), and remove
    the copied transform files if you don't want them lingering (see `REMOVE.md`).
    
    ## Troubleshooting
    
    - **Config parse error:** the JSON5 parser may not handle unusual syntax. Read
      the file directly and work with it manually.
    - **Credential not found:** likely a `file`/`exec` SecretRef — ask the user to
      paste the value.
    - **`init-first-agent` can't reach the CLI socket:** the service isn't running.
      Start it, then re-run.
    - **Multi-agent complexity:** do the primary/default agent first; add others as
      separate agent groups later.
    

Comments (0)

Sign in to join the conversation.

No comments yet.

Reviews (0)

No reviews yet.

Related