# Legacy sealseek Skills Import Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Build a one-shot, idempotent script that imports curated legacy sealseek skills from `~/.sealseek/workspaces/default/{active_skills,customized_skills}/` into sealclaw's global managed skills directory `~/.sealseek/skills/`, filtering out skills already present in any active sealclaw source.

**Architecture:** A single Node ESM script `scripts/migrate-legacy-skills.mjs` with exported pure helpers (frontmatter parsing, directory scanning, dedup-aware planning) and a small side-effectful orchestrator (`runCli`). Tests in `test/scripts/migrate-legacy-skills.test.ts` cover each helper and an end-to-end `runCli` flow that exercises the manifest, conflict, dry-run, force, and re-import paths. Patterns mirror `scripts/migrate-legacy-sessions.mjs`.

**Tech Stack:** Node ≥22 (uses `fs.cpSync`), ESM, Vitest, no new dependencies.

**Spec:** [docs/superpowers/specs/2026-05-26-legacy-skills-import-design.md](../specs/2026-05-26-legacy-skills-import-design.md)

---

## File Structure

**Create:**
- `scripts/migrate-legacy-skills.mjs` — the script (≈300 LOC)
- `test/scripts/migrate-legacy-skills.test.ts` — vitest tests (≈400 LOC)

**Touch (read-only reference):**
- `scripts/migrate-legacy-sessions.mjs` — pattern source for atomic writes, manifest, CLI arg parsing
- `test/scripts/migrate-legacy-sessions.test.ts` — pattern source for per-test tmpdir testing
- `scripts/CLAUDE.md` — wrapper rules (just FYI; this new script is a one-shot personal tool, not part of a wrapper chain)

No source files outside `scripts/` and `test/scripts/` are touched. No package.json entries added.

---

## Task 1: Script scaffolding + shared manifest helpers

Build the file skeleton with shebang, imports, and the small file-local JSON helpers (`atomicWriteJson`, `readJsonIfExists`) plus the manifest API (`loadManifest`, `recordImport`). These are copied/adapted from `scripts/migrate-legacy-sessions.mjs` with a different filename, error code, and entry-key parameter.

**Files:**
- Create: `scripts/migrate-legacy-skills.mjs`
- Create: `test/scripts/migrate-legacy-skills.test.ts`

- [ ] **Step 1: Create the script file with imports and shared file helpers**

Write `scripts/migrate-legacy-skills.mjs`:

```javascript
#!/usr/bin/env node
// Migrate legacy sealseek skills from `<workspace>/active_skills/` and
// `<workspace>/customized_skills/` into sealclaw's managed skills directory at
// `<stateDir>/skills/`, deduped by SKILL.md frontmatter `name` and idempotent
// via a manifest at `<stateDir>/skills/.legacy-skills-import-manifest.json`.

import crypto from "node:crypto";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { pathToFileURL } from "node:url";

function atomicWriteJson(filePath, value) {
  const tmpPath = `${filePath}.tmp`;
  fs.mkdirSync(path.dirname(filePath), { recursive: true });
  fs.writeFileSync(tmpPath, JSON.stringify(value, null, 2));
  fs.renameSync(tmpPath, filePath);
}

function readJsonIfExists(filePath) {
  if (!fs.existsSync(filePath)) return undefined;
  return JSON.parse(fs.readFileSync(filePath, "utf8"));
}

export function loadManifest(manifestPath) {
  if (!fs.existsSync(manifestPath)) return { version: 1, imported: {} };
  let raw;
  try {
    raw = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
  } catch {
    raw = null;
  }
  if (
    raw &&
    typeof raw === "object" &&
    raw.version === 1 &&
    raw.imported &&
    typeof raw.imported === "object"
  ) {
    return raw;
  }
  const err = new Error(`legacy-skills-import-manifest at ${manifestPath} is corrupt`);
  err.code = "LEGACY_SKILLS_MANIFEST_CORRUPT";
  throw err;
}

export function recordImport({ manifestPath, name, record }) {
  const current = loadManifest(manifestPath);
  current.imported[name] = record;
  atomicWriteJson(manifestPath, current);
}
```

- [ ] **Step 2: Write the failing tests for manifest helpers**

Write `test/scripts/migrate-legacy-skills.test.ts`:

```typescript
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { loadManifest, recordImport } from "../../scripts/migrate-legacy-skills.mjs";

function mkTmp(prefix: string): string {
  return fs.mkdtempSync(path.join(os.tmpdir(), `${prefix}-`));
}

describe("loadManifest", () => {
  it("returns empty manifest when file is missing", () => {
    const tmp = mkTmp("skills-manifest-missing");
    const result = loadManifest(path.join(tmp, "missing.json"));
    expect(result).toEqual({ version: 1, imported: {} });
  });

  it("loads a valid manifest", () => {
    const tmp = mkTmp("skills-manifest-valid");
    const p = path.join(tmp, "m.json");
    fs.writeFileSync(
      p,
      JSON.stringify({ version: 1, imported: { foo: { sourceDir: "x" } } }),
    );
    expect(loadManifest(p)).toEqual({
      version: 1,
      imported: { foo: { sourceDir: "x" } },
    });
  });

  it("throws LEGACY_SKILLS_MANIFEST_CORRUPT on garbage", () => {
    const tmp = mkTmp("skills-manifest-corrupt");
    const p = path.join(tmp, "m.json");
    fs.writeFileSync(p, "{not json");
    expect(() => loadManifest(p)).toThrow(/corrupt/);
    try {
      loadManifest(p);
    } catch (err) {
      expect((err as { code?: string }).code).toBe("LEGACY_SKILLS_MANIFEST_CORRUPT");
    }
  });

  it("throws on wrong version", () => {
    const tmp = mkTmp("skills-manifest-version");
    const p = path.join(tmp, "m.json");
    fs.writeFileSync(p, JSON.stringify({ version: 2, imported: {} }));
    expect(() => loadManifest(p)).toThrow(/corrupt/);
  });
});

describe("recordImport", () => {
  it("creates a new manifest entry atomically", () => {
    const tmp = mkTmp("skills-record");
    const p = path.join(tmp, "m.json");
    recordImport({
      manifestPath: p,
      name: "alpha",
      record: { sourceDir: "/src/a", sourceMtimeMs: 1, destDir: "/d/a", importedAt: 2 },
    });
    const loaded = loadManifest(p);
    expect(loaded.imported.alpha).toEqual({
      sourceDir: "/src/a",
      sourceMtimeMs: 1,
      destDir: "/d/a",
      importedAt: 2,
    });
  });

  it("merges with existing entries", () => {
    const tmp = mkTmp("skills-record-merge");
    const p = path.join(tmp, "m.json");
    recordImport({
      manifestPath: p,
      name: "alpha",
      record: { sourceDir: "/src/a", sourceMtimeMs: 1, destDir: "/d/a", importedAt: 2 },
    });
    recordImport({
      manifestPath: p,
      name: "beta",
      record: { sourceDir: "/src/b", sourceMtimeMs: 3, destDir: "/d/b", importedAt: 4 },
    });
    const loaded = loadManifest(p);
    expect(Object.keys(loaded.imported).sort()).toEqual(["alpha", "beta"]);
  });
});
```

