Просмотр исходного кода

feat(cli): force bundled skill updates

Alvin Unreal 1 месяц назад
Родитель
Сommit
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
 when the task calls for it. The installer bundles eight skills and keeps them
 updated on plugin auto-update; local customizations are preserved.
 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 |
 | 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` |
 | <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', () => {
 describe('configureBackgroundSubagents', () => {
   let tempDir: string | undefined;
   let tempDir: string | undefined;
   const originalBackgroundEnv =
   const originalBackgroundEnv =

+ 9 - 3
src/cli/index.ts

@@ -4,9 +4,9 @@ import { install } from './install';
 import { getGeneratedPresetNames, isGeneratedPresetName } from './providers';
 import { getGeneratedPresetNames, isGeneratedPresetName } from './providers';
 import type {
 import type {
   BackgroundSubagentsArg,
   BackgroundSubagentsArg,
-  BooleanArg,
   CompanionArg,
   CompanionArg,
   InstallArgs,
   InstallArgs,
+  SkillsArg,
 } from './types';
 } from './types';
 
 
 export function parseArgs(args: string[]): InstallArgs {
 export function parseArgs(args: string[]): InstallArgs {
@@ -20,7 +20,12 @@ export function parseArgs(args: string[]): InstallArgs {
     if (arg === '--no-tui') {
     if (arg === '--no-tui') {
       result.tui = false;
       result.tui = false;
     } else if (arg.startsWith('--skills=')) {
     } 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=')) {
     } else if (arg.startsWith('--companion=')) {
       const mode = arg.split('=')[1] as CompanionArg;
       const mode = arg.split('=')[1] as CompanionArg;
       if (!['ask', 'yes', 'no'].includes(mode)) {
       if (!['ask', 'yes', 'no'].includes(mode)) {
@@ -72,7 +77,8 @@ Usage:
   bunx oh-my-opencode-slim doctor [OPTIONS]
   bunx oh-my-opencode-slim doctor [OPTIONS]
 
 
 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
   --companion=ask|yes|no Install desktop companion binary and enable config
                          (default: ask; prompt defaults to no)
                          (default: ask; prompt defaults to no)
   --preset=<name>        Active generated config preset (default: openai)
   --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 mockStagedResult: string[] = [];
 let mockAdoptedResult: string[] = [];
 let mockAdoptedResult: string[] = [];
 let mockCustomizedResult: string[] = [];
 let mockCustomizedResult: string[] = [];
+let receivedSkillSyncOptions: unknown;
 let enableInstallMocks = false;
 let enableInstallMocks = false;
 
 
 mock.module('../hooks/auto-update-checker/skill-sync', () => {
 mock.module('../hooks/auto-update-checker/skill-sync', () => {
   return {
   return {
     ...actualSkillSync,
     ...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 {
   return {
     hasTmux: false,
     hasTmux: false,
     installCustomSkills: false,
     installCustomSkills: false,
+    forceSkillSync: false,
     reset: false,
     reset: false,
     backgroundSubagents: 'no',
     backgroundSubagents: 'no',
     companion: 'ask',
     companion: 'ask',
@@ -197,6 +202,7 @@ describe('install skill synchronization error mapping', () => {
     mockStagedResult = [];
     mockStagedResult = [];
     mockAdoptedResult = [];
     mockAdoptedResult = [];
     mockCustomizedResult = [];
     mockCustomizedResult = [];
+    receivedSkillSyncOptions = undefined;
     originalConsoleLog = console.log;
     originalConsoleLog = console.log;
     logSpy = mock(() => {});
     logSpy = mock(() => {});
     console.log = logSpy;
     console.log = logSpy;
@@ -385,4 +391,16 @@ describe('install skill synchronization error mapping', () => {
       '0 skipped/preserved, 1 staged, 1 adopted, 1 customized, 0 failed.',
       '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 {
     } else {
       try {
       try {
         const packageRoot = fileURLToPath(new URL('../..', import.meta.url));
         const packageRoot = fileURLToPath(new URL('../..', import.meta.url));
-        const result = syncBundledSkillsFromPackage(packageRoot);
+        const result = syncBundledSkillsFromPackage(packageRoot, {
+          force: config.forceSkillSync,
+        });
         const categorizedSkipped = new Set([
         const categorizedSkipped = new Set([
           ...result.staged,
           ...result.staged,
           ...result.adopted,
           ...result.adopted,
@@ -547,7 +549,8 @@ async function runInstall(config: InstallConfig): Promise<number> {
 export async function install(args: InstallArgs): Promise<number> {
 export async function install(args: InstallArgs): Promise<number> {
   const config: InstallConfig = {
   const config: InstallConfig = {
     hasTmux: false,
     hasTmux: false,
-    installCustomSkills: args.skills === 'yes',
+    installCustomSkills: args.skills === 'yes' || args.skills === 'force',
+    forceSkillSync: args.skills === 'force',
     preset: args.preset,
     preset: args.preset,
     promptForStar: args.tui,
     promptForStar: args.tui,
     dryRun: args.dryRun,
     dryRun: args.dryRun,

+ 3 - 1
src/cli/types.ts

@@ -1,10 +1,11 @@
 export type BooleanArg = 'yes' | 'no';
 export type BooleanArg = 'yes' | 'no';
+export type SkillsArg = BooleanArg | 'force';
 export type BackgroundSubagentsArg = 'ask' | 'yes' | 'no';
 export type BackgroundSubagentsArg = 'ask' | 'yes' | 'no';
 export type CompanionArg = 'ask' | BooleanArg;
 export type CompanionArg = 'ask' | BooleanArg;
 
 
 export interface InstallArgs {
 export interface InstallArgs {
   tui: boolean;
   tui: boolean;
-  skills?: BooleanArg;
+  skills?: SkillsArg;
   preset?: string;
   preset?: string;
   dryRun?: boolean;
   dryRun?: boolean;
   reset?: boolean;
   reset?: boolean;
@@ -23,6 +24,7 @@ export interface OpenCodeConfig {
 export interface InstallConfig {
 export interface InstallConfig {
   hasTmux: boolean;
   hasTmux: boolean;
   installCustomSkills: boolean;
   installCustomSkills: boolean;
+  forceSkillSync: boolean;
   preset?: string;
   preset?: string;
   promptForStar?: boolean;
   promptForStar?: boolean;
   dryRun?: 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;
 let importCounter = 0;
 
 
-async function syncBundledSkillsFromPackage(packageRoot: string) {
+async function syncBundledSkillsFromPackage(
+  packageRoot: string,
+  options: { force?: boolean } = {},
+) {
   const module = await import(`./skill-sync?test=${importCounter++}`);
   const module = await import(`./skill-sync?test=${importCounter++}`);
   return module.syncBundledSkillsFromPackage(packageRoot, {
   return module.syncBundledSkillsFromPackage(packageRoot, {
     skills: getFakeManagedSkills(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 () => {
   test('ignores non-skill directories without SKILL.md', async () => {
     const skillName = 'no-skill-md';
     const skillName = 'no-skill-md';
     const skillSrcDir = path.join(fakePackageRoot, 'src', 'skills', skillName);
     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 {
 interface SkillSyncOptions {
   skills?: ManagedSkillSource[];
   skills?: ManagedSkillSource[];
+  force?: boolean;
 }
 }
 
 
 /**
 /**
@@ -737,6 +738,37 @@ export function syncBundledSkillsFromPackage(
         }
         }
 
 
         const sourceHash = computeDirectoryHash(sourcePath);
         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 (isManifestCorrupt) {
           if (!destExists) {
           if (!destExists) {
@@ -818,8 +850,6 @@ export function syncBundledSkillsFromPackage(
           continue;
           continue;
         }
         }
 
 
-        const entry = manifest.skills[skill.name];
-
         if (!destExists) {
         if (!destExists) {
           if (entry && entry.status === 'deleted') {
           if (entry && entry.status === 'deleted') {
             log(
             log(