Browse Source

Sync bundled skills after auto-update

Alvin Unreal 2 months ago
parent
commit
bdd1848850

+ 6 - 0
docs/installation.md

@@ -98,6 +98,12 @@ bunx oh-my-opencode-slim@latest install --reset
 
 The installer generates both OpenAI and OpenCode Go presets, with OpenAI active by default (using variant-aware `gpt-5.5` and `gpt-5.4-mini` models, including `gpt-5.5 (medium)` for Orchestrator, `gpt-5.5 (high)` for Oracle, `gpt-5.5 (low)` for Fixer, and `gpt-5.4-mini` variants for other specialists). To make OpenCode Go active during install, run `bunx oh-my-opencode-slim@latest install --preset=opencode-go`. That preset uses GLM-5.1 for Orchestrator, so the installer also enables Observer with `opencode-go/kimi-k2.6` for visual analysis. To switch providers later or build a mixed setup, use **[Configuration Reference](configuration.md)** for the full option reference and the preset docs for copyable examples.
 
+When auto-update successfully installs a newer package version, it also copies
+new bundled skills from that updated package into your OpenCode skills directory
+if they are missing. This is additive only: existing skill folders are skipped,
+and skills are never removed automatically. Restart OpenCode after an auto-update
+to load the updated plugin and any newly copied skills.
+
 Then:
 
 ```bash

+ 102 - 0
src/hooks/auto-update-checker/index.test.ts

@@ -21,6 +21,14 @@ const cacheMocks = {
   resolveInstallContext: mock(() => ({ installDir: '/tmp/opencode' })),
 };
 
+const skillSyncMocks = {
+  syncBundledSkillsFromPackage: mock(() => ({
+    installed: [],
+    skippedExisting: [],
+    failed: [],
+  })),
+};
+
 const crossSpawnMock = mock((_command: string[]) => ({
   exited: Promise.resolve(0),
   exitCode: 0,
@@ -38,6 +46,8 @@ mock.module('./checker', () => checkerMocks);
 
 mock.module('./cache', () => cacheMocks);
 
+mock.module('./skill-sync', () => skillSyncMocks);
+
 mock.module('../../utils/compat', () => ({
   crossSpawn: crossSpawnMock,
   crossWrite: mock(() => Promise.resolve()),
@@ -114,6 +124,13 @@ describe('auto-update-checker/index', () => {
       stderr: () => Promise.resolve(''),
       proc: {} as never,
     }));
+
+    skillSyncMocks.syncBundledSkillsFromPackage.mockReset();
+    skillSyncMocks.syncBundledSkillsFromPackage.mockImplementation(() => ({
+      installed: [],
+      skippedExisting: [],
+      failed: [],
+    }));
   });
 
   afterEach(() => {
@@ -183,6 +200,82 @@ describe('auto-update-checker/index', () => {
       ['bun', 'install'],
       expect.objectContaining({ cwd: '/tmp/opencode' }),
     );
+    expect(skillSyncMocks.syncBundledSkillsFromPackage).toHaveBeenCalledWith(
+      '/tmp/opencode/node_modules/oh-my-opencode-slim',
+    );
+    expect(showToast).toHaveBeenCalledWith({
+      body: {
+        title: 'OMO-Slim Updated!',
+        message: 'v0.9.1 → v0.9.11\nRestart OpenCode to apply.',
+        variant: 'success',
+        duration: 8000,
+      },
+    });
+  });
+
+  test('includes newly installed bundled skills in success toast', async () => {
+    checkerMocks.findPluginEntry.mockImplementation(() => ({
+      pinnedVersion: null,
+      isPinned: false,
+    }));
+    checkerMocks.getCachedVersion.mockImplementation(() => '0.9.1');
+    checkerMocks.getLatestCompatibleVersion.mockImplementation(async () => ({
+      latestVersion: '0.9.11',
+      latestMajorVersion: null,
+      blockedByMajor: false,
+    }));
+    skillSyncMocks.syncBundledSkillsFromPackage.mockImplementation(() => ({
+      installed: ['reflect', 'worktrees'],
+      skippedExisting: ['codemap'],
+      failed: [],
+    }));
+
+    const { createAutoUpdateCheckerHook } = await import(
+      `./index?test=${importCounter++}`
+    );
+    const { ctx, showToast } = createCtx();
+
+    const hook = createAutoUpdateCheckerHook(ctx as never);
+    hook.event({ event: { type: 'session.created', properties: {} } });
+    await waitForCalls(showToast);
+
+    expect(showToast).toHaveBeenCalledWith({
+      body: {
+        title: 'OMO-Slim Updated!',
+        message:
+          'v0.9.1 → v0.9.11\nAdded bundled skills: reflect, worktrees\nRestart OpenCode to apply.',
+        variant: 'success',
+        duration: 8000,
+      },
+    });
+  });
+
+  test('still reports update success when bundled skill sync has failures', async () => {
+    checkerMocks.findPluginEntry.mockImplementation(() => ({
+      pinnedVersion: null,
+      isPinned: false,
+    }));
+    checkerMocks.getCachedVersion.mockImplementation(() => '0.9.1');
+    checkerMocks.getLatestCompatibleVersion.mockImplementation(async () => ({
+      latestVersion: '0.9.11',
+      latestMajorVersion: null,
+      blockedByMajor: false,
+    }));
+    skillSyncMocks.syncBundledSkillsFromPackage.mockImplementation(() => ({
+      installed: [],
+      skippedExisting: [],
+      failed: ['reflect'],
+    }));
+
+    const { createAutoUpdateCheckerHook } = await import(
+      `./index?test=${importCounter++}`
+    );
+    const { ctx, showToast } = createCtx();
+
+    const hook = createAutoUpdateCheckerHook(ctx as never);
+    hook.event({ event: { type: 'session.created', properties: {} } });
+    await waitForCalls(showToast);
+
     expect(showToast).toHaveBeenCalledWith({
       body: {
         title: 'OMO-Slim Updated!',
@@ -191,6 +284,9 @@ describe('auto-update-checker/index', () => {
         duration: 8000,
       },
     });
+    expect(logMock).toHaveBeenCalledWith(
+      '[auto-update-checker] Skill sync warnings/failures: reflect',
+    );
   });
 
   test('shows notification-only toast when auto-update is disabled', async () => {
@@ -226,6 +322,7 @@ describe('auto-update-checker/index', () => {
     });
     expect(cacheMocks.preparePackageUpdate).not.toHaveBeenCalled();
     expect(crossSpawnMock).not.toHaveBeenCalled();
+    expect(skillSyncMocks.syncBundledSkillsFromPackage).not.toHaveBeenCalled();
   });
 
   test('shows prepare failure toast and skips installation when active install cannot be resolved', async () => {
@@ -251,6 +348,7 @@ describe('auto-update-checker/index', () => {
     await waitForCalls(showToast);
 
     expect(crossSpawnMock).not.toHaveBeenCalled();
+    expect(skillSyncMocks.syncBundledSkillsFromPackage).not.toHaveBeenCalled();
     expect(showToast).toHaveBeenCalledWith({
       body: {
         title: 'OMO-Slim 0.9.11',
@@ -296,6 +394,7 @@ describe('auto-update-checker/index', () => {
       ['bun', 'install'],
       expect.objectContaining({ cwd: '/tmp/opencode' }),
     );
+    expect(skillSyncMocks.syncBundledSkillsFromPackage).not.toHaveBeenCalled();
     expect(showToast).toHaveBeenCalledWith({
       body: {
         title: 'OMO-Slim 0.9.11',
@@ -339,6 +438,7 @@ describe('auto-update-checker/index', () => {
     });
     expect(cacheMocks.preparePackageUpdate).not.toHaveBeenCalled();
     expect(crossSpawnMock).not.toHaveBeenCalled();
+    expect(skillSyncMocks.syncBundledSkillsFromPackage).not.toHaveBeenCalled();
   });
 
   test('shows only migration toast when compatible and blocked major updates coexist', async () => {
@@ -370,6 +470,7 @@ describe('auto-update-checker/index', () => {
     });
     expect(cacheMocks.preparePackageUpdate).not.toHaveBeenCalled();
     expect(crossSpawnMock).not.toHaveBeenCalled();
+    expect(skillSyncMocks.syncBundledSkillsFromPackage).not.toHaveBeenCalled();
   });
 
   test('does not show migration copy for unparseable current versions', async () => {
@@ -406,5 +507,6 @@ describe('auto-update-checker/index', () => {
     });
     expect(cacheMocks.preparePackageUpdate).not.toHaveBeenCalled();
     expect(crossSpawnMock).not.toHaveBeenCalled();
+    expect(skillSyncMocks.syncBundledSkillsFromPackage).not.toHaveBeenCalled();
   });
 });

+ 27 - 7
src/hooks/auto-update-checker/index.ts

@@ -1,3 +1,4 @@
+import * as path from 'node:path';
 import type { PluginInput } from '@opencode-ai/plugin';
 import { crossSpawn } from '../../utils/compat';
 import { log } from '../../utils/logger';
@@ -10,6 +11,7 @@ import {
   getLocalDevVersion,
 } from './checker';
 import { CACHE_DIR, PACKAGE_NAME } from './constants';
+import { syncBundledSkillsFromPackage } from './skill-sync';
 import type { AutoUpdateCheckerOptions } from './types';
 
 /**
@@ -163,13 +165,31 @@ async function runBackgroundUpdateCheck(
   const installSuccess = await runBunInstallSafe(installDir);
 
   if (installSuccess) {
-    showToast(
-      ctx,
-      'OMO-Slim Updated!',
-      `v${currentVersion} → v${latestVersion}\nRestart OpenCode to apply.`,
-      'success',
-      8000,
-    );
+    let installedSkills: string[] = [];
+    try {
+      const packageRoot = path.join(installDir, 'node_modules', PACKAGE_NAME);
+      const syncResult = syncBundledSkillsFromPackage(packageRoot);
+      installedSkills = syncResult.installed;
+      if (syncResult.failed.length > 0) {
+        log(
+          `[auto-update-checker] Skill sync warnings/failures: ${syncResult.failed.join(', ')}`,
+        );
+      }
+      if (syncResult.skippedExisting.length > 0) {
+        log(
+          `[auto-update-checker] Skill sync skipped existing: ${syncResult.skippedExisting.join(', ')}`,
+        );
+      }
+    } catch (err) {
+      log('[auto-update-checker] Skill sync failed silently:', err);
+    }
+
+    let message = `v${currentVersion} → v${latestVersion}\nRestart OpenCode to apply.`;
+    if (installedSkills.length > 0) {
+      message = `v${currentVersion} → v${latestVersion}\nAdded bundled skills: ${installedSkills.join(', ')}\nRestart OpenCode to apply.`;
+    }
+
+    showToast(ctx, 'OMO-Slim Updated!', message, 'success', 8000);
     log(
       `[auto-update-checker] Update installed: ${currentVersion} → ${latestVersion}`,
     );

+ 270 - 0
src/hooks/auto-update-checker/skill-sync.test.ts

@@ -0,0 +1,270 @@
+import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
+import * as fs from 'node:fs';
+import * as os from 'node:os';
+import * as path from 'node:path';
+
+let importCounter = 0;
+
+async function syncBundledSkillsFromPackage(packageRoot: string) {
+  const module = await import(`./skill-sync?test=${importCounter++}`);
+  return module.syncBundledSkillsFromPackage(packageRoot);
+}
+
+describe('syncBundledSkillsFromPackage', () => {
+  let tempDir: string;
+  let fakePackageRoot: string;
+  let fakeDestConfigDir: string;
+  let origEnvConfigDir: string | undefined;
+
+  beforeEach(() => {
+    origEnvConfigDir = process.env.OPENCODE_CONFIG_DIR;
+    // Create a unique temporary directory for this test run
+    const randomId = Math.random().toString(36).substring(2, 10);
+    tempDir = path.join(os.tmpdir(), `omo-test-${randomId}`);
+    fs.mkdirSync(tempDir, { recursive: true });
+
+    fakePackageRoot = path.join(tempDir, 'fake-package');
+    fakeDestConfigDir = path.join(tempDir, 'fake-config');
+
+    fs.mkdirSync(path.join(fakePackageRoot, 'src', 'skills'), {
+      recursive: true,
+    });
+    fs.mkdirSync(fakeDestConfigDir, { recursive: true });
+
+    process.env.OPENCODE_CONFIG_DIR = fakeDestConfigDir;
+  });
+
+  afterEach(() => {
+    process.env.OPENCODE_CONFIG_DIR = origEnvConfigDir;
+    // Clean up temporary directories
+    try {
+      // Restore permissions of any potentially locked files first
+      const restorePermissions = (dir: string) => {
+        if (!fs.existsSync(dir)) return;
+        const entries = fs.readdirSync(dir);
+        for (const entry of entries) {
+          const entryPath = path.join(dir, entry);
+          try {
+            fs.chmodSync(entryPath, 0o777);
+          } catch {
+            // ignore
+          }
+          if (fs.statSync(entryPath).isDirectory()) {
+            restorePermissions(entryPath);
+          }
+        }
+      };
+      restorePermissions(tempDir);
+      fs.rmSync(tempDir, { recursive: true, force: true });
+    } catch {
+      // Ignore cleanup error
+    }
+  });
+
+  test('installs missing bundled skill directories from a fake package root', async () => {
+    const skillName = 'test-skill';
+    const skillSrcDir = path.join(fakePackageRoot, 'src', 'skills', skillName);
+    fs.mkdirSync(skillSrcDir, { recursive: true });
+    fs.writeFileSync(path.join(skillSrcDir, 'SKILL.md'), '# Test Skill');
+    fs.writeFileSync(path.join(skillSrcDir, 'some-file.txt'), 'hello world');
+
+    const result = await syncBundledSkillsFromPackage(fakePackageRoot);
+
+    expect(result.installed).toContain(skillName);
+    expect(result.skippedExisting).toHaveLength(0);
+    expect(result.failed).toHaveLength(0);
+
+    const destSkillDir = path.join(fakeDestConfigDir, 'skills', skillName);
+    expect(fs.existsSync(destSkillDir)).toBe(true);
+    expect(fs.readFileSync(path.join(destSkillDir, 'SKILL.md'), 'utf-8')).toBe(
+      '# Test Skill',
+    );
+    expect(
+      fs.readFileSync(path.join(destSkillDir, 'some-file.txt'), 'utf-8'),
+    ).toBe('hello world');
+  });
+
+  test('skips existing destination skill folders without overwriting', async () => {
+    const skillName = 'existing-skill';
+    const skillSrcDir = path.join(fakePackageRoot, 'src', 'skills', skillName);
+    fs.mkdirSync(skillSrcDir, { recursive: true });
+    fs.writeFileSync(path.join(skillSrcDir, 'SKILL.md'), '# Updated Skill');
+
+    const destSkillsDir = path.join(fakeDestConfigDir, 'skills');
+    fs.mkdirSync(destSkillsDir, { recursive: true });
+    const destSkillDir = path.join(destSkillsDir, skillName);
+    fs.mkdirSync(destSkillDir, { recursive: true });
+    fs.writeFileSync(path.join(destSkillDir, 'SKILL.md'), '# Original Skill');
+
+    const result = await syncBundledSkillsFromPackage(fakePackageRoot);
+
+    expect(result.installed).toHaveLength(0);
+    expect(result.skippedExisting).toContain(skillName);
+    expect(result.failed).toHaveLength(0);
+
+    // Should not have overwritten
+    expect(fs.readFileSync(path.join(destSkillDir, 'SKILL.md'), 'utf-8')).toBe(
+      '# Original Skill',
+    );
+  });
+
+  test('ignores non-skill directories without SKILL.md', async () => {
+    const skillName = 'no-skill-md';
+    const skillSrcDir = path.join(fakePackageRoot, 'src', 'skills', skillName);
+    fs.mkdirSync(skillSrcDir, { recursive: true });
+    fs.writeFileSync(path.join(skillSrcDir, 'other-file.txt'), 'hello');
+
+    const result = await syncBundledSkillsFromPackage(fakePackageRoot);
+
+    expect(result.installed).toHaveLength(0);
+    expect(result.skippedExisting).toHaveLength(0);
+    expect(result.failed).toHaveLength(0);
+
+    const destSkillDir = path.join(fakeDestConfigDir, 'skills', skillName);
+    expect(fs.existsSync(destSkillDir)).toBe(false);
+  });
+
+  test('records failures and continues on errors', async () => {
+    // We create one good skill and one bad/locked skill to cause failure.
+    // The good skill should still install.
+    const goodSkill = 'good-skill';
+    const goodSrcDir = path.join(fakePackageRoot, 'src', 'skills', goodSkill);
+    fs.mkdirSync(goodSrcDir, { recursive: true });
+    fs.writeFileSync(path.join(goodSrcDir, 'SKILL.md'), '# Good');
+
+    const badSkill = 'bad-skill';
+    const badSrcDir = path.join(fakePackageRoot, 'src', 'skills', badSkill);
+    fs.mkdirSync(badSrcDir, { recursive: true });
+    fs.writeFileSync(path.join(badSrcDir, 'SKILL.md'), '# Bad');
+
+    // We lock a nested file/dir or create a file inside staging with chmod 000
+    // Actually, making a nested directory unreadable inside badSrcDir will cause copyDirRecursive to fail
+    const unreadableDir = path.join(badSrcDir, 'locked-subdir');
+    fs.mkdirSync(unreadableDir, { recursive: true });
+    fs.writeFileSync(path.join(unreadableDir, 'secret.txt'), 'top secret');
+    fs.chmodSync(unreadableDir, 0o000);
+
+    const result = await syncBundledSkillsFromPackage(fakePackageRoot);
+
+    expect(result.installed).toContain(goodSkill);
+    expect(result.failed).toContain(badSkill);
+
+    // Staging and final bad-skill dir helper cleanup checks
+    const destSkillsDir = path.join(fakeDestConfigDir, 'skills');
+    const badDestDir = path.join(destSkillsDir, badSkill);
+    expect(fs.existsSync(badDestDir)).toBe(false);
+
+    // Verify no staging directories are left behind in destSkillsDir
+    const destEntries = fs.readdirSync(destSkillsDir);
+    const stagingDirs = destEntries.filter((entry) =>
+      entry.startsWith('.sync-staging-'),
+    );
+    expect(stagingDirs).toHaveLength(0);
+  });
+
+  test('missing source skills directory returns empty results and does not throw', async () => {
+    // Delete the source skills directory entirely
+    const sourceSkillsDir = path.join(fakePackageRoot, 'src', 'skills');
+    fs.rmSync(sourceSkillsDir, { recursive: true, force: true });
+
+    const result = await syncBundledSkillsFromPackage(fakePackageRoot);
+    expect(result.installed).toHaveLength(0);
+    expect(result.skippedExisting).toHaveLength(0);
+    expect(result.failed).toHaveLength(0);
+  });
+
+  test('creates destination skills parent directory when absent', async () => {
+    // Delete the fake-config directory completely so even the parent is missing
+    fs.rmSync(fakeDestConfigDir, { recursive: true, force: true });
+
+    const skillName = 'auto-create-parent';
+    const skillSrcDir = path.join(fakePackageRoot, 'src', 'skills', skillName);
+    fs.mkdirSync(skillSrcDir, { recursive: true });
+    fs.writeFileSync(path.join(skillSrcDir, 'SKILL.md'), '# Parent Created');
+
+    const result = await syncBundledSkillsFromPackage(fakePackageRoot);
+
+    expect(result.installed).toContain(skillName);
+    const destSkillDir = path.join(fakeDestConfigDir, 'skills', skillName);
+    expect(fs.existsSync(destSkillDir)).toBe(true);
+  });
+
+  test('existing destination file/symlink is skipped and not overwritten', async () => {
+    const skillName = 'file-blocking-skill';
+    const skillSrcDir = path.join(fakePackageRoot, 'src', 'skills', skillName);
+    fs.mkdirSync(skillSrcDir, { recursive: true });
+    fs.writeFileSync(path.join(skillSrcDir, 'SKILL.md'), '# Target');
+
+    const destSkillsDir = path.join(fakeDestConfigDir, 'skills');
+    fs.mkdirSync(destSkillsDir, { recursive: true });
+    const destSkillPath = path.join(destSkillsDir, skillName);
+
+    // Create a regular file in place of the skill directory
+    fs.writeFileSync(destSkillPath, 'I am a blocking file');
+
+    const result = await syncBundledSkillsFromPackage(fakePackageRoot);
+
+    expect(result.installed).toHaveLength(0);
+    expect(result.skippedExisting).toContain(skillName);
+    expect(result.failed).toHaveLength(0);
+
+    // Should still be the file, not a directory
+    expect(fs.lstatSync(destSkillPath).isFile()).toBe(true);
+    expect(fs.readFileSync(destSkillPath, 'utf-8')).toBe(
+      'I am a blocking file',
+    );
+  });
+
+  test('existing destination symlink is skipped and not overwritten', async () => {
+    const skillName = 'symlink-blocking-skill';
+    const skillSrcDir = path.join(fakePackageRoot, 'src', 'skills', skillName);
+    fs.mkdirSync(skillSrcDir, { recursive: true });
+    fs.writeFileSync(path.join(skillSrcDir, 'SKILL.md'), '# Target');
+
+    const destSkillsDir = path.join(fakeDestConfigDir, 'skills');
+    fs.mkdirSync(destSkillsDir, { recursive: true });
+    const symlinkTarget = path.join(fakeDestConfigDir, 'custom-skill-target');
+    fs.mkdirSync(symlinkTarget, { recursive: true });
+    fs.writeFileSync(path.join(symlinkTarget, 'SKILL.md'), '# Custom');
+    const destSkillPath = path.join(destSkillsDir, skillName);
+    fs.symlinkSync(symlinkTarget, destSkillPath, 'dir');
+
+    const result = await syncBundledSkillsFromPackage(fakePackageRoot);
+
+    expect(result.installed).toHaveLength(0);
+    expect(result.skippedExisting).toContain(skillName);
+    expect(result.failed).toHaveLength(0);
+    expect(fs.lstatSync(destSkillPath).isSymbolicLink()).toBe(true);
+    expect(fs.readFileSync(path.join(symlinkTarget, 'SKILL.md'), 'utf-8')).toBe(
+      '# Custom',
+    );
+  });
+
+  test('source symlink directories are ignored', async () => {
+    const realSkill = 'real-skill';
+    const realSrcDir = path.join(fakePackageRoot, 'src', 'skills', realSkill);
+    fs.mkdirSync(realSrcDir, { recursive: true });
+    fs.writeFileSync(path.join(realSrcDir, 'SKILL.md'), '# Real');
+
+    const symlinkSkill = 'symlink-skill';
+    const symlinkSrcDir = path.join(
+      fakePackageRoot,
+      'src',
+      'skills',
+      symlinkSkill,
+    );
+
+    // Create a symlink in source pointing to real-skill directory
+    fs.symlinkSync(realSrcDir, symlinkSrcDir, 'dir');
+
+    const result = await syncBundledSkillsFromPackage(fakePackageRoot);
+
+    expect(result.installed).toContain(realSkill);
+    expect(result.installed).not.toContain(symlinkSkill);
+    expect(result.skippedExisting).toHaveLength(0);
+    expect(result.failed).toHaveLength(0);
+
+    const destSkillDir = path.join(fakeDestConfigDir, 'skills', symlinkSkill);
+    expect(fs.existsSync(destSkillDir)).toBe(false);
+  });
+});

+ 178 - 0
src/hooks/auto-update-checker/skill-sync.ts

@@ -0,0 +1,178 @@
+import {
+  copyFileSync,
+  existsSync,
+  lstatSync,
+  mkdirSync,
+  mkdtempSync,
+  readdirSync,
+  renameSync,
+  rmSync,
+} from 'node:fs';
+import * as path from 'node:path';
+import { getConfigDir } from '../../cli/paths';
+import { log } from '../../utils/logger';
+
+export interface SkillSyncResult {
+  installed: string[];
+  skippedExisting: string[];
+  failed: string[];
+}
+
+/**
+ * Recursively copies src to dest. Does not follow/copy symbolic links.
+ */
+function copyDirRecursive(src: string, dest: string): void {
+  const stat = lstatSync(src);
+  if (stat.isSymbolicLink()) {
+    return;
+  }
+  if (stat.isDirectory()) {
+    mkdirSync(dest, { recursive: true });
+    const entries = readdirSync(src);
+    for (const entry of entries) {
+      copyDirRecursive(path.join(src, entry), path.join(dest, entry));
+    }
+  } else if (stat.isFile()) {
+    const destDir = path.dirname(dest);
+    if (!existsSync(destDir)) {
+      mkdirSync(destDir, { recursive: true });
+    }
+    copyFileSync(src, dest);
+  }
+}
+
+/**
+ * Synchronizes bundled skills from the newly installed package root to OpenCode config skills directory.
+ */
+export function syncBundledSkillsFromPackage(
+  packageRoot: string,
+): SkillSyncResult {
+  const installed: string[] = [];
+  const skippedExisting: string[] = [];
+  const failed: string[] = [];
+
+  const sourceSkillsDir = path.join(packageRoot, 'src', 'skills');
+
+  try {
+    const stat = lstatSync(sourceSkillsDir);
+    if (stat.isSymbolicLink() || !stat.isDirectory()) {
+      log(
+        `[skill-sync] Source skills directory is not a valid directory: ${sourceSkillsDir}`,
+      );
+      return { installed, skippedExisting, failed };
+    }
+  } catch {
+    log(
+      `[skill-sync] Source skills directory does not exist or is unreadable: ${sourceSkillsDir}`,
+    );
+    return { installed, skippedExisting, failed };
+  }
+
+  const destSkillsDir = path.join(getConfigDir(), 'skills');
+
+  try {
+    if (!existsSync(destSkillsDir)) {
+      mkdirSync(destSkillsDir, { recursive: true });
+    }
+  } catch (err) {
+    log(
+      `[skill-sync] Failed to create destination skills directory: ${destSkillsDir}`,
+      err,
+    );
+  }
+
+  let entries: string[] = [];
+  try {
+    entries = readdirSync(sourceSkillsDir);
+  } catch (err) {
+    log(
+      `[skill-sync] Failed to read source skills directory: ${sourceSkillsDir}`,
+      err,
+    );
+    return { installed, skippedExisting, failed };
+  }
+
+  for (const entry of entries) {
+    const entryPath = path.join(sourceSkillsDir, entry);
+    try {
+      if (entry.startsWith('.')) {
+        continue;
+      }
+
+      const entryStat = lstatSync(entryPath);
+      if (entryStat.isSymbolicLink() || !entryStat.isDirectory()) {
+        continue;
+      }
+
+      const skillMdPath = path.join(entryPath, 'SKILL.md');
+      try {
+        const skillMdStat = lstatSync(skillMdPath);
+        if (skillMdStat.isSymbolicLink() || !skillMdStat.isFile()) {
+          continue;
+        }
+      } catch {
+        continue;
+      }
+
+      const destPath = path.join(destSkillsDir, entry);
+
+      let destExists = false;
+      try {
+        lstatSync(destPath);
+        destExists = true;
+      } catch {
+        // Does not exist
+      }
+
+      if (destExists) {
+        log(`[skill-sync] Skill already exists in destination: ${entry}`);
+        skippedExisting.push(entry);
+        continue;
+      }
+
+      const stagingDir = mkdtempSync(
+        path.join(destSkillsDir, `.sync-staging-${entry}-`),
+      );
+
+      try {
+        copyDirRecursive(entryPath, stagingDir);
+
+        let destExistsLate = false;
+        try {
+          lstatSync(destPath);
+          destExistsLate = true;
+        } catch {}
+
+        if (destExistsLate) {
+          log(
+            `[skill-sync] Destination path was created during staging for ${entry}, skipping promotion.`,
+          );
+          skippedExisting.push(entry);
+        } else {
+          renameSync(stagingDir, destPath);
+          installed.push(entry);
+          log(`[skill-sync] Successfully synced skill: ${entry}`);
+        }
+      } catch (err) {
+        log(`[skill-sync] Failed to sync skill ${entry}:`, err);
+        failed.push(entry);
+      } finally {
+        try {
+          if (existsSync(stagingDir)) {
+            rmSync(stagingDir, { recursive: true, force: true });
+          }
+        } catch (err) {
+          log(
+            `[skill-sync] Failed to clean up staging directory ${stagingDir}:`,
+            err,
+          );
+        }
+      }
+    } catch (err) {
+      log(`[skill-sync] Error processing source entry ${entry}:`, err);
+      failed.push(entry);
+    }
+  }
+
+  return { installed, skippedExisting, failed };
+}