Browse Source

feat(cli): force bundled skill updates

Alvin Unreal 3 weeks ago
parent
commit
397b57f5a9

+ 5 - 0
README.md

@@ -578,6 +578,11 @@ servers), a skill runs no process — it is a focused playbook an agent activate
 when the task calls for it. The installer bundles eight skills and keeps them
 updated on plugin auto-update; local customizations are preserved.
 
+> [!TIP]
+> To discard local bundled-skill customizations and receive package updates, run
+> `bunx oh-my-opencode-slim install --skills=force`. This deliberately replaces
+> installed bundled skills with the package versions.
+
 | Skill | Purpose | Default agent | How to invoke |
 |:-----:|---------|---------------|---------------|
 | <img src="img/skills/codemap.webp" width="120" alt="Codemap artifact"><br>[`codemap`](src/skills/codemap/SKILL.md) | Hierarchical repository maps so agents understand codebases without re-reading everything | `orchestrator` | `run codemap` |

+ 6 - 0
src/cli/background-subagents.test.ts

@@ -154,6 +154,12 @@ describe('parseArgs companion', () => {
   });
 });
 
+describe('parseArgs skills', () => {
+  test('parses force skill synchronization mode', () => {
+    expect(parseArgs(['--skills=force']).skills).toBe('force');
+  });
+});
+
 describe('configureBackgroundSubagents', () => {
   let tempDir: string | undefined;
   const originalBackgroundEnv =

+ 9 - 3
src/cli/index.ts

@@ -4,9 +4,9 @@ import { install } from './install';
 import { getGeneratedPresetNames, isGeneratedPresetName } from './providers';
 import type {
   BackgroundSubagentsArg,
-  BooleanArg,
   CompanionArg,
   InstallArgs,
+  SkillsArg,
 } from './types';
 
 export function parseArgs(args: string[]): InstallArgs {
@@ -20,7 +20,12 @@ export function parseArgs(args: string[]): InstallArgs {
     if (arg === '--no-tui') {
       result.tui = false;
     } else if (arg.startsWith('--skills=')) {
-      result.skills = arg.split('=')[1] as BooleanArg;
+      const mode = arg.split('=')[1] as SkillsArg;
+      if (!['yes', 'no', 'force'].includes(mode)) {
+        console.error('Unsupported --skills value: use yes, no, or force');
+        process.exit(1);
+      }
+      result.skills = mode;
     } else if (arg.startsWith('--companion=')) {
       const mode = arg.split('=')[1] as CompanionArg;
       if (!['ask', 'yes', 'no'].includes(mode)) {
@@ -72,7 +77,8 @@ Usage:
   bunx oh-my-opencode-slim doctor [OPTIONS]
 
 Options:
-  --skills=yes|no        Install bundled skills (default: yes)
+  --skills=yes|no|force  Install bundled skills; force replaces existing skill
+                         directories (default: yes)
   --companion=ask|yes|no Install desktop companion binary and enable config
                          (default: ask; prompt defaults to no)
   --preset=<name>        Active generated config preset (default: openai)

+ 29 - 11
src/cli/install.test.ts

@@ -48,22 +48,26 @@ let mockFailedResult: string[] = [];
 let mockStagedResult: string[] = [];
 let mockAdoptedResult: string[] = [];
 let mockCustomizedResult: string[] = [];
+let receivedSkillSyncOptions: unknown;
 let enableInstallMocks = false;
 
 mock.module('../hooks/auto-update-checker/skill-sync', () => {
   return {
     ...actualSkillSync,
-    syncBundledSkillsFromPackage: (packageRoot: string, options?: any) =>
-      enableInstallMocks
-        ? {
-            installed: [],
-            skippedExisting: mockSkippedResult,
-            failed: mockFailedResult,
-            staged: mockStagedResult,
-            adopted: mockAdoptedResult,
-            customized: mockCustomizedResult,
-          }
-        : originalSyncBundledSkillsFromPackage(packageRoot, options),
+    syncBundledSkillsFromPackage: (packageRoot: string, options?: any) => {
+      if (enableInstallMocks) {
+        receivedSkillSyncOptions = options;
+        return {
+          installed: [],
+          skippedExisting: mockSkippedResult,
+          failed: mockFailedResult,
+          staged: mockStagedResult,
+          adopted: mockAdoptedResult,
+          customized: mockCustomizedResult,
+        };
+      }
+      return originalSyncBundledSkillsFromPackage(packageRoot, options);
+    },
   };
 });
 
@@ -145,6 +149,7 @@ function baseConfig(): InstallConfig {
   return {
     hasTmux: false,
     installCustomSkills: false,
+    forceSkillSync: false,
     reset: false,
     backgroundSubagents: 'no',
     companion: 'ask',
@@ -197,6 +202,7 @@ describe('install skill synchronization error mapping', () => {
     mockStagedResult = [];
     mockAdoptedResult = [];
     mockCustomizedResult = [];
+    receivedSkillSyncOptions = undefined;
     originalConsoleLog = console.log;
     logSpy = mock(() => {});
     console.log = logSpy;
@@ -385,4 +391,16 @@ describe('install skill synchronization error mapping', () => {
       '0 skipped/preserved, 1 staged, 1 adopted, 1 customized, 0 failed.',
     );
   });
+
+  test('passes force mode to bundled skill synchronization', async () => {
+    const { install } = await import(`./install?test=${importCounter++}`);
+
+    await install({
+      skills: 'force',
+      tui: false,
+      companion: 'no',
+    });
+
+    expect(receivedSkillSyncOptions).toEqual({ force: true });
+  });
 });

+ 5 - 2
src/cli/install.ts

@@ -421,7 +421,9 @@ async function runInstall(config: InstallConfig): Promise<number> {
     } else {
       try {
         const packageRoot = fileURLToPath(new URL('../..', import.meta.url));
-        const result = syncBundledSkillsFromPackage(packageRoot);
+        const result = syncBundledSkillsFromPackage(packageRoot, {
+          force: config.forceSkillSync,
+        });
         const categorizedSkipped = new Set([
           ...result.staged,
           ...result.adopted,
@@ -547,7 +549,8 @@ async function runInstall(config: InstallConfig): Promise<number> {
 export async function install(args: InstallArgs): Promise<number> {
   const config: InstallConfig = {
     hasTmux: false,
-    installCustomSkills: args.skills === 'yes',
+    installCustomSkills: args.skills === 'yes' || args.skills === 'force',
+    forceSkillSync: args.skills === 'force',
     preset: args.preset,
     promptForStar: args.tui,
     dryRun: args.dryRun,

+ 3 - 1
src/cli/types.ts

@@ -1,10 +1,11 @@
 export type BooleanArg = 'yes' | 'no';
+export type SkillsArg = BooleanArg | 'force';
 export type BackgroundSubagentsArg = 'ask' | 'yes' | 'no';
 export type CompanionArg = 'ask' | BooleanArg;
 
 export interface InstallArgs {
   tui: boolean;
-  skills?: BooleanArg;
+  skills?: SkillsArg;
   preset?: string;
   dryRun?: boolean;
   reset?: boolean;
@@ -23,6 +24,7 @@ export interface OpenCodeConfig {
 export interface InstallConfig {
   hasTmux: boolean;
   installCustomSkills: boolean;
+  forceSkillSync: boolean;
   preset?: string;
   promptForStar?: boolean;
   dryRun?: boolean;

+ 101 - 1
src/hooks/auto-update-checker/skill-sync.test.ts

@@ -20,10 +20,14 @@ mock.module('node:fs', () => {
 
 let importCounter = 0;
 
-async function syncBundledSkillsFromPackage(packageRoot: string) {
+async function syncBundledSkillsFromPackage(
+  packageRoot: string,
+  options: { force?: boolean } = {},
+) {
   const module = await import(`./skill-sync?test=${importCounter++}`);
   return module.syncBundledSkillsFromPackage(packageRoot, {
     skills: getFakeManagedSkills(packageRoot),
+    ...options,
   });
 }
 
@@ -138,6 +142,102 @@ describe('syncBundledSkillsFromPackage', () => {
     );
   });
 
+  test('force overwrites customized managed skill directories', async () => {
+    const skillName = 'force-customized-skill';
+    const skillSrcDir = path.join(fakePackageRoot, 'src', 'skills', skillName);
+    fs.mkdirSync(skillSrcDir, { recursive: true });
+    fs.writeFileSync(path.join(skillSrcDir, 'SKILL.md'), '# Bundled Skill');
+
+    const manifestDir = path.join(fakeDestConfigDir, '.oh-my-opencode-slim');
+    const stagedDir = path.join(
+      manifestDir,
+      'skill-updates',
+      '1.0.0',
+      skillName,
+    );
+    fs.mkdirSync(stagedDir, { recursive: true });
+    fs.writeFileSync(path.join(stagedDir, 'SKILL.md'), '# Staged Skill');
+    fs.writeFileSync(
+      path.join(manifestDir, 'skills-manifest.json'),
+      JSON.stringify({
+        schemaVersion: 1,
+        updatedAt: new Date().toISOString(),
+        skills: {
+          [skillName]: {
+            status: 'customized',
+            packageVersion: '1.0.0',
+            sourceHash: 'old-source-hash',
+            lastManagedHash: 'old-managed-hash',
+            lastSeenHash: 'customized-hash',
+            stagedPath: stagedDir,
+            updatedAt: new Date().toISOString(),
+          },
+        },
+      }),
+    );
+
+    const destSkillDir = path.join(fakeDestConfigDir, 'skills', skillName);
+    fs.mkdirSync(destSkillDir, { recursive: true });
+    fs.writeFileSync(path.join(destSkillDir, 'SKILL.md'), '# Customized Skill');
+
+    const result = await syncBundledSkillsFromPackage(fakePackageRoot, {
+      force: true,
+    });
+
+    expect(result.installed).toContain(skillName);
+    expect(result.staged).not.toContain(skillName);
+    expect(result.customized).not.toContain(skillName);
+    expect(fs.readFileSync(path.join(destSkillDir, 'SKILL.md'), 'utf-8')).toBe(
+      '# Bundled Skill',
+    );
+    expect(fs.existsSync(stagedDir)).toBe(false);
+
+    const manifest = JSON.parse(
+      fs.readFileSync(path.join(manifestDir, 'skills-manifest.json'), 'utf-8'),
+    );
+    expect(manifest.skills[skillName].status).toBe('managed');
+    expect(manifest.skills[skillName].stagedPath).toBeUndefined();
+  });
+
+  test('force skips destination files and symlinks without overwriting', async () => {
+    const fileSkill = 'force-file-skill';
+    const symlinkSkill = 'force-symlink-skill';
+    for (const skillName of [fileSkill, symlinkSkill]) {
+      const skillSrcDir = path.join(
+        fakePackageRoot,
+        'src',
+        'skills',
+        skillName,
+      );
+      fs.mkdirSync(skillSrcDir, { recursive: true });
+      fs.writeFileSync(path.join(skillSrcDir, 'SKILL.md'), '# Bundled Skill');
+    }
+
+    const destSkillsDir = path.join(fakeDestConfigDir, 'skills');
+    fs.mkdirSync(destSkillsDir, { recursive: true });
+    const filePath = path.join(destSkillsDir, fileSkill);
+    fs.writeFileSync(filePath, '# User File');
+    const symlinkTarget = path.join(fakeDestConfigDir, 'symlink-target');
+    fs.mkdirSync(symlinkTarget, { recursive: true });
+    fs.writeFileSync(path.join(symlinkTarget, 'SKILL.md'), '# User Symlink');
+    const symlinkPath = path.join(destSkillsDir, symlinkSkill);
+    fs.symlinkSync(symlinkTarget, symlinkPath, 'dir');
+
+    const result = await syncBundledSkillsFromPackage(fakePackageRoot, {
+      force: true,
+    });
+
+    expect(result.installed).toHaveLength(0);
+    expect(result.skippedExisting).toEqual(
+      expect.arrayContaining([fileSkill, symlinkSkill]),
+    );
+    expect(fs.readFileSync(filePath, 'utf-8')).toBe('# User File');
+    expect(fs.lstatSync(symlinkPath).isSymbolicLink()).toBe(true);
+    expect(fs.readFileSync(path.join(symlinkTarget, 'SKILL.md'), 'utf-8')).toBe(
+      '# User Symlink',
+    );
+  });
+
   test('ignores non-skill directories without SKILL.md', async () => {
     const skillName = 'no-skill-md';
     const skillSrcDir = path.join(fakePackageRoot, 'src', 'skills', skillName);

+ 32 - 2
src/hooks/auto-update-checker/skill-sync.ts

@@ -63,6 +63,7 @@ interface ManagedSkillSource {
 
 interface SkillSyncOptions {
   skills?: ManagedSkillSource[];
+  force?: boolean;
 }
 
 /**
@@ -737,6 +738,37 @@ export function syncBundledSkillsFromPackage(
         }
 
         const sourceHash = computeDirectoryHash(sourcePath);
+        const entry = manifest.skills[skill.name];
+
+        if (options.force && destExists) {
+          try {
+            atomicReplaceDir(sourcePath, destPath);
+            if (entry?.stagedPath) {
+              removeManagedStagedPath(
+                entry.stagedPath,
+                manifestDir,
+                skill.name,
+              );
+            }
+            installed.push(skill.name);
+            manifest.skills[skill.name] = {
+              status: 'managed',
+              packageVersion,
+              sourceHash,
+              lastManagedHash: sourceHash,
+              lastSeenHash: sourceHash,
+              updatedAt: new Date().toISOString(),
+            };
+            log(`[skill-sync] Force-updated skill: ${skill.name}`);
+          } catch (err) {
+            log(
+              `[skill-sync] Failed to force-update skill ${skill.name}:`,
+              err,
+            );
+            failed.push(skill.name);
+          }
+          continue;
+        }
 
         if (isManifestCorrupt) {
           if (!destExists) {
@@ -818,8 +850,6 @@ export function syncBundledSkillsFromPackage(
           continue;
         }
 
-        const entry = manifest.skills[skill.name];
-
         if (!destExists) {
           if (entry && entry.status === 'deleted') {
             log(