From 20fc675af0e0fb445933a908be531adaa823fb74 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 3 Aug 2026 09:25:25 +0000 Subject: [PATCH] fix(skills): drive UI rename gating from server renamable flag Expose authoritative renamable on skill list responses using the same managed-root policy as renameSkill, drop the divergent UI path heuristic, and remove an unused rejection-test fixture. Co-authored-by: Serhii Dziupin --- .../sections/skills/SkillsSidebar.tsx | 3 +-- .../sections/skills/skillLocations.ts | 19 ------------------- packages/ui/src/stores/useSkillsStore.ts | 4 ++++ packages/vscode/src/bridge-config-runtime.ts | 17 ++++++++++++++++- packages/vscode/src/opencodeConfig.ts | 2 ++ .../web/server/lib/opencode/DOCUMENTATION.md | 1 + .../lib/opencode/feature-routes-runtime.js | 3 ++- .../web/server/lib/opencode/skill-routes.js | 9 ++++++++- packages/web/server/lib/opencode/skills.js | 1 + .../web/server/lib/opencode/skills.test.js | 16 ---------------- 10 files changed, 35 insertions(+), 40 deletions(-) diff --git a/packages/ui/src/components/sections/skills/SkillsSidebar.tsx b/packages/ui/src/components/sections/skills/SkillsSidebar.tsx index 98646486..f440b9a5 100644 --- a/packages/ui/src/components/sections/skills/SkillsSidebar.tsx +++ b/packages/ui/src/components/sections/skills/SkillsSidebar.tsx @@ -27,7 +27,6 @@ import { SidebarGroup } from '@/components/sections/shared/SidebarGroup'; import { Icon } from "@/components/icon/Icon"; import { useI18n } from '@/lib/i18n'; import { SETTINGS_PANEL_TITLE_CLASS } from '@/components/sections/shared/SettingsSection'; -import { isManagedSkillFilesystemPath } from '@/components/sections/skills/skillLocations'; interface SkillsSidebarProps { onItemSelect?: () => void; @@ -37,7 +36,7 @@ const BUILT_IN_SKILL_LOCATION = ''; const isBuiltInSkill = (skill: DiscoveredSkill | null | undefined): boolean => skill?.path === BUILT_IN_SKILL_LOCATION; const isRenamableSkill = (skill: DiscoveredSkill | null | undefined): boolean => ( - !!skill && !isBuiltInSkill(skill) && isManagedSkillFilesystemPath(skill.path) + !!skill && !isBuiltInSkill(skill) && skill.renamable === true ); export const SkillsSidebar: React.FC = ({ onItemSelect }) => { diff --git a/packages/ui/src/components/sections/skills/skillLocations.ts b/packages/ui/src/components/sections/skills/skillLocations.ts index cc07fd5e..a0009a20 100644 --- a/packages/ui/src/components/sections/skills/skillLocations.ts +++ b/packages/ui/src/components/sections/skills/skillLocations.ts @@ -57,22 +57,3 @@ export function locationPartsFrom(value: SkillLocationValue): { scope: SkillScop } return { scope: match.scope, source: match.source }; } - -/** True when a discovered skill path is under a managed skill root that rename/delete may mutate. */ -export function isManagedSkillFilesystemPath(skillPath: string | null | undefined): boolean { - if (!skillPath || skillPath === '') return false; - const normalized = skillPath.replace(/\\/g, '/'); - if ( - normalized.includes('/.cache/opencode/skills/') - || normalized.includes('/Caches/opencode/skills/') - || normalized.includes('/Library/Caches/opencode/skills/') - ) { - return false; - } - return ( - /\/\.opencode\/skills?\//.test(normalized) - || /\/\.claude\/skills\//.test(normalized) - || /\/\.agents\/skills\//.test(normalized) - || /\/\.config\/opencode\/skills?\//.test(normalized) - ); -} diff --git a/packages/ui/src/stores/useSkillsStore.ts b/packages/ui/src/stores/useSkillsStore.ts index 9acbde46..76f7b9c4 100644 --- a/packages/ui/src/stores/useSkillsStore.ts +++ b/packages/ui/src/stores/useSkillsStore.ts @@ -70,6 +70,8 @@ export interface DiscoveredSkill { description?: string; /** Domain folder parsed from file path, e.g. "automation-ai", "lark-ecosystem" */ group?: string; + /** Authoritative server flag: skill lives under a managed root and can be renamed in place. */ + renamable?: boolean; } /** Parse the domain group folder from a skill file path. @@ -93,6 +95,7 @@ interface RawSkillResponse { path: string; scope?: SkillScope; source?: SkillSource; + renamable?: boolean; sources?: { md?: { description?: string; @@ -237,6 +240,7 @@ export const useSkillsStore = create()( source: s.source ?? 'opencode', description: s.sources?.md?.description || '', group: parseSkillGroup(s.path), + renamable: s.renamable === true, })); set({ skills: configSkills, isLoading: false }); diff --git a/packages/vscode/src/bridge-config-runtime.ts b/packages/vscode/src/bridge-config-runtime.ts index 569900e5..0ec36bee 100644 --- a/packages/vscode/src/bridge-config-runtime.ts +++ b/packages/vscode/src/bridge-config-runtime.ts @@ -26,6 +26,7 @@ import { updateSkill, deleteSkill, renameSkill, + isManagedSkillPath, readSkillSupportingFile, writeSkillSupportingFile, deleteSkillSupportingFile, @@ -653,7 +654,21 @@ export async function handleConfigBridgeMessage( if (!name && normalizedMethod === 'GET') { const skills = await resolveDiscoveredSkills(deps, ctx, workingDirectory); - return { id, type, success: true, data: { skills } }; + return { + id, + type, + success: true, + data: { + skills: skills.map((skill) => ({ + ...skill, + renamable: Boolean( + skill.path + && skill.path !== '' + && isManagedSkillPath(skill.path, workingDirectory) + ), + })), + }, + }; } const skillName = typeof name === 'string' ? name.trim() : ''; diff --git a/packages/vscode/src/opencodeConfig.ts b/packages/vscode/src/opencodeConfig.ts index 1ecce46e..0bf94c32 100644 --- a/packages/vscode/src/opencodeConfig.ts +++ b/packages/vscode/src/opencodeConfig.ts @@ -2885,6 +2885,8 @@ const isManagedSkillPath = (skillMdPath: string, workingDirectory?: string): boo return getManagedSkillRoots(workingDirectory).some((root) => isPathInside(skillDir, root)); }; +export { isManagedSkillPath }; + export const renameSkill = (oldName: string, newName: string, workingDirectory?: string): void => { ensureSkillDirs(); validateSkillName(newName); diff --git a/packages/web/server/lib/opencode/DOCUMENTATION.md b/packages/web/server/lib/opencode/DOCUMENTATION.md index 1f21f955..9e4a6f3b 100644 --- a/packages/web/server/lib/opencode/DOCUMENTATION.md +++ b/packages/web/server/lib/opencode/DOCUMENTATION.md @@ -352,6 +352,7 @@ an authoritative loopback callback URL even when OpenChamber binds port `0`. - `registerSkillRoutes(app, dependencies)`: registers skills-related routes: - Skills config CRUD and metadata under `/api/config/skills*` - Skill rename via `PATCH /api/config/skills/:name` with `{ renameTo }` (directory rename preserves `SKILL.md` body and supporting files; restricted to managed skill roots under `.opencode/skills|skill`, `.claude/skills`, and `.agents/skills`) + - Skill list responses include authoritative `renamable` derived from the same managed-root policy used by rename - Skills catalog listing/source pagination, scan, and install routes - Supporting skill file read/write/delete routes diff --git a/packages/web/server/lib/opencode/feature-routes-runtime.js b/packages/web/server/lib/opencode/feature-routes-runtime.js index dd3726d7..69efc12b 100644 --- a/packages/web/server/lib/opencode/feature-routes-runtime.js +++ b/packages/web/server/lib/opencode/feature-routes-runtime.js @@ -38,7 +38,7 @@ import { decodePluginId, } from './plugins.js'; import { SKILL_DIR, SKILL_SCOPE, readSkillSupportingFile, writeSkillSupportingFile, deleteSkillSupportingFile } from './shared.js'; -import { getSkillSources, discoverSkills, mergeDiscoveredSkills, createSkill, updateSkill, deleteSkill, renameSkill } from './skills.js'; +import { getSkillSources, discoverSkills, mergeDiscoveredSkills, createSkill, updateSkill, deleteSkill, renameSkill, isManagedSkillPath } from './skills.js'; import { getCuratedSkillsSources } from '../skills-catalog/curated-sources.js'; import { getCacheKey, getCachedScan, setCachedScan } from '../skills-catalog/cache.js'; import { isClawdHubSource, parseSkillRepoSource } from '../skills-catalog/source.js'; @@ -257,6 +257,7 @@ export const createFeatureRoutesRuntime = (dependencies) => { updateSkill, deleteSkill, renameSkill, + isManagedSkillPath, readSkillSupportingFile, writeSkillSupportingFile, deleteSkillSupportingFile, diff --git a/packages/web/server/lib/opencode/skill-routes.js b/packages/web/server/lib/opencode/skill-routes.js index 314e6b99..db6893ce 100644 --- a/packages/web/server/lib/opencode/skill-routes.js +++ b/packages/web/server/lib/opencode/skill-routes.js @@ -22,6 +22,7 @@ export const registerSkillRoutes = (app, dependencies) => { updateSkill, deleteSkill, renameSkill, + isManagedSkillPath, readSkillSupportingFile, writeSkillSupportingFile, deleteSkillSupportingFile, @@ -213,9 +214,15 @@ export const registerSkillRoutes = (app, dependencies) => { const enrichedSkills = skills.map((skill) => { const sources = getSkillSources(skill.name, directory, skill); + const skillPath = typeof skill.path === 'string' ? skill.path : null; return { ...skill, - sources + sources, + renamable: Boolean( + skillPath + && skillPath !== '' + && isManagedSkillPath(skillPath, directory) + ), }; }); diff --git a/packages/web/server/lib/opencode/skills.js b/packages/web/server/lib/opencode/skills.js index a0594ad3..9ee24d6b 100644 --- a/packages/web/server/lib/opencode/skills.js +++ b/packages/web/server/lib/opencode/skills.js @@ -734,4 +734,5 @@ export { updateSkill, deleteSkill, renameSkill, + isManagedSkillPath, }; diff --git a/packages/web/server/lib/opencode/skills.test.js b/packages/web/server/lib/opencode/skills.test.js index 6fec177e..d19bf3d3 100644 --- a/packages/web/server/lib/opencode/skills.test.js +++ b/packages/web/server/lib/opencode/skills.test.js @@ -224,7 +224,6 @@ describe('skills', () => { const managedDir = path.join(projectRoot, '.opencode', 'skills', 'managed-skill'); const conflictDir = path.join(projectRoot, '.opencode', 'skills', 'taken-name'); const mismatchDir = path.join(projectRoot, '.opencode', 'skills', 'folder-name'); - const unmanagedDir = path.join(projectRoot, 'custom-skills', 'unmanaged-skill'); const cacheStamp = `oc-rename-${Date.now()}`; const cacheDir = path.join(os.homedir(), '.cache', 'opencode', 'skills', cacheStamp, 'cache-skill'); @@ -274,21 +273,6 @@ describe('skills', () => { 'utf8', ); - await fsPromises.mkdir(unmanagedDir, { recursive: true }); - await fsPromises.writeFile( - path.join(unmanagedDir, 'SKILL.md'), - [ - '---', - 'name: unmanaged-skill', - 'description: Unmanaged', - '---', - '', - 'Unmanaged body', - '', - ].join('\n'), - 'utf8', - ); - await fsPromises.mkdir(cacheDir, { recursive: true }); await fsPromises.writeFile( path.join(cacheDir, 'SKILL.md'),