diff --git a/internal/api/handler_test.go b/internal/api/handler_test.go index 1f8d43c2..69df7b22 100644 --- a/internal/api/handler_test.go +++ b/internal/api/handler_test.go @@ -3088,8 +3088,27 @@ enabled = true if !strings.Contains(string(script), "echo ready") { t.Fatalf("script = %q, want remote script content", script) } - if rootTreePages != 2 { - t.Fatalf("root tree pages = %d, want 2", rootTreePages) + staleFile := filepath.Join(skillRoot, "stale.txt") + if err := os.WriteFile(staleFile, []byte("stale"), 0o644); err != nil { + t.Fatalf("WriteFile(stale) error = %v", err) + } + + replaceReq := httptest.NewRequest(http.MethodPost, "/api/v1/skills:install", strings.NewReader(`{ + "remote_path": "AIWizards/agent-builder", + "ref": "dev", + "replace": true + }`)) + replaceRec := httptest.NewRecorder() + srv.Routes().ServeHTTP(replaceRec, replaceReq) + + if replaceRec.Code != http.StatusCreated { + t.Fatalf("replace install status = %d, want %d; body=%s", replaceRec.Code, http.StatusCreated, replaceRec.Body.String()) + } + if _, err := os.Stat(staleFile); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("stale file still exists after replace, err=%v", err) + } + if rootTreePages != 4 { + t.Fatalf("root tree pages = %d, want 4", rootTreePages) } } diff --git a/internal/api/skill_remote_install.go b/internal/api/skill_remote_install.go index b3c483b1..fcc47278 100644 --- a/internal/api/skill_remote_install.go +++ b/internal/api/skill_remote_install.go @@ -15,6 +15,7 @@ import ( type skillInstallRequest struct { RemotePath string `json:"remote_path"` Ref string `json:"ref,omitempty"` + Replace bool `json:"replace,omitempty"` } func (h *Handler) handleSkillInstall(w http.ResponseWriter, r *http.Request) { @@ -56,7 +57,12 @@ func (h *Handler) handleSkillInstall(w http.ResponseWriter, r *http.Request) { http.Error(w, err.Error(), http.StatusBadGateway) return } - item, err := skilllocal.InstallArchive(root, skillremote.AgenticHubSkillArchiveName(remotePath), archive) + item, err := skilllocal.InstallArchiveWithOptions( + root, + skillremote.AgenticHubSkillArchiveName(remotePath), + archive, + skilllocal.InstallArchiveOptions{Replace: req.Replace}, + ) if err != nil { writeSkillInstallError(w, err) return diff --git a/internal/skill/local/skill_test.go b/internal/skill/local/skill_test.go index 25cd3d2b..7b460359 100644 --- a/internal/skill/local/skill_test.go +++ b/internal/skill/local/skill_test.go @@ -114,6 +114,29 @@ func TestInstallArchiveRejectsDuplicate(t *testing.T) { } } +func TestInstallArchiveWithOptionsReplacesDuplicate(t *testing.T) { + root := t.TempDir() + mustWriteFile(t, filepath.Join(root, "alpha", "SKILL.md"), "# Alpha\n") + mustWriteFile(t, filepath.Join(root, "alpha", "old.txt"), "old") + + got, err := InstallArchiveWithOptions(root, "alpha.zip", mustZip(t, map[string]string{ + "alpha/SKILL.md": "---\ndescription: Replacement skill\n---\n# Alpha v2\n", + "alpha/new.txt": "new", + }), InstallArchiveOptions{Replace: true}) + if err != nil { + t.Fatalf("InstallArchiveWithOptions() error = %v", err) + } + if got.Name != "alpha" || got.Description != "Replacement skill" { + t.Fatalf("InstallArchiveWithOptions() = %+v, want replacement summary", got) + } + if _, err := os.Stat(filepath.Join(root, "alpha", "old.txt")); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("old file still exists, err = %v", err) + } + if _, err := os.Stat(filepath.Join(root, "alpha", "new.txt")); err != nil { + t.Fatalf("replacement file missing: %v", err) + } +} + func TestInstallArchiveRejectsSystemSkillName(t *testing.T) { root := t.TempDir() diff --git a/internal/skill/local/upload.go b/internal/skill/local/upload.go index a460824a..e6bd27b3 100644 --- a/internal/skill/local/upload.go +++ b/internal/skill/local/upload.go @@ -23,7 +23,15 @@ var ( ErrSKILLMDMissing = errors.New("skill archive must contain SKILL.md") ) +type InstallArchiveOptions struct { + Replace bool +} + func InstallArchive(root, filename string, archive []byte) (SkillSummary, error) { + return InstallArchiveWithOptions(root, filename, archive, InstallArchiveOptions{}) +} + +func InstallArchiveWithOptions(root, filename string, archive []byte, options InstallArchiveOptions) (SkillSummary, error) { root = strings.TrimSpace(root) if root == "" { return SkillSummary{}, fmt.Errorf("skills root is required") @@ -66,7 +74,13 @@ func InstallArchive(root, filename string, archive []byte) (SkillSummary, error) return SkillSummary{}, err } if _, err := os.Stat(destDir); err == nil { - return SkillSummary{}, fmt.Errorf("%w: %s", ErrSkillAlreadyExists, summary.Name) + if !options.Replace { + return SkillSummary{}, fmt.Errorf("%w: %s", ErrSkillAlreadyExists, summary.Name) + } + if err := replaceDir(skillDir, destDir, root); err != nil { + return SkillSummary{}, err + } + return summary, nil } else if !errors.Is(err, os.ErrNotExist) { return SkillSummary{}, fmt.Errorf("stat skill destination %q: %w", destDir, err) } @@ -78,6 +92,41 @@ func InstallArchive(root, filename string, archive []byte) (SkillSummary, error) return summary, nil } +func replaceDir(srcDir, dstDir, root string) error { + tempRoot := filepath.Dir(root) + tempDest, err := os.MkdirTemp(tempRoot, ".csgclaw-skill-replace-*") + if err != nil { + return fmt.Errorf("create replacement temp dir: %w", err) + } + if err := copyDir(srcDir, tempDest); err != nil { + _ = os.RemoveAll(tempDest) + return err + } + + backupDir, err := os.MkdirTemp(tempRoot, ".csgclaw-skill-backup-*") + if err != nil { + _ = os.RemoveAll(tempDest) + return fmt.Errorf("create replacement backup dir: %w", err) + } + if err := os.Remove(backupDir); err != nil { + _ = os.RemoveAll(tempDest) + return fmt.Errorf("prepare replacement backup dir %q: %w", backupDir, err) + } + if err := os.Rename(dstDir, backupDir); err != nil { + _ = os.RemoveAll(tempDest) + return fmt.Errorf("backup existing skill destination %q: %w", dstDir, err) + } + if err := os.Rename(tempDest, dstDir); err != nil { + _ = os.Rename(backupDir, dstDir) + _ = os.RemoveAll(tempDest) + return fmt.Errorf("replace skill destination %q: %w", dstDir, err) + } + if err := os.RemoveAll(backupDir); err != nil { + return fmt.Errorf("remove replaced skill backup %q: %w", backupDir, err) + } + return nil +} + func extractArchive(archive []byte, dstDir string) error { reader, err := zip.NewReader(bytes.NewReader(archive), int64(len(archive))) if err != nil { diff --git a/web/app/src/api/skills.ts b/web/app/src/api/skills.ts index ada85b16..9312d5a3 100644 --- a/web/app/src/api/skills.ts +++ b/web/app/src/api/skills.ts @@ -28,12 +28,15 @@ export function fetchSkills(): Promise { export async function fetchAgenticHubOfficialSkillsPage(page = 1, search = ""): Promise { const currentPage = Math.max(Math.trunc(page), 1); - const endpoint = await agenticHubOfficialSkillsPath(currentPage, search); + const baseURL = await officialHubBaseURL(); + const endpoint = agenticHubOfficialSkillsPath(baseURL, currentPage, search); const payload = await get(endpoint, { credentials: "omit", }); const records = Array.isArray(payload?.data) ? payload.data : []; - const items = records.map(normalizeAgenticHubOfficialSkill).filter((item): item is SkillSummary => Boolean(item)); + const items = records + .map((record) => normalizeAgenticHubOfficialSkill(record, baseURL)) + .filter((item): item is SkillSummary => Boolean(item)); const total = nullableNumberFromUnknown(payload?.total); const hasMore = total === null ? records.length >= AGENTICHUB_SKILLS_PAGE_SIZE : currentPage * AGENTICHUB_SKILLS_PAGE_SIZE < total; @@ -74,20 +77,24 @@ export function uploadSkillArchive(file: File): Promise { }); } -export function installRemoteSkillRequest(remotePath: string, ref = ""): Promise { - const payload: { ref?: string; remote_path: string } = { remote_path: String(remotePath || "").trim() }; +export function installRemoteSkillRequest(remotePath: string, ref = "", replace = false): Promise { + const payload: { ref?: string; remote_path: string; replace?: boolean } = { + remote_path: String(remotePath || "").trim(), + }; const normalizedRef = String(ref || "").trim(); if (normalizedRef) { payload.ref = normalizedRef; } + if (replace) { + payload.replace = true; + } return request("api/v1/skills:install", { json: payload, method: "POST", }); } -async function agenticHubOfficialSkillsPath(page: number, search: string): Promise { - const baseURL = await officialHubBaseURL(); +function agenticHubOfficialSkillsPath(baseURL: string, page: number, search: string): string { const params = new URLSearchParams({ page: String(Math.max(Math.trunc(page), 1)), per: String(AGENTICHUB_SKILLS_PAGE_SIZE), @@ -125,11 +132,11 @@ function normalizeOfficialHubBaseURL(value: string): string { } } -function normalizeAgenticHubOfficialSkill(record: unknown): SkillSummary | null { - return normalizeAgenticHubSkill(record, SKILL_SOURCE_OFFICIAL); +function normalizeAgenticHubOfficialSkill(record: unknown, baseURL: string): SkillSummary | null { + return normalizeAgenticHubSkill(record, SKILL_SOURCE_OFFICIAL, baseURL); } -function normalizeAgenticHubSkill(record: unknown, source: string): SkillSummary | null { +function normalizeAgenticHubSkill(record: unknown, source: string, baseURL = ""): SkillSummary | null { if (!record || typeof record !== "object") { return null; } @@ -148,10 +155,34 @@ function normalizeAgenticHubSkill(record: unknown, source: string): SkillSummary readonly: true, remoteRef: stringFromUnknown(values.default_branch) || stringFromUnknown(values.defaultBranch) || undefined, remotePath: remotePath || undefined, + remoteURL: remotePath ? agenticHubSkillWebURL(baseURL, remotePath) || undefined : undefined, source, }; } +function agenticHubSkillWebURL(baseURL: string, remotePath: string): string { + if (!baseURL || !remotePath) { + return ""; + } + try { + const url = new URL(`${baseURL}/`); + const pathParts = url.pathname + .split("/") + .map((part) => part.trim()) + .filter(Boolean); + const remotePathParts = remotePath + .split("/") + .map((part) => part.trim()) + .filter(Boolean); + url.pathname = `/${[...pathParts, "skills", ...remotePathParts].join("/")}`; + url.search = ""; + url.hash = ""; + return url.toString(); + } catch (_) { + return ""; + } +} + function skillNameFromPath(value: unknown): string { const path = stringFromUnknown(value); if (!path) { diff --git a/web/app/src/hooks/workspace/useWorkspaceHubController.ts b/web/app/src/hooks/workspace/useWorkspaceHubController.ts index 9e3a536e..a86f2694 100644 --- a/web/app/src/hooks/workspace/useWorkspaceHubController.ts +++ b/web/app/src/hooks/workspace/useWorkspaceHubController.ts @@ -14,6 +14,9 @@ import type { UseWorkspaceHubControllerArgs } from "./types"; type WorkspaceHubSelection = ReturnType; type DeleteHubTemplate = (template: HubTemplate | null | undefined) => Promise; type DeleteSkill = (skill: SkillSummary | null | undefined) => Promise; +export type InstallRemoteSkillOptions = { + replace?: boolean; +}; export type WorkspaceHubController = { hub: Omit & { @@ -23,7 +26,10 @@ export type WorkspaceHubController = { skillDeleteBusy: boolean; remoteInstallBusy: string; remoteInstallError: string; - installRemoteSkill: (skill: SkillSummary | null | undefined) => Promise; + installRemoteSkill: ( + skill: SkillSummary | null | undefined, + options?: InstallRemoteSkillOptions, + ) => Promise; uploadBusy: boolean; uploadError: string; uploadSkill: (file: File) => Promise; @@ -165,7 +171,10 @@ export function useWorkspaceHubController({ ); const installRemoteSkill = useCallback( - async (skill: SkillSummary | null | undefined): Promise => { + async ( + skill: SkillSummary | null | undefined, + options: InstallRemoteSkillOptions = {}, + ): Promise => { const remotePath = String(skill?.remotePath || "").trim(); if (!remotePath) { setResourcesRemoteInstallError(t("resourcesSkillRemoteInstallFailed")); @@ -174,7 +183,7 @@ export function useWorkspaceHubController({ setResourcesRemoteInstallBusy(remotePath); setResourcesRemoteInstallError(""); try { - const installed = await installRemoteSkillRequest(remotePath, skill?.remoteRef); + const installed = await installRemoteSkillRequest(remotePath, skill?.remoteRef, Boolean(options.replace)); queryClient.setQueryData(workspaceQueryKeys.skills(), (current) => { return upsertSkillSummary(current, installed); }); diff --git a/web/app/src/models/skillhub.ts b/web/app/src/models/skillhub.ts index 0c71a53c..1dcee2a9 100644 --- a/web/app/src/models/skillhub.ts +++ b/web/app/src/models/skillhub.ts @@ -12,6 +12,7 @@ export type SkillSummary = { readonly?: boolean; remoteRef?: string; remotePath?: string; + remoteURL?: string; source?: string; }; @@ -61,6 +62,11 @@ export function hasSkillName( return (skills || []).some((item) => normalizeSkillName(item?.name) === value); } +export function remoteSkillInstallName(skill: SkillSummary | null | undefined): string { + const remotePathName = skillNameFromPath(skill?.remotePath); + return remotePathName || normalizeSkillName(skill?.name); +} + function normalizeSkillName(value: unknown): string { return String(value || "").trim(); } @@ -70,3 +76,17 @@ function normalizeSkillSource(value: unknown): string { .trim() .toLowerCase(); } + +function skillNameFromPath(value: unknown): string { + const path = String(value || "").trim(); + if (!path) { + return ""; + } + return ( + path + .split("/") + .map((part) => part.trim()) + .filter(Boolean) + .at(-1) || "" + ); +} diff --git a/web/app/src/pages/HubPage/components/HubDetailPane/HubDetailPane.tsx b/web/app/src/pages/HubPage/components/HubDetailPane/HubDetailPane.tsx index 0ed9dbad..a311cec8 100644 --- a/web/app/src/pages/HubPage/components/HubDetailPane/HubDetailPane.tsx +++ b/web/app/src/pages/HubPage/components/HubDetailPane/HubDetailPane.tsx @@ -17,7 +17,7 @@ import { } from "@/components/ui"; import type { LocaleCode, TranslateFn } from "@/models/conversations"; import type { HubTemplate } from "@/models/hubWorkspace"; -import { isReadonlySkill, skillSourceBadgeName } from "@/models/skillhub"; +import { isReadonlySkill } from "@/models/skillhub"; import type { SkillFile, SkillSummary, SkillTree } from "@/models/skillhub"; import type { WorkspaceEntry, WorkspaceFile } from "@/models/workspace"; @@ -165,7 +165,6 @@ export function HubDetailPane({ } = hub?.detailPaneProps ?? EMPTY_HUB_DETAIL_PROPS; const canDeleteTemplate = isDeletableHubTemplate(selectedTemplate); const canDeleteSkill = Boolean(selectedSkill && !isReadonlySkill(selectedSkill)); - const selectedSkillSourceBadge = skillSourceBadgeName(selectedSkill); const skillEntries = skillTree?.entries ?? EMPTY_WORKSPACE_ENTRIES; const activeResourceType = useMemo(() => { if (selectedResourceType === "skill" && skills.length) { @@ -343,14 +342,6 @@ export function HubDetailPane({

{selectedSkill.name}

- {selectedSkillSourceBadge ? ( -
- - - {localizeTemplateSourceTag(selectedSkillSourceBadge, locale)} - -
- ) : null}

{selectedSkill.description || selectedSkill.name}

diff --git a/web/app/src/pages/WorkspacePage/components/WorkspaceSidebar/SkillUploadDialog.tsx b/web/app/src/pages/WorkspacePage/components/WorkspaceSidebar/SkillUploadDialog.tsx index c7a16fc6..0284cf29 100644 --- a/web/app/src/pages/WorkspacePage/components/WorkspaceSidebar/SkillUploadDialog.tsx +++ b/web/app/src/pages/WorkspacePage/components/WorkspaceSidebar/SkillUploadDialog.tsx @@ -11,15 +11,19 @@ import { DialogTitle, TextInput, } from "@/components/ui"; -import type { LocaleCode, TranslateFn } from "@/models/conversations"; +import type { TranslateFn } from "@/models/conversations"; +import { hasSkillName, remoteSkillInstallName } from "@/models/skillhub"; import type { SkillSummary } from "@/models/skillhub"; -import { localizeTemplateSourceTag } from "@/shared/i18n"; + +type RemoteSkillInstallOptions = { + replace?: boolean; +}; export type SkillUploadDialogProps = { busy: boolean; error: string; - locale: LocaleCode; - onInstallRemoteSkill?: (skill: SkillSummary) => Promise; + installedSkills: readonly SkillSummary[]; + onInstallRemoteSkill?: (skill: SkillSummary, options?: RemoteSkillInstallOptions) => Promise; onLoadMoreRemoteSkills?: () => Promise; onOpenChange: (open: boolean) => void; onRefreshRemoteSkills?: () => Promise; @@ -51,7 +55,7 @@ export function SkillUploadDialog({ onSubmit, busy, error, - locale, + installedSkills, remoteInstallBusy, remoteInstallError, remoteSkillsHasMore, @@ -130,11 +134,11 @@ export function SkillUploadDialog({ }, [onOpenChange, onSubmit, selectedFile, t]); const handleRemoteInstall = useCallback( - async (skill: SkillSummary) => { + async (skill: SkillSummary, options: RemoteSkillInstallOptions = {}) => { if (!onInstallRemoteSkill) { return; } - const result = await onInstallRemoteSkill(skill); + const result = await onInstallRemoteSkill(skill, options); if (result) { onOpenChange(false); } @@ -255,32 +259,51 @@ export function SkillUploadDialog({ ) : remoteSkills.length ? ( <>
- {remoteSkills.map((item) => ( -
- - - {item.name} - {item.description || item.remotePath} - - - - {localizeTemplateSourceTag("official", locale)} - - -
- ))} + {remoteSkills.map((item) => { + const installKey = item.remotePath || item.name; + const installed = hasSkillName(installedSkills, remoteSkillInstallName(item)); + const description = item.description || item.remotePath || item.name; + const rowContent = ( + <> + + + {item.name} + {description} + + + ); + return ( +
+ {item.remoteURL ? ( + + {rowContent} + + ) : ( + {rowContent} + )} + +
+ ); + })} {remoteSkillsLoadingMore ? (
{t("resourcesSkillRemoteSkillsLoading")}
) : null} diff --git a/web/app/src/pages/WorkspacePage/components/WorkspaceSidebar/WorkspaceSidebar.css b/web/app/src/pages/WorkspacePage/components/WorkspaceSidebar/WorkspaceSidebar.css index 2b70d8bc..be5fa7f1 100644 --- a/web/app/src/pages/WorkspacePage/components/WorkspaceSidebar/WorkspaceSidebar.css +++ b/web/app/src/pages/WorkspacePage/components/WorkspaceSidebar/WorkspaceSidebar.css @@ -155,9 +155,10 @@ .hub-skill-remote-row { display: grid; - grid-template-columns: auto minmax(0, 1fr) auto auto; + grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 10px; + min-width: 0; min-height: 54px; padding: 9px 10px; border: 1px solid var(--gray-200); @@ -165,6 +166,28 @@ background: var(--white); } +.hub-skill-remote-link { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + align-items: center; + gap: 10px; + min-width: 0; + overflow: hidden; + color: inherit; + text-decoration: none; +} + +.hub-skill-remote-link:hover .hub-skill-remote-title, +.hub-skill-remote-link:focus-visible .hub-skill-remote-title { + color: var(--brand-600); +} + +.hub-skill-remote-link:focus-visible { + border-radius: 6px; + outline: 2px solid color-mix(in srgb, var(--brand-600) 40%, transparent); + outline-offset: 2px; +} + :root[data-theme="dark"] .hub-skill-remote-row { border-color: var(--gray-700); background: var(--panel-soft); @@ -194,6 +217,7 @@ .hub-skill-remote-main { display: grid; min-width: 0; + overflow: hidden; gap: 2px; } @@ -205,6 +229,7 @@ } .hub-skill-remote-meta { + min-width: 0; color: var(--gray-600); font-size: 12px; line-height: 18px; @@ -231,11 +256,7 @@ @media (max-width: 560px) { .hub-skill-remote-row { - grid-template-columns: auto minmax(0, 1fr) auto; - } - - .hub-skill-remote-row .mini-badge { - display: none; + grid-template-columns: minmax(0, 1fr); } } diff --git a/web/app/src/pages/WorkspacePage/components/WorkspaceSidebar/WorkspaceTabPanels.tsx b/web/app/src/pages/WorkspacePage/components/WorkspaceSidebar/WorkspaceTabPanels.tsx index 53afd3ff..34cb60b2 100644 --- a/web/app/src/pages/WorkspacePage/components/WorkspaceSidebar/WorkspaceTabPanels.tsx +++ b/web/app/src/pages/WorkspacePage/components/WorkspaceSidebar/WorkspaceTabPanels.tsx @@ -6,7 +6,6 @@ import { isDirectConversation, resolveConversationUser } from "@/models/conversa import { modelProviderAvatarPath, providerStatusTone, type ModelProvider } from "@/models/modelProviders"; import { WorkspacePaneTypes, WorkspaceTabs } from "@/models/routing"; import { displayTeam } from "@/models/tasks"; -import { skillSourceBadgeName } from "@/models/skillhub"; import { localizeTemplateSourceTag } from "@/shared/i18n"; import { WORKSPACE_SECTION_ORDER_STORAGE_KEY } from "@/shared/storage/keys"; import { SkillUploadDialog } from "./SkillUploadDialog"; @@ -729,36 +728,25 @@ export function WorkspaceTabPanels({ addIcon={