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
40 changes: 31 additions & 9 deletions apps/web/src/hooks/use-org-fs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,23 @@ export function useOrgFsList(volume: string, path: string) {
});
}

/** Metadata for one entry — null when absent. Imperative twin of {@link useOrgFsStat}. */
export async function fetchOrgFsStat(
orgSlug: string,
volume: string,
path: string,
): Promise<OrgFsEntry | null> {
const res = await fsFetch(
fsUrl(orgSlug, volume, "stat", { path }),
undefined,
{
allow404: true,
},
);
if (res.status === 404) return null;
return ((await res.json()) as { entry: OrgFsEntry }).entry;
}

/** Metadata for one entry — null when absent. Powers the Library preview. */
export function useOrgFsStat(
volume: string | null,
Expand All @@ -125,15 +142,7 @@ export function useOrgFsStat(
? (query) =>
query.state.data ? false : (opts.refetchIntervalWhenAbsent ?? false)
: undefined,
queryFn: async (): Promise<OrgFsEntry | null> => {
const res = await fsFetch(
fsUrl(org.slug, volume ?? "", "stat", { path }),
undefined,
{ allow404: true },
);
if (res.status === 404) return null;
return ((await res.json()) as { entry: OrgFsEntry }).entry;
},
queryFn: () => fetchOrgFsStat(org.slug, volume ?? "", path),
});
}

Expand Down Expand Up @@ -273,6 +282,19 @@ export async function fetchOrgFsSkillCatalog(
return skills;
}

/** react-query wrapper around {@link fetchOrgFsSkillCatalog} — same cache key
* the chat "/" picker uses (`KEYS.slashSkills`), so both surfaces share one
* fetch of the org's full skill catalog (home + public sets + synced repos). */
export function useOrgFsSkillCatalog() {
const { org } = useProjectContext();
return useQuery({
queryKey: KEYS.slashSkills(org.id),
queryFn: () => fetchOrgFsSkillCatalog(org.slug),
// Same window as the picker: a build rescans home + every synced volume.
staleTime: 60_000,
});
}

