Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 21 additions & 2 deletions internal/api/handler_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}

Expand Down
8 changes: 7 additions & 1 deletion internal/api/skill_remote_install.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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
Expand Down
23 changes: 23 additions & 0 deletions internal/skill/local/skill_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down
51 changes: 50 additions & 1 deletion internal/skill/local/upload.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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)
}
Expand All @@ -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 {
Expand Down
49 changes: 40 additions & 9 deletions web/app/src/api/skills.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,12 +28,15 @@ export function fetchSkills(): Promise<SkillSummary[]> {

export async function fetchAgenticHubOfficialSkillsPage(page = 1, search = ""): Promise<AgenticHubSkillsPage> {
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<AgenticHubSkillsResponse>(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;
Expand Down Expand Up @@ -74,20 +77,24 @@ export function uploadSkillArchive(file: File): Promise<SkillSummary> {
});
}

export function installRemoteSkillRequest(remotePath: string, ref = ""): Promise<SkillSummary> {
const payload: { ref?: string; remote_path: string } = { remote_path: String(remotePath || "").trim() };
export function installRemoteSkillRequest(remotePath: string, ref = "", replace = false): Promise<SkillSummary> {
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<SkillSummary>("api/v1/skills:install", {
json: payload,
method: "POST",
});
}

async function agenticHubOfficialSkillsPath(page: number, search: string): Promise<string> {
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),
Expand Down Expand Up @@ -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;
}
Expand All @@ -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) {
Expand Down
15 changes: 12 additions & 3 deletions web/app/src/hooks/workspace/useWorkspaceHubController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ import type { UseWorkspaceHubControllerArgs } from "./types";
type WorkspaceHubSelection = ReturnType<typeof useWorkspaceHubSelection>;
type DeleteHubTemplate = (template: HubTemplate | null | undefined) => Promise<boolean>;
type DeleteSkill = (skill: SkillSummary | null | undefined) => Promise<boolean>;
export type InstallRemoteSkillOptions = {
replace?: boolean;
};

export type WorkspaceHubController = {
hub: Omit<WorkspaceHubSelection, "detailPaneProps"> & {
Expand All @@ -23,7 +26,10 @@ export type WorkspaceHubController = {
skillDeleteBusy: boolean;
remoteInstallBusy: string;
remoteInstallError: string;
installRemoteSkill: (skill: SkillSummary | null | undefined) => Promise<SkillSummary | null>;
installRemoteSkill: (
skill: SkillSummary | null | undefined,
options?: InstallRemoteSkillOptions,
) => Promise<SkillSummary | null>;
uploadBusy: boolean;
uploadError: string;
uploadSkill: (file: File) => Promise<SkillSummary | null>;
Expand Down Expand Up @@ -165,7 +171,10 @@ export function useWorkspaceHubController({
);

const installRemoteSkill = useCallback(
async (skill: SkillSummary | null | undefined): Promise<SkillSummary | null> => {
async (
skill: SkillSummary | null | undefined,
options: InstallRemoteSkillOptions = {},
): Promise<SkillSummary | null> => {
const remotePath = String(skill?.remotePath || "").trim();
if (!remotePath) {
setResourcesRemoteInstallError(t("resourcesSkillRemoteInstallFailed"));
Expand All @@ -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<SkillSummary[]>(workspaceQueryKeys.skills(), (current) => {
return upsertSkillSummary(current, installed);
});
Expand Down
20 changes: 20 additions & 0 deletions web/app/src/models/skillhub.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export type SkillSummary = {
readonly?: boolean;
remoteRef?: string;
remotePath?: string;
remoteURL?: string;
source?: string;
};

Expand Down Expand Up @@ -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();
}
Expand All @@ -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) || ""
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -343,14 +342,6 @@ export function HubDetailPane({
<FileCode2 size={18} strokeWidth={2} />
</span>
<h2>{selectedSkill.name}</h2>
{selectedSkillSourceBadge ? (
<div className="hub-inspector-badge-row">
<span className="mini-badge template-source-badge">
<span className="template-source-badge-dot" aria-hidden="true"></span>
{localizeTemplateSourceTag(selectedSkillSourceBadge, locale)}
</span>
</div>
) : null}
</div>
<p>{selectedSkill.description || selectedSkill.name}</p>
</div>
Expand Down
Loading
Loading