- [ ] **Step 3: Run tests to verify they pass**

Run: `pnpm test test/scripts/migrate-legacy-skills.test.ts`

Expected: 6 tests pass (4 `loadManifest`, 2 `recordImport`).

- [ ] **Step 4: Commit**

```bash
git add scripts/migrate-legacy-skills.mjs test/scripts/migrate-legacy-skills.test.ts
git commit -m "feat(scripts): bootstrap migrate-legacy-skills with manifest helpers"
```

---

## Task 2: parseFrontmatterName

A minimal YAML frontmatter walker that extracts `name:` from the leading `---`-fenced block. No dependency on a YAML library — only handles bare strings, quoted strings, and Chinese/non-ASCII slugs, and skips indented (nested) lines to avoid matching `name:` under `metadata:`.

**Files:**
- Modify: `scripts/migrate-legacy-skills.mjs` (append `parseFrontmatterName`)
- Modify: `test/scripts/migrate-legacy-skills.test.ts` (append tests)

- [ ] **Step 1: Write the failing tests**

Append to `test/scripts/migrate-legacy-skills.test.ts`:

```typescript
import { parseFrontmatterName } from "../../scripts/migrate-legacy-skills.mjs";

describe("parseFrontmatterName", () => {
  it("returns name from simple frontmatter", () => {
    const text = "---\nname: market-analysis\ndescription: x\n---\n\nbody";
    expect(parseFrontmatterName(text)).toBe("market-analysis");
  });

  it("strips double quotes", () => {
    expect(parseFrontmatterName('---\nname: "quoted"\n---\n')).toBe("quoted");
  });

  it("strips single quotes", () => {
    expect(parseFrontmatterName("---\nname: 'quoted'\n---\n")).toBe("quoted");
  });

  it("handles Chinese names", () => {
    expect(parseFrontmatterName("---\nname: 关键词助手\n---\n")).toBe("关键词助手");
  });

  it("ignores indented name: lines (nested keys)", () => {
    const text = "---\ndescription: |\n  name: shadowed\nname: real\n---\n";
    expect(parseFrontmatterName(text)).toBe("real");
  });

  it("returns null when name is missing", () => {
    expect(parseFrontmatterName("---\ndescription: x\n---\n")).toBeNull();
  });

  it("returns null when name is empty", () => {
    expect(parseFrontmatterName("---\nname: \n---\n")).toBeNull();
  });

  it("returns null when frontmatter is missing", () => {
    expect(parseFrontmatterName("no frontmatter here\nname: foo")).toBeNull();
  });

  it("returns null on non-string input", () => {
    // @ts-expect-error testing runtime guard
    expect(parseFrontmatterName(null)).toBeNull();
    // @ts-expect-error testing runtime guard
    expect(parseFrontmatterName(undefined)).toBeNull();
  });

  it("handles Windows line endings", () => {
    expect(parseFrontmatterName("---\r\nname: win\r\n---\r\n")).toBe("win");
  });

  it("returns first name when nested name: exists later under indented block", () => {
    const text = "---\nname: top\nmetadata:\n  name: ignored\n---\n";
    expect(parseFrontmatterName(text)).toBe("top");
  });
});
```

- [ ] **Step 2: Run tests to verify they fail**

Run: `pnpm test test/scripts/migrate-legacy-skills.test.ts -t parseFrontmatterName`

Expected: All `parseFrontmatterName` tests fail with `parseFrontmatterName is not a function`.

- [ ] **Step 3: Implement parseFrontmatterName**

Append to `scripts/migrate-legacy-skills.mjs`:

```javascript
const FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---/;

export function parseFrontmatterName(text) {
  if (typeof text !== "string") return null;
  const m = FRONTMATTER_RE.exec(text);
  if (!m) return null;
  for (const line of m[1].split(/\r?\n/)) {
    if (/^\s/.test(line)) continue;
    const km = /^name:\s*(.*)$/.exec(line);
    if (!km) continue;
    let val = km[1].trim();
    if (!val) return null;
    if (
      (val.startsWith('"') && val.endsWith('"')) ||
      (val.startsWith("'") && val.endsWith("'"))
    ) {
      val = val.slice(1, -1);
    }
    return val || null;
  }
  return null;
}
```

- [ ] **Step 4: Run tests to verify they pass**

Run: `pnpm test test/scripts/migrate-legacy-skills.test.ts -t parseFrontmatterName`

Expected: 11 `parseFrontmatterName` tests pass.

- [ ] **Step 5: Commit**

```bash
git add scripts/migrate-legacy-skills.mjs test/scripts/migrate-legacy-skills.test.ts
git commit -m "feat(scripts): add parseFrontmatterName to migrate-legacy-skills"
```

---

## Task 3: readSkillEntry

Reads one candidate skill directory: returns `{ name, sourceDir, sourceMtimeMs }` if SKILL.md exists, is a file, and has a parseable name; otherwise returns `null`.

**Files:**
- Modify: `scripts/migrate-legacy-skills.mjs`
- Modify: `test/scripts/migrate-legacy-skills.test.ts`

- [ ] **Step 1: Write the failing tests**

Append to `test/scripts/migrate-legacy-skills.test.ts`:

