diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 9b125165..83e5ff79 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -557,6 +557,7 @@ export { companySkillDetailSchema, companySkillUpdateStatusSchema, companySkillImportSchema, + companySkillUpdateAuthSchema, companySkillProjectScanRequestSchema, companySkillProjectScanSkippedSchema, companySkillProjectScanConflictSchema, diff --git a/packages/shared/src/validators/company-skill.ts b/packages/shared/src/validators/company-skill.ts index 7f1df34b..58b9165d 100644 --- a/packages/shared/src/validators/company-skill.ts +++ b/packages/shared/src/validators/company-skill.ts @@ -66,6 +66,11 @@ export const companySkillUpdateStatusSchema = z.object({ export const companySkillImportSchema = z.object({ source: z.string().min(1), + authToken: z.string().min(1).optional(), +}); + +export const companySkillUpdateAuthSchema = z.object({ + authToken: z.string().min(1).nullable(), }); export const companySkillProjectScanRequestSchema = z.object({ @@ -133,3 +138,4 @@ export type CompanySkillImport = z.infer; export type CompanySkillProjectScan = z.infer; export type CompanySkillCreate = z.infer; export type CompanySkillFileUpdate = z.infer; +export type CompanySkillUpdateAuth = z.infer; diff --git a/packages/shared/src/validators/index.ts b/packages/shared/src/validators/index.ts index aca19625..d1bc3929 100644 --- a/packages/shared/src/validators/index.ts +++ b/packages/shared/src/validators/index.ts @@ -44,6 +44,7 @@ export { companySkillDetailSchema, companySkillUpdateStatusSchema, companySkillImportSchema, + companySkillUpdateAuthSchema, companySkillProjectScanRequestSchema, companySkillProjectScanSkippedSchema, companySkillProjectScanConflictSchema, @@ -55,6 +56,7 @@ export { type CompanySkillProjectScan, type CompanySkillCreate, type CompanySkillFileUpdate, + type CompanySkillUpdateAuth, } from "./company-skill.js"; export { agentSkillStateSchema, diff --git a/server/src/__tests__/company-skills-routes.test.ts b/server/src/__tests__/company-skills-routes.test.ts index 6dbad659..16b4f692 100644 --- a/server/src/__tests__/company-skills-routes.test.ts +++ b/server/src/__tests__/company-skills-routes.test.ts @@ -89,6 +89,7 @@ describe("company skill mutation permissions", () => { expect(mockCompanySkillService.importFromSource).toHaveBeenCalledWith( "company-1", "https://github.com/vercel-labs/agent-browser", + undefined, ); }); @@ -266,6 +267,7 @@ describe("company skill mutation permissions", () => { expect(mockCompanySkillService.importFromSource).toHaveBeenCalledWith( "company-1", "https://github.com/vercel-labs/agent-browser", + undefined, ); }); diff --git a/server/src/routes/company-skills.ts b/server/src/routes/company-skills.ts index 9e91bf26..b1a5f7cf 100644 --- a/server/src/routes/company-skills.ts +++ b/server/src/routes/company-skills.ts @@ -4,6 +4,7 @@ import { companySkillCreateSchema, companySkillFileUpdateSchema, companySkillImportSchema, + companySkillUpdateAuthSchema, companySkillProjectScanRequestSchema, } from "@paperclipai/shared"; import { trackSkillImported } from "@paperclipai/shared/telemetry"; @@ -194,7 +195,8 @@ export function companySkillRoutes(db: Db) { const companyId = req.params.companyId as string; await assertCanMutateCompanySkills(req, companyId); const source = String(req.body.source ?? ""); - const result = await svc.importFromSource(companyId, source); + const authToken = typeof req.body.authToken === "string" ? req.body.authToken.trim() : undefined; + const result = await svc.importFromSource(companyId, source, authToken || undefined); const actor = getActorInfo(req); await logActivity(db, { @@ -260,6 +262,36 @@ export function companySkillRoutes(db: Db) { }, ); + router.delete("/companies/:companyId/skills/by-source", async (req, res) => { + const companyId = req.params.companyId as string; + const sourceLocator = String(req.query.source ?? "").trim(); + if (!sourceLocator) { + res.status(400).json({ error: "source query parameter is required" }); + return; + } + await assertCanMutateCompanySkills(req, companyId); + const deleted = await svc.deleteBySource(companyId, sourceLocator); + + const actor = getActorInfo(req); + await logActivity(db, { + companyId, + actorType: actor.actorType, + actorId: actor.actorId, + agentId: actor.agentId, + runId: actor.runId, + action: "company.skills_source_deleted", + entityType: "company", + entityId: companyId, + details: { + sourceLocator, + deletedCount: deleted.length, + deletedSlugs: deleted.map((s) => s.slug), + }, + }); + + res.json({ deleted }); + }); + router.delete("/companies/:companyId/skills/:skillId", async (req, res) => { const companyId = req.params.companyId as string; const skillId = req.params.skillId as string; @@ -318,5 +350,38 @@ export function companySkillRoutes(db: Db) { res.json(result); }); + router.patch( + "/companies/:companyId/skills/:skillId/auth", + validate(companySkillUpdateAuthSchema), + async (req, res) => { + const companyId = req.params.companyId as string; + const skillId = req.params.skillId as string; + await assertCanMutateCompanySkills(req, companyId); + const authToken = req.body.authToken as string | null; + const result = await svc.updateSkillAuth(companyId, skillId, authToken); + if (!result) { + res.status(404).json({ error: "Skill not found" }); + return; + } + + const actor = getActorInfo(req); + await logActivity(db, { + companyId, + actorType: actor.actorType, + actorId: actor.actorId, + agentId: actor.agentId, + runId: actor.runId, + action: authToken ? "company.skill_auth_updated" : "company.skill_auth_removed", + entityType: "company_skill", + entityId: result.id, + details: { + slug: result.slug, + }, + }); + + res.json(result); + }, + ); + return router; } diff --git a/server/src/services/company-skills.ts b/server/src/services/company-skills.ts index 60fc06b4..6549fda8 100644 --- a/server/src/services/company-skills.ts +++ b/server/src/services/company-skills.ts @@ -471,20 +471,20 @@ function parseFrontmatterMarkdown(raw: string): { frontmatter: Record(url: string): Promise { +async function fetchJson(url: string, authToken?: string): Promise { const response = await ghFetch(url, { headers: { accept: "application/vnd.github+json", }, - }); + }, authToken); if (!response.ok) { throw unprocessable(`Failed to fetch ${url}: ${response.status}`); } @@ -492,16 +492,18 @@ async function fetchJson(url: string): Promise { } -async function resolveGitHubDefaultBranch(owner: string, repo: string, apiBase: string) { +async function resolveGitHubDefaultBranch(owner: string, repo: string, apiBase: string, authToken?: string) { const response = await fetchJson<{ default_branch?: string }>( `${apiBase}/repos/${owner}/${repo}`, + authToken, ); return asString(response.default_branch) ?? "main"; } -async function resolveGitHubCommitSha(owner: string, repo: string, ref: string, apiBase: string) { +async function resolveGitHubCommitSha(owner: string, repo: string, ref: string, apiBase: string, authToken?: string) { const response = await fetchJson<{ sha?: string }>( `${apiBase}/repos/${owner}/${repo}/commits/${encodeURIComponent(ref)}`, + authToken, ); const sha = asString(response.sha); if (!sha) { @@ -538,7 +540,7 @@ function parseGitHubSourceUrl(rawUrl: string) { return { hostname: url.hostname, owner, repo, ref, basePath, filePath, explicitRef }; } -async function resolveGitHubPinnedRef(parsed: ReturnType) { +async function resolveGitHubPinnedRef(parsed: ReturnType, authToken?: string) { const apiBase = gitHubApiBase(parsed.hostname); if (/^[0-9a-f]{40}$/i.test(parsed.ref.trim())) { return { @@ -549,8 +551,8 @@ async function resolveGitHubPinnedRef(parsed: ReturnType { const url = sourceUrl.trim(); const warnings: string[] = []; @@ -995,10 +998,11 @@ async function readUrlSkillImports( if (looksLikeRepoUrl) { const parsed = parseGitHubSourceUrl(url); const apiBase = gitHubApiBase(parsed.hostname); - const { pinnedRef, trackingRef } = await resolveGitHubPinnedRef(parsed); + const { pinnedRef, trackingRef } = await resolveGitHubPinnedRef(parsed, authToken); let ref = pinnedRef; const tree = await fetchJson<{ tree?: Array<{ path: string; type: string }> }>( `${apiBase}/repos/${parsed.owner}/${parsed.repo}/git/trees/${ref}?recursive=1`, + authToken, ).catch(() => { throw unprocessable(`Failed to read GitHub tree for ${url}`); }); @@ -1025,7 +1029,7 @@ async function readUrlSkillImports( const skills: ImportedSkill[] = []; for (const relativeSkillPath of skillPaths) { const repoSkillPath = basePrefix ? `${basePrefix}${relativeSkillPath}` : relativeSkillPath; - const markdown = await fetchText(resolveRawGitHubUrl(parsed.hostname, parsed.owner, parsed.repo, ref, repoSkillPath)); + const markdown = await fetchText(resolveRawGitHubUrl(parsed.hostname, parsed.owner, parsed.repo, ref, repoSkillPath), authToken); const parsedMarkdown = parseFrontmatterMarkdown(markdown); const skillDir = path.posix.dirname(relativeSkillPath); const slug = deriveImportedSkillSlug(parsedMarkdown.frontmatter, path.posix.basename(skillDir)); @@ -1087,7 +1091,7 @@ async function readUrlSkillImports( } if (url.startsWith("http://") || url.startsWith("https://")) { - const markdown = await fetchText(url); + const markdown = await fetchText(url, authToken); const parsedMarkdown = parseFrontmatterMarkdown(markdown); const urlObj = new URL(url); const fileName = path.posix.basename(urlObj.pathname); @@ -1459,6 +1463,22 @@ export function companySkillService(db: Db) { const projects = projectService(db); const secretsSvc = secretService(db); + /** Resolve the GitHub auth token from a skill's metadata, if stored. */ + async function resolveSkillAuthToken( + companyId: string, + skill: { metadata: Record | null }, + ): Promise { + const meta = skill.metadata; + if (!meta) return undefined; + const secretId = typeof meta.sourceAuthSecretId === "string" ? meta.sourceAuthSecretId.trim() : ""; + if (!secretId) return undefined; + try { + return await secretsSvc.resolveSecretValue(companyId, secretId, "latest"); + } catch { + return undefined; + } + } + async function ensureBundledSkills(companyId: string) { for (const skillsRoot of resolveBundledSkillsRoot()) { const stats = await fs.stat(skillsRoot).catch(() => null); @@ -1656,7 +1676,8 @@ export function companySkillService(db: Db) { const hostname = asString(metadata.hostname) || "github.com"; const apiBase = gitHubApiBase(hostname); - const latestRef = await resolveGitHubCommitSha(owner, repo, trackingRef, apiBase); + const authToken = await resolveSkillAuthToken(companyId, skill); + const latestRef = await resolveGitHubCommitSha(owner, repo, trackingRef, apiBase, authToken); return { supported: true, reason: null, @@ -1700,8 +1721,9 @@ export function companySkillService(db: Db) { if (!owner || !repo) { throw unprocessable("Skill source metadata is incomplete."); } + const authToken = await resolveSkillAuthToken(companyId, skill); const repoPath = normalizePortablePath(path.posix.join(repoSkillDir, normalizedPath)); - content = await fetchText(resolveRawGitHubUrl(hostname, owner, repo, ref, repoPath)); + content = await fetchText(resolveRawGitHubUrl(hostname, owner, repo, ref, repoPath), authToken); } else if (skill.sourceType === "url") { if (normalizedPath !== "SKILL.md") { throw notFound("This skill source only exposes SKILL.md"); @@ -1818,7 +1840,8 @@ export function companySkillService(db: Db) { throw unprocessable("Skill source locator is missing."); } - const result = await readUrlSkillImports(companyId, skill.sourceLocator, skill.slug); + const authToken = await resolveSkillAuthToken(companyId, skill); + const result = await readUrlSkillImports(companyId, skill.sourceLocator, skill.slug, authToken); const matching = result.skills.find((entry) => entry.key === skill.key) ?? result.skills[0] ?? null; if (!matching) { throw unprocessable(`Skill ${skill.key} could not be re-imported from its source.`); @@ -2230,6 +2253,10 @@ export function companySkillService(db: Db) { const metadata = { ...(skill.metadata ?? {}), skillKey: skill.key, + // Preserve auth secret reference across re-imports/updates + ...(existing?.metadata && typeof (existing.metadata as Record).sourceAuthSecretId === "string" + ? { sourceAuthSecretId: (existing.metadata as Record).sourceAuthSecretId } + : {}), }; const values = { companyId, @@ -2265,7 +2292,7 @@ export function companySkillService(db: Db) { return out; } - async function importFromSource(companyId: string, source: string): Promise { + async function importFromSource(companyId: string, source: string, authToken?: string): Promise { await ensureSkillInventoryCurrent(companyId); const parsed = parseSkillImportSourceInput(source); const local = !/^https?:\/\//i.test(parsed.resolvedSource); @@ -2275,7 +2302,7 @@ export function companySkillService(db: Db) { .filter((skill) => !parsed.requestedSkillSlug || skill.slug === parsed.requestedSkillSlug), warnings: parsed.warnings, } - : await readUrlSkillImports(companyId, parsed.resolvedSource, parsed.requestedSkillSlug) + : await readUrlSkillImports(companyId, parsed.resolvedSource, parsed.requestedSkillSlug, authToken) .then((result) => ({ skills: result.skills, warnings: [...parsed.warnings, ...result.warnings], @@ -2302,6 +2329,35 @@ export function companySkillService(db: Db) { } } const imported = await upsertImportedSkills(companyId, filteredSkills); + + // Store the auth token as an encrypted company secret and link to imported skills + if (authToken && imported.length > 0) { + for (const skill of imported) { + const secretName = `skill-pat:${skill.id}`; + let secretId: string; + const existing = await secretsSvc.getByName(companyId, secretName); + if (existing) { + await secretsSvc.rotate(existing.id, { value: authToken }); + secretId = existing.id; + } else { + const created = await secretsSvc.create(companyId, { + name: secretName, + provider: "local_encrypted", + value: authToken, + description: `GitHub PAT for skill ${skill.slug}`, + }); + secretId = created.id; + } + // Store the secret ID in skill metadata + const meta = (skill.metadata ?? {}) as Record; + meta.sourceAuthSecretId = secretId; + await db + .update(companySkills) + .set({ metadata: meta, updatedAt: new Date() }) + .where(and(eq(companySkills.id, skill.id), eq(companySkills.companyId, companyId))); + } + } + return { imported, warnings }; } @@ -2341,9 +2397,104 @@ export function companySkillService(db: Db) { // Clean up materialized runtime files await fs.rm(resolveRuntimeSkillMaterializedPath(companyId, skill), { recursive: true, force: true }); + // Delete associated PAT secret if present + const meta = skill.metadata as Record | null; + const secretId = typeof meta?.sourceAuthSecretId === "string" ? meta.sourceAuthSecretId : null; + if (secretId) { + try { + await secretsSvc.remove(secretId); + } catch { + // Best-effort: don't fail the skill deletion if secret cleanup fails + } + } + return skill; } + async function updateSkillAuth( + companyId: string, + skillId: string, + authToken: string | null, + ): Promise { + const skill = await getById(skillId); + if (!skill || skill.companyId !== companyId) return null; + + const meta = (skill.metadata ?? {}) as Record; + const existingSecretId = typeof meta.sourceAuthSecretId === "string" ? meta.sourceAuthSecretId : null; + + if (authToken) { + // Set or update the PAT + const secretName = `skill-pat:${skill.id}`; + let secretId: string; + // Check metadata reference first, then fall back to name lookup + // (metadata ref may have been lost during a skill update/re-import) + const existingSecret = existingSecretId + ? await secretsSvc.getById(existingSecretId) + : await secretsSvc.getByName(companyId, secretName); + if (existingSecret) { + await secretsSvc.rotate(existingSecret.id, { value: authToken }); + secretId = existingSecret.id; + } else { + const created = await secretsSvc.create(companyId, { + name: secretName, + provider: "local_encrypted", + value: authToken, + description: `GitHub PAT for skill ${skill.slug}`, + }); + secretId = created.id; + } + meta.sourceAuthSecretId = secretId; + } else { + // Clear the PAT + delete meta.sourceAuthSecretId; + // Note: we don't delete the secret itself — it may be referenced in audit logs + } + + const [updated] = await db + .update(companySkills) + .set({ metadata: meta, updatedAt: new Date() }) + .where(and(eq(companySkills.id, skillId), eq(companySkills.companyId, companyId))) + .returning(); + return updated ? toCompanySkill(updated) : null; + } + + async function deleteBySource(companyId: string, sourceLocator: string): Promise { + const rows = await db + .select() + .from(companySkills) + .where(and(eq(companySkills.companyId, companyId), eq(companySkills.sourceLocator, sourceLocator))); + if (rows.length === 0) return []; + + // Pre-check all skills for agent usage before deleting any (atomicity) + const skills = rows.map(toCompanySkill); + for (const skill of skills) { + const usedByAgents = await usage(companyId, skill.key); + if (usedByAgents.length > 0) { + const agentNames = usedByAgents.map((agent) => agent.name).sort((left, right) => left.localeCompare(right)); + throw unprocessable( + `Cannot delete skills from "${sourceLocator}" because skill "${skill.name}" is still used by ${agentNames.join(", ")}. Detach it from those agents first.`, + { + skillId: skill.id, + skillKey: skill.key, + usedByAgents: usedByAgents.map((agent) => ({ + id: agent.id, + name: agent.name, + urlKey: agent.urlKey, + adapterType: agent.adapterType, + })), + }, + ); + } + } + + const deleted: CompanySkill[] = []; + for (const row of rows) { + const result = await deleteSkill(companyId, row.id); + if (result) deleted.push(result); + } + return deleted; + } + return { list, listFull, @@ -2359,7 +2510,9 @@ export function companySkillService(db: Db) { updateFile, createLocalSkill, deleteSkill, + deleteBySource, importFromSource, + updateSkillAuth, scanProjectWorkspaces, importPackageFiles, installUpdate, diff --git a/server/src/services/github-fetch.ts b/server/src/services/github-fetch.ts index 787ae0ef..e8f8aee5 100644 --- a/server/src/services/github-fetch.ts +++ b/server/src/services/github-fetch.ts @@ -16,9 +16,13 @@ export function resolveRawGitHubUrl(hostname: string, owner: string, repo: strin : `https://${hostname}/raw/${owner}/${repo}/${ref}/${p}`; } -export async function ghFetch(url: string, init?: RequestInit): Promise { +export async function ghFetch(url: string, init?: RequestInit, authToken?: string): Promise { + const headers = new Headers(init?.headers); + if (authToken) { + headers.set("Authorization", `Bearer ${authToken}`); + } try { - return await fetch(url, init); + return await fetch(url, { ...init, headers }); } catch { throw unprocessable(`Could not connect to ${new URL(url).hostname} — ensure the URL points to a GitHub or GitHub Enterprise instance`); } diff --git a/ui/src/api/companySkills.ts b/ui/src/api/companySkills.ts index 7377b2fa..f72c64c3 100644 --- a/ui/src/api/companySkills.ts +++ b/ui/src/api/companySkills.ts @@ -36,10 +36,23 @@ export const companySkillsApi = { `/companies/${encodeURIComponent(companyId)}/skills`, payload, ), - importFromSource: (companyId: string, source: string) => + importFromSource: (companyId: string, source: string, authToken?: string) => api.post( `/companies/${encodeURIComponent(companyId)}/skills/import`, - { source }, + { source, ...(authToken ? { authToken } : {}) }, + ), + updateAuth: (companyId: string, skillId: string, authToken: string | null) => + api.patch( + `/companies/${encodeURIComponent(companyId)}/skills/${encodeURIComponent(skillId)}/auth`, + { authToken }, + ), + remove: (companyId: string, skillId: string) => + api.delete( + `/companies/${encodeURIComponent(companyId)}/skills/${encodeURIComponent(skillId)}`, + ), + removeBySource: (companyId: string, sourceLocator: string) => + api.delete<{ deleted: CompanySkill[] }>( + `/companies/${encodeURIComponent(companyId)}/skills/by-source?source=${encodeURIComponent(sourceLocator)}`, ), scanProjects: (companyId: string, payload: CompanySkillProjectScanRequest = {}) => api.post( @@ -51,8 +64,4 @@ export const companySkillsApi = { `/companies/${encodeURIComponent(companyId)}/skills/${encodeURIComponent(skillId)}/install-update`, {}, ), - delete: (companyId: string, skillId: string) => - api.delete( - `/companies/${encodeURIComponent(companyId)}/skills/${encodeURIComponent(skillId)}`, - ), }; diff --git a/ui/src/pages/CompanySkills.tsx b/ui/src/pages/CompanySkills.tsx index cc2d5605..d2124b03 100644 --- a/ui/src/pages/CompanySkills.tsx +++ b/ui/src/pages/CompanySkills.tsx @@ -2,6 +2,7 @@ import { useEffect, useMemo, useState, type SVGProps } from "react"; import { Link, useNavigate, useParams } from "@/lib/router"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import type { + CompanySkill, CompanySkillCreateRequest, CompanySkillDetail, CompanySkillFileDetail, @@ -52,6 +53,7 @@ import { RefreshCw, Save, Search, + ShieldCheck, Trash2, } from "lucide-react"; @@ -487,6 +489,103 @@ function SkillList({ ); } +function SkillAuthSection({ + companyId, + skillId, + hasAuth, +}: { + companyId: string; + skillId: string; + hasAuth: boolean; +}) { + const queryClient = useQueryClient(); + const { pushToast } = useToast(); + const [editing, setEditing] = useState(false); + const [token, setToken] = useState(""); + + const updateAuth = useMutation({ + mutationFn: (authToken: string | null) => + companySkillsApi.updateAuth(companyId, skillId, authToken), + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: queryKeys.companySkills.detail(companyId, skillId) }); + setEditing(false); + setToken(""); + pushToast({ tone: "success", title: "Auth updated" }); + }, + onError: (error) => { + pushToast({ + tone: "error", + title: "Failed to update auth", + body: error instanceof Error ? error.message : "Unknown error", + }); + }, + }); + + return ( +
+ Auth + {!editing ? ( + <> + {hasAuth ? ( + <> + + + + ) : ( + + )} + + ) : ( + <> + setToken(e.target.value)} + placeholder="GitHub Personal Access Token" + className="flex-1 min-w-[200px] rounded-md border border-border px-2 py-1 bg-transparent text-xs outline-none placeholder:text-muted-foreground/50" + autoComplete="off" + autoFocus + /> + + + + )} +
+ ); +} + function SkillPane({ loading, detail, @@ -525,7 +624,7 @@ function SkillPane({ checkUpdatesPending: boolean; onInstallUpdate: () => void; installUpdatePending: boolean; - onDelete: () => void; + onDelete: (sourceLocator?: string | null) => void; deletePending: boolean; onSave: () => void; savePending: boolean; @@ -572,7 +671,7 @@ function SkillPane({ + {(detail.sourceType === "github" || detail.sourceType === "skills_sh") && ( + | null)?.sourceAuthSecretId)} + /> + )} {detail.sourceType === "github" && (
Pin @@ -762,6 +876,7 @@ export function CompanySkills() { const { pushToast } = useToast(); const [skillFilter, setSkillFilter] = useState(""); const [source, setSource] = useState(""); + const [importAuthToken, setImportAuthToken] = useState(""); const [createOpen, setCreateOpen] = useState(false); const [emptySourceHelpOpen, setEmptySourceHelpOpen] = useState(false); const [expandedSkillId, setExpandedSkillId] = useState(null); @@ -775,6 +890,7 @@ export function CompanySkills() { const [deleteOpen, setDeleteOpen] = useState(false); const [deleteTargetSkillId, setDeleteTargetSkillId] = useState(null); const [deleteTargetDetail, setDeleteTargetDetail] = useState(null); + const [deleteTargetSourceLocator, setDeleteTargetSourceLocator] = useState(null); const parsedRoute = useMemo(() => parseSkillRoute(routePath), [routePath]); const routeSkillId = parsedRoute.skillId; const selectedPath = parsedRoute.filePath; @@ -883,11 +999,24 @@ export function CompanySkills() { if (!open) { setDeleteTargetSkillId(null); setDeleteTargetDetail(null); + setDeleteTargetSourceLocator(null); + } + } + + function handleDeleteSkill(sourceLocator?: string | null) { + if (sourceLocator) { + setDeleteTargetSourceLocator(sourceLocator); + setDeleteTargetSkillId(null); + setDeleteTargetDetail(null); + setDeleteOpen(true); + } else { + openDeleteDialog(); } } const importSkill = useMutation({ - mutationFn: (importSource: string) => companySkillsApi.importFromSource(selectedCompanyId!, importSource), + mutationFn: ({ importSource, authToken }: { importSource: string; authToken?: string }) => + companySkillsApi.importFromSource(selectedCompanyId!, importSource, authToken), onSuccess: async (result) => { await queryClient.invalidateQueries({ queryKey: queryKeys.companySkills.list(selectedCompanyId!) }); if (result.imported[0]) navigate(skillRoute(result.imported[0].id)); @@ -900,6 +1029,7 @@ export function CompanySkills() { pushToast({ tone: "warn", title: "Import warnings", body: result.warnings[0] }); } setSource(""); + setImportAuthToken(""); }, onError: (error) => { pushToast({ @@ -1026,8 +1156,13 @@ export function CompanySkills() { }); const deleteSkill = useMutation({ - mutationFn: () => companySkillsApi.delete(selectedCompanyId!, deleteTargetSkillId!), - onSuccess: async (skill) => { + mutationFn: (sourceLocator?: string | null): Promise<{ deleted: CompanySkill[] }> => { + if (sourceLocator) { + return companySkillsApi.removeBySource(selectedCompanyId!, sourceLocator); + } + return companySkillsApi.remove(selectedCompanyId!, deleteTargetSkillId!).then((skill) => ({ deleted: [skill] })); + }, + onSuccess: async (result) => { closeDeleteDialog(false); setDisplayedDetail(null); setDisplayedFile(null); @@ -1048,10 +1183,10 @@ export function CompanySkills() { type: "active", }); navigate("/skills", { replace: true }); + const count = result.deleted.length; pushToast({ tone: "success", - title: "Skill removed", - body: `${skill.name} was removed from the company skill library.`, + title: `${count} skill${count === 1 ? "" : "s"} removed`, }); }, onError: (error) => { @@ -1073,7 +1208,8 @@ export function CompanySkills() { setEmptySourceHelpOpen(true); return; } - importSkill.mutate(trimmedSource); + const token = importAuthToken.trim() || undefined; + importSkill.mutate({ importSource: trimmedSource, authToken: token }); } return ( @@ -1081,30 +1217,40 @@ export function CompanySkills() { - Remove skill + {deleteTargetSourceLocator ? "Remove skills from source" : "Remove skill"} - Remove this skill from the company library. If any agents still use it, removal will be blocked until it is detached. + {deleteTargetSourceLocator + ? `All skills imported from this source will be permanently removed from the company library.` + : "Remove this skill from the company library. If any agents still use it, removal will be blocked until it is detached."}
-

- {deleteTargetDetail - ? `You are about to remove ${deleteTargetDetail.name}.` - : "You are about to remove this skill."} -

- {deleteTargetDetail?.usedByAgents?.length ? ( -
- Currently used by {deleteTargetDetail.usedByAgents.map((agent) => agent.name).join(", ")}. -
- ) : null} - {(deleteTargetDetail?.usedByAgents.length ?? 0) > 0 ? ( -

- Detach this skill from all agents to enable removal. + {deleteTargetSourceLocator ? ( +

+ {deleteTargetSourceLocator}

- ) : null} + ) : ( + <> +

+ {deleteTargetDetail + ? `You are about to remove ${deleteTargetDetail.name}.` + : "You are about to remove this skill."} +

+ {deleteTargetDetail?.usedByAgents?.length ? ( +
+ Currently used by {deleteTargetDetail.usedByAgents.map((agent) => agent.name).join(", ")}. +
+ ) : null} + {(deleteTargetDetail?.usedByAgents.length ?? 0) > 0 ? ( +

+ Detach this skill from all agents to enable removal. +

+ ) : null} + + )}
- {(deleteTargetDetail?.usedByAgents.length ?? 0) > 0 ? ( + {(deleteTargetDetail?.usedByAgents.length ?? 0) > 0 && !deleteTargetSourceLocator ? ( @@ -1115,10 +1261,10 @@ export function CompanySkills() { )} @@ -1220,6 +1366,18 @@ export function CompanySkills() { {importSkill.isPending ? : "Add"}
+ {source.trim().length > 0 && /github\.com|^[a-zA-Z0-9_-]+\/[a-zA-Z0-9_.-]+/.test(source.trim()) && ( +
+ setImportAuthToken(event.target.value)} + placeholder="GitHub PAT (optional, for private repos)" + className="w-full bg-transparent text-sm outline-none placeholder:text-muted-foreground" + autoComplete="off" + /> +
+ )} {scanStatusMessage && (

{scanStatusMessage} @@ -1284,7 +1442,7 @@ export function CompanySkills() { checkUpdatesPending={updateStatusQuery.isFetching} onInstallUpdate={() => installUpdate.mutate()} installUpdatePending={installUpdate.isPending} - onDelete={openDeleteDialog} + onDelete={handleDeleteSkill} deletePending={deleteSkill.isPending} onSave={() => saveFile.mutate()} savePending={saveFile.isPending}