feat(harness-f2): marketplace de skills (instaladas + catálogo + upload .zip)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012Xh2xNpYkUm23ijNBVjDVa
This commit is contained in:
Rafael Melgaço
2026-07-24 15:56:55 -03:00
co-authored by Claude Opus 4.8
parent b170a5e693
commit 225ba2deb2
7 changed files with 378 additions and 1 deletions
+1
View File
@@ -38,3 +38,4 @@
- 2026-07-24 — FASE 2 Task 5 concluída: API de skills (`GET /api/v1/ai/skills`, `POST .../import` multipart, `POST .../[name]/install`, `DELETE .../[name]`). 3 ações de audit novas (`ai.skill_imported`, `ai.skill_installed`, `ai.skill_uninstalled`). Achado do "Before You Begin": NENHUMA rota do app usava `pg.Pool` antes desta — só o worker/scripts chamavam `createPool` do agent-engine. Resolvido reusando o MESMO `createPool` (não duplica setup de conexão) atrás de um singleton lazy novo (`lib/ai/skills/db.ts::getSkillsPool()`), com `SUPABASE_DB_URL` promovido de env exclusivo do engine pra `lib/env.ts` (schema `required()`, tolerante em dev — já existia no `.env.example`). GET resolve `installed` via os pointers da PRÓPRIA org (join em skill_versions; `source` derivado de `forked_from_version_id`) e `catalog` via pointers de plataforma cujo name não está em installed — 3 queries simples, sem embed do PostgREST (padrão do repo). DELETE só remove `skill_pointers`, nunca `skill_versions` (histórico imutável). Achado de teste: multipart real (`File`/`FormData`) corrompe em ambiente jsdom (default do projeto) ao passar pelo parser undici do `NextRequest` — `import/route.test.ts` roda em `// @vitest-environment node` (isolado, só este arquivo). TDD: 13/13 testes verdes (4 arquivos: GET, import, install, delete). Typecheck e lint zerados (0 erros, 0 warnings).
- 2026-07-24 — FASE 2 Task 6 concluída: tool `read_skill_reference` no turno + telemetria `skill_activations`. `LoadedSkill.manifest` não existia (só Task 2 tinha trazido `versionId`) — trazido pra `loadSkills` (SELECT `v.manifest` + campo na interface, 2 linhas, sem quebrar `skills.test.ts` que mocka rows sem o campo). Achado de ordenação: `matchSkills` rodava DEPOIS de `wrapToolsWithBreaker` no arquivo original — movido pra ANTES da montagem de `rawTools` (junto da telemetria fire-and-forget do brief) porque o gate "só entra quando há skill casada com references" precisa do resultado do match no MESMO ponto onde `search_knowledge`/`request_human_handoff` já são ligados/desligados, antes do breaker envolver as tools. Storage: `deps.crmCfg.supabase` já era o admin client (service role) usado pelas outras bordas do CRM — nenhum seam novo. Guarda anti-vazamento entre skills: `readSkillReference` só aceita `skill_name` presente em `skillMatch.matched` (as skills CASADAS deste turno, nunca todas as skills do tenant) — pedir uma skill instalada mas não casada volta `skill_not_active` antes de tocar manifest/storage. TDD: 8/8 testes novos verdes (`skill-references.test.ts` + 1 em `tool-breaker.test.ts`); suite completa `lib/agent-engine` 27/27 verde; typecheck e lint zerados. Não existe `llm-cache.test.ts`/teste de byte-identidade dedicado no repo (confirmado por busca — mesmo achado da Task 3 da Fase 1).
- 2026-07-24 — FASE 2 Task 8 concluída: migration 0069 — seed de 2 skills de plataforma (`organization_id null`) no catálogo do marketplace: `objecao-preco` (diagnostica o motivo real por trás do "caro" — orçamento/valor não visto/concorrente/tática/timing — antes de reagir, nunca cede desconto não documentado) e `agendamento` (nunca inventa disponibilidade sem checar agenda real, oferece horários fechados em vez de pergunta aberta, exige confirmação explícita por escrito). Bodies markdown if-then (~80/76 linhas, teto 200) + `matcher` `{any_keywords, probe_keywords}` validado por `skillMatcherSchema` — não há CHECK no banco pro shape (confirmado lendo migration 0050: "shape validado no código"). Idempotente via guard `where not exists (select 1 from skill_pointers where organization_id is null and name=...)` em `do $seed$` blocks — aplicado 2x no banco dev: 2 pointers/1 versão cada nas duas rodadas (sem duplicação). Espelhado no apêndice do baseline.sql + linha no MANIFEST. Sem mudança de contrato — `database.types.ts` não regenerado (seed de dados). Typecheck zerado.
- 2026-07-24 — FASE 2 Task 7 concluída: tela `app/app/ai/skills` (hooks/ai/useSkills.ts + page.tsx + _client.tsx) — "Skills instaladas" (cards com badge manual/do catálogo, Desinstalar, nota de que reenviar um .zip com o mesmo nome personaliza) e "Catálogo" (skills de plataforma não instaladas, Instalar) + botão "Enviar skill (.zip)" (upload multipart via `fetch` direto — `apiClient` sempre serializa JSON, não dá pra FormData). Item "Skills da IA" na Sidebar + permissões `ai.skills.view`/`ai.skills.manage` (ambas manager) em AuthProvider. Ícone `PuzzlePiece` (não `Puzzle` — nome real no phosphor 2.1.10) + `UploadSimple`/`DownloadSimple` adicionados ao mapa canônico `lib/ui/icons.ts`. Typecheck e eslint zerados. Prova real clicando (manager e2e-manager, sem MFA): catálogo mostrou os 2 seeds da Task 8 → Instalar `objecao-preco` moveu pra Instaladas com badge "do catálogo" → Desinstalar removeu; upload de um .zip mínimo (SKILL.md+references/, montado com fflate) → apareceu em Instaladas com badge "manual". Screenshot em `.superpowers/sdd/evidence/f2-skills-tela.png`. Cleanup: `skill_pointers`(0)/`skill_versions`(0) da org de teste deletados via psql + objeto de storage em `skill-assets/{org}/teste-f2-upload/...` removido via admin client — confirmado 0/0 e catálogo de plataforma intacto (os 2 seeds voltaram a aparecer).
+215
View File
@@ -0,0 +1,215 @@
"use client";
import * as React from "react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { showApiError } from "@/components/feedback/ApiErrorToast";
import { PuzzlePiece, UploadSimple, DownloadSimple, Trash, Info } from "@/lib/ui/icons";
import { usePermission } from "@/hooks/auth/AuthProvider";
import {
useSkills,
useInstallSkill,
useUninstallSkill,
useImportSkill,
type SkillsState,
} from "@/hooks/ai/useSkills";
interface Props {
initialState: SkillsState;
}
function formatDate(iso: string): string {
return new Date(iso).toLocaleString("pt-BR", {
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
});
}
export function SkillsClient({ initialState }: Props) {
const { data } = useSkills(initialState);
const installed = data?.installed ?? [];
const catalog = data?.catalog ?? [];
const canManage = usePermission("ai.skills.manage");
const install = useInstallSkill();
const uninstall = useUninstallSkill();
const importSkill = useImportSkill();
const fileInputRef = React.useRef<HTMLInputElement>(null);
const [pendingName, setPendingName] = React.useState<string | null>(null);
function handleInstall(name: string) {
setPendingName(name);
install.mutate(name, {
onSuccess: () => {
toast.success(`Skill "${name}" instalada — já vale para os agentes desta organização.`);
setPendingName(null);
},
onError: (err) => {
showApiError(err);
setPendingName(null);
},
});
}
function handleUninstall(name: string) {
setPendingName(name);
uninstall.mutate(name, {
onSuccess: () => {
toast.success(`Skill "${name}" desinstalada.`);
setPendingName(null);
},
onError: (err) => {
showApiError(err);
setPendingName(null);
},
});
}
function handleFileChosen(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
e.target.value = "";
if (!file) return;
importSkill.mutate(file, {
onSuccess: (res) => {
toast.success(`Skill "${res.data.name}" enviada e instalada com sucesso.`);
},
onError: showApiError,
});
}
return (
<div className="flex flex-col gap-6">
<Card>
<CardHeader>
<div className="flex flex-wrap items-center justify-between gap-2">
<div>
<CardTitle>Skills instaladas</CardTitle>
<CardDescription>
O que seus agentes já sabem fazer além da conversa comum — cada skill só entra
em ação quando o assunto pede.
</CardDescription>
</div>
{canManage && (
<>
<input
ref={fileInputRef}
type="file"
accept=".zip"
className="hidden"
onChange={handleFileChosen}
/>
<Button
variant="secondary"
size="sm"
disabled={importSkill.isPending}
onClick={() => fileInputRef.current?.click()}
>
<UploadSimple /> {importSkill.isPending ? "Enviando…" : "Enviar skill (.zip)"}
</Button>
</>
)}
</div>
</CardHeader>
<CardContent className="flex flex-col gap-4">
{installed.length === 0 ? (
<p className="rounded-md border border-dashed border-border p-4 text-sm text-muted-foreground">
Nenhuma skill instalada ainda. Instale uma pronta do catálogo abaixo ou envie a
sua em "Enviar skill (.zip)".
</p>
) : (
<ul className="flex flex-col gap-2">
{installed.map((skill) => (
<li
key={skill.name}
className="flex flex-col gap-1.5 rounded-md border border-border/60 p-3 text-sm"
>
<div className="flex flex-wrap items-center gap-2">
<PuzzlePiece className="text-accent" aria-hidden />
<span className="font-medium">{skill.name}</span>
<Badge variant={skill.source === "catalog" ? "info" : "neutral"} className="text-[10px]">
{skill.source === "catalog" ? "do catálogo" : "manual"}
</Badge>
<span className="ml-auto text-xs text-muted-foreground">
atualizada em {formatDate(skill.updated_at)}
</span>
</div>
{skill.description && <p className="text-text-muted">{skill.description}</p>}
{canManage && (
<div className="flex justify-end">
<Button
variant="ghost"
size="sm"
disabled={uninstall.isPending && pendingName === skill.name}
onClick={() => handleUninstall(skill.name)}
>
<Trash /> Desinstalar
</Button>
</div>
)}
</li>
))}
</ul>
)}
<div className="flex items-start gap-2 rounded-md bg-accent-soft p-3 text-xs text-text-muted">
<Info className="mt-0.5 shrink-0" aria-hidden />
<p>
Para personalizar uma skill instalada, basta reenviar um .zip com o mesmo nome —
a sua versão passa a valer no lugar da do catálogo. Não há editor dentro do sistema
nesta fase.
</p>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Catálogo</CardTitle>
<CardDescription>
Skills prontas, mantidas pela plataforma, disponíveis para instalar com um clique.
</CardDescription>
</CardHeader>
<CardContent>
{catalog.length === 0 ? (
<p className="rounded-md border border-dashed border-border p-4 text-sm text-muted-foreground">
Nenhuma skill nova no catálogo — você já instalou tudo que a plataforma oferece
hoje.
</p>
) : (
<ul className="flex flex-col gap-2">
{catalog.map((skill) => (
<li
key={skill.name}
className="flex flex-col gap-1.5 rounded-md border border-border/60 p-3 text-sm"
>
<div className="flex flex-wrap items-center gap-2">
<PuzzlePiece aria-hidden />
<span className="font-medium">{skill.name}</span>
</div>
{skill.description && <p className="text-text-muted">{skill.description}</p>}
{canManage && (
<div className="flex justify-end">
<Button
size="sm"
disabled={install.isPending && pendingName === skill.name}
onClick={() => handleInstall(skill.name)}
>
<DownloadSimple />
{install.isPending && pendingName === skill.name ? "Instalando…" : "Instalar"}
</Button>
</div>
)}
</li>
))}
</ul>
)}
</CardContent>
</Card>
</div>
);
}
+67
View File
@@ -0,0 +1,67 @@
import { redirect } from "next/navigation";
import { requireAuth, resolveActiveOrg } from "@/lib/auth/server";
import { ROLE_RANK } from "@/lib/auth/types";
import { createAdminClient } from "@/lib/supabase/admin";
import type { SkillsState } from "@/hooks/ai/useSkills";
import { SkillsClient } from "./_client";
export const dynamic = "force-dynamic";
export default async function SkillsPage() {
const user = await requireAuth();
const activeOrg = await resolveActiveOrg(user);
if (!activeOrg) redirect("/app");
if (!user.is_platform_admin && ROLE_RANK[activeOrg.role] < ROLE_RANK.manager) {
redirect("/403");
}
const admin = createAdminClient();
const [{ data: orgPointers }, { data: platformPointers }] = await Promise.all([
admin.from("skill_pointers").select("name, version_id, updated_at").eq("organization_id", activeOrg.orgId),
admin.from("skill_pointers").select("name, version_id").is("organization_id", null),
]);
const orgRows = orgPointers ?? [];
const platformRows = platformPointers ?? [];
const versionIds = [...new Set([...orgRows, ...platformRows].map((p) => p.version_id))];
const { data: versionsRaw } =
versionIds.length > 0
? await admin.from("skill_versions").select("id, description, forked_from_version_id").in("id", versionIds)
: { data: [] };
const versionById = new Map((versionsRaw ?? []).map((v) => [v.id, v]));
const installed: SkillsState["installed"] = orgRows.map((p) => {
const v = versionById.get(p.version_id);
return {
name: p.name,
description: v?.description ?? "",
version_id: p.version_id,
source: (v?.forked_from_version_id ? "catalog" : "manual") as "catalog" | "manual",
updated_at: p.updated_at,
};
});
const installedNames = new Set(installed.map((i) => i.name));
const catalog: SkillsState["catalog"] = platformRows
.filter((p) => !installedNames.has(p.name))
.map((p) => ({ name: p.name, description: versionById.get(p.version_id)?.description ?? "" }));
const initialState: SkillsState = { installed, catalog };
return (
<div className="flex h-full flex-col gap-6 p-6">
<header>
<h1 className="text-2xl font-semibold tracking-tight">Skills da IA</h1>
<p className="text-sm text-muted-foreground">
Habilidades especializadas que seus agentes carregam só quando a conversa pede —
instale prontas do catálogo ou envie a sua.
</p>
</header>
<SkillsClient initialState={initialState} />
</div>
);
}
+5 -1
View File
@@ -2,7 +2,7 @@
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useTransition } from "react";
import { Kanban, Users, UsersThree, Gear, CaretDoubleLeft, CaretDoubleRight, Inbox, ScalesSimple, Robot, Brain, PlugsConnected, ChartBar, WebhooksLogo } from "@/lib/ui/icons";
import { Kanban, Users, UsersThree, Gear, CaretDoubleLeft, CaretDoubleRight, Inbox, ScalesSimple, Robot, Brain, PuzzlePiece, PlugsConnected, ChartBar, WebhooksLogo, ClockCountdown } from "@/lib/ui/icons";
import type { Icon as PhosphorIcon } from "@phosphor-icons/react";
import { cn } from "@/lib/utils";
import { toggleSidebar } from "@/app/actions/shell/toggleSidebar";
@@ -19,6 +19,7 @@ interface NavItem {
const NAV_ITEMS: NavItem[] = [
{ href: "/app/inbox", label: "Inbox", icon: Inbox },
{ href: "/app/radar", label: "Radar", icon: ClockCountdown },
{ href: "/app/connections", label: "Conexões", icon: PlugsConnected, healthDot: true },
{ href: "/app/kanban", label: "Kanban", icon: Kanban },
{ href: "/app/contacts", label: "Contatos", icon: Users },
@@ -27,6 +28,7 @@ const NAV_ITEMS: NavItem[] = [
{ href: "/app/lgpd/requests", label: "LGPD", icon: ScalesSimple, permission: "lgpd.execute_redact" },
{ href: "/app/ai/agents", label: "Agentes IA", icon: Robot, permission: "ai.agents.view" },
{ href: "/app/ai/memory", label: "Memória da IA", icon: Brain, permission: "ai.memory.view" },
{ href: "/app/ai/skills", label: "Skills da IA", icon: PuzzlePiece, permission: "ai.skills.view" },
{ href: "/app/webhooks", label: "Webhooks", icon: WebhooksLogo, permission: "webhooks.manage" },
{ href: "/app/settings", label: "Configurações", icon: Gear },
];
@@ -37,6 +39,7 @@ export function Sidebar({ collapsed }: { collapsed: boolean }) {
const canLgpd = usePermission("lgpd.execute_redact");
const canAiAgents = usePermission("ai.agents.view");
const canAiMemory = usePermission("ai.memory.view");
const canAiSkills = usePermission("ai.skills.view");
const canWebhooks = usePermission("webhooks.manage");
return (
@@ -55,6 +58,7 @@ export function Sidebar({ collapsed }: { collapsed: boolean }) {
if (item.permission === "lgpd.execute_redact") return canLgpd;
if (item.permission === "ai.agents.view") return canAiAgents;
if (item.permission === "ai.memory.view") return canAiMemory;
if (item.permission === "ai.skills.view") return canAiSkills;
if (item.permission === "webhooks.manage") return canWebhooks;
return true;
}).map((item) => {
+85
View File
@@ -0,0 +1,85 @@
"use client";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { apiClient } from "@/lib/api/client";
import { ApiError, type ApiErrorBody } from "@/lib/api/types";
import { randomId } from "@/lib/random-id";
export interface InstalledSkill {
name: string;
description: string;
version_id: string;
source: "manual" | "catalog";
updated_at: string;
}
export interface CatalogSkill {
name: string;
description: string;
}
export interface SkillsState {
installed: InstalledSkill[];
catalog: CatalogSkill[];
}
const KEY = ["skills"];
export function useSkills(initial?: SkillsState) {
return useQuery({
queryKey: KEY,
...(initial !== undefined ? { initialData: initial } : {}),
queryFn: () => apiClient.get<{ data: SkillsState }>("/api/v1/ai/skills").then((r) => r.data),
});
}
export function useInstallSkill() {
const qc = useQueryClient();
return useMutation({
mutationFn: (name: string) =>
apiClient.post<{ data: { version_id: string } }>(`/api/v1/ai/skills/${encodeURIComponent(name)}/install`, {}),
onSuccess: () => void qc.invalidateQueries({ queryKey: KEY }),
});
}
export function useUninstallSkill() {
const qc = useQueryClient();
return useMutation({
mutationFn: (name: string) =>
apiClient.delete<{ data: { name: string } }>(`/api/v1/ai/skills/${encodeURIComponent(name)}`),
onSuccess: () => void qc.invalidateQueries({ queryKey: KEY }),
});
}
/**
* Upload multipart — `apiClient` sempre serializa o body como JSON, então este
* mutation faz o fetch direto (mesmo tratamento de erro do apiClient: parseia
* `{error}` e lança ApiError, pra `showApiError` funcionar igual nos outros hooks).
*/
export function useImportSkill() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (file: File) => {
const form = new FormData();
form.append("file", file);
const res = await fetch("/api/v1/ai/skills/import", {
method: "POST",
headers: { "Idempotency-Key": randomId() },
body: form,
credentials: "same-origin",
});
const text = await res.text();
const parsed = text ? JSON.parse(text) : null;
if (!res.ok) {
const errBody = parsed as ApiErrorBody | null;
const e = errBody?.error;
throw new ApiError(
res.status,
e?.code ?? "unknown_error",
e?.details,
e?.request_id ?? randomId(),
e?.message,
);
}
return parsed as { data: { name: string; version_id: string } };
},
onSuccess: () => void qc.invalidateQueries({ queryKey: KEY }),
});
}
+2
View File
@@ -105,6 +105,8 @@ const ACTION_MIN_ROLE: Record<string, Role> = {
"ai.agents.write": "admin",
"ai.memory.view": "manager",
"ai.memory.publish": "admin",
"ai.skills.view": "manager",
"ai.skills.manage": "manager",
"ai.credentials.view": "manager",
"ai.credentials.write": "admin",
"webhooks.manage": "manager",
+3
View File
@@ -86,4 +86,7 @@ export {
ArrowRight,
SignOut,
WebhooksLogo,
PuzzlePiece,
UploadSimple,
DownloadSimple,
} from "@phosphor-icons/react/dist/ssr";