```typescript
import { readSkillEntry } from "../../scripts/migrate-legacy-skills.mjs";

function writeSkill(rootDir: string, dirName: string, skillMdContent: string | null): string {
  const dir = path.join(rootDir, dirName);
  fs.mkdirSync(dir, { recursive: true });
  if (skillMdContent !== null) {
    fs.writeFileSync(path.join(dir, "SKILL.md"), skillMdContent);
  }
  return dir;
}

describe("readSkillEntry", () => {
  it("returns entry for a valid skill", () => {
    const tmp = mkTmp("readskill-valid");
    const dir = writeSkill(tmp, "work-browser", "---\nname: work-browser\n---\n");
    const result = readSkillEntry({ rootDir: tmp, dirName: "work-browser" });
    expect(result).not.toBeNull();
    expect(result?.name).toBe("work-browser");
    expect(result?.sourceDir).toBe(dir);
    expect(typeof result?.sourceMtimeMs).toBe("number");
  });

  it("returns null when SKILL.md is missing", () => {
    const tmp = mkTmp("readskill-no-md");
    writeSkill(tmp, "no-md", null);
    expect(readSkillEntry({ rootDir: tmp, dirName: "no-md" })).toBeNull();
  });

  it("returns null when SKILL.md is a directory", () => {
    const tmp = mkTmp("readskill-md-is-dir");
    const dir = path.join(tmp, "weird");
    fs.mkdirSync(path.join(dir, "SKILL.md"), { recursive: true });
    expect(readSkillEntry({ rootDir: tmp, dirName: "weird" })).toBeNull();
  });

  it("returns null when frontmatter has no name", () => {
    const tmp = mkTmp("readskill-noname");
    writeSkill(tmp, "nameless", "---\ndescription: x\n---\n");
    expect(readSkillEntry({ rootDir: tmp, dirName: "nameless" })).toBeNull();
  });

  it("returns null when the skill dir does not exist", () => {
    const tmp = mkTmp("readskill-no-dir");
    expect(readSkillEntry({ rootDir: tmp, dirName: "ghost" })).toBeNull();
  });
});
```

- [ ] **Step 2: Run tests to verify they fail**

Run: `pnpm test test/scripts/migrate-legacy-skills.test.ts -t readSkillEntry`

Expected: 5 tests fail with `readSkillEntry is not a function`.

- [ ] **Step 3: Implement readSkillEntry**

Append to `scripts/migrate-legacy-skills.mjs`:

```javascript
export function readSkillEntry({ rootDir, dirName }) {
  const sourceDir = path.join(rootDir, dirName);
  const skillMd = path.join(sourceDir, "SKILL.md");
  let stat;
  try {
    stat = fs.statSync(skillMd);
  } catch {
    return null;
  }
  if (!stat.isFile()) return null;
  let text;
  try {
    text = fs.readFileSync(skillMd, "utf8");
  } catch {
    return null;
  }
  const name = parseFrontmatterName(text);
  if (!name) return null;
  let dirStat;
  try {
    dirStat = fs.statSync(sourceDir);
  } catch {
    return null;
  }
  return {
    name,
    sourceDir,
    sourceMtimeMs: dirStat.mtimeMs,
  };
}
```

- [ ] **Step 4: Run tests to verify they pass**

Run: `pnpm test test/scripts/migrate-legacy-skills.test.ts -t readSkillEntry`

Expected: 5 `readSkillEntry` tests pass.

- [ ] **Step 5: Commit**

```bash
git add scripts/migrate-legacy-skills.mjs test/scripts/migrate-legacy-skills.test.ts
git commit -m "feat(scripts): add readSkillEntry to migrate-legacy-skills"
```

---

## Task 4: scanLegacySkills

Walks `<workspaceDir>/active_skills/` then `<workspaceDir>/customized_skills/`, collecting entries into a `Map<name, entry>`. Later writes overwrite earlier ones, so customized_skills overrides active_skills. Children are iterated in lexicographic order for determinism.

**Files:**
- Modify: `scripts/migrate-legacy-skills.mjs`
- Modify: `test/scripts/migrate-legacy-skills.test.ts`

- [ ] **Step 1: Write the failing tests**

Append to `test/scripts/migrate-legacy-skills.test.ts`:

```typescript
import { scanLegacySkills } from "../../scripts/migrate-legacy-skills.mjs";

describe("scanLegacySkills", () => {
  it("returns empty map when both sources missing", () => {
    const tmp = mkTmp("scan-empty");
    const result = scanLegacySkills({ workspaceDir: tmp });
    expect(result.size).toBe(0);
  });

  it("scans only active_skills when customized_skills missing", () => {
    const tmp = mkTmp("scan-active-only");
    const activeDir = path.join(tmp, "active_skills");
    fs.mkdirSync(activeDir, { recursive: true });
    writeSkill(activeDir, "a", "---\nname: alpha\n---\n");
    const result = scanLegacySkills({ workspaceDir: tmp });
    expect(Array.from(result.keys())).toEqual(["alpha"]);
  });

  it("scans only customized_skills when active_skills missing", () => {
    const tmp = mkTmp("scan-custom-only");
    const customDir = path.join(tmp, "customized_skills");
    fs.mkdirSync(customDir, { recursive: true });
    writeSkill(customDir, "b", "---\nname: beta\n---\n");
    const result = scanLegacySkills({ workspaceDir: tmp });
    expect(Array.from(result.keys())).toEqual(["beta"]);
  });

  it("customized_skills overrides active_skills on same name", () => {
    const tmp = mkTmp("scan-override");
    const activeDir = path.join(tmp, "active_skills");
    const customDir = path.join(tmp, "customized_skills");
    fs.mkdirSync(activeDir, { recursive: true });
    fs.mkdirSync(customDir, { recursive: true });
    const activeEntry = writeSkill(activeDir, "x", "---\nname: market\n---\nactive body");
    const customEntry = writeSkill(customDir, "市场", "---\nname: market\n---\ncustom body");
    const result = scanLegacySkills({ workspaceDir: tmp });
    expect(result.size).toBe(1);
    expect(result.get("market")?.sourceDir).toBe(customEntry);
    expect(result.get("market")?.sourceDir).not.toBe(activeEntry);
  });

  it("skips children without SKILL.md", () => {
    const tmp = mkTmp("scan-skip-no-md");
    const activeDir = path.join(tmp, "active_skills");
    fs.mkdirSync(path.join(activeDir, "empty"), { recursive: true });
    writeSkill(activeDir, "real", "---\nname: real\n---\n");
    const result = scanLegacySkills({ workspaceDir: tmp });
    expect(Array.from(result.keys())).toEqual(["real"]);
  });

  it("skips children without parseable name", () => {
    const tmp = mkTmp("scan-skip-no-name");
    const activeDir = path.join(tmp, "active_skills");
    fs.mkdirSync(activeDir, { recursive: true });
    writeSkill(activeDir, "nameless", "---\ndescription: x\n---\n");
    writeSkill(activeDir, "good", "---\nname: good\n---\n");
    const result = scanLegacySkills({ workspaceDir: tmp });
    expect(Array.from(result.keys())).toEqual(["good"]);
  });

  it("ignores non-directory entries in source dirs", () => {
    const tmp = mkTmp("scan-skip-files");
    const activeDir = path.join(tmp, "active_skills");
    fs.mkdirSync(activeDir, { recursive: true });
    fs.writeFileSync(path.join(activeDir, "stray.zip"), "noise");
    writeSkill(activeDir, "real", "---\nname: real\n---\n");
    const result = scanLegacySkills({ workspaceDir: tmp });
    expect(Array.from(result.keys())).toEqual(["real"]);
  });
});
```

