nanocoai/migrate-from-v1
Finish migrating a NanoClaw v1 install into v2. Run after `bash migrate-v2.sh` completes. Seeds the owner, migrates legacy memory, reconciles container configs, and helps port custom v1 code. Triggers on "migrate from v1", "finish migration", "v1 migration".
npx skills add https://github.com/nanocoai/nanoclaw --skill migrate-from-v1
bash migrate-v2.sh already ran the deterministic migration. It handled:
store/auth and aliased into messaging_groupsYour job is the parts that need human judgment: triage failed steps, seed the owner, run the shared-memory migration, reconcile configs, and port fork customizations.
Read logs/setup-migration/handoff.json first — it has overall_status, per-step results in steps, and a followups list.
Before anything else, check that logs/setup-migration/handoff.json exists. If it doesn't, the user is invoking this skill before migrate-v2.sh ran. Stop and tell them, verbatim:
> This skill finishes a migration that migrate-v2.sh started. Run that first, in your terminal — not from inside Claude:
>
> `bash
> bash migrate-v2.sh
> `
>
> It needs interactive prompts (channel selection, service switchover) and runs Node/pnpm bootstrap, Docker, OneCLI setup, and a container build that don't fit inside a Claude session. When it finishes, it'll hand control back to Claude automatically — at which point this skill picks up.
Do not attempt to run the script yourself, simulate its effects, or pick up the migration mid-stream. The deterministic side has dependencies on a real interactive shell.
Once handoff.json exists, proceed to Phase 0.
Before any deeper migration work, prove v2 actually answers messages on the user's real channels. v1 is paused, not touched — flipping back is a service restart.
Walk handoff.steps. Fix only the failures that would stop the bot from routing one message; defer the rest to its later phase.
Tell the user the switch is non-destructive (v1 is paused, not modified; reverting is one command). Help them stop v1's service unit and start v2's, tail the host log for a clean boot, and have them send a real test message. Use AskUserQuestion to confirm the bot responded.
If yes, continue to Phase 1. If no, diagnose from logs/nanoclaw.log and re-test — don't proceed to deeper work on a broken router.
Re-visit anything you skipped in 0a before declaring the migration done. Most surface naturally in later phases (1c-groups ↔ Phase 2, 1e-tasks ↔ task verification).
v2 auto-creates a users row for every sender it sees (via extractAndUpsertUser in src/modules/permissions/index.ts). By the time this skill runs, the owner's row likely already exists — it just needs the owner role granted.
User ID format: always <channel_type>:<platform_handle>. Each channel populates this differently:
telegram:<numeric_user_id> (e.g. telegram:6037840640)discord:<snowflake_user_id> (e.g. discord:123456789012345678)whatsapp:<phone>@s.whatsapp.net (e.g. whatsapp:[email protected])slack:<user_id> (e.g. slack:U04ABCDEF)<channel_type>:<platform_id>Steps:
users table: SELECT id, kind, display_name FROM users.AskUserQuestion: "Is <display_name> (<id>) you?" — Yes / No, let me type it.AskUserQuestion.user_roles via getUserRoles(userId). If an owner row already exists, skip. Otherwise grant it with grantRole. grantRole inserts a new row per call, so the getUserRoles check keeps this re-runnable.Use the DB helpers in src/modules/permissions/db/user-roles.ts (getUserRoles, grantRole). Init the DB first, then call the helpers:
import path from 'path';
import { initDb } from '../src/db/connection.js';
import { runMigrations } from '../src/db/migrations/index.js';
import { DATA_DIR } from '../src/config.js';
import { getUserRoles, grantRole } from '../src/modules/permissions/db/user-roles.js';
const db = initDb(path.join(DATA_DIR, 'v2.db'));
runMigrations(db); // idempotent
const userId = '<user_id>';
if (!getUserRoles(userId).some((r) => r.role === 'owner')) {
grantRole({
user_id: userId,
role: 'owner',
agent_group_id: null, // owner role must be global
granted_by: null,
granted_at: new Date().toISOString(),
});
}
After seeding the owner, discuss the access policy. v2's messaging_groups.unknown_sender_policy controls who can interact with the bot. migrate-v2.sh set it to public so the bot would respond during the switchover test, but the user may want to tighten it.
Present the options via AskUserQuestion:
public, current) — anyone can message the bot. Good for personal DM bots.strict) — only users the access gate accepts (owner, admin, or agent_group_members) can trigger the bot. Others are silently dropped.request_approval) — unknown senders trigger an approval request to the owner. Good for group chats where you want to vet new members.The unknown_sender_policy column accepts exactly these three values; use the parenthesized value for <chosen_policy> below.
If the user picks option 2 or 3, seed the known users from v1's message history. The v1 database is at <handoff.v1_path>/store/messages.db. It has a messages table with sender and sender_name columns. For each group:
-- v1: unique senders per chat (excluding bot messages)
SELECT DISTINCT sender, sender_name
FROM messages
WHERE chat_jid = '<v1_jid>' AND is_from_me = 0 AND sender IS NOT NULL
The sender value is a platform handle (e.g. 6037840640 for Telegram). Build the v2 user ID by inferring the channel type from the chat JID prefix (use parseJid from setup/migrate-v2/shared.ts) and combining: <channel_type>:<sender>.
For each sender:
users(id, kind, display_name) if not already present.agent_group_members(user_id, agent_group_id) for each agent group wired to that messaging group.Show the user the list of senders being imported and let them deselect any they don't want.
Then update the messaging groups:
UPDATE messaging_groups SET unknown_sender_policy = '<chosen_policy>'
WHERE id IN (SELECT id FROM messaging_groups WHERE channel_type IN (<migrated_channels>))
Run /migrate-memory for the imported groups. It quiesces each group, moves the
v1 CLAUDE.local.md into the shared memory/ tree without reading it during
staging, then has the invoking coding harness distill standing identity into
instructions.prepend.md and durable facts into Core Memory or focused linked
files before the NanoClaw group runs again.
Do not duplicate that migration logic here. Record each group's result in the
handoff before continuing.
migrate-v2.sh writes container.json directly from v1's container_config (the additionalMounts shape is identical). If the v1 config was unparseable, it falls back to a .v1-container-config.json sidecar.
For each group, check:
container.json exists, read it and verify the additionalMounts host paths are still valid on this machine. Flag any that don't exist..v1-container-config.json exists (parse failure fallback), read it, discuss with the user, and write a proper container.json. Then delete the sidecar.env or packages fields — env may overlap with OneCLI vault, packages (apt/npm) are portable.Check whether the user's v1 install was a customized fork.
cd <v1_path>
git remote -v
git log --oneline <upstream>/main..HEAD 2>/dev/null
If no commits ahead of upstream: stock v1, skip this phase.
If there are commits:
AskUserQuestion: "How do you want to handle your v1 customizations?"container/skills/*, .claude/skills/*, docs/*. Grep each copied file for v1-only references that won't resolve in v2 and flag them to the user: workspace paths (/workspace/group/, /workspace/project/, /workspace/ipc/, /workspace/extra/), the v1 IPC mechanism, registered_groups / is_main, the v1 sender allowlist, and store/messages.db.docs/v1-fork-reference/ for later.src/*, container/agent-runner/src/*) is NOT portable — v2's architecture is fundamentally different. Stash to docs/v1-fork-reference/ with a README explaining what each file did. Don't translate.handoff.v1_path.... + last 4 characters).handoff.json is the recovery point. If context gets compacted, re-read it and git status to recover state.The setup flow at setup/index.ts has individual steps you can invoke if something is missing or failed:
pnpm exec tsx setup/index.ts --step <name>
| Step | When to use |
|------|-------------|
| onecli | OneCLI not installed or not healthy |
| auth | No Anthropic credential in vault |
| container | Container image needs rebuild |
| service | Service not installed or not running |
| mounts | Mount allowlist missing |
| verify | End-to-end health check (run after everything else) |
| environment | System check (Node, dirs) |
pnpm exec tsx setup/index.ts --step verify
logs/setup-migration/handoff.json — offer to save as docs/migration-<date>.md first.nanoclaw-v2-<slug> / com.nanoclaw-v2-<slug>), so derive it from src/install-slug.ts rather than guessing: # Linux
UNIT=$(pnpm exec tsx -e "import{getSystemdUnit}from'./src/install-slug.js';console.log(getSystemdUnit())")
systemctl --user restart "$UNIT"
# macOS
LABEL=$(pnpm exec tsx -e "import{getLaunchdLabel}from'./src/install-slug.js';console.log(getLaunchdLabel())")
launchctl kickstart -k "gui/$(id -u)/$LABEL"
Take nanocoai/migrate-from-v1 from the repository into ~/.claude/skills for personal
use, or into .claude/skills inside a project.
The agent identifies a skill by the name field in its header. Two skills with the
same name cannot sit side by side — one of them will be ignored.