/** * Skills migration helpers — legacy XDG store → `~/.cleo/skills/` (SSoT). * * @remarks * Implements the filesystem half of `cleo skills migrate` (T9653; relocated * from caamp to CORE under T9741 so the citty CLI + dispatch chain can call * it directly without a cross-package adapter). Pure functions only — every * side effect is parameterised so the CLI handler and tests can drive * identical code paths against tmpdirs. * * Migration shape (per architecture-v3.md §1): * * 1. **Detect** legacy install at `~/.local/share/agents/skills/`. * 2. **Plan** the set of skill directories to copy into `~/.cleo/skills/` * (skipping any name already present at the new root — first-wins). * 3. **Backup** the entire legacy tree to a timestamped tgz under * `~/.cleo/backups/skills/skills-pre-migrate-.tgz` BEFORE any write. * 4. **Copy** (recursively) each planned entry from legacy → canonical. * 5. **Marker** writes a `.MIGRATED-TO-CLEO` sentinel into the legacy root so * re-runs detect the already-migrated state and become a no-op. The legacy * tree itself is left intact for one release cycle as a safety net. * * Rollback (`--rollback`) restores from the most recent backup tarball by * extracting it back over `~/.local/share/agents/skills/` and removing the * sentinel. * * Side-effect injection: `now` (timestamp) and `tarExec` (child-process * wrapper) are parameters so tests can deterministically drive the planner + * runner. The default `tarExec` shells out to the system `tar` binary; tests * supply an in-memory fake. * * @task T9653 * @epic T9571 * @saga T9560 * @architecture docs/architecture/SG-CLEO-SKILLS-architecture-v3.md §1 */ /** * Filename of the legacy-root sentinel written after a successful migration. * * @remarks * The presence of this file in the legacy skills directory short-circuits * subsequent migrate runs to a no-op. It is also removed by `--rollback`. * * @public */ export declare const LEGACY_MIGRATED_MARKER = ".MIGRATED-TO-CLEO"; /** * Absolute path of the legacy XDG canonical skills directory. * * @remarks * Pre-v3 installs stored canonical and user skills at * `~/.local/share/agents/skills/`. Post-T9746 this constant is the ONE place * in the entire codebase that knows about that legacy location — only the * migrator (`cleo skills migrate`) reads from it. Production resolvers * (`resolveSkillsRoot()`, `is_canonical()`) NEVER consult this path. * * @public */ export declare const LEGACY_AGENTS_SKILLS_DIR: string; /** * Provenance row that the migrator emits per migrated skill directory. * * @remarks * The CLI handler funnels these into a callback (`recordRow`) so that the * skills.db upsert site stays opt-in — colocated callers can wire * `upsertSkillRow` from `../store/skills-db.js` directly, while tests can * substitute a fake sink. See architecture-v3.md §4 for the matching * `skills` table schema. * * @public */ export interface MigratedSkillRecord { /** Skill folder basename (matches `skills.name` column). */ name: string; /** Resolved destination path under `~/.cleo/skills/`. */ installPath: string; /** Resolved source path under the legacy XDG store. */ legacyPath: string; /** `canonical` when the basename matches the bundled manifest, else `user`. */ sourceType: 'canonical' | 'user' | 'community'; } /** * Provenance row for skills that were SKIPPED during planning. * * @remarks * The most common skip reason is `'already-present'` — the same directory * name already exists under `~/.cleo/skills/`. We never overwrite a * pre-existing target because the canonical store may have been touched by * a newer install path. Returned to callers so they can present the user * with an actionable summary (and optionally remove dupes by hand). * * @public */ export interface SkippedSkillRecord { /** Skill folder basename. */ name: string; /** Reason for skipping. */ reason: 'already-present' | 'not-a-directory'; /** Resolved legacy path that triggered the skip. */ legacyPath: string; } /** * Aggregated outcome of a planning or migration pass. * * @remarks * `backupPath` is `null` when running in dry-run mode (no archive is * produced) or when there are no entries to migrate. `durationMs` is wall * clock from the start of {@link runMigration} until the marker write. * * @public */ export interface MigrationOutcome { /** Action that was performed — `'migrate'`, `'dry-run'`, `'rollback'`, `'no-op'`. */ action: 'migrate' | 'dry-run' | 'rollback' | 'no-op'; /** Entries that were (or would be) copied across. */ migrated: MigratedSkillRecord[]; /** Entries that were SKIPPED with reasons. */ skipped: SkippedSkillRecord[]; /** Path to the produced backup archive, or `null` on dry-run / no-op. */ backupPath: string | null; /** Resolved legacy root the migrator was driven against. */ legacyRoot: string; /** Resolved destination root the migrator was driven against. */ canonicalRoot: string; /** Wall-clock duration in milliseconds. */ durationMs: number; } /** * Inputs every migration helper accepts. * * @remarks * All filesystem roots and side effects are parameterised so tests can wire * tmpdirs + fakes without monkey-patching `os.homedir()`. Production callers * use {@link defaultMigrationOptions} to fill the defaults. * * @public */ export interface MigrationOptions { /** Legacy skills directory (default: `~/.local/share/agents/skills`). */ legacyRoot: string; /** Destination canonical skills directory (default: `~/.cleo/skills`). */ canonicalRoot: string; /** Backup directory (default: `~/.cleo/backups/skills`). */ backupDir: string; /** Names of canonical skills from the bundled manifest (sphere-A discriminator). */ manifestNames: string[]; /** Wall-clock injection for testability. Defaults to `() => new Date()`. */ now?: () => Date; /** * `tar` wrapper for testability. Default implementation shells out to the * system `tar` binary; tests pass an in-memory fake. */ tarExec?: TarExec; /** * Per-row sink invoked after each successful copy. The CORE handler for * `tools.skill.migrate` (T9742) plugs `upsertSkillRow` here so the * skills.db registry is kept in lock-step with the on-disk copy. * Defaults to a no-op. */ recordRow?: (row: MigratedSkillRecord) => Promise | void; } /** * Shape of the optional tar wrapper used by {@link MigrationOptions}. * * @remarks * Tests can swap the default `execFile`-backed implementation for a pure * in-memory fake. The functions return on success and throw on failure * (mirroring `execFileAsync` semantics). * * @public */ export interface TarExec { /** Create a tgz at `archivePath` containing the contents of `sourceRoot`. */ create(args: { archivePath: string; sourceRoot: string; }): Promise; /** Extract a tgz from `archivePath` back into `destinationRoot`. */ extract(args: { archivePath: string; destinationRoot: string; }): Promise; } /** * Build a {@link MigrationOptions} bag populated with production defaults. * * @remarks * Reads `$HOME` via {@link homedir} and wires the canonical paths described * in architecture-v3.md §1. Callers that want to override only some fields * spread the result, e.g. `{ ...defaultMigrationOptions(names), recordRow }`. * * @param manifestNames - Canonical-skill names parsed from the bundled * manifest (`packages/skills/manifest.json`). Used to discriminate * `source_type` for the {@link MigratedSkillRecord} payload. * @returns A complete options bag for the migration helpers. * * @public */ export declare function defaultMigrationOptions(manifestNames: string[]): MigrationOptions; /** * Default {@link TarExec} that shells out to the system `tar` binary. * * @remarks * Uses `-czf` for create and `-xzf` for extract — the same flags the rest of * the CLEO backup pipeline (e.g. `cleo backup add`) relies on. Surfaces * stderr in thrown errors so callers can debug missing dependencies. * * @public */ export declare const systemTarExec: TarExec; /** * Detect whether the legacy XDG store has already been migrated. * * @remarks * Returns `true` when the legacy root either does not exist OR contains the * {@link LEGACY_MIGRATED_MARKER} sentinel. The CLI handler uses this to * short-circuit to a `'no-op'` outcome on idempotent re-runs. * * @param legacyRoot - Absolute path to the legacy skills directory. * @returns `true` when no migration work is needed. * * @public */ export declare function isAlreadyMigrated(legacyRoot: string): boolean; /** * Enumerate the top-level skill directories under a given root. * * @remarks * Filters out the migration sentinel and any non-directory entries (the * legacy tree historically also contained stray lock files). Returned in * stable lexicographic order so plans are reproducible across runs. * * @param root - Directory to scan; if missing, returns an empty array. * @returns Skill folder basenames in lexicographic order. * * @public */ export declare function listSkillDirs(root: string): string[]; /** * Plan a migration without performing any writes. * * @remarks * Used both as the engine for `--dry-run` and as the first step of a real * migration. Resolves which entries will be copied vs skipped based on * presence-at-destination. The returned outcome carries `action: 'dry-run'` * and `durationMs` reflecting the planning cost only. * * @param options - Resolved migration options (see {@link MigrationOptions}). * @returns Outcome describing the planned migration. * * @public */ export declare function planMigration(options: MigrationOptions): MigrationOutcome; /** * Format a UTC timestamp suitable for backup filenames. * * @remarks * Produces `YYYYMMDD-HHmmss` in UTC so the lexicographic order matches * chronological order. Exposed publicly so tests and downstream callers can * generate matching filenames without re-implementing the format. * * @param now - The `Date` to format. * @returns A filename-safe timestamp string. * * @public */ export declare function formatBackupTimestamp(now: Date): string; /** * List backup tarballs in chronological order (newest first). * * @remarks * Used by `--rollback` to pick the most recent archive. Returns absolute * paths so callers do not need to know the backup directory layout. * * @param backupDir - Directory that holds `skills-pre-migrate-*.tgz`. * @returns Absolute paths, newest first; empty when the directory is empty. * * @public */ export declare function listBackups(backupDir: string): string[]; /** * Perform a real migration from legacy → canonical, leaving a sentinel * behind in the legacy root so subsequent runs are no-ops. * * @remarks * Steps performed (in order): * * 1. Short-circuit to `{action:'no-op'}` if the sentinel already exists. * 2. Compute the plan via {@link planMigration} (read-only). * 3. Create `~/.cleo/skills/` and the backup dir. * 4. Tar+gzip the entire legacy tree into the timestamped backup path. * 5. `cp -a` each planned entry (recursive, preserves symlinks + mtime). * 6. Invoke `recordRow` for each migrated entry (db side-effect hook). * 7. Write the sentinel into the legacy root. * * On error before step 7 the partial state is left in place — re-running * after fixing the error will pick up any entries that weren't copied * (idempotent at the directory level: existing destinations are skipped). * * @param options - Resolved migration options. * @returns Outcome describing the work performed. * * @public */ export declare function runMigration(options: MigrationOptions): Promise; /** * Restore the legacy skills tree from the most recent backup tarball. * * @remarks * Steps performed (in order): * * 1. Locate the newest tarball via {@link listBackups}; error if none. * 2. Delete the existing legacy root (including the sentinel) so the * extract lands in a clean directory. * 3. Extract the tarball back over the legacy root. * * Rollback is intentionally non-destructive toward the new `~/.cleo/skills/` * directory: copies that were already made remain in place. The next migrate * call will short-circuit any names that already exist at the destination * (idempotent overlap handling). * * @param options - Resolved migration options. * @returns Outcome describing the rollback. * @throws If no backup tarballs are available under `options.backupDir`. * * @public */ export declare function runRollback(options: MigrationOptions): Promise; //# sourceMappingURL=migration.d.ts.map