forked from farhoodlabs/paperclip
Merge branch 'skill-pat-feature'
This commit is contained in:
@@ -557,6 +557,7 @@ export {
|
|||||||
companySkillDetailSchema,
|
companySkillDetailSchema,
|
||||||
companySkillUpdateStatusSchema,
|
companySkillUpdateStatusSchema,
|
||||||
companySkillImportSchema,
|
companySkillImportSchema,
|
||||||
|
companySkillUpdateAuthSchema,
|
||||||
companySkillProjectScanRequestSchema,
|
companySkillProjectScanRequestSchema,
|
||||||
companySkillProjectScanSkippedSchema,
|
companySkillProjectScanSkippedSchema,
|
||||||
companySkillProjectScanConflictSchema,
|
companySkillProjectScanConflictSchema,
|
||||||
|
|||||||
@@ -66,6 +66,11 @@ export const companySkillUpdateStatusSchema = z.object({
|
|||||||
|
|
||||||
export const companySkillImportSchema = z.object({
|
export const companySkillImportSchema = z.object({
|
||||||
source: z.string().min(1),
|
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({
|
export const companySkillProjectScanRequestSchema = z.object({
|
||||||
@@ -133,3 +138,4 @@ export type CompanySkillImport = z.infer<typeof companySkillImportSchema>;
|
|||||||
export type CompanySkillProjectScan = z.infer<typeof companySkillProjectScanRequestSchema>;
|
export type CompanySkillProjectScan = z.infer<typeof companySkillProjectScanRequestSchema>;
|
||||||
export type CompanySkillCreate = z.infer<typeof companySkillCreateSchema>;
|
export type CompanySkillCreate = z.infer<typeof companySkillCreateSchema>;
|
||||||
export type CompanySkillFileUpdate = z.infer<typeof companySkillFileUpdateSchema>;
|
export type CompanySkillFileUpdate = z.infer<typeof companySkillFileUpdateSchema>;
|
||||||
|
export type CompanySkillUpdateAuth = z.infer<typeof companySkillUpdateAuthSchema>;
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ export {
|
|||||||
companySkillDetailSchema,
|
companySkillDetailSchema,
|
||||||
companySkillUpdateStatusSchema,
|
companySkillUpdateStatusSchema,
|
||||||
companySkillImportSchema,
|
companySkillImportSchema,
|
||||||
|
companySkillUpdateAuthSchema,
|
||||||
companySkillProjectScanRequestSchema,
|
companySkillProjectScanRequestSchema,
|
||||||
companySkillProjectScanSkippedSchema,
|
companySkillProjectScanSkippedSchema,
|
||||||
companySkillProjectScanConflictSchema,
|
companySkillProjectScanConflictSchema,
|
||||||
@@ -55,6 +56,7 @@ export {
|
|||||||
type CompanySkillProjectScan,
|
type CompanySkillProjectScan,
|
||||||
type CompanySkillCreate,
|
type CompanySkillCreate,
|
||||||
type CompanySkillFileUpdate,
|
type CompanySkillFileUpdate,
|
||||||
|
type CompanySkillUpdateAuth,
|
||||||
} from "./company-skill.js";
|
} from "./company-skill.js";
|
||||||
export {
|
export {
|
||||||
agentSkillStateSchema,
|
agentSkillStateSchema,
|
||||||
|
|||||||
@@ -89,6 +89,7 @@ describe("company skill mutation permissions", () => {
|
|||||||
expect(mockCompanySkillService.importFromSource).toHaveBeenCalledWith(
|
expect(mockCompanySkillService.importFromSource).toHaveBeenCalledWith(
|
||||||
"company-1",
|
"company-1",
|
||||||
"https://github.com/vercel-labs/agent-browser",
|
"https://github.com/vercel-labs/agent-browser",
|
||||||
|
undefined,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -266,6 +267,7 @@ describe("company skill mutation permissions", () => {
|
|||||||
expect(mockCompanySkillService.importFromSource).toHaveBeenCalledWith(
|
expect(mockCompanySkillService.importFromSource).toHaveBeenCalledWith(
|
||||||
"company-1",
|
"company-1",
|
||||||
"https://github.com/vercel-labs/agent-browser",
|
"https://github.com/vercel-labs/agent-browser",
|
||||||
|
undefined,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
companySkillCreateSchema,
|
companySkillCreateSchema,
|
||||||
companySkillFileUpdateSchema,
|
companySkillFileUpdateSchema,
|
||||||
companySkillImportSchema,
|
companySkillImportSchema,
|
||||||
|
companySkillUpdateAuthSchema,
|
||||||
companySkillProjectScanRequestSchema,
|
companySkillProjectScanRequestSchema,
|
||||||
} from "@paperclipai/shared";
|
} from "@paperclipai/shared";
|
||||||
import { trackSkillImported } from "@paperclipai/shared/telemetry";
|
import { trackSkillImported } from "@paperclipai/shared/telemetry";
|
||||||
@@ -194,7 +195,8 @@ export function companySkillRoutes(db: Db) {
|
|||||||
const companyId = req.params.companyId as string;
|
const companyId = req.params.companyId as string;
|
||||||
await assertCanMutateCompanySkills(req, companyId);
|
await assertCanMutateCompanySkills(req, companyId);
|
||||||
const source = String(req.body.source ?? "");
|
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);
|
const actor = getActorInfo(req);
|
||||||
await logActivity(db, {
|
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) => {
|
router.delete("/companies/:companyId/skills/:skillId", async (req, res) => {
|
||||||
const companyId = req.params.companyId as string;
|
const companyId = req.params.companyId as string;
|
||||||
const skillId = req.params.skillId as string;
|
const skillId = req.params.skillId as string;
|
||||||
@@ -318,5 +350,38 @@ export function companySkillRoutes(db: Db) {
|
|||||||
res.json(result);
|
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;
|
return router;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -471,20 +471,20 @@ function parseFrontmatterMarkdown(raw: string): { frontmatter: Record<string, un
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchText(url: string) {
|
async function fetchText(url: string, authToken?: string) {
|
||||||
const response = await ghFetch(url);
|
const response = await ghFetch(url, undefined, authToken);
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw unprocessable(`Failed to fetch ${url}: ${response.status}`);
|
throw unprocessable(`Failed to fetch ${url}: ${response.status}`);
|
||||||
}
|
}
|
||||||
return response.text();
|
return response.text();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchJson<T>(url: string): Promise<T> {
|
async function fetchJson<T>(url: string, authToken?: string): Promise<T> {
|
||||||
const response = await ghFetch(url, {
|
const response = await ghFetch(url, {
|
||||||
headers: {
|
headers: {
|
||||||
accept: "application/vnd.github+json",
|
accept: "application/vnd.github+json",
|
||||||
},
|
},
|
||||||
});
|
}, authToken);
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw unprocessable(`Failed to fetch ${url}: ${response.status}`);
|
throw unprocessable(`Failed to fetch ${url}: ${response.status}`);
|
||||||
}
|
}
|
||||||
@@ -492,16 +492,18 @@ async function fetchJson<T>(url: string): Promise<T> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
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 }>(
|
const response = await fetchJson<{ default_branch?: string }>(
|
||||||
`${apiBase}/repos/${owner}/${repo}`,
|
`${apiBase}/repos/${owner}/${repo}`,
|
||||||
|
authToken,
|
||||||
);
|
);
|
||||||
return asString(response.default_branch) ?? "main";
|
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 }>(
|
const response = await fetchJson<{ sha?: string }>(
|
||||||
`${apiBase}/repos/${owner}/${repo}/commits/${encodeURIComponent(ref)}`,
|
`${apiBase}/repos/${owner}/${repo}/commits/${encodeURIComponent(ref)}`,
|
||||||
|
authToken,
|
||||||
);
|
);
|
||||||
const sha = asString(response.sha);
|
const sha = asString(response.sha);
|
||||||
if (!sha) {
|
if (!sha) {
|
||||||
@@ -538,7 +540,7 @@ function parseGitHubSourceUrl(rawUrl: string) {
|
|||||||
return { hostname: url.hostname, owner, repo, ref, basePath, filePath, explicitRef };
|
return { hostname: url.hostname, owner, repo, ref, basePath, filePath, explicitRef };
|
||||||
}
|
}
|
||||||
|
|
||||||
async function resolveGitHubPinnedRef(parsed: ReturnType<typeof parseGitHubSourceUrl>) {
|
async function resolveGitHubPinnedRef(parsed: ReturnType<typeof parseGitHubSourceUrl>, authToken?: string) {
|
||||||
const apiBase = gitHubApiBase(parsed.hostname);
|
const apiBase = gitHubApiBase(parsed.hostname);
|
||||||
if (/^[0-9a-f]{40}$/i.test(parsed.ref.trim())) {
|
if (/^[0-9a-f]{40}$/i.test(parsed.ref.trim())) {
|
||||||
return {
|
return {
|
||||||
@@ -549,8 +551,8 @@ async function resolveGitHubPinnedRef(parsed: ReturnType<typeof parseGitHubSourc
|
|||||||
|
|
||||||
const trackingRef = parsed.explicitRef
|
const trackingRef = parsed.explicitRef
|
||||||
? parsed.ref
|
? parsed.ref
|
||||||
: await resolveGitHubDefaultBranch(parsed.owner, parsed.repo, apiBase);
|
: await resolveGitHubDefaultBranch(parsed.owner, parsed.repo, apiBase, authToken);
|
||||||
const pinnedRef = await resolveGitHubCommitSha(parsed.owner, parsed.repo, trackingRef, apiBase);
|
const pinnedRef = await resolveGitHubCommitSha(parsed.owner, parsed.repo, trackingRef, apiBase, authToken);
|
||||||
return { pinnedRef, trackingRef };
|
return { pinnedRef, trackingRef };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -981,6 +983,7 @@ async function readUrlSkillImports(
|
|||||||
companyId: string,
|
companyId: string,
|
||||||
sourceUrl: string,
|
sourceUrl: string,
|
||||||
requestedSkillSlug: string | null = null,
|
requestedSkillSlug: string | null = null,
|
||||||
|
authToken?: string,
|
||||||
): Promise<{ skills: ImportedSkill[]; warnings: string[] }> {
|
): Promise<{ skills: ImportedSkill[]; warnings: string[] }> {
|
||||||
const url = sourceUrl.trim();
|
const url = sourceUrl.trim();
|
||||||
const warnings: string[] = [];
|
const warnings: string[] = [];
|
||||||
@@ -995,10 +998,11 @@ async function readUrlSkillImports(
|
|||||||
if (looksLikeRepoUrl) {
|
if (looksLikeRepoUrl) {
|
||||||
const parsed = parseGitHubSourceUrl(url);
|
const parsed = parseGitHubSourceUrl(url);
|
||||||
const apiBase = gitHubApiBase(parsed.hostname);
|
const apiBase = gitHubApiBase(parsed.hostname);
|
||||||
const { pinnedRef, trackingRef } = await resolveGitHubPinnedRef(parsed);
|
const { pinnedRef, trackingRef } = await resolveGitHubPinnedRef(parsed, authToken);
|
||||||
let ref = pinnedRef;
|
let ref = pinnedRef;
|
||||||
const tree = await fetchJson<{ tree?: Array<{ path: string; type: string }> }>(
|
const tree = await fetchJson<{ tree?: Array<{ path: string; type: string }> }>(
|
||||||
`${apiBase}/repos/${parsed.owner}/${parsed.repo}/git/trees/${ref}?recursive=1`,
|
`${apiBase}/repos/${parsed.owner}/${parsed.repo}/git/trees/${ref}?recursive=1`,
|
||||||
|
authToken,
|
||||||
).catch(() => {
|
).catch(() => {
|
||||||
throw unprocessable(`Failed to read GitHub tree for ${url}`);
|
throw unprocessable(`Failed to read GitHub tree for ${url}`);
|
||||||
});
|
});
|
||||||
@@ -1025,7 +1029,7 @@ async function readUrlSkillImports(
|
|||||||
const skills: ImportedSkill[] = [];
|
const skills: ImportedSkill[] = [];
|
||||||
for (const relativeSkillPath of skillPaths) {
|
for (const relativeSkillPath of skillPaths) {
|
||||||
const repoSkillPath = basePrefix ? `${basePrefix}${relativeSkillPath}` : relativeSkillPath;
|
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 parsedMarkdown = parseFrontmatterMarkdown(markdown);
|
||||||
const skillDir = path.posix.dirname(relativeSkillPath);
|
const skillDir = path.posix.dirname(relativeSkillPath);
|
||||||
const slug = deriveImportedSkillSlug(parsedMarkdown.frontmatter, path.posix.basename(skillDir));
|
const slug = deriveImportedSkillSlug(parsedMarkdown.frontmatter, path.posix.basename(skillDir));
|
||||||
@@ -1087,7 +1091,7 @@ async function readUrlSkillImports(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (url.startsWith("http://") || url.startsWith("https://")) {
|
if (url.startsWith("http://") || url.startsWith("https://")) {
|
||||||
const markdown = await fetchText(url);
|
const markdown = await fetchText(url, authToken);
|
||||||
const parsedMarkdown = parseFrontmatterMarkdown(markdown);
|
const parsedMarkdown = parseFrontmatterMarkdown(markdown);
|
||||||
const urlObj = new URL(url);
|
const urlObj = new URL(url);
|
||||||
const fileName = path.posix.basename(urlObj.pathname);
|
const fileName = path.posix.basename(urlObj.pathname);
|
||||||
@@ -1459,6 +1463,22 @@ export function companySkillService(db: Db) {
|
|||||||
const projects = projectService(db);
|
const projects = projectService(db);
|
||||||
const secretsSvc = secretService(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<string, unknown> | null },
|
||||||
|
): Promise<string | undefined> {
|
||||||
|
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) {
|
async function ensureBundledSkills(companyId: string) {
|
||||||
for (const skillsRoot of resolveBundledSkillsRoot()) {
|
for (const skillsRoot of resolveBundledSkillsRoot()) {
|
||||||
const stats = await fs.stat(skillsRoot).catch(() => null);
|
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 hostname = asString(metadata.hostname) || "github.com";
|
||||||
const apiBase = gitHubApiBase(hostname);
|
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 {
|
return {
|
||||||
supported: true,
|
supported: true,
|
||||||
reason: null,
|
reason: null,
|
||||||
@@ -1700,8 +1721,9 @@ export function companySkillService(db: Db) {
|
|||||||
if (!owner || !repo) {
|
if (!owner || !repo) {
|
||||||
throw unprocessable("Skill source metadata is incomplete.");
|
throw unprocessable("Skill source metadata is incomplete.");
|
||||||
}
|
}
|
||||||
|
const authToken = await resolveSkillAuthToken(companyId, skill);
|
||||||
const repoPath = normalizePortablePath(path.posix.join(repoSkillDir, normalizedPath));
|
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") {
|
} else if (skill.sourceType === "url") {
|
||||||
if (normalizedPath !== "SKILL.md") {
|
if (normalizedPath !== "SKILL.md") {
|
||||||
throw notFound("This skill source only exposes 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.");
|
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;
|
const matching = result.skills.find((entry) => entry.key === skill.key) ?? result.skills[0] ?? null;
|
||||||
if (!matching) {
|
if (!matching) {
|
||||||
throw unprocessable(`Skill ${skill.key} could not be re-imported from its source.`);
|
throw unprocessable(`Skill ${skill.key} could not be re-imported from its source.`);
|
||||||
@@ -2230,6 +2253,10 @@ export function companySkillService(db: Db) {
|
|||||||
const metadata = {
|
const metadata = {
|
||||||
...(skill.metadata ?? {}),
|
...(skill.metadata ?? {}),
|
||||||
skillKey: skill.key,
|
skillKey: skill.key,
|
||||||
|
// Preserve auth secret reference across re-imports/updates
|
||||||
|
...(existing?.metadata && typeof (existing.metadata as Record<string, unknown>).sourceAuthSecretId === "string"
|
||||||
|
? { sourceAuthSecretId: (existing.metadata as Record<string, unknown>).sourceAuthSecretId }
|
||||||
|
: {}),
|
||||||
};
|
};
|
||||||
const values = {
|
const values = {
|
||||||
companyId,
|
companyId,
|
||||||
@@ -2265,7 +2292,7 @@ export function companySkillService(db: Db) {
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function importFromSource(companyId: string, source: string): Promise<CompanySkillImportResult> {
|
async function importFromSource(companyId: string, source: string, authToken?: string): Promise<CompanySkillImportResult> {
|
||||||
await ensureSkillInventoryCurrent(companyId);
|
await ensureSkillInventoryCurrent(companyId);
|
||||||
const parsed = parseSkillImportSourceInput(source);
|
const parsed = parseSkillImportSourceInput(source);
|
||||||
const local = !/^https?:\/\//i.test(parsed.resolvedSource);
|
const local = !/^https?:\/\//i.test(parsed.resolvedSource);
|
||||||
@@ -2275,7 +2302,7 @@ export function companySkillService(db: Db) {
|
|||||||
.filter((skill) => !parsed.requestedSkillSlug || skill.slug === parsed.requestedSkillSlug),
|
.filter((skill) => !parsed.requestedSkillSlug || skill.slug === parsed.requestedSkillSlug),
|
||||||
warnings: parsed.warnings,
|
warnings: parsed.warnings,
|
||||||
}
|
}
|
||||||
: await readUrlSkillImports(companyId, parsed.resolvedSource, parsed.requestedSkillSlug)
|
: await readUrlSkillImports(companyId, parsed.resolvedSource, parsed.requestedSkillSlug, authToken)
|
||||||
.then((result) => ({
|
.then((result) => ({
|
||||||
skills: result.skills,
|
skills: result.skills,
|
||||||
warnings: [...parsed.warnings, ...result.warnings],
|
warnings: [...parsed.warnings, ...result.warnings],
|
||||||
@@ -2302,6 +2329,35 @@ export function companySkillService(db: Db) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
const imported = await upsertImportedSkills(companyId, filteredSkills);
|
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<string, unknown>;
|
||||||
|
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 };
|
return { imported, warnings };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2341,9 +2397,104 @@ export function companySkillService(db: Db) {
|
|||||||
// Clean up materialized runtime files
|
// Clean up materialized runtime files
|
||||||
await fs.rm(resolveRuntimeSkillMaterializedPath(companyId, skill), { recursive: true, force: true });
|
await fs.rm(resolveRuntimeSkillMaterializedPath(companyId, skill), { recursive: true, force: true });
|
||||||
|
|
||||||
|
// Delete associated PAT secret if present
|
||||||
|
const meta = skill.metadata as Record<string, unknown> | 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;
|
return skill;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function updateSkillAuth(
|
||||||
|
companyId: string,
|
||||||
|
skillId: string,
|
||||||
|
authToken: string | null,
|
||||||
|
): Promise<CompanySkill | null> {
|
||||||
|
const skill = await getById(skillId);
|
||||||
|
if (!skill || skill.companyId !== companyId) return null;
|
||||||
|
|
||||||
|
const meta = (skill.metadata ?? {}) as Record<string, unknown>;
|
||||||
|
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<CompanySkill[]> {
|
||||||
|
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 {
|
return {
|
||||||
list,
|
list,
|
||||||
listFull,
|
listFull,
|
||||||
@@ -2359,7 +2510,9 @@ export function companySkillService(db: Db) {
|
|||||||
updateFile,
|
updateFile,
|
||||||
createLocalSkill,
|
createLocalSkill,
|
||||||
deleteSkill,
|
deleteSkill,
|
||||||
|
deleteBySource,
|
||||||
importFromSource,
|
importFromSource,
|
||||||
|
updateSkillAuth,
|
||||||
scanProjectWorkspaces,
|
scanProjectWorkspaces,
|
||||||
importPackageFiles,
|
importPackageFiles,
|
||||||
installUpdate,
|
installUpdate,
|
||||||
|
|||||||
@@ -16,9 +16,13 @@ export function resolveRawGitHubUrl(hostname: string, owner: string, repo: strin
|
|||||||
: `https://${hostname}/raw/${owner}/${repo}/${ref}/${p}`;
|
: `https://${hostname}/raw/${owner}/${repo}/${ref}/${p}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function ghFetch(url: string, init?: RequestInit): Promise<Response> {
|
export async function ghFetch(url: string, init?: RequestInit, authToken?: string): Promise<Response> {
|
||||||
|
const headers = new Headers(init?.headers);
|
||||||
|
if (authToken) {
|
||||||
|
headers.set("Authorization", `Bearer ${authToken}`);
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
return await fetch(url, init);
|
return await fetch(url, { ...init, headers });
|
||||||
} catch {
|
} catch {
|
||||||
throw unprocessable(`Could not connect to ${new URL(url).hostname} — ensure the URL points to a GitHub or GitHub Enterprise instance`);
|
throw unprocessable(`Could not connect to ${new URL(url).hostname} — ensure the URL points to a GitHub or GitHub Enterprise instance`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,10 +36,23 @@ export const companySkillsApi = {
|
|||||||
`/companies/${encodeURIComponent(companyId)}/skills`,
|
`/companies/${encodeURIComponent(companyId)}/skills`,
|
||||||
payload,
|
payload,
|
||||||
),
|
),
|
||||||
importFromSource: (companyId: string, source: string) =>
|
importFromSource: (companyId: string, source: string, authToken?: string) =>
|
||||||
api.post<CompanySkillImportResult>(
|
api.post<CompanySkillImportResult>(
|
||||||
`/companies/${encodeURIComponent(companyId)}/skills/import`,
|
`/companies/${encodeURIComponent(companyId)}/skills/import`,
|
||||||
{ source },
|
{ source, ...(authToken ? { authToken } : {}) },
|
||||||
|
),
|
||||||
|
updateAuth: (companyId: string, skillId: string, authToken: string | null) =>
|
||||||
|
api.patch<CompanySkill>(
|
||||||
|
`/companies/${encodeURIComponent(companyId)}/skills/${encodeURIComponent(skillId)}/auth`,
|
||||||
|
{ authToken },
|
||||||
|
),
|
||||||
|
remove: (companyId: string, skillId: string) =>
|
||||||
|
api.delete<CompanySkill>(
|
||||||
|
`/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 = {}) =>
|
scanProjects: (companyId: string, payload: CompanySkillProjectScanRequest = {}) =>
|
||||||
api.post<CompanySkillProjectScanResult>(
|
api.post<CompanySkillProjectScanResult>(
|
||||||
@@ -51,8 +64,4 @@ export const companySkillsApi = {
|
|||||||
`/companies/${encodeURIComponent(companyId)}/skills/${encodeURIComponent(skillId)}/install-update`,
|
`/companies/${encodeURIComponent(companyId)}/skills/${encodeURIComponent(skillId)}/install-update`,
|
||||||
{},
|
{},
|
||||||
),
|
),
|
||||||
delete: (companyId: string, skillId: string) =>
|
|
||||||
api.delete<CompanySkill>(
|
|
||||||
`/companies/${encodeURIComponent(companyId)}/skills/${encodeURIComponent(skillId)}`,
|
|
||||||
),
|
|
||||||
};
|
};
|
||||||
|
|||||||
+187
-29
@@ -2,6 +2,7 @@ import { useEffect, useMemo, useState, type SVGProps } from "react";
|
|||||||
import { Link, useNavigate, useParams } from "@/lib/router";
|
import { Link, useNavigate, useParams } from "@/lib/router";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import type {
|
import type {
|
||||||
|
CompanySkill,
|
||||||
CompanySkillCreateRequest,
|
CompanySkillCreateRequest,
|
||||||
CompanySkillDetail,
|
CompanySkillDetail,
|
||||||
CompanySkillFileDetail,
|
CompanySkillFileDetail,
|
||||||
@@ -52,6 +53,7 @@ import {
|
|||||||
RefreshCw,
|
RefreshCw,
|
||||||
Save,
|
Save,
|
||||||
Search,
|
Search,
|
||||||
|
ShieldCheck,
|
||||||
Trash2,
|
Trash2,
|
||||||
} from "lucide-react";
|
} 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 (
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<span className="text-[11px] uppercase tracking-[0.18em] text-muted-foreground">Auth</span>
|
||||||
|
{!editing ? (
|
||||||
|
<>
|
||||||
|
{hasAuth ? (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setEditing(true)}
|
||||||
|
>
|
||||||
|
<ShieldCheck className="mr-1.5 h-3.5 w-3.5" />
|
||||||
|
PAT configured
|
||||||
|
</Button>
|
||||||
|
<button
|
||||||
|
className="inline-flex items-center text-muted-foreground/50 hover:text-destructive transition-colors"
|
||||||
|
onClick={() => updateAuth.mutate(null)}
|
||||||
|
disabled={updateAuth.isPending}
|
||||||
|
title="Remove PAT"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setEditing(true)}
|
||||||
|
>
|
||||||
|
Add PAT
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={token}
|
||||||
|
onChange={(e) => 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
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={() => updateAuth.mutate(token.trim())}
|
||||||
|
disabled={!token.trim() || updateAuth.isPending}
|
||||||
|
>
|
||||||
|
{updateAuth.isPending ? "Saving..." : "Save"}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => { setEditing(false); setToken(""); }}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function SkillPane({
|
function SkillPane({
|
||||||
loading,
|
loading,
|
||||||
detail,
|
detail,
|
||||||
@@ -525,7 +624,7 @@ function SkillPane({
|
|||||||
checkUpdatesPending: boolean;
|
checkUpdatesPending: boolean;
|
||||||
onInstallUpdate: () => void;
|
onInstallUpdate: () => void;
|
||||||
installUpdatePending: boolean;
|
installUpdatePending: boolean;
|
||||||
onDelete: () => void;
|
onDelete: (sourceLocator?: string | null) => void;
|
||||||
deletePending: boolean;
|
deletePending: boolean;
|
||||||
onSave: () => void;
|
onSave: () => void;
|
||||||
savePending: boolean;
|
savePending: boolean;
|
||||||
@@ -572,7 +671,7 @@ function SkillPane({
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={onDelete}
|
onClick={() => onDelete()}
|
||||||
disabled={deletePending}
|
disabled={deletePending}
|
||||||
title={removeDisabledReason ?? undefined}
|
title={removeDisabledReason ?? undefined}
|
||||||
>
|
>
|
||||||
@@ -612,8 +711,23 @@ function SkillPane({
|
|||||||
) : (
|
) : (
|
||||||
<span className="truncate">{source.label}</span>
|
<span className="truncate">{source.label}</span>
|
||||||
)}
|
)}
|
||||||
|
<button
|
||||||
|
className="inline-flex items-center text-muted-foreground/50 hover:text-destructive transition-colors"
|
||||||
|
onClick={() => onDelete(detail.sourceLocator)}
|
||||||
|
disabled={deletePending}
|
||||||
|
title={detail.sourceLocator ? "Remove this source and all its skills" : "Remove this skill"}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
{(detail.sourceType === "github" || detail.sourceType === "skills_sh") && (
|
||||||
|
<SkillAuthSection
|
||||||
|
companyId={detail.companyId}
|
||||||
|
skillId={detail.id}
|
||||||
|
hasAuth={Boolean((detail.metadata as Record<string, unknown> | null)?.sourceAuthSecretId)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{detail.sourceType === "github" && (
|
{detail.sourceType === "github" && (
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<span className="text-[11px] uppercase tracking-[0.18em] text-muted-foreground">Pin</span>
|
<span className="text-[11px] uppercase tracking-[0.18em] text-muted-foreground">Pin</span>
|
||||||
@@ -762,6 +876,7 @@ export function CompanySkills() {
|
|||||||
const { pushToast } = useToast();
|
const { pushToast } = useToast();
|
||||||
const [skillFilter, setSkillFilter] = useState("");
|
const [skillFilter, setSkillFilter] = useState("");
|
||||||
const [source, setSource] = useState("");
|
const [source, setSource] = useState("");
|
||||||
|
const [importAuthToken, setImportAuthToken] = useState("");
|
||||||
const [createOpen, setCreateOpen] = useState(false);
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
const [emptySourceHelpOpen, setEmptySourceHelpOpen] = useState(false);
|
const [emptySourceHelpOpen, setEmptySourceHelpOpen] = useState(false);
|
||||||
const [expandedSkillId, setExpandedSkillId] = useState<string | null>(null);
|
const [expandedSkillId, setExpandedSkillId] = useState<string | null>(null);
|
||||||
@@ -775,6 +890,7 @@ export function CompanySkills() {
|
|||||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||||
const [deleteTargetSkillId, setDeleteTargetSkillId] = useState<string | null>(null);
|
const [deleteTargetSkillId, setDeleteTargetSkillId] = useState<string | null>(null);
|
||||||
const [deleteTargetDetail, setDeleteTargetDetail] = useState<CompanySkillDetail | null>(null);
|
const [deleteTargetDetail, setDeleteTargetDetail] = useState<CompanySkillDetail | null>(null);
|
||||||
|
const [deleteTargetSourceLocator, setDeleteTargetSourceLocator] = useState<string | null>(null);
|
||||||
const parsedRoute = useMemo(() => parseSkillRoute(routePath), [routePath]);
|
const parsedRoute = useMemo(() => parseSkillRoute(routePath), [routePath]);
|
||||||
const routeSkillId = parsedRoute.skillId;
|
const routeSkillId = parsedRoute.skillId;
|
||||||
const selectedPath = parsedRoute.filePath;
|
const selectedPath = parsedRoute.filePath;
|
||||||
@@ -883,11 +999,24 @@ export function CompanySkills() {
|
|||||||
if (!open) {
|
if (!open) {
|
||||||
setDeleteTargetSkillId(null);
|
setDeleteTargetSkillId(null);
|
||||||
setDeleteTargetDetail(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({
|
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) => {
|
onSuccess: async (result) => {
|
||||||
await queryClient.invalidateQueries({ queryKey: queryKeys.companySkills.list(selectedCompanyId!) });
|
await queryClient.invalidateQueries({ queryKey: queryKeys.companySkills.list(selectedCompanyId!) });
|
||||||
if (result.imported[0]) navigate(skillRoute(result.imported[0].id));
|
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] });
|
pushToast({ tone: "warn", title: "Import warnings", body: result.warnings[0] });
|
||||||
}
|
}
|
||||||
setSource("");
|
setSource("");
|
||||||
|
setImportAuthToken("");
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
pushToast({
|
pushToast({
|
||||||
@@ -1026,8 +1156,13 @@ export function CompanySkills() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const deleteSkill = useMutation({
|
const deleteSkill = useMutation({
|
||||||
mutationFn: () => companySkillsApi.delete(selectedCompanyId!, deleteTargetSkillId!),
|
mutationFn: (sourceLocator?: string | null): Promise<{ deleted: CompanySkill[] }> => {
|
||||||
onSuccess: async (skill) => {
|
if (sourceLocator) {
|
||||||
|
return companySkillsApi.removeBySource(selectedCompanyId!, sourceLocator);
|
||||||
|
}
|
||||||
|
return companySkillsApi.remove(selectedCompanyId!, deleteTargetSkillId!).then((skill) => ({ deleted: [skill] }));
|
||||||
|
},
|
||||||
|
onSuccess: async (result) => {
|
||||||
closeDeleteDialog(false);
|
closeDeleteDialog(false);
|
||||||
setDisplayedDetail(null);
|
setDisplayedDetail(null);
|
||||||
setDisplayedFile(null);
|
setDisplayedFile(null);
|
||||||
@@ -1048,10 +1183,10 @@ export function CompanySkills() {
|
|||||||
type: "active",
|
type: "active",
|
||||||
});
|
});
|
||||||
navigate("/skills", { replace: true });
|
navigate("/skills", { replace: true });
|
||||||
|
const count = result.deleted.length;
|
||||||
pushToast({
|
pushToast({
|
||||||
tone: "success",
|
tone: "success",
|
||||||
title: "Skill removed",
|
title: `${count} skill${count === 1 ? "" : "s"} removed`,
|
||||||
body: `${skill.name} was removed from the company skill library.`,
|
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
@@ -1073,7 +1208,8 @@ export function CompanySkills() {
|
|||||||
setEmptySourceHelpOpen(true);
|
setEmptySourceHelpOpen(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
importSkill.mutate(trimmedSource);
|
const token = importAuthToken.trim() || undefined;
|
||||||
|
importSkill.mutate({ importSource: trimmedSource, authToken: token });
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -1081,30 +1217,40 @@ export function CompanySkills() {
|
|||||||
<Dialog open={deleteOpen} onOpenChange={closeDeleteDialog}>
|
<Dialog open={deleteOpen} onOpenChange={closeDeleteDialog}>
|
||||||
<DialogContent className="sm:max-w-md">
|
<DialogContent className="sm:max-w-md">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Remove skill</DialogTitle>
|
<DialogTitle>{deleteTargetSourceLocator ? "Remove skills from source" : "Remove skill"}</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
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."}
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="space-y-3 text-sm">
|
<div className="space-y-3 text-sm">
|
||||||
<p>
|
{deleteTargetSourceLocator ? (
|
||||||
{deleteTargetDetail
|
<p className="rounded-md border border-destructive/50 bg-destructive/5 px-3 py-2 font-mono text-xs break-all">
|
||||||
? `You are about to remove ${deleteTargetDetail.name}.`
|
{deleteTargetSourceLocator}
|
||||||
: "You are about to remove this skill."}
|
|
||||||
</p>
|
|
||||||
{deleteTargetDetail?.usedByAgents?.length ? (
|
|
||||||
<div className="rounded-md border border-border px-3 py-3 text-muted-foreground">
|
|
||||||
Currently used by {deleteTargetDetail.usedByAgents.map((agent) => agent.name).join(", ")}.
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
{(deleteTargetDetail?.usedByAgents.length ?? 0) > 0 ? (
|
|
||||||
<p className="text-muted-foreground">
|
|
||||||
Detach this skill from all agents to enable removal.
|
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : (
|
||||||
|
<>
|
||||||
|
<p>
|
||||||
|
{deleteTargetDetail
|
||||||
|
? `You are about to remove ${deleteTargetDetail.name}.`
|
||||||
|
: "You are about to remove this skill."}
|
||||||
|
</p>
|
||||||
|
{deleteTargetDetail?.usedByAgents?.length ? (
|
||||||
|
<div className="rounded-md border border-border px-3 py-3 text-muted-foreground">
|
||||||
|
Currently used by {deleteTargetDetail.usedByAgents.map((agent) => agent.name).join(", ")}.
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{(deleteTargetDetail?.usedByAgents.length ?? 0) > 0 ? (
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Detach this skill from all agents to enable removal.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
{(deleteTargetDetail?.usedByAgents.length ?? 0) > 0 ? (
|
{(deleteTargetDetail?.usedByAgents.length ?? 0) > 0 && !deleteTargetSourceLocator ? (
|
||||||
<Button variant="ghost" onClick={() => closeDeleteDialog(false)}>
|
<Button variant="ghost" onClick={() => closeDeleteDialog(false)}>
|
||||||
Close
|
Close
|
||||||
</Button>
|
</Button>
|
||||||
@@ -1115,10 +1261,10 @@ export function CompanySkills() {
|
|||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
onClick={() => deleteSkill.mutate()}
|
onClick={() => deleteSkill.mutate(deleteTargetSourceLocator ?? undefined)}
|
||||||
disabled={deleteSkill.isPending || !deleteTargetSkillId}
|
disabled={deleteSkill.isPending || (!deleteTargetSkillId && !deleteTargetSourceLocator)}
|
||||||
>
|
>
|
||||||
{deleteSkill.isPending ? "Removing..." : "Remove skill"}
|
{deleteSkill.isPending ? "Removing..." : deleteTargetSourceLocator ? "Remove all from source" : "Remove skill"}
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -1220,6 +1366,18 @@ export function CompanySkills() {
|
|||||||
{importSkill.isPending ? <RefreshCw className="h-4 w-4 animate-spin" /> : "Add"}
|
{importSkill.isPending ? <RefreshCw className="h-4 w-4 animate-spin" /> : "Add"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
{source.trim().length > 0 && /github\.com|^[a-zA-Z0-9_-]+\/[a-zA-Z0-9_.-]+/.test(source.trim()) && (
|
||||||
|
<div className="mt-1 flex items-center gap-2 border-b border-border pb-2">
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={importAuthToken}
|
||||||
|
onChange={(event) => 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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{scanStatusMessage && (
|
{scanStatusMessage && (
|
||||||
<p className="mt-3 text-xs text-muted-foreground">
|
<p className="mt-3 text-xs text-muted-foreground">
|
||||||
{scanStatusMessage}
|
{scanStatusMessage}
|
||||||
@@ -1284,7 +1442,7 @@ export function CompanySkills() {
|
|||||||
checkUpdatesPending={updateStatusQuery.isFetching}
|
checkUpdatesPending={updateStatusQuery.isFetching}
|
||||||
onInstallUpdate={() => installUpdate.mutate()}
|
onInstallUpdate={() => installUpdate.mutate()}
|
||||||
installUpdatePending={installUpdate.isPending}
|
installUpdatePending={installUpdate.isPending}
|
||||||
onDelete={openDeleteDialog}
|
onDelete={handleDeleteSkill}
|
||||||
deletePending={deleteSkill.isPending}
|
deletePending={deleteSkill.isPending}
|
||||||
onSave={() => saveFile.mutate()}
|
onSave={() => saveFile.mutate()}
|
||||||
savePending={saveFile.isPending}
|
savePending={saveFile.isPending}
|
||||||
|
|||||||
Reference in New Issue
Block a user