/** Read a file's contents as UTF-8 text (org-fs `/read` endpoint). */
async function fetchOrgFsText(
orgSlug: string,
Expand Down
33 changes: 33 additions & 0 deletions apps/web/src/i18n/en/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,7 @@ export const settings = {
"settings.nav.connections": "Connections",
"settings.nav.agents": "Agents",
"settings.nav.automations": "Automations",
"settings.nav.skills": "Skills",
"settings.nav.store": "Store",
"settings.nav.monitor": "Monitor",
"settings.nav.members": "Members",
Expand Down Expand Up @@ -170,6 +171,38 @@ export const settings = {
"settings.automations.noResultsTitle": "No automations found",
"settings.automations.pageTitle": "Automations",
"settings.automations.searchPlaceholder": "Search automations...",
"settings.skills.pageTitle": "Skills",
"settings.skills.importButton": "Import skill",
"settings.skills.importing": "Importing…",
"settings.skills.importSuccess": 'Imported "{name}"',
"settings.skills.importError": "Failed to import skill",
"settings.skills.importMissingSkillMd":
"That folder has no SKILL.md at its root. Pick the skill's own folder.",
"settings.skills.importNeedsFolder":
"Pick a folder, not individual files — this browser may not support folder upload.",
"settings.skills.searchPlaceholder": "Search skills...",
"settings.skills.noDescription": "No description",
"settings.skills.filterAll": "All",
"settings.skills.emptyTitle": "No skills yet",
"settings.skills.emptyDescription":
"Import a folder with a SKILL.md to give your agents reusable instructions they can load on demand.",
"settings.skills.noResultsTitle": "No skills found",
"settings.skills.noResultsDescription": 'No skills match "{search}"',
"settings.skills.cancel": "Cancel",
"settings.skills.deleteButton": "Delete",
"settings.skills.deleteDialogTitle": "Delete this skill?",
"settings.skills.deleteDialogDescription":
'This removes "{name}" and its files. This can\'t be undone.',
"settings.skills.deleteSuccess": "Skill deleted",
"settings.skills.deleteError": "Failed to delete skill",
"settings.skills.importTooManyFiles":
"That folder has {count} files (limit {max}). Import a folder with just the skill's own files.",
"settings.skills.importSlugTaken":
'A skill named "{slug}" already exists. Delete it first to re-import.',
"settings.skills.errorTitle": "Couldn't load skills",
"settings.skills.errorDescription":
"The skill catalog could not be loaded. You may not have access to this org's files.",
"settings.skills.retry": "Try again",
"settings.buckets.accessKeyIdLabel": "Access key ID",
"settings.buckets.addBucket": "Add bucket",
"settings.buckets.addBucketButton": "Add bucket",
Expand Down
34 changes: 34 additions & 0 deletions apps/web/src/i18n/pt-br/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,7 @@ export const settings = {
"settings.nav.connections": "Conexões",
"settings.nav.agents": "Agentes",
"settings.nav.automations": "Automações",
"settings.nav.skills": "Skills",
"settings.nav.store": "Loja",
"settings.nav.monitor": "Monitoramento",
"settings.nav.members": "Membros",
Expand Down Expand Up @@ -175,6 +176,39 @@ export const settings = {
"Nenhuma automa\u00e7\u00e3o encontrada",
"settings.automations.pageTitle": "Automa\u00e7\u00f5es",
"settings.automations.searchPlaceholder": "Pesquisar automa\u00e7\u00f5es...",
"settings.skills.pageTitle": "Skills",
"settings.skills.importButton": "Importar skill",
"settings.skills.importing": "Importando\u2026",
"settings.skills.importSuccess": '"{name}" importada',
"settings.skills.importError": "Falha ao importar a skill",
"settings.skills.importMissingSkillMd":
"Essa pasta n\u00e3o tem um SKILL.md na raiz. Selecione a pasta da pr\u00f3pria skill.",
"settings.skills.importNeedsFolder":
"Selecione uma pasta, não arquivos individuais — este navegador pode não suportar upload de pastas.",
"settings.skills.searchPlaceholder": "Pesquisar skills...",
"settings.skills.noDescription": "Sem descri\u00e7\u00e3o",
"settings.skills.filterAll": "Todas",
"settings.skills.emptyTitle": "Nenhuma skill ainda",
"settings.skills.emptyDescription":
"Importe uma pasta com um SKILL.md para dar aos seus agentes instru\u00e7\u00f5es reutiliz\u00e1veis que eles podem carregar sob demanda.",
"settings.skills.noResultsTitle": "Nenhuma skill encontrada",
"settings.skills.noResultsDescription":
'Nenhuma skill corresponde a "{search}"',
"settings.skills.cancel": "Cancelar",
"settings.skills.deleteButton": "Excluir",
"settings.skills.deleteDialogTitle": "Excluir esta skill?",
"settings.skills.deleteDialogDescription":
'Isso remove "{name}" e seus arquivos. Essa a\u00e7\u00e3o n\u00e3o pode ser desfeita.',
"settings.skills.deleteSuccess": "Skill exclu\u00edda",
"settings.skills.deleteError": "Falha ao excluir a skill",
"settings.skills.importTooManyFiles":
"Essa pasta tem {count} arquivos (limite {max}). Importe uma pasta com apenas os arquivos da skill.",
"settings.skills.importSlugTaken":
'Já existe uma skill chamada "{slug}". Exclua-a antes de reimportar.',
"settings.skills.errorTitle": "Não foi possível carregar as skills",
"settings.skills.errorDescription":
"O catálogo de skills não pôde ser carregado. Você pode não ter acesso aos arquivos desta organização.",
"settings.skills.retry": "Tentar novamente",
"settings.buckets.accessKeyIdLabel": "ID de chave de acesso",
"settings.buckets.addBucket": "Adicionar bucket",
"settings.buckets.addBucketButton": "Adicionar bucket",
Expand Down
7 changes: 7 additions & 0 deletions apps/web/src/layouts/settings-layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import {
ChevronDown,
ChevronRight,
Columns03,
Stars01,
ZapSquare,
CreditCard01,
Loading01,
Expand Down Expand Up @@ -179,6 +180,12 @@ function useSettingsSidebarGroups(): SettingsNavGroup[] {
to: "/$org/settings/automations",
requires: "automations:manage",
},
{
key: "skills",
label: t("settings.nav.skills"),
icon: <Stars01 size={14} />,
to: "/$org/settings/skills",
},
],
},
{
Expand Down
19 changes: 19 additions & 0 deletions apps/web/src/react.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
/**
* React type augmentations.
*
* Its own file, not `globals.d.ts`: that one is a global script, where
* `declare module "react"` would declare an ambient module that SHADOWS the
* real one (erasing every React export) instead of merging with it. The
* top-level `import` here makes this a module, so the block below is a module
* augmentation and merges as intended.
*/

import "react";

declare module "react" {
interface InputHTMLAttributes<T> {
/** Directory picking — supported by every browser we target, but missing
* from React's DOM attribute types. */
webkitdirectory?: string;
}
}
9 changes: 9 additions & 0 deletions apps/web/src/router.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -917,6 +917,14 @@ const settingsAutomationsRoute = createRoute({
),
});

const settingsSkillsRoute = createRoute({
getParentRoute: () => settingsLayout,
path: "/skills",
component: lazyRouteComponent(
() => import("./routes/orgs/settings/skills.tsx"),
),
});

// ============================================
// ROUTE TREE
// ============================================
Expand All @@ -928,6 +936,7 @@ const settingsWithChildren = settingsLayout.addChildren([
collectionDetailRoute,
settingsAgentsRoute,
settingsAutomationsRoute,
settingsSkillsRoute,
monitoringRoute,
settingsGeneralRoute,
settingsConnectRoute,
Expand Down
106 changes: 106 additions & 0 deletions apps/web/src/routes/orgs/settings/skills-import.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import { describe, expect, test } from "bun:test";
import {
groupByDestination,
importable,
relativePath,
slugify,
uploadAllGroups,
} from "./skills-import.ts";

/** A picked file, standing in for what a directory `<input>` hands back. */
function picked(webkitRelativePath: string): File {
const name = webkitRelativePath.split("/").pop() ?? "";
const file = new File(["x"], name);
Object.defineProperty(file, "webkitRelativePath", {
value: webkitRelativePath,
});
return file;
}

describe("relativePath", () => {
test("strips the picked folder root", () => {
expect(relativePath(picked("my-skill/SKILL.md"))).toBe("SKILL.md");
expect(relativePath(picked("my-skill/references/style.md"))).toBe(
"references/style.md",
);
});

test("falls back to the bare name on a non-directory pick", () => {
// What a plain file input reports: a bare name, no folder root to strip.
expect(relativePath(picked("SKILL.md"))).toBe("SKILL.md");
});
});

describe("importable", () => {
test("keeps the skill's own files at any depth", () => {
expect(importable(picked("s/SKILL.md"))).toBe(true);
expect(importable(picked("s/references/deep/style.md"))).toBe(true);
});

test("drops dotfiles, dot-dirs and tooling dirs", () => {
expect(importable(picked("s/.DS_Store"))).toBe(false);
expect(importable(picked("s/.git/config"))).toBe(false);
expect(importable(picked("s/node_modules/left-pad/index.js"))).toBe(false);
expect(importable(picked("s/scripts/__pycache__/a.pyc"))).toBe(false);
});
});

describe("slugify", () => {
test("normalizes a folder name", () => {
expect(slugify(" My Cool Skill! ")).toBe("my-cool-skill");
});

test("never yields an empty slug", () => {
expect(slugify("!!!")).toBe("skill");
expect(slugify("")).toBe("skill");
});
});

describe("groupByDestination", () => {
test("keeps subdirectories instead of flattening onto the root", () => {
const groups = groupByDestination(
[
picked("my-skill/SKILL.md"),
picked("my-skill/references/style.md"),
picked("my-skill/references/tone.md"),
],
"my-skill",
);
expect([...groups.keys()].toSorted()).toEqual([
"skills/my-skill",
"skills/my-skill/references",
]);
expect(
groups
.get("skills/my-skill/references")
?.map((f) => f.name)
.toSorted(),
).toEqual(["style.md", "tone.md"]);
});
});

describe("uploadAllGroups", () => {
const groups = new Map([
["skills/s", [new File([], "SKILL.md")]],
["skills/s/a", [new File([], "a.md")]],
["skills/s/b", [new File([], "b.md")]],
]);

test("uploads every group", async () => {
const seen: string[] = [];
await uploadAllGroups(groups, async ({ dir }) => void seen.push(dir));
expect(seen.toSorted()).toEqual(["skills/s", "skills/s/a", "skills/s/b"]);
});

test("waits for the other groups before rejecting", async () => {
const landed: string[] = [];
const put = async ({ dir }: { dir: string }) => {
if (dir === "skills/s") throw new Error("quota");
await new Promise((r) => setTimeout(r, 5));
landed.push(dir);
};
await expect(uploadAllGroups(groups, put)).rejects.toThrow("quota");
// Not the `Promise.all` behaviour: the slow PUTs are done, not in flight.
expect(landed.toSorted()).toEqual(["skills/s/a", "skills/s/b"]);
});
});
72 changes: 72 additions & 0 deletions apps/web/src/routes/orgs/settings/skills-import.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
/**
* Pure mapping from a picked folder onto `home/skills/<slug>/…`, split from the
* page so it can be unit-tested without the route's React module graph.
*/

/** One PUT per file, so a stray `node_modules` would fan out to thousands. */
export const MAX_IMPORT_FILES = 200;

const SKIPPED_DIRS = new Set(["node_modules", "__pycache__"]);

/** A picked file's path relative to the folder root the user chose. */
export function relativePath(file: File): string {
const [, ...rest] = file.webkitRelativePath.split("/");
return rest.length > 0 ? rest.join("/") : file.name;
}

/** Drop what a skill folder never means to ship: tooling dirs and dotfiles. */
export function importable(file: File): boolean {
return relativePath(file)
.split("/")
.every((segment) => !segment.startsWith(".") && !SKIPPED_DIRS.has(segment));
}

export function slugify(name: string): string {
return (
name
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "") || "skill"
);
}

/**
* Group the picked files by destination directory: the upload endpoint takes
* one directory plus files whose own `name` completes the path, so nested
* files must be grouped rather than flattened (which would collapse
* `references/style.md` onto the root).
*/
export function groupByDestination(
files: File[],
slug: string,
): Map<string, File[]> {
const groups = new Map<string, File[]>();
for (const file of files) {
const segments = relativePath(file).split("/");
segments.pop();
const dir = ["skills", slug, ...segments].join("/");
groups.set(dir, [...(groups.get(dir) ?? []), file]);
}
return groups;
}

/**
* Upload every destination group concurrently.
*
* `allSettled`, not `all`: `all` rejects on the first failure while the rest
* are still in flight, resolving the caller's catch (and its catalog refresh)
* before those PUTs land — the same trap `useOrgFsMutations` avoids per-file.
*/
export async function uploadAllGroups(
groups: Map<string, File[]>,
put: (input: { dir: string; files: File[] }) => Promise<unknown>,
): Promise<void> {
const results = await Promise.allSettled(
[...groups].map(([dir, files]) => put({ dir, files })),
);
const failure = results.find(
(r): r is PromiseRejectedResult => r.status === "rejected",
);
if (failure) throw failure.reason;
}
Loading
Loading