- [ ] **Step 2: Run tests to verify they fail**

Run: `pnpm test test/scripts/migrate-legacy-skills.test.ts -t scanLegacySkills`

Expected: 7 tests fail with `scanLegacySkills is not a function`.

- [ ] **Step 3: Implement scanLegacySkills**

Append to `scripts/migrate-legacy-skills.mjs`:

```javascript
const LEGACY_SOURCES = ["active_skills", "customized_skills"];

export function scanLegacySkills({ workspaceDir }) {
  const out = new Map();
  for (const source of LEGACY_SOURCES) {
    const sourceDir = path.join(workspaceDir, source);
    if (!fs.existsSync(sourceDir)) continue;
    let children;
    try {
      children = fs.readdirSync(sourceDir, { withFileTypes: true });
    } catch {
      continue;
    }
    const dirs = children
      .filter((c) => c.isDirectory())
      .map((c) => c.name)
      .sort();
    for (const dirName of dirs) {
      const entry = readSkillEntry({ rootDir: sourceDir, dirName });
      if (!entry) continue;
      out.set(entry.name, entry);
    }
  }
  return out;
}
```

- [ ] **Step 4: Run tests to verify they pass**

Run: `pnpm test test/scripts/migrate-legacy-skills.test.ts -t scanLegacySkills`

Expected: 7 `scanLegacySkills` tests pass.

- [ ] **Step 5: Commit**

```bash
git add scripts/migrate-legacy-skills.mjs test/scripts/migrate-legacy-skills.test.ts
git commit -m "feat(scripts): add scanLegacySkills with customized > active precedence"
```

---

## Task 5: collectExistingSealclawSkillNames

Reads frontmatter `name` from each immediate child of the five sealclaw skill source directories, returning a `Set<string>`. Missing directories are silently ignored. Symlinks are followed (sealclaw's plugin-skills directory contains symlinks).

**Files:**
- Modify: `scripts/migrate-legacy-skills.mjs`
- Modify: `test/scripts/migrate-legacy-skills.test.ts`

- [ ] **Step 1: Write the failing tests**

Append to `test/scripts/migrate-legacy-skills.test.ts`:

```typescript
import { collectExistingSealclawSkillNames } from "../../scripts/migrate-legacy-skills.mjs";

describe("collectExistingSealclawSkillNames", () => {
  it("returns empty set when stateDir has no skill source directories", () => {
    const tmp = mkTmp("collect-empty");
    const result = collectExistingSealclawSkillNames({ stateDir: tmp });
    expect(result.size).toBe(0);
  });

  it("picks up names from ~/.sealseek/skills/", () => {
    const tmp = mkTmp("collect-managed");
    const skills = path.join(tmp, "skills");
    fs.mkdirSync(skills, { recursive: true });
    writeSkill(skills, "alpha", "---\nname: alpha\n---\n");
    writeSkill(skills, "beta", "---\nname: beta\n---\n");
    const result = collectExistingSealclawSkillNames({ stateDir: tmp });
    expect(Array.from(result).sort()).toEqual(["alpha", "beta"]);
  });

  it("picks up names from ~/.sealseek/workspace/skills/", () => {
    const tmp = mkTmp("collect-ws");
    const skills = path.join(tmp, "workspace", "skills");
    fs.mkdirSync(skills, { recursive: true });
    writeSkill(skills, "x", "---\nname: gamma\n---\n");
    const result = collectExistingSealclawSkillNames({ stateDir: tmp });
    expect(Array.from(result)).toEqual(["gamma"]);
  });

  it("follows symlinks (plugin-skills are symlinked)", () => {
    const tmp = mkTmp("collect-symlink");
    const real = mkTmp("collect-symlink-real");
    fs.writeFileSync(path.join(real, "SKILL.md"), "---\nname: linked\n---\n");
    const pluginSkills = path.join(tmp, "plugin-skills");
    fs.mkdirSync(pluginSkills, { recursive: true });
    fs.symlinkSync(real, path.join(pluginSkills, "router"), "dir");
    const result = collectExistingSealclawSkillNames({ stateDir: tmp });
    expect(result.has("linked")).toBe(true);
  });

  it("merges names from all five sources", () => {
    const tmp = mkTmp("collect-all");
    for (const [rel, name] of [
      ["skills", "managed"],
      ["workspace/skills", "workspace"],
      ["plugin-skills", "plugin"],
      [".agents/skills", "personal"],
      ["workspace/.agents/skills", "project"],
    ] as const) {
      const d = path.join(tmp, rel);
      fs.mkdirSync(d, { recursive: true });
      writeSkill(d, name, `---\nname: ${name}\n---\n`);
    }
    const result = collectExistingSealclawSkillNames({ stateDir: tmp });
    expect(Array.from(result).sort()).toEqual([
      "managed",
      "personal",
      "plugin",
      "project",
      "workspace",
    ]);
  });

  it("skips entries without SKILL.md", () => {
    const tmp = mkTmp("collect-no-md");
    const skills = path.join(tmp, "skills");
    fs.mkdirSync(path.join(skills, "empty"), { recursive: true });
    writeSkill(skills, "real", "---\nname: real\n---\n");
    const result = collectExistingSealclawSkillNames({ stateDir: tmp });
    expect(Array.from(result)).toEqual(["real"]);
  });
});
```

- [ ] **Step 2: Run tests to verify they fail**

Run: `pnpm test test/scripts/migrate-legacy-skills.test.ts -t collectExistingSealclawSkillNames`

Expected: 6 tests fail with `collectExistingSealclawSkillNames is not a function`.

- [ ] **Step 3: Implement collectExistingSealclawSkillNames**

Append to `scripts/migrate-legacy-skills.mjs`:

```javascript
const SEALCLAW_SKILL_SOURCES = [
  ["skills"],
  ["workspace", "skills"],
  ["plugin-skills"],
  [".agents", "skills"],
  ["workspace", ".agents", "skills"],
];

export function collectExistingSealclawSkillNames({ stateDir }) {
  const names = new Set();
  for (const segments of SEALCLAW_SKILL_SOURCES) {
    const dir = path.join(stateDir, ...segments);
    let children;
    try {
      children = fs.readdirSync(dir, { withFileTypes: true });
    } catch {
      continue;
    }
    for (const child of children) {
      if (!child.isDirectory() && !child.isSymbolicLink()) continue;
      const skillMd = path.join(dir, child.name, "SKILL.md");
      let text;
      try {
        text = fs.readFileSync(skillMd, "utf8");
      } catch {
        continue;
      }
      const name = parseFrontmatterName(text);
      if (name) names.add(name);
    }
  }
  return names;
}
```

- [ ] **Step 4: Run tests to verify they pass**

Run: `pnpm test test/scripts/migrate-legacy-skills.test.ts -t collectExistingSealclawSkillNames`

Expected: 6 `collectExistingSealclawSkillNames` tests pass.

- [ ] **Step 5: Commit**

```bash
git add scripts/migrate-legacy-skills.mjs test/scripts/migrate-legacy-skills.test.ts
git commit -m "feat(scripts): add collectExistingSealclawSkillNames"
```

---

## Task 6: copySkillDirectory

Atomic recursive copy: copy to `<dest>.tmp-<random>` first, then rename to `<dest>`. If destination already exists and `force` is false, throw with `code = "SKILL_DEST_EXISTS"`. On failure, best-effort tmp cleanup. No filtering of legacy artifacts (per spec).

**Files:**
- Modify: `scripts/migrate-legacy-skills.mjs`
- Modify: `test/scripts/migrate-legacy-skills.test.ts`

- [ ] **Step 1: Write the failing tests**

Append to `test/scripts/migrate-legacy-skills.test.ts`:

```typescript
import { copySkillDirectory } from "../../scripts/migrate-legacy-skills.mjs";

describe("copySkillDirectory", () => {
  it("copies a directory with nested files", () => {
    const tmp = mkTmp("copy-nested");
    const src = path.join(tmp, "src");
    const dest = path.join(tmp, "dest");
    fs.mkdirSync(path.join(src, "inner"), { recursive: true });
    fs.writeFileSync(path.join(src, "SKILL.md"), "---\nname: x\n---\n");
    fs.writeFileSync(path.join(src, "inner", "run.sh"), "#!/bin/sh\n");
    copySkillDirectory({ src, dest });
    expect(fs.existsSync(path.join(dest, "SKILL.md"))).toBe(true);
    expect(fs.readFileSync(path.join(dest, "inner", "run.sh"), "utf8")).toBe("#!/bin/sh\n");
  });

  it("throws SKILL_DEST_EXISTS when destination exists and force is false", () => {
    const tmp = mkTmp("copy-exists");
    const src = path.join(tmp, "src");
    const dest = path.join(tmp, "dest");
    fs.mkdirSync(src, { recursive: true });
    fs.writeFileSync(path.join(src, "SKILL.md"), "x");
    fs.mkdirSync(dest, { recursive: true });
    try {
      copySkillDirectory({ src, dest });
      throw new Error("expected throw");
    } catch (err) {
      expect((err as { code?: string }).code).toBe("SKILL_DEST_EXISTS");
    }
  });

  it("overwrites destination when force is true", () => {
    const tmp = mkTmp("copy-force");
    const src = path.join(tmp, "src");
    const dest = path.join(tmp, "dest");
    fs.mkdirSync(src, { recursive: true });
    fs.writeFileSync(path.join(src, "SKILL.md"), "new content");
    fs.mkdirSync(dest, { recursive: true });
    fs.writeFileSync(path.join(dest, "old.txt"), "old");
    copySkillDirectory({ src, dest, force: true });
    expect(fs.existsSync(path.join(dest, "old.txt"))).toBe(false);
    expect(fs.readFileSync(path.join(dest, "SKILL.md"), "utf8")).toBe("new content");
  });

  it("creates parent directories of dest as needed", () => {
    const tmp = mkTmp("copy-mkdir-parent");
    const src = path.join(tmp, "src");
    const dest = path.join(tmp, "nested", "deeper", "dest");
    fs.mkdirSync(src, { recursive: true });
    fs.writeFileSync(path.join(src, "SKILL.md"), "x");
    copySkillDirectory({ src, dest });
    expect(fs.existsSync(path.join(dest, "SKILL.md"))).toBe(true);
  });

  it("does not leave a tmp directory on success", () => {
    const tmp = mkTmp("copy-no-leftover");
    const src = path.join(tmp, "src");
    const dest = path.join(tmp, "dest");
    fs.mkdirSync(src, { recursive: true });
    fs.writeFileSync(path.join(src, "SKILL.md"), "x");
    copySkillDirectory({ src, dest });
    const parent = fs.readdirSync(tmp);
    const leftovers = parent.filter((n) => n.startsWith("dest.tmp-"));
    expect(leftovers).toEqual([]);
  });
});
```

- [ ] **Step 2: Run tests to verify they fail**

Run: `pnpm test test/scripts/migrate-legacy-skills.test.ts -t copySkillDirectory`

Expected: 5 tests fail with `copySkillDirectory is not a function`.

- [ ] **Step 3: Implement copySkillDirectory**

Append to `scripts/migrate-legacy-skills.mjs`:

```javascript
export function copySkillDirectory({ src, dest, force = false }) {
  if (fs.existsSync(dest)) {
    if (!force) {
      const err = new Error(`destination already exists: ${dest}`);
      err.code = "SKILL_DEST_EXISTS";
      throw err;
    }
    fs.rmSync(dest, { recursive: true, force: true });
  }
  const tmpDir = `${dest}.tmp-${crypto.randomBytes(4).toString("hex")}`;
  try {
    fs.mkdirSync(path.dirname(dest), { recursive: true });
    fs.cpSync(src, tmpDir, { recursive: true });
    fs.renameSync(tmpDir, dest);
  } catch (err) {
    try {
      fs.rmSync(tmpDir, { recursive: true, force: true });
    } catch {}
    throw err;
  }
}
```

- [ ] **Step 4: Run tests to verify they pass**

Run: `pnpm test test/scripts/migrate-legacy-skills.test.ts -t copySkillDirectory`

Expected: 5 `copySkillDirectory` tests pass.

- [ ] **Step 5: Commit**

```bash
git add scripts/migrate-legacy-skills.mjs test/scripts/migrate-legacy-skills.test.ts
git commit -m "feat(scripts): add copySkillDirectory with atomic tmp+rename"
```

---

## Task 7: runCli end-to-end

Wires the helpers together: parse args, resolve dirs, load manifest, scan legacy, detect existing sealclaw names, then iterate with the three-state manifest logic (skip / re-import / conflict). Prints summary line.

**Files:**
- Modify: `scripts/migrate-legacy-skills.mjs`
- Modify: `test/scripts/migrate-legacy-skills.test.ts`

- [ ] **Step 1: Write the failing tests**

Append to `test/scripts/migrate-legacy-skills.test.ts`:

```typescript
import { runCli } from "../../scripts/migrate-legacy-skills.mjs";

function makeWorkspace(tmp: string, spec: {
  active?: Array<{ dir: string; name: string; body?: string }>;
  custom?: Array<{ dir: string; name: string; body?: string }>;
}): string {
  const workspace = path.join(tmp, "workspaces", "default");
  if (spec.active) {
    const d = path.join(workspace, "active_skills");
    fs.mkdirSync(d, { recursive: true });
    for (const s of spec.active) {
      writeSkill(d, s.dir, `---\nname: ${s.name}\n---\n${s.body ?? ""}`);
    }
  }
  if (spec.custom) {
    const d = path.join(workspace, "customized_skills");
    fs.mkdirSync(d, { recursive: true });
    for (const s of spec.custom) {
      writeSkill(d, s.dir, `---\nname: ${s.name}\n---\n${s.body ?? ""}`);
    }
  }
  return workspace;
}

describe("runCli", () => {
  it("returns 1 when workspace dir is missing", async () => {
    const tmp = mkTmp("cli-missing-ws");
    const code = await runCli({
      argv: ["--workspace", path.join(tmp, "nope"), "--state-dir", tmp],
      env: {},
    });
    expect(code).toBe(1);
  });

  it("returns 1 when both source dirs are missing", async () => {
    const tmp = mkTmp("cli-missing-sources");
    const workspace = path.join(tmp, "workspaces", "default");
    fs.mkdirSync(workspace, { recursive: true });
    const code = await runCli({
      argv: ["--workspace", workspace, "--state-dir", tmp],
      env: {},
    });
    expect(code).toBe(1);
  });

  it("imports a new skill and writes manifest", async () => {
    const tmp = mkTmp("cli-import");
    const workspace = makeWorkspace(tmp, { active: [{ dir: "alpha", name: "alpha" }] });
    const code = await runCli({
      argv: ["--workspace", workspace, "--state-dir", tmp],
      env: {},
    });
    expect(code).toBe(0);
    expect(fs.existsSync(path.join(tmp, "skills", "alpha", "SKILL.md"))).toBe(true);
    const manifest = loadManifest(
      path.join(tmp, "skills", ".legacy-skills-import-manifest.json"),
    );
    expect(Object.keys(manifest.imported)).toEqual(["alpha"]);
  });

  it("is idempotent: second run reports 0 imports", async () => {
    const tmp = mkTmp("cli-idempotent");
    const workspace = makeWorkspace(tmp, { active: [{ dir: "alpha", name: "alpha" }] });
    await runCli({ argv: ["--workspace", workspace, "--state-dir", tmp], env: {} });
    const logs: string[] = [];
    const origLog = console.log;
    console.log = (msg: string) => logs.push(msg);
    try {
      await runCli({ argv: ["--workspace", workspace, "--state-dir", tmp], env: {} });
    } finally {
      console.log = origLog;
    }
    const summary = logs.find((l) => l.startsWith("legacy="));
    expect(summary).toMatch(/imported=0/);
    expect(summary).toMatch(/skipped=1/);
  });

  it("skips with conflict when name exists in sealclaw managed", async () => {
    const tmp = mkTmp("cli-conflict");
    const workspace = makeWorkspace(tmp, { active: [{ dir: "alpha", name: "alpha" }] });
    const sealclawManaged = path.join(tmp, "skills", "alpha");
    fs.mkdirSync(sealclawManaged, { recursive: true });
    fs.writeFileSync(path.join(sealclawManaged, "SKILL.md"), "---\nname: alpha\n---\n");
    const logs: string[] = [];
    const origLog = console.log;
    const origWarn = console.warn;
    console.log = (msg: string) => logs.push(msg);
    console.warn = (msg: string) => logs.push(msg);
    try {
      await runCli({ argv: ["--workspace", workspace, "--state-dir", tmp], env: {} });
    } finally {
      console.log = origLog;
      console.warn = origWarn;
    }
    const summary = logs.find((l) => l.startsWith("legacy="));
    expect(summary).toMatch(/imported=0/);
    expect(summary).toMatch(/conflict=1/);
  });

  it("customized overrides active in legacy when both define the same name", async () => {
    const tmp = mkTmp("cli-override");
    const workspace = makeWorkspace(tmp, {
      active: [{ dir: "a", name: "shared", body: "FROM_ACTIVE" }],
      custom: [{ dir: "市场", name: "shared", body: "FROM_CUSTOM" }],
    });
    await runCli({ argv: ["--workspace", workspace, "--state-dir", tmp], env: {} });
    const body = fs.readFileSync(
      path.join(tmp, "skills", "shared", "SKILL.md"),
      "utf8",
    );
    expect(body).toContain("FROM_CUSTOM");
    expect(body).not.toContain("FROM_ACTIVE");
  });

  it("--dry-run writes nothing", async () => {
    const tmp = mkTmp("cli-dry");
    const workspace = makeWorkspace(tmp, { active: [{ dir: "alpha", name: "alpha" }] });
    await runCli({
      argv: ["--workspace", workspace, "--state-dir", tmp, "--dry-run"],
      env: {},
    });
    expect(fs.existsSync(path.join(tmp, "skills", "alpha"))).toBe(false);
    expect(
      fs.existsSync(path.join(tmp, "skills", ".legacy-skills-import-manifest.json")),
    ).toBe(false);
  });

  it("re-imports when legacy SKILL.md mtime changes since last import", async () => {
    const tmp = mkTmp("cli-reimport");
    const workspace = makeWorkspace(tmp, {
      active: [{ dir: "alpha", name: "alpha", body: "v1" }],
    });
    await runCli({ argv: ["--workspace", workspace, "--state-dir", tmp], env: {} });

    const skillDirInLegacy = path.join(workspace, "active_skills", "alpha");
    fs.writeFileSync(
      path.join(skillDirInLegacy, "SKILL.md"),
      "---\nname: alpha\n---\nv2",
    );
    const future = new Date(Date.now() + 60_000);
    fs.utimesSync(skillDirInLegacy, future, future);

    const logs: string[] = [];
    const origLog = console.log;
    console.log = (msg: string) => logs.push(msg);
    try {
      await runCli({ argv: ["--workspace", workspace, "--state-dir", tmp], env: {} });
    } finally {
      console.log = origLog;
    }
    const summary = logs.find((l) => l.startsWith("legacy="));
    expect(summary).toMatch(/imported=1/);
    expect(summary).toMatch(/conflict=0/);
    const body = fs.readFileSync(
      path.join(tmp, "skills", "alpha", "SKILL.md"),
      "utf8",
    );
    expect(body).toContain("v2");
  });

  it("--force overrides manifest and re-imports", async () => {
    const tmp = mkTmp("cli-force");
    const workspace = makeWorkspace(tmp, { active: [{ dir: "alpha", name: "alpha" }] });
    await runCli({ argv: ["--workspace", workspace, "--state-dir", tmp], env: {} });
    const logs: string[] = [];
    const origLog = console.log;
    console.log = (msg: string) => logs.push(msg);
    try {
      await runCli({
        argv: ["--workspace", workspace, "--state-dir", tmp, "--force"],
        env: {},
      });
    } finally {
      console.log = origLog;
    }
    const summary = logs.find((l) => l.startsWith("legacy="));
    expect(summary).toMatch(/imported=1/);
  });

  it("refuses to run when manifest is corrupt unless --force", async () => {
    const tmp = mkTmp("cli-corrupt");
    const workspace = makeWorkspace(tmp, { active: [{ dir: "alpha", name: "alpha" }] });
    const skillsDir = path.join(tmp, "skills");
    fs.mkdirSync(skillsDir, { recursive: true });
    fs.writeFileSync(
      path.join(skillsDir, ".legacy-skills-import-manifest.json"),
      "{not json",
    );
    const code = await runCli({
      argv: ["--workspace", workspace, "--state-dir", tmp],
      env: {},
    });
    expect(code).toBe(1);
    const forceCode = await runCli({
      argv: ["--workspace", workspace, "--state-dir", tmp, "--force"],
      env: {},
    });
    expect(forceCode).toBe(0);
  });
});
```

- [ ] **Step 2: Run tests to verify they fail**

Run: `pnpm test test/scripts/migrate-legacy-skills.test.ts -t runCli`

Expected: 10 tests fail with `runCli is not a function`.

- [ ] **Step 3: Implement runCli**

Append to `scripts/migrate-legacy-skills.mjs`:

```javascript
function parseArgs(argv) {
  const out = {
    workspace: null,
    stateDir: null,
    dryRun: false,
    force: false,
    verbose: false,
  };
  for (let i = 0; i < argv.length; i++) {
    const a = argv[i];
    switch (a) {
      case "--workspace":
        out.workspace = argv[++i];
        break;
      case "--state-dir":
        out.stateDir = argv[++i];
        break;
      case "--dry-run":
        out.dryRun = true;
        break;
      case "--force":
        out.force = true;
        break;
      case "--verbose":
        out.verbose = true;
        break;
      default:
        throw new Error(`unknown arg: ${a}`);
    }
  }
  return out;
}

function resolveStateDir(env, override) {
  if (override) return override;
  const explicit = env.OPENCLAW_STATE_DIR;
  if (explicit) return explicit;
  const home = env.HOME || env.USERPROFILE || os.homedir();
  return path.join(home, ".sealseek");
}

export async function runCli({ argv, env }) {
  const args = parseArgs(argv);
  const stateDir = resolveStateDir(env, args.stateDir);
  const workspaceDir = args.workspace ?? path.join(stateDir, "workspaces", "default");
  const skillsDir = path.join(stateDir, "skills");
  const manifestPath = path.join(skillsDir, ".legacy-skills-import-manifest.json");

  if (!fs.existsSync(workspaceDir)) {
    console.error(`workspace not found: ${workspaceDir}`);
    return 1;
  }
  const activeDir = path.join(workspaceDir, "active_skills");
  const customDir = path.join(workspaceDir, "customized_skills");
  if (!fs.existsSync(activeDir) && !fs.existsSync(customDir)) {
    console.error(
      `neither active_skills/ nor customized_skills/ found under ${workspaceDir}`,
    );
    return 1;
  }

  let manifest;
  try {
    manifest = loadManifest(manifestPath);
  } catch (err) {
    if (err.code !== "LEGACY_SKILLS_MANIFEST_CORRUPT" || !args.force) {
      console.error(
        err.message +
          (err.code === "LEGACY_SKILLS_MANIFEST_CORRUPT"
            ? " (rerun with --force to overwrite)"
            : ""),
      );
      return 1;
    }
    console.error(`${err.message} — proceeding because --force is set`);
    manifest = { version: 1, imported: {} };
  }

  const legacy = scanLegacySkills({ workspaceDir });
  const existing = collectExistingSealclawSkillNames({ stateDir });

  let imported = 0;
  let skipped = 0;
  let conflict = 0;
  let failed = 0;
  const newManifestRecords = [];

  for (const [name, entry] of legacy) {
    const prior = manifest.imported[name];
    const manifestUnchanged =
      prior &&
      prior.sourceDir === entry.sourceDir &&
      prior.sourceMtimeMs === entry.sourceMtimeMs;
    if (!args.force && manifestUnchanged) {
      if (args.verbose) console.log(`skip ${name} (manifest unchanged)`);
      skipped++;
      continue;
    }
    const isReimport = Boolean(prior);
    if (!isReimport && existing.has(name)) {
      console.warn(`skip ${name}: already in sealclaw`);
      conflict++;
      continue;
    }

    const destDir = path.join(skillsDir, name);
    if (args.dryRun) {
      if (args.verbose) console.log(`plan ${name} -> ${destDir}`);
      imported++;
      continue;
    }

    try {
      copySkillDirectory({
        src: entry.sourceDir,
        dest: destDir,
        force: isReimport || args.force,
      });
    } catch (err) {
      console.error(`fail ${name}: ${err.message}`);
      failed++;
      continue;
    }

    newManifestRecords.push({
      name,
      record: {
        sourceDir: entry.sourceDir,
        sourceMtimeMs: entry.sourceMtimeMs,
        destDir,
        importedAt: Date.now(),
      },
    });
    imported++;
    if (args.verbose) console.log(`import ${name} -> ${destDir}`);
  }

  if (!args.dryRun && newManifestRecords.length > 0) {
    for (const r of newManifestRecords) {
      recordImport({ manifestPath, name: r.name, record: r.record });
    }
  }

  console.log(
    `legacy=${legacy.size} imported=${imported} skipped=${skipped} conflict=${conflict} failed=${failed} dry=${args.dryRun ? "true" : "false"}`,
  );
  return 0;
}

const invokedDirect = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
if (invokedDirect) {
  runCli({ argv: process.argv.slice(2), env: process.env })
    .then((code) => process.exit(code ?? 0))
    .catch((err) => {
      console.error(err?.stack ?? String(err));
      process.exit(1);
    });
}
```

- [ ] **Step 4: Run all script tests to verify they pass**

Run: `pnpm test test/scripts/migrate-legacy-skills.test.ts`

Expected: All tests pass (manifest helpers + parseFrontmatterName + readSkillEntry + scanLegacySkills + collectExistingSealclawSkillNames + copySkillDirectory + runCli — roughly 50 tests total).

- [ ] **Step 5: Commit**

```bash
git add scripts/migrate-legacy-skills.mjs test/scripts/migrate-legacy-skills.test.ts
git commit -m "feat(scripts): add runCli orchestration for migrate-legacy-skills"
```

---

## Task 8: Real-data dry run + real run

Run the script against the actual legacy workspace, verify the numbers match expectations, then do the real import.

- [ ] **Step 1: Dry-run against real workspace**

Run: `node scripts/migrate-legacy-skills.mjs --dry-run --verbose`

Expected output:
- `legacy=N` where N is between 25 and 30 (active_skills has 28 entries; customized_skills overrides 6 of them but adds nothing new)
- `conflict≥4` because sealclaw workspace already has `keyword-assistant`, `keyword-traffic`, `market-analysis`, `market-trend`
- `imported=` (legacy − conflict)
- `skipped=0 failed=0 dry=true`
- Per-skill lines show plan targets under `~/.sealseek/skills/`

If `conflict` count looks wrong: inspect the verbose output to confirm the right skills are being flagged.

- [ ] **Step 2: Real run**

Run: `node scripts/migrate-legacy-skills.mjs --verbose`

Expected: same numbers as dry-run, but `dry=false` and `~/.sealseek/skills/` is now populated. Manifest exists at `~/.sealseek/skills/.legacy-skills-import-manifest.json`.

Spot-check: `ls ~/.sealseek/skills/` shows directories named after frontmatter names (e.g. `work-browser`, `taobao-item`, `image-understanding`). One imported skill should have nested artifacts (`work-browser/run.sh`, `work-browser/profiles.json`).

- [ ] **Step 3: Idempotency check**

Run: `node scripts/migrate-legacy-skills.mjs`

Expected: `imported=0 skipped=N conflict=C failed=0 dry=false` where `N` matches the previous `imported` count.

- [ ] **Step 4: Open sealclaw and verify**

Launch sealclaw, open the skill list UI, confirm:
- New skills appear (e.g. `work-browser`, `taobao-item`)
- Existing skills (`market-analysis` etc.) are not duplicated
- No transcript / load errors in `~/.sealseek/sealclaw.log` or `~/.sealseek/runtime.log` related to the new skill directories

If anything looks wrong, the import is rolled back by simply removing the offending dirs under `~/.sealseek/skills/` (and the corresponding manifest entries).

---

## Notes

- **Node version:** This script uses `fs.cpSync` (added Node 16.7). The repo requires Node ≥22, so no compatibility shim is needed.
- **No new dependencies.** Everything uses the Node standard library.
- **No npm script entry.** This is a one-shot personal migration tool. If we later need it part of `pnpm` lifecycle, add a wrapper in `scripts/CLAUDE.md` style; for now, invoke directly with `node`.
- **No backups required.** The destination `~/.sealseek/skills/` did not exist before this script ran. If the user wants to roll back, removing that whole directory restores the prior state. Manifest tracks what was written so partial rollback is trivial.
- **Test perf:** Vitest tests use per-test tmpdirs (no shared fixtures). Each test cleans up implicitly via OS tmp eviction; we do not register cleanup hooks because that pattern is already established in `migrate-legacy-sessions.test.ts`.
- **`pnpm test:changed` lane:** This change touches `scripts/` and `test/scripts/` only. Per the root `CLAUDE.md` lane rules, this counts as "core tests" + "extension tests" only if scripts under test are part of broader test infra. `migrate-legacy-skills.mjs` is a standalone tool, so the lane gate should only run its own test file. Use `pnpm test test/scripts/migrate-legacy-skills.test.ts` for the targeted verify, and `pnpm test:changed` for the broader gate before pushing.
