Add company skills library and agent skills UI
This commit is contained in:
parent
2137c2f715
commit
0bf53bc513
22 changed files with 8050 additions and 131 deletions
21
packages/db/src/migrations/0028_bent_eternals.sql
Normal file
21
packages/db/src/migrations/0028_bent_eternals.sql
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
CREATE TABLE "company_skills" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"company_id" uuid NOT NULL,
|
||||||
|
"slug" text NOT NULL,
|
||||||
|
"name" text NOT NULL,
|
||||||
|
"description" text,
|
||||||
|
"markdown" text NOT NULL,
|
||||||
|
"source_type" text DEFAULT 'local_path' NOT NULL,
|
||||||
|
"source_locator" text,
|
||||||
|
"source_ref" text,
|
||||||
|
"trust_level" text DEFAULT 'markdown_only' NOT NULL,
|
||||||
|
"compatibility" text DEFAULT 'compatible' NOT NULL,
|
||||||
|
"file_inventory" jsonb DEFAULT '[]'::jsonb NOT NULL,
|
||||||
|
"metadata" jsonb,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "company_skills" ADD CONSTRAINT "company_skills_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "company_skills_company_slug_idx" ON "company_skills" USING btree ("company_id","slug");--> statement-breakpoint
|
||||||
|
CREATE INDEX "company_skills_company_name_idx" ON "company_skills" USING btree ("company_id","name");
|
||||||
6372
packages/db/src/migrations/meta/0028_snapshot.json
Normal file
6372
packages/db/src/migrations/meta/0028_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -197,6 +197,13 @@
|
||||||
"when": 1773150731736,
|
"when": 1773150731736,
|
||||||
"tag": "0027_tranquil_tenebrous",
|
"tag": "0027_tranquil_tenebrous",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 28,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1773503397855,
|
||||||
|
"tag": "0028_bent_eternals",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
35
packages/db/src/schema/company_skills.ts
Normal file
35
packages/db/src/schema/company_skills.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
import {
|
||||||
|
pgTable,
|
||||||
|
uuid,
|
||||||
|
text,
|
||||||
|
timestamp,
|
||||||
|
jsonb,
|
||||||
|
index,
|
||||||
|
uniqueIndex,
|
||||||
|
} from "drizzle-orm/pg-core";
|
||||||
|
import { companies } from "./companies.js";
|
||||||
|
|
||||||
|
export const companySkills = pgTable(
|
||||||
|
"company_skills",
|
||||||
|
{
|
||||||
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
companyId: uuid("company_id").notNull().references(() => companies.id),
|
||||||
|
slug: text("slug").notNull(),
|
||||||
|
name: text("name").notNull(),
|
||||||
|
description: text("description"),
|
||||||
|
markdown: text("markdown").notNull(),
|
||||||
|
sourceType: text("source_type").notNull().default("local_path"),
|
||||||
|
sourceLocator: text("source_locator"),
|
||||||
|
sourceRef: text("source_ref"),
|
||||||
|
trustLevel: text("trust_level").notNull().default("markdown_only"),
|
||||||
|
compatibility: text("compatibility").notNull().default("compatible"),
|
||||||
|
fileInventory: jsonb("file_inventory").$type<Array<Record<string, unknown>>>().notNull().default([]),
|
||||||
|
metadata: jsonb("metadata").$type<Record<string, unknown>>(),
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
},
|
||||||
|
(table) => ({
|
||||||
|
companySlugUniqueIdx: uniqueIndex("company_skills_company_slug_idx").on(table.companyId, table.slug),
|
||||||
|
companyNameIdx: index("company_skills_company_name_idx").on(table.companyId, table.name),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
@ -32,3 +32,4 @@ export { approvalComments } from "./approval_comments.js";
|
||||||
export { activityLog } from "./activity_log.js";
|
export { activityLog } from "./activity_log.js";
|
||||||
export { companySecrets } from "./company_secrets.js";
|
export { companySecrets } from "./company_secrets.js";
|
||||||
export { companySecretVersions } from "./company_secret_versions.js";
|
export { companySecretVersions } from "./company_secret_versions.js";
|
||||||
|
export { companySkills } from "./company_skills.js";
|
||||||
|
|
|
||||||
|
|
@ -65,6 +65,16 @@ export {
|
||||||
|
|
||||||
export type {
|
export type {
|
||||||
Company,
|
Company,
|
||||||
|
CompanySkillSourceType,
|
||||||
|
CompanySkillTrustLevel,
|
||||||
|
CompanySkillCompatibility,
|
||||||
|
CompanySkillFileInventoryEntry,
|
||||||
|
CompanySkill,
|
||||||
|
CompanySkillListItem,
|
||||||
|
CompanySkillUsageAgent,
|
||||||
|
CompanySkillDetail,
|
||||||
|
CompanySkillImportRequest,
|
||||||
|
CompanySkillImportResult,
|
||||||
AgentSkillSyncMode,
|
AgentSkillSyncMode,
|
||||||
AgentSkillState,
|
AgentSkillState,
|
||||||
AgentSkillEntry,
|
AgentSkillEntry,
|
||||||
|
|
@ -238,6 +248,15 @@ export {
|
||||||
type ClaimJoinRequestApiKey,
|
type ClaimJoinRequestApiKey,
|
||||||
type UpdateMemberPermissions,
|
type UpdateMemberPermissions,
|
||||||
type UpdateUserCompanyAccess,
|
type UpdateUserCompanyAccess,
|
||||||
|
companySkillSourceTypeSchema,
|
||||||
|
companySkillTrustLevelSchema,
|
||||||
|
companySkillCompatibilitySchema,
|
||||||
|
companySkillFileInventoryEntrySchema,
|
||||||
|
companySkillSchema,
|
||||||
|
companySkillListItemSchema,
|
||||||
|
companySkillUsageAgentSchema,
|
||||||
|
companySkillDetailSchema,
|
||||||
|
companySkillImportSchema,
|
||||||
portabilityIncludeSchema,
|
portabilityIncludeSchema,
|
||||||
portabilityEnvInputSchema,
|
portabilityEnvInputSchema,
|
||||||
portabilityCompanyManifestEntrySchema,
|
portabilityCompanyManifestEntrySchema,
|
||||||
|
|
|
||||||
55
packages/shared/src/types/company-skill.ts
Normal file
55
packages/shared/src/types/company-skill.ts
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
export type CompanySkillSourceType = "local_path" | "github" | "url" | "catalog";
|
||||||
|
|
||||||
|
export type CompanySkillTrustLevel = "markdown_only" | "assets" | "scripts_executables";
|
||||||
|
|
||||||
|
export type CompanySkillCompatibility = "compatible" | "unknown" | "invalid";
|
||||||
|
|
||||||
|
export interface CompanySkillFileInventoryEntry {
|
||||||
|
path: string;
|
||||||
|
kind: "skill" | "markdown" | "reference" | "script" | "asset" | "other";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CompanySkill {
|
||||||
|
id: string;
|
||||||
|
companyId: string;
|
||||||
|
slug: string;
|
||||||
|
name: string;
|
||||||
|
description: string | null;
|
||||||
|
markdown: string;
|
||||||
|
sourceType: CompanySkillSourceType;
|
||||||
|
sourceLocator: string | null;
|
||||||
|
sourceRef: string | null;
|
||||||
|
trustLevel: CompanySkillTrustLevel;
|
||||||
|
compatibility: CompanySkillCompatibility;
|
||||||
|
fileInventory: CompanySkillFileInventoryEntry[];
|
||||||
|
metadata: Record<string, unknown> | null;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CompanySkillListItem extends CompanySkill {
|
||||||
|
attachedAgentCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CompanySkillUsageAgent {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
urlKey: string;
|
||||||
|
adapterType: string;
|
||||||
|
desired: boolean;
|
||||||
|
actualState: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CompanySkillDetail extends CompanySkill {
|
||||||
|
attachedAgentCount: number;
|
||||||
|
usedByAgents: CompanySkillUsageAgent[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CompanySkillImportRequest {
|
||||||
|
source: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CompanySkillImportResult {
|
||||||
|
imported: CompanySkill[];
|
||||||
|
warnings: string[];
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,16 @@
|
||||||
export type { Company } from "./company.js";
|
export type { Company } from "./company.js";
|
||||||
|
export type {
|
||||||
|
CompanySkillSourceType,
|
||||||
|
CompanySkillTrustLevel,
|
||||||
|
CompanySkillCompatibility,
|
||||||
|
CompanySkillFileInventoryEntry,
|
||||||
|
CompanySkill,
|
||||||
|
CompanySkillListItem,
|
||||||
|
CompanySkillUsageAgent,
|
||||||
|
CompanySkillDetail,
|
||||||
|
CompanySkillImportRequest,
|
||||||
|
CompanySkillImportResult,
|
||||||
|
} from "./company-skill.js";
|
||||||
export type {
|
export type {
|
||||||
AgentSkillSyncMode,
|
AgentSkillSyncMode,
|
||||||
AgentSkillState,
|
AgentSkillState,
|
||||||
|
|
|
||||||
52
packages/shared/src/validators/company-skill.ts
Normal file
52
packages/shared/src/validators/company-skill.ts
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
export const companySkillSourceTypeSchema = z.enum(["local_path", "github", "url", "catalog"]);
|
||||||
|
export const companySkillTrustLevelSchema = z.enum(["markdown_only", "assets", "scripts_executables"]);
|
||||||
|
export const companySkillCompatibilitySchema = z.enum(["compatible", "unknown", "invalid"]);
|
||||||
|
|
||||||
|
export const companySkillFileInventoryEntrySchema = z.object({
|
||||||
|
path: z.string().min(1),
|
||||||
|
kind: z.enum(["skill", "markdown", "reference", "script", "asset", "other"]),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const companySkillSchema = z.object({
|
||||||
|
id: z.string().uuid(),
|
||||||
|
companyId: z.string().uuid(),
|
||||||
|
slug: z.string().min(1),
|
||||||
|
name: z.string().min(1),
|
||||||
|
description: z.string().nullable(),
|
||||||
|
markdown: z.string(),
|
||||||
|
sourceType: companySkillSourceTypeSchema,
|
||||||
|
sourceLocator: z.string().nullable(),
|
||||||
|
sourceRef: z.string().nullable(),
|
||||||
|
trustLevel: companySkillTrustLevelSchema,
|
||||||
|
compatibility: companySkillCompatibilitySchema,
|
||||||
|
fileInventory: z.array(companySkillFileInventoryEntrySchema).default([]),
|
||||||
|
metadata: z.record(z.unknown()).nullable(),
|
||||||
|
createdAt: z.coerce.date(),
|
||||||
|
updatedAt: z.coerce.date(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const companySkillListItemSchema = companySkillSchema.extend({
|
||||||
|
attachedAgentCount: z.number().int().nonnegative(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const companySkillUsageAgentSchema = z.object({
|
||||||
|
id: z.string().uuid(),
|
||||||
|
name: z.string().min(1),
|
||||||
|
urlKey: z.string().min(1),
|
||||||
|
adapterType: z.string().min(1),
|
||||||
|
desired: z.boolean(),
|
||||||
|
actualState: z.string().nullable(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const companySkillDetailSchema = companySkillSchema.extend({
|
||||||
|
attachedAgentCount: z.number().int().nonnegative(),
|
||||||
|
usedByAgents: z.array(companySkillUsageAgentSchema).default([]),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const companySkillImportSchema = z.object({
|
||||||
|
source: z.string().min(1),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type CompanySkillImport = z.infer<typeof companySkillImportSchema>;
|
||||||
|
|
@ -4,6 +4,18 @@ export {
|
||||||
type CreateCompany,
|
type CreateCompany,
|
||||||
type UpdateCompany,
|
type UpdateCompany,
|
||||||
} from "./company.js";
|
} from "./company.js";
|
||||||
|
export {
|
||||||
|
companySkillSourceTypeSchema,
|
||||||
|
companySkillTrustLevelSchema,
|
||||||
|
companySkillCompatibilitySchema,
|
||||||
|
companySkillFileInventoryEntrySchema,
|
||||||
|
companySkillSchema,
|
||||||
|
companySkillListItemSchema,
|
||||||
|
companySkillUsageAgentSchema,
|
||||||
|
companySkillDetailSchema,
|
||||||
|
companySkillImportSchema,
|
||||||
|
type CompanySkillImport,
|
||||||
|
} from "./company-skill.js";
|
||||||
export {
|
export {
|
||||||
agentSkillStateSchema,
|
agentSkillStateSchema,
|
||||||
agentSkillSyncModeSchema,
|
agentSkillSyncModeSchema,
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import { boardMutationGuard } from "./middleware/board-mutation-guard.js";
|
||||||
import { privateHostnameGuard, resolvePrivateHostnameAllowSet } from "./middleware/private-hostname-guard.js";
|
import { privateHostnameGuard, resolvePrivateHostnameAllowSet } from "./middleware/private-hostname-guard.js";
|
||||||
import { healthRoutes } from "./routes/health.js";
|
import { healthRoutes } from "./routes/health.js";
|
||||||
import { companyRoutes } from "./routes/companies.js";
|
import { companyRoutes } from "./routes/companies.js";
|
||||||
|
import { companySkillRoutes } from "./routes/company-skills.js";
|
||||||
import { agentRoutes } from "./routes/agents.js";
|
import { agentRoutes } from "./routes/agents.js";
|
||||||
import { projectRoutes } from "./routes/projects.js";
|
import { projectRoutes } from "./routes/projects.js";
|
||||||
import { issueRoutes } from "./routes/issues.js";
|
import { issueRoutes } from "./routes/issues.js";
|
||||||
|
|
@ -103,6 +104,7 @@ export async function createApp(
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
api.use("/companies", companyRoutes(db));
|
api.use("/companies", companyRoutes(db));
|
||||||
|
api.use(companySkillRoutes(db));
|
||||||
api.use(agentRoutes(db));
|
api.use(agentRoutes(db));
|
||||||
api.use(assetRoutes(db, opts.storageService));
|
api.use(assetRoutes(db, opts.storageService));
|
||||||
api.use(projectRoutes(db));
|
api.use(projectRoutes(db));
|
||||||
|
|
|
||||||
63
server/src/routes/company-skills.ts
Normal file
63
server/src/routes/company-skills.ts
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
import { Router } from "express";
|
||||||
|
import type { Db } from "@paperclipai/db";
|
||||||
|
import { companySkillImportSchema } from "@paperclipai/shared";
|
||||||
|
import { validate } from "../middleware/validate.js";
|
||||||
|
import { companySkillService, logActivity } from "../services/index.js";
|
||||||
|
import { assertCompanyAccess, getActorInfo } from "./authz.js";
|
||||||
|
|
||||||
|
export function companySkillRoutes(db: Db) {
|
||||||
|
const router = Router();
|
||||||
|
const svc = companySkillService(db);
|
||||||
|
|
||||||
|
router.get("/companies/:companyId/skills", async (req, res) => {
|
||||||
|
const companyId = req.params.companyId as string;
|
||||||
|
assertCompanyAccess(req, companyId);
|
||||||
|
const result = await svc.list(companyId);
|
||||||
|
res.json(result);
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get("/companies/:companyId/skills/:skillId", async (req, res) => {
|
||||||
|
const companyId = req.params.companyId as string;
|
||||||
|
const skillId = req.params.skillId as string;
|
||||||
|
assertCompanyAccess(req, companyId);
|
||||||
|
const result = await svc.detail(companyId, skillId);
|
||||||
|
if (!result) {
|
||||||
|
res.status(404).json({ error: "Skill not found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.json(result);
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
"/companies/:companyId/skills/import",
|
||||||
|
validate(companySkillImportSchema),
|
||||||
|
async (req, res) => {
|
||||||
|
const companyId = req.params.companyId as string;
|
||||||
|
assertCompanyAccess(req, companyId);
|
||||||
|
const source = String(req.body.source ?? "");
|
||||||
|
const result = await svc.importFromSource(companyId, source);
|
||||||
|
|
||||||
|
const actor = getActorInfo(req);
|
||||||
|
await logActivity(db, {
|
||||||
|
companyId,
|
||||||
|
actorType: actor.actorType,
|
||||||
|
actorId: actor.actorId,
|
||||||
|
agentId: actor.agentId,
|
||||||
|
runId: actor.runId,
|
||||||
|
action: "company.skills_imported",
|
||||||
|
entityType: "company",
|
||||||
|
entityId: companyId,
|
||||||
|
details: {
|
||||||
|
source,
|
||||||
|
importedCount: result.imported.length,
|
||||||
|
importedSlugs: result.imported.map((skill) => skill.slug),
|
||||||
|
warningCount: result.warnings.length,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(201).json(result);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return router;
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
export { healthRoutes } from "./health.js";
|
export { healthRoutes } from "./health.js";
|
||||||
export { companyRoutes } from "./companies.js";
|
export { companyRoutes } from "./companies.js";
|
||||||
|
export { companySkillRoutes } from "./company-skills.js";
|
||||||
export { agentRoutes } from "./agents.js";
|
export { agentRoutes } from "./agents.js";
|
||||||
export { projectRoutes } from "./projects.js";
|
export { projectRoutes } from "./projects.js";
|
||||||
export { issueRoutes } from "./issues.js";
|
export { issueRoutes } from "./issues.js";
|
||||||
|
|
|
||||||
621
server/src/services/company-skills.ts
Normal file
621
server/src/services/company-skills.ts
Normal file
|
|
@ -0,0 +1,621 @@
|
||||||
|
import { promises as fs } from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { and, asc, eq } from "drizzle-orm";
|
||||||
|
import type { Db } from "@paperclipai/db";
|
||||||
|
import { companySkills } from "@paperclipai/db";
|
||||||
|
import type {
|
||||||
|
CompanySkill,
|
||||||
|
CompanySkillCompatibility,
|
||||||
|
CompanySkillDetail,
|
||||||
|
CompanySkillFileInventoryEntry,
|
||||||
|
CompanySkillImportResult,
|
||||||
|
CompanySkillListItem,
|
||||||
|
CompanySkillSourceType,
|
||||||
|
CompanySkillTrustLevel,
|
||||||
|
CompanySkillUsageAgent,
|
||||||
|
} from "@paperclipai/shared";
|
||||||
|
import { normalizeAgentUrlKey } from "@paperclipai/shared";
|
||||||
|
import { readPaperclipSkillSyncPreference } from "@paperclipai/adapter-utils/server-utils";
|
||||||
|
import { findServerAdapter } from "../adapters/index.js";
|
||||||
|
import { notFound, unprocessable } from "../errors.js";
|
||||||
|
import { agentService } from "./agents.js";
|
||||||
|
import { secretService } from "./secrets.js";
|
||||||
|
|
||||||
|
type CompanySkillRow = typeof companySkills.$inferSelect;
|
||||||
|
|
||||||
|
type ImportedSkill = {
|
||||||
|
slug: string;
|
||||||
|
name: string;
|
||||||
|
description: string | null;
|
||||||
|
markdown: string;
|
||||||
|
sourceType: CompanySkillSourceType;
|
||||||
|
sourceLocator: string | null;
|
||||||
|
sourceRef: string | null;
|
||||||
|
trustLevel: CompanySkillTrustLevel;
|
||||||
|
compatibility: CompanySkillCompatibility;
|
||||||
|
fileInventory: CompanySkillFileInventoryEntry[];
|
||||||
|
metadata: Record<string, unknown> | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
function asString(value: unknown): string | null {
|
||||||
|
if (typeof value !== "string") return null;
|
||||||
|
const trimmed = value.trim();
|
||||||
|
return trimmed.length > 0 ? trimmed : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPlainRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePortablePath(input: string) {
|
||||||
|
return input.replace(/\\/g, "/").replace(/^\.\/+/, "").replace(/^\/+/, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function classifyInventoryKind(relativePath: string): CompanySkillFileInventoryEntry["kind"] {
|
||||||
|
const normalized = normalizePortablePath(relativePath).toLowerCase();
|
||||||
|
if (normalized.endsWith("/skill.md") || normalized === "skill.md") return "skill";
|
||||||
|
if (normalized.startsWith("references/")) return "reference";
|
||||||
|
if (normalized.startsWith("scripts/")) return "script";
|
||||||
|
if (normalized.startsWith("assets/")) return "asset";
|
||||||
|
if (normalized.endsWith(".md")) return "markdown";
|
||||||
|
const fileName = path.posix.basename(normalized);
|
||||||
|
if (
|
||||||
|
fileName.endsWith(".sh")
|
||||||
|
|| fileName.endsWith(".js")
|
||||||
|
|| fileName.endsWith(".mjs")
|
||||||
|
|| fileName.endsWith(".cjs")
|
||||||
|
|| fileName.endsWith(".ts")
|
||||||
|
|| fileName.endsWith(".py")
|
||||||
|
|| fileName.endsWith(".rb")
|
||||||
|
|| fileName.endsWith(".bash")
|
||||||
|
) {
|
||||||
|
return "script";
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
fileName.endsWith(".png")
|
||||||
|
|| fileName.endsWith(".jpg")
|
||||||
|
|| fileName.endsWith(".jpeg")
|
||||||
|
|| fileName.endsWith(".gif")
|
||||||
|
|| fileName.endsWith(".svg")
|
||||||
|
|| fileName.endsWith(".webp")
|
||||||
|
|| fileName.endsWith(".pdf")
|
||||||
|
) {
|
||||||
|
return "asset";
|
||||||
|
}
|
||||||
|
return "other";
|
||||||
|
}
|
||||||
|
|
||||||
|
function deriveTrustLevel(fileInventory: CompanySkillFileInventoryEntry[]): CompanySkillTrustLevel {
|
||||||
|
if (fileInventory.some((entry) => entry.kind === "script")) return "scripts_executables";
|
||||||
|
if (fileInventory.some((entry) => entry.kind === "asset" || entry.kind === "other")) return "assets";
|
||||||
|
return "markdown_only";
|
||||||
|
}
|
||||||
|
|
||||||
|
function prepareYamlLines(raw: string) {
|
||||||
|
return raw
|
||||||
|
.split("\n")
|
||||||
|
.map((line) => ({
|
||||||
|
indent: line.match(/^ */)?.[0].length ?? 0,
|
||||||
|
content: line.trim(),
|
||||||
|
}))
|
||||||
|
.filter((line) => line.content.length > 0 && !line.content.startsWith("#"));
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseYamlScalar(rawValue: string): unknown {
|
||||||
|
const trimmed = rawValue.trim();
|
||||||
|
if (trimmed === "") return "";
|
||||||
|
if (trimmed === "null" || trimmed === "~") return null;
|
||||||
|
if (trimmed === "true") return true;
|
||||||
|
if (trimmed === "false") return false;
|
||||||
|
if (trimmed === "[]") return [];
|
||||||
|
if (trimmed === "{}") return {};
|
||||||
|
if (/^-?\d+(\.\d+)?$/.test(trimmed)) return Number(trimmed);
|
||||||
|
if (trimmed.startsWith("\"") || trimmed.startsWith("[") || trimmed.startsWith("{")) {
|
||||||
|
try {
|
||||||
|
return JSON.parse(trimmed);
|
||||||
|
} catch {
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseYamlBlock(
|
||||||
|
lines: Array<{ indent: number; content: string }>,
|
||||||
|
startIndex: number,
|
||||||
|
indentLevel: number,
|
||||||
|
): { value: unknown; nextIndex: number } {
|
||||||
|
let index = startIndex;
|
||||||
|
while (index < lines.length && lines[index]!.content.length === 0) index += 1;
|
||||||
|
if (index >= lines.length || lines[index]!.indent < indentLevel) {
|
||||||
|
return { value: {}, nextIndex: index };
|
||||||
|
}
|
||||||
|
|
||||||
|
const isArray = lines[index]!.indent === indentLevel && lines[index]!.content.startsWith("-");
|
||||||
|
if (isArray) {
|
||||||
|
const values: unknown[] = [];
|
||||||
|
while (index < lines.length) {
|
||||||
|
const line = lines[index]!;
|
||||||
|
if (line.indent < indentLevel) break;
|
||||||
|
if (line.indent !== indentLevel || !line.content.startsWith("-")) break;
|
||||||
|
const remainder = line.content.slice(1).trim();
|
||||||
|
index += 1;
|
||||||
|
if (!remainder) {
|
||||||
|
const nested = parseYamlBlock(lines, index, indentLevel + 2);
|
||||||
|
values.push(nested.value);
|
||||||
|
index = nested.nextIndex;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
values.push(parseYamlScalar(remainder));
|
||||||
|
}
|
||||||
|
return { value: values, nextIndex: index };
|
||||||
|
}
|
||||||
|
|
||||||
|
const record: Record<string, unknown> = {};
|
||||||
|
while (index < lines.length) {
|
||||||
|
const line = lines[index]!;
|
||||||
|
if (line.indent < indentLevel) break;
|
||||||
|
if (line.indent !== indentLevel) {
|
||||||
|
index += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const separatorIndex = line.content.indexOf(":");
|
||||||
|
if (separatorIndex <= 0) {
|
||||||
|
index += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const key = line.content.slice(0, separatorIndex).trim();
|
||||||
|
const remainder = line.content.slice(separatorIndex + 1).trim();
|
||||||
|
index += 1;
|
||||||
|
if (!remainder) {
|
||||||
|
const nested = parseYamlBlock(lines, index, indentLevel + 2);
|
||||||
|
record[key] = nested.value;
|
||||||
|
index = nested.nextIndex;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
record[key] = parseYamlScalar(remainder);
|
||||||
|
}
|
||||||
|
return { value: record, nextIndex: index };
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseYamlFrontmatter(raw: string): Record<string, unknown> {
|
||||||
|
const prepared = prepareYamlLines(raw);
|
||||||
|
if (prepared.length === 0) return {};
|
||||||
|
const parsed = parseYamlBlock(prepared, 0, prepared[0]!.indent);
|
||||||
|
return isPlainRecord(parsed.value) ? parsed.value : {};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseFrontmatterMarkdown(raw: string): { frontmatter: Record<string, unknown>; body: string } {
|
||||||
|
const normalized = raw.replace(/\r\n/g, "\n");
|
||||||
|
if (!normalized.startsWith("---\n")) {
|
||||||
|
return { frontmatter: {}, body: normalized.trim() };
|
||||||
|
}
|
||||||
|
const closing = normalized.indexOf("\n---\n", 4);
|
||||||
|
if (closing < 0) {
|
||||||
|
return { frontmatter: {}, body: normalized.trim() };
|
||||||
|
}
|
||||||
|
const frontmatterRaw = normalized.slice(4, closing).trim();
|
||||||
|
const body = normalized.slice(closing + 5).trim();
|
||||||
|
return {
|
||||||
|
frontmatter: parseYamlFrontmatter(frontmatterRaw),
|
||||||
|
body,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchText(url: string) {
|
||||||
|
const response = await fetch(url);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw unprocessable(`Failed to fetch ${url}: ${response.status}`);
|
||||||
|
}
|
||||||
|
return response.text();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchJson<T>(url: string): Promise<T> {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
headers: {
|
||||||
|
accept: "application/vnd.github+json",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw unprocessable(`Failed to fetch ${url}: ${response.status}`);
|
||||||
|
}
|
||||||
|
return response.json() as Promise<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseGitHubSourceUrl(rawUrl: string) {
|
||||||
|
const url = new URL(rawUrl);
|
||||||
|
if (url.hostname !== "github.com") {
|
||||||
|
throw unprocessable("GitHub source must use github.com URL");
|
||||||
|
}
|
||||||
|
const parts = url.pathname.split("/").filter(Boolean);
|
||||||
|
if (parts.length < 2) {
|
||||||
|
throw unprocessable("Invalid GitHub URL");
|
||||||
|
}
|
||||||
|
const owner = parts[0]!;
|
||||||
|
const repo = parts[1]!.replace(/\.git$/i, "");
|
||||||
|
let ref = "main";
|
||||||
|
let basePath = "";
|
||||||
|
let filePath: string | null = null;
|
||||||
|
if (parts[2] === "tree") {
|
||||||
|
ref = parts[3] ?? "main";
|
||||||
|
basePath = parts.slice(4).join("/");
|
||||||
|
} else if (parts[2] === "blob") {
|
||||||
|
ref = parts[3] ?? "main";
|
||||||
|
filePath = parts.slice(4).join("/");
|
||||||
|
basePath = filePath ? path.posix.dirname(filePath) : "";
|
||||||
|
}
|
||||||
|
return { owner, repo, ref, basePath, filePath };
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveRawGitHubUrl(owner: string, repo: string, ref: string, filePath: string) {
|
||||||
|
return `https://raw.githubusercontent.com/${owner}/${repo}/${ref}/${filePath.replace(/^\/+/, "")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function walkLocalFiles(root: string, current: string, out: string[]) {
|
||||||
|
const entries = await fs.readdir(current, { withFileTypes: true });
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (entry.name === ".git" || entry.name === "node_modules") continue;
|
||||||
|
const absolutePath = path.join(current, entry.name);
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
await walkLocalFiles(root, absolutePath, out);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!entry.isFile()) continue;
|
||||||
|
out.push(normalizePortablePath(path.relative(root, absolutePath)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readLocalSkillImports(sourcePath: string): Promise<ImportedSkill[]> {
|
||||||
|
const resolvedPath = path.resolve(sourcePath);
|
||||||
|
const stat = await fs.stat(resolvedPath).catch(() => null);
|
||||||
|
if (!stat) {
|
||||||
|
throw unprocessable(`Skill source path does not exist: ${sourcePath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stat.isFile()) {
|
||||||
|
const markdown = await fs.readFile(resolvedPath, "utf8");
|
||||||
|
const parsed = parseFrontmatterMarkdown(markdown);
|
||||||
|
const slug = normalizeAgentUrlKey(path.basename(path.dirname(resolvedPath))) ?? "skill";
|
||||||
|
const inventory: CompanySkillFileInventoryEntry[] = [
|
||||||
|
{ path: "SKILL.md", kind: "skill" },
|
||||||
|
];
|
||||||
|
return [{
|
||||||
|
slug,
|
||||||
|
name: asString(parsed.frontmatter.name) ?? slug,
|
||||||
|
description: asString(parsed.frontmatter.description),
|
||||||
|
markdown,
|
||||||
|
sourceType: "local_path",
|
||||||
|
sourceLocator: resolvedPath,
|
||||||
|
sourceRef: null,
|
||||||
|
trustLevel: deriveTrustLevel(inventory),
|
||||||
|
compatibility: "compatible",
|
||||||
|
fileInventory: inventory,
|
||||||
|
metadata: null,
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
|
||||||
|
const root = resolvedPath;
|
||||||
|
const allFiles: string[] = [];
|
||||||
|
await walkLocalFiles(root, root, allFiles);
|
||||||
|
const skillPaths = allFiles.filter((entry) => path.posix.basename(entry).toLowerCase() === "skill.md");
|
||||||
|
if (skillPaths.length === 0) {
|
||||||
|
throw unprocessable("No SKILL.md files were found in the provided path.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const imports: ImportedSkill[] = [];
|
||||||
|
for (const skillPath of skillPaths) {
|
||||||
|
const skillDir = path.posix.dirname(skillPath);
|
||||||
|
const markdown = await fs.readFile(path.join(root, skillPath), "utf8");
|
||||||
|
const parsed = parseFrontmatterMarkdown(markdown);
|
||||||
|
const slug = normalizeAgentUrlKey(path.posix.basename(skillDir)) ?? "skill";
|
||||||
|
const inventory = allFiles
|
||||||
|
.filter((entry) => entry === skillPath || entry.startsWith(`${skillDir}/`))
|
||||||
|
.map((entry) => {
|
||||||
|
const relative = entry === skillPath ? "SKILL.md" : entry.slice(skillDir.length + 1);
|
||||||
|
return {
|
||||||
|
path: normalizePortablePath(relative),
|
||||||
|
kind: classifyInventoryKind(relative),
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.sort((left, right) => left.path.localeCompare(right.path));
|
||||||
|
imports.push({
|
||||||
|
slug,
|
||||||
|
name: asString(parsed.frontmatter.name) ?? slug,
|
||||||
|
description: asString(parsed.frontmatter.description),
|
||||||
|
markdown,
|
||||||
|
sourceType: "local_path",
|
||||||
|
sourceLocator: resolvedPath,
|
||||||
|
sourceRef: null,
|
||||||
|
trustLevel: deriveTrustLevel(inventory),
|
||||||
|
compatibility: "compatible",
|
||||||
|
fileInventory: inventory,
|
||||||
|
metadata: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return imports;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readUrlSkillImports(sourceUrl: string): Promise<{ skills: ImportedSkill[]; warnings: string[] }> {
|
||||||
|
const url = sourceUrl.trim();
|
||||||
|
const warnings: string[] = [];
|
||||||
|
if (url.includes("github.com/")) {
|
||||||
|
const parsed = parseGitHubSourceUrl(url);
|
||||||
|
let ref = parsed.ref;
|
||||||
|
if (!/^[0-9a-f]{40}$/i.test(ref.trim())) {
|
||||||
|
warnings.push("GitHub skill source is not pinned to a commit SHA; imports may drift if the ref changes.");
|
||||||
|
}
|
||||||
|
const tree = await fetchJson<{ tree?: Array<{ path: string; type: string }> }>(
|
||||||
|
`https://api.github.com/repos/${parsed.owner}/${parsed.repo}/git/trees/${ref}?recursive=1`,
|
||||||
|
).catch(async () => {
|
||||||
|
if (ref === "main") {
|
||||||
|
ref = "master";
|
||||||
|
warnings.push("GitHub ref main not found; falling back to master.");
|
||||||
|
return fetchJson<{ tree?: Array<{ path: string; type: string }> }>(
|
||||||
|
`https://api.github.com/repos/${parsed.owner}/${parsed.repo}/git/trees/${ref}?recursive=1`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw unprocessable(`Failed to read GitHub tree for ${url}`);
|
||||||
|
});
|
||||||
|
const allPaths = (tree.tree ?? [])
|
||||||
|
.filter((entry) => entry.type === "blob")
|
||||||
|
.map((entry) => entry.path)
|
||||||
|
.filter((entry): entry is string => typeof entry === "string");
|
||||||
|
const basePrefix = parsed.basePath ? `${parsed.basePath.replace(/^\/+|\/+$/g, "")}/` : "";
|
||||||
|
const scopedPaths = basePrefix
|
||||||
|
? allPaths.filter((entry) => entry.startsWith(basePrefix))
|
||||||
|
: allPaths;
|
||||||
|
const relativePaths = scopedPaths.map((entry) => basePrefix ? entry.slice(basePrefix.length) : entry);
|
||||||
|
const filteredPaths = parsed.filePath
|
||||||
|
? relativePaths.filter((entry) => entry === path.posix.relative(parsed.basePath || ".", parsed.filePath!))
|
||||||
|
: relativePaths;
|
||||||
|
const skillPaths = filteredPaths.filter((entry) => path.posix.basename(entry).toLowerCase() === "skill.md");
|
||||||
|
if (skillPaths.length === 0) {
|
||||||
|
throw unprocessable("No SKILL.md files were found in the provided GitHub source.");
|
||||||
|
}
|
||||||
|
const skills: ImportedSkill[] = [];
|
||||||
|
for (const relativeSkillPath of skillPaths) {
|
||||||
|
const repoSkillPath = basePrefix ? `${basePrefix}${relativeSkillPath}` : relativeSkillPath;
|
||||||
|
const markdown = await fetchText(resolveRawGitHubUrl(parsed.owner, parsed.repo, ref, repoSkillPath));
|
||||||
|
const parsedMarkdown = parseFrontmatterMarkdown(markdown);
|
||||||
|
const skillDir = path.posix.dirname(relativeSkillPath);
|
||||||
|
const slug = normalizeAgentUrlKey(path.posix.basename(skillDir)) ?? "skill";
|
||||||
|
const inventory = filteredPaths
|
||||||
|
.filter((entry) => entry === relativeSkillPath || entry.startsWith(`${skillDir}/`))
|
||||||
|
.map((entry) => ({
|
||||||
|
path: entry === relativeSkillPath ? "SKILL.md" : entry.slice(skillDir.length + 1),
|
||||||
|
kind: classifyInventoryKind(entry === relativeSkillPath ? "SKILL.md" : entry.slice(skillDir.length + 1)),
|
||||||
|
}))
|
||||||
|
.sort((left, right) => left.path.localeCompare(right.path));
|
||||||
|
skills.push({
|
||||||
|
slug,
|
||||||
|
name: asString(parsedMarkdown.frontmatter.name) ?? slug,
|
||||||
|
description: asString(parsedMarkdown.frontmatter.description),
|
||||||
|
markdown,
|
||||||
|
sourceType: "github",
|
||||||
|
sourceLocator: sourceUrl,
|
||||||
|
sourceRef: ref,
|
||||||
|
trustLevel: deriveTrustLevel(inventory),
|
||||||
|
compatibility: "compatible",
|
||||||
|
fileInventory: inventory,
|
||||||
|
metadata: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return { skills, warnings };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.startsWith("http://") || url.startsWith("https://")) {
|
||||||
|
const markdown = await fetchText(url);
|
||||||
|
const parsedMarkdown = parseFrontmatterMarkdown(markdown);
|
||||||
|
const urlObj = new URL(url);
|
||||||
|
const fileName = path.posix.basename(urlObj.pathname);
|
||||||
|
const slug = normalizeAgentUrlKey(fileName.replace(/\.md$/i, "")) ?? "skill";
|
||||||
|
const inventory: CompanySkillFileInventoryEntry[] = [{ path: "SKILL.md", kind: "skill" }];
|
||||||
|
return {
|
||||||
|
skills: [{
|
||||||
|
slug,
|
||||||
|
name: asString(parsedMarkdown.frontmatter.name) ?? slug,
|
||||||
|
description: asString(parsedMarkdown.frontmatter.description),
|
||||||
|
markdown,
|
||||||
|
sourceType: "url",
|
||||||
|
sourceLocator: url,
|
||||||
|
sourceRef: null,
|
||||||
|
trustLevel: deriveTrustLevel(inventory),
|
||||||
|
compatibility: "compatible",
|
||||||
|
fileInventory: inventory,
|
||||||
|
metadata: null,
|
||||||
|
}],
|
||||||
|
warnings,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
throw unprocessable("Unsupported skill source. Use a local path or URL.");
|
||||||
|
}
|
||||||
|
|
||||||
|
function toCompanySkill(row: CompanySkillRow): CompanySkill {
|
||||||
|
return {
|
||||||
|
...row,
|
||||||
|
description: row.description ?? null,
|
||||||
|
sourceType: row.sourceType as CompanySkillSourceType,
|
||||||
|
sourceLocator: row.sourceLocator ?? null,
|
||||||
|
sourceRef: row.sourceRef ?? null,
|
||||||
|
trustLevel: row.trustLevel as CompanySkillTrustLevel,
|
||||||
|
compatibility: row.compatibility as CompanySkillCompatibility,
|
||||||
|
fileInventory: Array.isArray(row.fileInventory)
|
||||||
|
? row.fileInventory.flatMap((entry) => {
|
||||||
|
if (!isPlainRecord(entry)) return [];
|
||||||
|
return [{
|
||||||
|
path: String(entry.path ?? ""),
|
||||||
|
kind: (String(entry.kind ?? "other") as CompanySkillFileInventoryEntry["kind"]),
|
||||||
|
}];
|
||||||
|
})
|
||||||
|
: [],
|
||||||
|
metadata: isPlainRecord(row.metadata) ? row.metadata : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function serializeFileInventory(
|
||||||
|
fileInventory: CompanySkillFileInventoryEntry[],
|
||||||
|
): Array<Record<string, unknown>> {
|
||||||
|
return fileInventory.map((entry) => ({
|
||||||
|
path: entry.path,
|
||||||
|
kind: entry.kind,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function companySkillService(db: Db) {
|
||||||
|
const agents = agentService(db);
|
||||||
|
const secretsSvc = secretService(db);
|
||||||
|
|
||||||
|
async function list(companyId: string): Promise<CompanySkillListItem[]> {
|
||||||
|
const rows = await db
|
||||||
|
.select()
|
||||||
|
.from(companySkills)
|
||||||
|
.where(eq(companySkills.companyId, companyId))
|
||||||
|
.orderBy(asc(companySkills.name), asc(companySkills.slug));
|
||||||
|
const agentRows = await agents.list(companyId);
|
||||||
|
return rows.map((row) => {
|
||||||
|
const skill = toCompanySkill(row);
|
||||||
|
const attachedAgentCount = agentRows.filter((agent) => {
|
||||||
|
const preference = readPaperclipSkillSyncPreference(agent.adapterConfig as Record<string, unknown>);
|
||||||
|
return preference.desiredSkills.includes(skill.slug);
|
||||||
|
}).length;
|
||||||
|
return {
|
||||||
|
...skill,
|
||||||
|
attachedAgentCount,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getById(id: string) {
|
||||||
|
const row = await db
|
||||||
|
.select()
|
||||||
|
.from(companySkills)
|
||||||
|
.where(eq(companySkills.id, id))
|
||||||
|
.then((rows) => rows[0] ?? null);
|
||||||
|
return row ? toCompanySkill(row) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getBySlug(companyId: string, slug: string) {
|
||||||
|
const row = await db
|
||||||
|
.select()
|
||||||
|
.from(companySkills)
|
||||||
|
.where(and(eq(companySkills.companyId, companyId), eq(companySkills.slug, slug)))
|
||||||
|
.then((rows) => rows[0] ?? null);
|
||||||
|
return row ? toCompanySkill(row) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function usage(companyId: string, slug: string): Promise<CompanySkillUsageAgent[]> {
|
||||||
|
const agentRows = await agents.list(companyId);
|
||||||
|
const desiredAgents = agentRows.filter((agent) => {
|
||||||
|
const preference = readPaperclipSkillSyncPreference(agent.adapterConfig as Record<string, unknown>);
|
||||||
|
return preference.desiredSkills.includes(slug);
|
||||||
|
});
|
||||||
|
|
||||||
|
return Promise.all(
|
||||||
|
desiredAgents.map(async (agent) => {
|
||||||
|
const adapter = findServerAdapter(agent.adapterType);
|
||||||
|
let actualState: string | null = null;
|
||||||
|
|
||||||
|
if (!adapter?.listSkills) {
|
||||||
|
actualState = "unsupported";
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
const { config: runtimeConfig } = await secretsSvc.resolveAdapterConfigForRuntime(
|
||||||
|
agent.companyId,
|
||||||
|
agent.adapterConfig as Record<string, unknown>,
|
||||||
|
);
|
||||||
|
const snapshot = await adapter.listSkills({
|
||||||
|
agentId: agent.id,
|
||||||
|
companyId: agent.companyId,
|
||||||
|
adapterType: agent.adapterType,
|
||||||
|
config: runtimeConfig,
|
||||||
|
});
|
||||||
|
actualState = snapshot.entries.find((entry) => entry.name === slug)?.state
|
||||||
|
?? (snapshot.supported ? "missing" : "unsupported");
|
||||||
|
} catch {
|
||||||
|
actualState = "unknown";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: agent.id,
|
||||||
|
name: agent.name,
|
||||||
|
urlKey: agent.urlKey,
|
||||||
|
adapterType: agent.adapterType,
|
||||||
|
desired: true,
|
||||||
|
actualState,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function detail(companyId: string, id: string): Promise<CompanySkillDetail | null> {
|
||||||
|
const skill = await getById(id);
|
||||||
|
if (!skill || skill.companyId !== companyId) return null;
|
||||||
|
const usedByAgents = await usage(companyId, skill.slug);
|
||||||
|
return {
|
||||||
|
...skill,
|
||||||
|
attachedAgentCount: usedByAgents.length,
|
||||||
|
usedByAgents,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function upsertImportedSkills(companyId: string, imported: ImportedSkill[]): Promise<CompanySkill[]> {
|
||||||
|
const out: CompanySkill[] = [];
|
||||||
|
for (const skill of imported) {
|
||||||
|
const existing = await getBySlug(companyId, skill.slug);
|
||||||
|
const values = {
|
||||||
|
companyId,
|
||||||
|
slug: skill.slug,
|
||||||
|
name: skill.name,
|
||||||
|
description: skill.description,
|
||||||
|
markdown: skill.markdown,
|
||||||
|
sourceType: skill.sourceType,
|
||||||
|
sourceLocator: skill.sourceLocator,
|
||||||
|
sourceRef: skill.sourceRef,
|
||||||
|
trustLevel: skill.trustLevel,
|
||||||
|
compatibility: skill.compatibility,
|
||||||
|
fileInventory: serializeFileInventory(skill.fileInventory),
|
||||||
|
metadata: skill.metadata,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
};
|
||||||
|
const row = existing
|
||||||
|
? await db
|
||||||
|
.update(companySkills)
|
||||||
|
.set(values)
|
||||||
|
.where(eq(companySkills.id, existing.id))
|
||||||
|
.returning()
|
||||||
|
.then((rows) => rows[0] ?? null)
|
||||||
|
: await db
|
||||||
|
.insert(companySkills)
|
||||||
|
.values(values)
|
||||||
|
.returning()
|
||||||
|
.then((rows) => rows[0] ?? null);
|
||||||
|
if (!row) throw notFound("Failed to persist company skill");
|
||||||
|
out.push(toCompanySkill(row));
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function importFromSource(companyId: string, source: string): Promise<CompanySkillImportResult> {
|
||||||
|
const trimmed = source.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
throw unprocessable("Skill source is required.");
|
||||||
|
}
|
||||||
|
const local = !/^https?:\/\//i.test(trimmed);
|
||||||
|
const { skills, warnings } = local
|
||||||
|
? { skills: await readLocalSkillImports(trimmed), warnings: [] as string[] }
|
||||||
|
: await readUrlSkillImports(trimmed);
|
||||||
|
const imported = await upsertImportedSkills(companyId, skills);
|
||||||
|
return { imported, warnings };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
list,
|
||||||
|
getById,
|
||||||
|
getBySlug,
|
||||||
|
detail,
|
||||||
|
importFromSource,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
export { companyService } from "./companies.js";
|
export { companyService } from "./companies.js";
|
||||||
|
export { companySkillService } from "./company-skills.js";
|
||||||
export { agentService, deduplicateAgentName } from "./agents.js";
|
export { agentService, deduplicateAgentName } from "./agents.js";
|
||||||
export { assetService } from "./assets.js";
|
export { assetService } from "./assets.js";
|
||||||
export { projectService } from "./projects.js";
|
export { projectService } from "./projects.js";
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ import { Costs } from "./pages/Costs";
|
||||||
import { Activity } from "./pages/Activity";
|
import { Activity } from "./pages/Activity";
|
||||||
import { Inbox } from "./pages/Inbox";
|
import { Inbox } from "./pages/Inbox";
|
||||||
import { CompanySettings } from "./pages/CompanySettings";
|
import { CompanySettings } from "./pages/CompanySettings";
|
||||||
|
import { CompanySkills } from "./pages/CompanySkills";
|
||||||
import { DesignGuide } from "./pages/DesignGuide";
|
import { DesignGuide } from "./pages/DesignGuide";
|
||||||
import { InstanceSettings } from "./pages/InstanceSettings";
|
import { InstanceSettings } from "./pages/InstanceSettings";
|
||||||
import { RunTranscriptUxLab } from "./pages/RunTranscriptUxLab";
|
import { RunTranscriptUxLab } from "./pages/RunTranscriptUxLab";
|
||||||
|
|
@ -111,6 +112,8 @@ function boardRoutes() {
|
||||||
<Route path="onboarding" element={<OnboardingRoutePage />} />
|
<Route path="onboarding" element={<OnboardingRoutePage />} />
|
||||||
<Route path="companies" element={<Companies />} />
|
<Route path="companies" element={<Companies />} />
|
||||||
<Route path="company/settings" element={<CompanySettings />} />
|
<Route path="company/settings" element={<CompanySettings />} />
|
||||||
|
<Route path="skills" element={<CompanySkills />} />
|
||||||
|
<Route path="skills/:skillId" element={<CompanySkills />} />
|
||||||
<Route path="settings" element={<LegacySettingsRedirect />} />
|
<Route path="settings" element={<LegacySettingsRedirect />} />
|
||||||
<Route path="settings/*" element={<LegacySettingsRedirect />} />
|
<Route path="settings/*" element={<LegacySettingsRedirect />} />
|
||||||
<Route path="org" element={<OrgChart />} />
|
<Route path="org" element={<OrgChart />} />
|
||||||
|
|
@ -302,6 +305,8 @@ export function App() {
|
||||||
<Route path="companies" element={<UnprefixedBoardRedirect />} />
|
<Route path="companies" element={<UnprefixedBoardRedirect />} />
|
||||||
<Route path="issues" element={<UnprefixedBoardRedirect />} />
|
<Route path="issues" element={<UnprefixedBoardRedirect />} />
|
||||||
<Route path="issues/:issueId" element={<UnprefixedBoardRedirect />} />
|
<Route path="issues/:issueId" element={<UnprefixedBoardRedirect />} />
|
||||||
|
<Route path="skills" element={<UnprefixedBoardRedirect />} />
|
||||||
|
<Route path="skills/:skillId" element={<UnprefixedBoardRedirect />} />
|
||||||
<Route path="settings" element={<LegacySettingsRedirect />} />
|
<Route path="settings" element={<LegacySettingsRedirect />} />
|
||||||
<Route path="settings/*" element={<LegacySettingsRedirect />} />
|
<Route path="settings/*" element={<LegacySettingsRedirect />} />
|
||||||
<Route path="agents" element={<UnprefixedBoardRedirect />} />
|
<Route path="agents" element={<UnprefixedBoardRedirect />} />
|
||||||
|
|
|
||||||
20
ui/src/api/companySkills.ts
Normal file
20
ui/src/api/companySkills.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
import type {
|
||||||
|
CompanySkillDetail,
|
||||||
|
CompanySkillImportResult,
|
||||||
|
CompanySkillListItem,
|
||||||
|
} from "@paperclipai/shared";
|
||||||
|
import { api } from "./client";
|
||||||
|
|
||||||
|
export const companySkillsApi = {
|
||||||
|
list: (companyId: string) =>
|
||||||
|
api.get<CompanySkillListItem[]>(`/companies/${encodeURIComponent(companyId)}/skills`),
|
||||||
|
detail: (companyId: string, skillId: string) =>
|
||||||
|
api.get<CompanySkillDetail>(
|
||||||
|
`/companies/${encodeURIComponent(companyId)}/skills/${encodeURIComponent(skillId)}`,
|
||||||
|
),
|
||||||
|
importFromSource: (companyId: string, source: string) =>
|
||||||
|
api.post<CompanySkillImportResult>(
|
||||||
|
`/companies/${encodeURIComponent(companyId)}/skills/import`,
|
||||||
|
{ source },
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
@ -13,3 +13,4 @@ export { activityApi } from "./activity";
|
||||||
export { dashboardApi } from "./dashboard";
|
export { dashboardApi } from "./dashboard";
|
||||||
export { heartbeatsApi } from "./heartbeats";
|
export { heartbeatsApi } from "./heartbeats";
|
||||||
export { sidebarBadgesApi } from "./sidebarBadges";
|
export { sidebarBadgesApi } from "./sidebarBadges";
|
||||||
|
export { companySkillsApi } from "./companySkills";
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import {
|
||||||
Search,
|
Search,
|
||||||
SquarePen,
|
SquarePen,
|
||||||
Network,
|
Network,
|
||||||
|
Boxes,
|
||||||
Settings,
|
Settings,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
|
@ -93,6 +94,7 @@ export function Sidebar() {
|
||||||
|
|
||||||
<SidebarSection label="Company">
|
<SidebarSection label="Company">
|
||||||
<SidebarNavItem to="/org" label="Org" icon={Network} />
|
<SidebarNavItem to="/org" label="Org" icon={Network} />
|
||||||
|
<SidebarNavItem to="/skills" label="Skills" icon={Boxes} />
|
||||||
<SidebarNavItem to="/costs" label="Costs" icon={DollarSign} />
|
<SidebarNavItem to="/costs" label="Costs" icon={DollarSign} />
|
||||||
<SidebarNavItem to="/activity" label="Activity" icon={History} />
|
<SidebarNavItem to="/activity" label="Activity" icon={History} />
|
||||||
<SidebarNavItem to="/company/settings" label="Settings" icon={Settings} />
|
<SidebarNavItem to="/company/settings" label="Settings" icon={Settings} />
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,10 @@ export const queryKeys = {
|
||||||
detail: (id: string) => ["companies", id] as const,
|
detail: (id: string) => ["companies", id] as const,
|
||||||
stats: ["companies", "stats"] as const,
|
stats: ["companies", "stats"] as const,
|
||||||
},
|
},
|
||||||
|
companySkills: {
|
||||||
|
list: (companyId: string) => ["company-skills", companyId] as const,
|
||||||
|
detail: (companyId: string, skillId: string) => ["company-skills", companyId, skillId] as const,
|
||||||
|
},
|
||||||
agents: {
|
agents: {
|
||||||
list: (companyId: string) => ["agents", companyId] as const,
|
list: (companyId: string) => ["agents", companyId] as const,
|
||||||
detail: (id: string) => ["agents", "detail", id] as const,
|
detail: (id: string) => ["agents", "detail", id] as const,
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useState, useRef } from "react";
|
||||||
import { useParams, useNavigate, Link, Navigate, useBeforeUnload } from "@/lib/router";
|
import { useParams, useNavigate, Link, Navigate, useBeforeUnload } from "@/lib/router";
|
||||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import { agentsApi, type AgentKey, type ClaudeLoginResult } from "../api/agents";
|
import { agentsApi, type AgentKey, type ClaudeLoginResult } from "../api/agents";
|
||||||
|
import { companySkillsApi } from "../api/companySkills";
|
||||||
import { heartbeatsApi } from "../api/heartbeats";
|
import { heartbeatsApi } from "../api/heartbeats";
|
||||||
import { ApiError } from "../api/client";
|
import { ApiError } from "../api/client";
|
||||||
import { ChartCard, RunActivityChart, PriorityChart, IssueStatusChart, SuccessRateChart } from "../components/ActivityCharts";
|
import { ChartCard, RunActivityChart, PriorityChart, IssueStatusChart, SuccessRateChart } from "../components/ActivityCharts";
|
||||||
|
|
@ -175,10 +176,11 @@ function scrollToContainerBottom(container: ScrollContainer, behavior: ScrollBeh
|
||||||
container.scrollTo({ top: container.scrollHeight, behavior });
|
container.scrollTo({ top: container.scrollHeight, behavior });
|
||||||
}
|
}
|
||||||
|
|
||||||
type AgentDetailView = "dashboard" | "configuration" | "runs";
|
type AgentDetailView = "dashboard" | "configuration" | "skills" | "runs";
|
||||||
|
|
||||||
function parseAgentDetailView(value: string | null): AgentDetailView {
|
function parseAgentDetailView(value: string | null): AgentDetailView {
|
||||||
if (value === "configure" || value === "configuration") return "configuration";
|
if (value === "configure" || value === "configuration") return "configuration";
|
||||||
|
if (value === "skills") return "skills";
|
||||||
if (value === "runs") return value;
|
if (value === "runs") return value;
|
||||||
return "dashboard";
|
return "dashboard";
|
||||||
}
|
}
|
||||||
|
|
@ -315,6 +317,8 @@ export function AgentDetail() {
|
||||||
const canonicalTab =
|
const canonicalTab =
|
||||||
activeView === "configuration"
|
activeView === "configuration"
|
||||||
? "configuration"
|
? "configuration"
|
||||||
|
: activeView === "skills"
|
||||||
|
? "skills"
|
||||||
: activeView === "runs"
|
: activeView === "runs"
|
||||||
? "runs"
|
? "runs"
|
||||||
: "dashboard";
|
: "dashboard";
|
||||||
|
|
@ -414,6 +418,8 @@ export function AgentDetail() {
|
||||||
crumbs.push({ label: `Run ${urlRunId.slice(0, 8)}` });
|
crumbs.push({ label: `Run ${urlRunId.slice(0, 8)}` });
|
||||||
} else if (activeView === "configuration") {
|
} else if (activeView === "configuration") {
|
||||||
crumbs.push({ label: "Configuration" });
|
crumbs.push({ label: "Configuration" });
|
||||||
|
} else if (activeView === "skills") {
|
||||||
|
crumbs.push({ label: "Skills" });
|
||||||
} else if (activeView === "runs") {
|
} else if (activeView === "runs") {
|
||||||
crumbs.push({ label: "Runs" });
|
crumbs.push({ label: "Runs" });
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -571,6 +577,7 @@ export function AgentDetail() {
|
||||||
items={[
|
items={[
|
||||||
{ value: "dashboard", label: "Dashboard" },
|
{ value: "dashboard", label: "Dashboard" },
|
||||||
{ value: "configuration", label: "Configuration" },
|
{ value: "configuration", label: "Configuration" },
|
||||||
|
{ value: "skills", label: "Skills" },
|
||||||
{ value: "runs", label: "Runs" },
|
{ value: "runs", label: "Runs" },
|
||||||
]}
|
]}
|
||||||
value={activeView}
|
value={activeView}
|
||||||
|
|
@ -667,6 +674,13 @@ export function AgentDetail() {
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{activeView === "skills" && (
|
||||||
|
<AgentSkillsTab
|
||||||
|
agent={agent}
|
||||||
|
companyId={resolvedCompanyId ?? undefined}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{activeView === "runs" && (
|
{activeView === "runs" && (
|
||||||
<RunsTab
|
<RunsTab
|
||||||
runs={heartbeats ?? []}
|
runs={heartbeats ?? []}
|
||||||
|
|
@ -1045,8 +1059,6 @@ function ConfigurationTab({
|
||||||
}) {
|
}) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [awaitingRefreshAfterSave, setAwaitingRefreshAfterSave] = useState(false);
|
const [awaitingRefreshAfterSave, setAwaitingRefreshAfterSave] = useState(false);
|
||||||
const [skillDraft, setSkillDraft] = useState<string[]>([]);
|
|
||||||
const [skillDirty, setSkillDirty] = useState(false);
|
|
||||||
const lastAgentRef = useRef(agent);
|
const lastAgentRef = useRef(agent);
|
||||||
|
|
||||||
const { data: adapterModels } = useQuery({
|
const { data: adapterModels } = useQuery({
|
||||||
|
|
@ -1058,12 +1070,6 @@ function ConfigurationTab({
|
||||||
enabled: Boolean(companyId),
|
enabled: Boolean(companyId),
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data: skillSnapshot } = useQuery({
|
|
||||||
queryKey: queryKeys.agents.skills(agent.id),
|
|
||||||
queryFn: () => agentsApi.skills(agent.id, companyId),
|
|
||||||
enabled: Boolean(companyId),
|
|
||||||
});
|
|
||||||
|
|
||||||
const updateAgent = useMutation({
|
const updateAgent = useMutation({
|
||||||
mutationFn: (data: Record<string, unknown>) => agentsApi.update(agent.id, data, companyId),
|
mutationFn: (data: Record<string, unknown>) => agentsApi.update(agent.id, data, companyId),
|
||||||
onMutate: () => {
|
onMutate: () => {
|
||||||
|
|
@ -1079,30 +1085,12 @@ function ConfigurationTab({
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const syncSkills = useMutation({
|
|
||||||
mutationFn: (desiredSkills: string[]) => agentsApi.syncSkills(agent.id, desiredSkills, companyId),
|
|
||||||
onSuccess: (snapshot) => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agent.id) });
|
|
||||||
queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agent.urlKey) });
|
|
||||||
queryClient.invalidateQueries({ queryKey: queryKeys.agents.skills(agent.id) });
|
|
||||||
setSkillDraft(snapshot.desiredSkills);
|
|
||||||
setSkillDirty(false);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (awaitingRefreshAfterSave && agent !== lastAgentRef.current) {
|
if (awaitingRefreshAfterSave && agent !== lastAgentRef.current) {
|
||||||
setAwaitingRefreshAfterSave(false);
|
setAwaitingRefreshAfterSave(false);
|
||||||
}
|
}
|
||||||
lastAgentRef.current = agent;
|
lastAgentRef.current = agent;
|
||||||
}, [agent, awaitingRefreshAfterSave]);
|
}, [agent, awaitingRefreshAfterSave]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!skillSnapshot) return;
|
|
||||||
setSkillDraft(skillSnapshot.desiredSkills);
|
|
||||||
setSkillDirty(false);
|
|
||||||
}, [skillSnapshot]);
|
|
||||||
|
|
||||||
const isConfigSaving = updateAgent.isPending || awaitingRefreshAfterSave;
|
const isConfigSaving = updateAgent.isPending || awaitingRefreshAfterSave;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -1143,53 +1131,300 @@ function ConfigurationTab({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
<div>
|
function AgentSkillsTab({
|
||||||
<h3 className="text-sm font-medium mb-3">Skills</h3>
|
agent,
|
||||||
<div className="border border-border rounded-lg p-4 space-y-3">
|
companyId,
|
||||||
{!skillSnapshot ? (
|
}: {
|
||||||
<p className="text-sm text-muted-foreground">Loading skill sync state…</p>
|
agent: Agent;
|
||||||
) : !skillSnapshot.supported ? (
|
companyId?: string;
|
||||||
<div className="space-y-2">
|
}) {
|
||||||
<p className="text-sm text-muted-foreground">
|
const queryClient = useQueryClient();
|
||||||
This adapter does not implement skill sync yet.
|
const [skillDraft, setSkillDraft] = useState<string[]>([]);
|
||||||
</p>
|
const [skillDirty, setSkillDirty] = useState(false);
|
||||||
|
|
||||||
|
const { data: skillSnapshot, isLoading } = useQuery({
|
||||||
|
queryKey: queryKeys.agents.skills(agent.id),
|
||||||
|
queryFn: () => agentsApi.skills(agent.id, companyId),
|
||||||
|
enabled: Boolean(companyId),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: companySkills } = useQuery({
|
||||||
|
queryKey: queryKeys.companySkills.list(companyId ?? ""),
|
||||||
|
queryFn: () => companySkillsApi.list(companyId!),
|
||||||
|
enabled: Boolean(companyId),
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!skillSnapshot) return;
|
||||||
|
setSkillDraft(skillSnapshot.desiredSkills);
|
||||||
|
setSkillDirty(false);
|
||||||
|
}, [skillSnapshot]);
|
||||||
|
|
||||||
|
const syncSkills = useMutation({
|
||||||
|
mutationFn: (desiredSkills: string[]) => agentsApi.syncSkills(agent.id, desiredSkills, companyId),
|
||||||
|
onSuccess: async (snapshot) => {
|
||||||
|
await Promise.all([
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agent.id) }),
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agent.urlKey) }),
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.agents.skills(agent.id) }),
|
||||||
|
companyId
|
||||||
|
? queryClient.invalidateQueries({ queryKey: queryKeys.companySkills.list(companyId) })
|
||||||
|
: Promise.resolve(),
|
||||||
|
]);
|
||||||
|
setSkillDraft(snapshot.desiredSkills);
|
||||||
|
setSkillDirty(false);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const companySkillBySlug = useMemo(
|
||||||
|
() => new Map((companySkills ?? []).map((skill) => [skill.slug, skill])),
|
||||||
|
[companySkills],
|
||||||
|
);
|
||||||
|
const adapterEntryByName = useMemo(
|
||||||
|
() => new Map((skillSnapshot?.entries ?? []).map((entry) => [entry.name, entry])),
|
||||||
|
[skillSnapshot],
|
||||||
|
);
|
||||||
|
const desiredOnlyMissingSkills = useMemo(
|
||||||
|
() => skillDraft.filter((slug) => !companySkillBySlug.has(slug)),
|
||||||
|
[companySkillBySlug, skillDraft],
|
||||||
|
);
|
||||||
|
const externalEntries = (skillSnapshot?.entries ?? []).filter((entry) => entry.state === "external");
|
||||||
|
|
||||||
|
const modeCopy = useMemo(() => {
|
||||||
|
if (!skillSnapshot) return "Loading skill state...";
|
||||||
|
if (!skillSnapshot.supported) {
|
||||||
|
return "This adapter does not implement direct skill sync yet. Paperclip can still store the desired skill set for this agent.";
|
||||||
|
}
|
||||||
|
if (skillSnapshot.mode === "persistent") {
|
||||||
|
return "Selected skills are synchronized into the adapter's persistent skills home.";
|
||||||
|
}
|
||||||
|
if (skillSnapshot.mode === "ephemeral") {
|
||||||
|
return "Selected skills are mounted for each run instead of being installed globally.";
|
||||||
|
}
|
||||||
|
return "This adapter reports skill state but does not define a persistent install model.";
|
||||||
|
}, [skillSnapshot]);
|
||||||
|
|
||||||
|
const primaryActionLabel = !skillSnapshot || skillSnapshot.supported
|
||||||
|
? "Sync skills"
|
||||||
|
: "Save desired skills";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-5xl space-y-6">
|
||||||
|
<section className="overflow-hidden rounded-2xl border border-border bg-card">
|
||||||
|
<div className="border-b border-border bg-[linear-gradient(135deg,rgba(14,165,233,0.08),transparent_45%),linear-gradient(315deg,rgba(16,185,129,0.08),transparent_45%)] px-5 py-5">
|
||||||
|
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
|
||||||
|
<div className="max-w-2xl">
|
||||||
|
<div className="mb-2 inline-flex items-center gap-2 rounded-full border border-border/70 bg-background/70 px-3 py-1 text-[11px] uppercase tracking-[0.18em] text-muted-foreground">
|
||||||
|
Skills
|
||||||
|
</div>
|
||||||
|
<h3 className="text-2xl font-semibold tracking-tight">Attach reusable skills to {agent.name}.</h3>
|
||||||
|
<p className="mt-2 text-sm text-muted-foreground">{modeCopy}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<Link
|
||||||
|
to="/skills"
|
||||||
|
className="inline-flex items-center gap-1 rounded-md border border-border px-3 py-2 text-sm font-medium text-foreground no-underline transition-colors hover:bg-accent/40"
|
||||||
|
>
|
||||||
|
Open company library
|
||||||
|
<ArrowLeft className="h-3.5 w-3.5 rotate-180" />
|
||||||
|
</Link>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={() => queryClient.invalidateQueries({ queryKey: queryKeys.agents.skills(agent.id) })}
|
||||||
|
disabled={isLoading}
|
||||||
|
variant="outline"
|
||||||
|
>
|
||||||
|
Refresh state
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4 px-5 py-5">
|
||||||
|
{skillSnapshot?.warnings.length ? (
|
||||||
|
<div className="space-y-1 rounded-xl border border-amber-300/60 bg-amber-50/60 px-4 py-3 text-sm text-amber-800 dark:border-amber-500/30 dark:bg-amber-950/20 dark:text-amber-200">
|
||||||
{skillSnapshot.warnings.map((warning) => (
|
{skillSnapshot.warnings.map((warning) => (
|
||||||
<p key={warning} className="text-xs text-muted-foreground">
|
<div key={warning}>{warning}</div>
|
||||||
{warning}
|
|
||||||
</p>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : null}
|
||||||
<>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
{skillSnapshot.mode === "persistent"
|
|
||||||
? "These skills are synced into the adapter's persistent skills home."
|
|
||||||
: "These skills are mounted ephemerally for each Claude run."}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
{isLoading ? (
|
||||||
{skillSnapshot.entries
|
<PageSkeleton variant="list" />
|
||||||
.filter((entry) => entry.managed)
|
) : (
|
||||||
.map((entry) => {
|
<div className="grid gap-5 xl:grid-cols-[minmax(0,1fr)_20rem]">
|
||||||
const checked = skillDraft.includes(entry.name);
|
<div className="space-y-4">
|
||||||
return (
|
<section className="rounded-xl border border-border/70 bg-background px-4 py-4">
|
||||||
<label
|
<div className="flex items-center justify-between gap-3">
|
||||||
key={entry.name}
|
<div>
|
||||||
className="flex items-start gap-3 rounded-md border border-border/70 px-3 py-2"
|
<h4 className="text-sm font-medium">Company skills</h4>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Attach skills from the company library by shortname.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span className="rounded-full border border-border/70 px-2 py-0.5 text-[10px] uppercase tracking-wide text-muted-foreground">
|
||||||
|
{(companySkills ?? []).length} available
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(companySkills ?? []).length === 0 ? (
|
||||||
|
<div className="mt-4 rounded-lg border border-dashed border-border px-4 py-6 text-sm text-muted-foreground">
|
||||||
|
Import skills into the company library first, then attach them here.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="mt-4 space-y-2">
|
||||||
|
{(companySkills ?? []).map((skill) => {
|
||||||
|
const checked = skillDraft.includes(skill.slug);
|
||||||
|
const adapterEntry = adapterEntryByName.get(skill.slug);
|
||||||
|
return (
|
||||||
|
<label
|
||||||
|
key={skill.id}
|
||||||
|
className="flex items-start gap-3 rounded-xl border border-border/70 px-3 py-3 transition-colors hover:bg-accent/20"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={checked}
|
||||||
|
onChange={(event) => {
|
||||||
|
const next = event.target.checked
|
||||||
|
? Array.from(new Set([...skillDraft, skill.slug]))
|
||||||
|
: skillDraft.filter((value) => value !== skill.slug);
|
||||||
|
setSkillDraft(next);
|
||||||
|
setSkillDirty(true);
|
||||||
|
}}
|
||||||
|
className="mt-1"
|
||||||
|
/>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm font-medium">{skill.name}</span>
|
||||||
|
<span className="rounded-full border border-border/70 px-2 py-0.5 text-[10px] uppercase tracking-wide text-muted-foreground">
|
||||||
|
{skill.slug}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{skill.description && (
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">{skill.description}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
{adapterEntry?.state && (
|
||||||
|
<span className="rounded-full border border-border px-2 py-0.5 text-[10px] uppercase tracking-wide text-muted-foreground">
|
||||||
|
{adapterEntry.state}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<Link
|
||||||
|
to={`/skills/${skill.id}`}
|
||||||
|
className="text-xs text-muted-foreground no-underline hover:text-foreground"
|
||||||
|
>
|
||||||
|
View skill
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{desiredOnlyMissingSkills.length > 0 && (
|
||||||
|
<section className="rounded-xl border border-amber-300/60 bg-amber-50/60 px-4 py-4 dark:border-amber-500/30 dark:bg-amber-950/20">
|
||||||
|
<h4 className="text-sm font-medium text-amber-900 dark:text-amber-100">
|
||||||
|
Desired skills not found in the company library
|
||||||
|
</h4>
|
||||||
|
<div className="mt-3 space-y-2">
|
||||||
|
{desiredOnlyMissingSkills.map((skillName) => {
|
||||||
|
const adapterEntry = adapterEntryByName.get(skillName);
|
||||||
|
return (
|
||||||
|
<div key={skillName} className="flex items-center justify-between gap-3 rounded-lg border border-amber-300/50 bg-background/70 px-3 py-2 dark:border-amber-500/20">
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-medium">{skillName}</div>
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
This skill is still requested for the agent, but it is not tracked in the company library.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{adapterEntry?.state && (
|
||||||
|
<span className="rounded-full border border-border px-2 py-0.5 text-[10px] uppercase tracking-wide text-muted-foreground">
|
||||||
|
{adapterEntry.state}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<section className="rounded-xl border border-border/70 bg-background px-4 py-4">
|
||||||
|
<h4 className="text-sm font-medium">Adapter state</h4>
|
||||||
|
<div className="mt-3 grid gap-2 text-sm">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<span className="text-muted-foreground">Adapter</span>
|
||||||
|
<span className="font-medium">{agent.adapterType}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<span className="text-muted-foreground">Sync mode</span>
|
||||||
|
<span className="rounded-full border border-border px-2 py-0.5 text-[10px] uppercase tracking-wide text-muted-foreground">
|
||||||
|
{skillSnapshot?.mode ?? "unsupported"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<span className="text-muted-foreground">Desired skills</span>
|
||||||
|
<span>{skillDraft.length}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<span className="text-muted-foreground">External skills</span>
|
||||||
|
<span>{externalEntries.length}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={() => syncSkills.mutate(skillDraft)}
|
||||||
|
disabled={syncSkills.isPending || !skillDirty}
|
||||||
|
>
|
||||||
|
{syncSkills.isPending ? "Saving..." : primaryActionLabel}
|
||||||
|
</Button>
|
||||||
|
{skillDirty && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
setSkillDraft(skillSnapshot?.desiredSkills ?? []);
|
||||||
|
setSkillDirty(false);
|
||||||
|
}}
|
||||||
|
disabled={syncSkills.isPending}
|
||||||
>
|
>
|
||||||
<input
|
Reset
|
||||||
type="checkbox"
|
</Button>
|
||||||
checked={checked}
|
)}
|
||||||
onChange={(e) => {
|
</div>
|
||||||
const next = e.target.checked
|
|
||||||
? Array.from(new Set([...skillDraft, entry.name]))
|
{syncSkills.isError && (
|
||||||
: skillDraft.filter((value) => value !== entry.name);
|
<p className="mt-3 text-xs text-destructive">
|
||||||
setSkillDraft(next);
|
{syncSkills.error instanceof Error ? syncSkills.error.message : "Failed to update skills"}
|
||||||
setSkillDirty(true);
|
</p>
|
||||||
}}
|
)}
|
||||||
/>
|
</section>
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
|
<section className="rounded-xl border border-border/70 bg-background px-4 py-4">
|
||||||
|
<h4 className="text-sm font-medium">External skills</h4>
|
||||||
|
{externalEntries.length === 0 ? (
|
||||||
|
<p className="mt-3 text-sm text-muted-foreground">
|
||||||
|
No external skills were discovered by the adapter.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="mt-3 space-y-2">
|
||||||
|
{externalEntries.map((entry) => (
|
||||||
|
<div key={entry.name} className="rounded-lg border border-border/70 px-3 py-2">
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center justify-between gap-2">
|
||||||
<span className="text-sm font-medium">{entry.name}</span>
|
<span className="text-sm font-medium">{entry.name}</span>
|
||||||
<span className="rounded-full border border-border px-2 py-0.5 text-[10px] uppercase tracking-wide text-muted-foreground">
|
<span className="rounded-full border border-border px-2 py-0.5 text-[10px] uppercase tracking-wide text-muted-foreground">
|
||||||
|
|
@ -1197,74 +1432,18 @@ function ConfigurationTab({
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{entry.detail && (
|
{entry.detail && (
|
||||||
<p className="mt-1 text-xs text-muted-foreground">
|
<p className="mt-1 text-xs text-muted-foreground">{entry.detail}</p>
|
||||||
{entry.detail}
|
|
||||||
</p>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</label>
|
))}
|
||||||
);
|
</div>
|
||||||
})}
|
)}
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
{skillSnapshot.entries.some((entry) => entry.state === "external") && (
|
|
||||||
<div className="space-y-1">
|
|
||||||
<div className="text-xs uppercase tracking-wide text-muted-foreground">
|
|
||||||
External skills
|
|
||||||
</div>
|
|
||||||
{skillSnapshot.entries
|
|
||||||
.filter((entry) => entry.state === "external")
|
|
||||||
.map((entry) => (
|
|
||||||
<div key={entry.name} className="text-xs text-muted-foreground">
|
|
||||||
{entry.name}
|
|
||||||
{entry.detail ? ` - ${entry.detail}` : ""}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{skillSnapshot.warnings.length > 0 && (
|
|
||||||
<div className="space-y-1 rounded-md border border-amber-300/60 bg-amber-50/60 px-3 py-2 text-xs text-amber-700">
|
|
||||||
{skillSnapshot.warnings.map((warning) => (
|
|
||||||
<div key={warning}>{warning}</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{syncSkills.isError && (
|
|
||||||
<p className="text-xs text-destructive">
|
|
||||||
{syncSkills.error instanceof Error
|
|
||||||
? syncSkills.error.message
|
|
||||||
: "Failed to sync skills"}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
onClick={() => syncSkills.mutate(skillDraft)}
|
|
||||||
disabled={syncSkills.isPending || !skillDirty}
|
|
||||||
>
|
|
||||||
{syncSkills.isPending ? "Syncing..." : "Sync skills"}
|
|
||||||
</Button>
|
|
||||||
{skillDirty && (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => {
|
|
||||||
setSkillDraft(skillSnapshot.desiredSkills);
|
|
||||||
setSkillDirty(false);
|
|
||||||
}}
|
|
||||||
disabled={syncSkills.isPending}
|
|
||||||
>
|
|
||||||
Reset
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
434
ui/src/pages/CompanySkills.tsx
Normal file
434
ui/src/pages/CompanySkills.tsx
Normal file
|
|
@ -0,0 +1,434 @@
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { Link, useNavigate, useParams } from "@/lib/router";
|
||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import type {
|
||||||
|
CompanySkillDetail,
|
||||||
|
CompanySkillListItem,
|
||||||
|
CompanySkillTrustLevel,
|
||||||
|
} from "@paperclipai/shared";
|
||||||
|
import { companySkillsApi } from "../api/companySkills";
|
||||||
|
import { useCompany } from "../context/CompanyContext";
|
||||||
|
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
||||||
|
import { useToast } from "../context/ToastContext";
|
||||||
|
import { queryKeys } from "../lib/queryKeys";
|
||||||
|
import { EmptyState } from "../components/EmptyState";
|
||||||
|
import { MarkdownBody } from "../components/MarkdownBody";
|
||||||
|
import { PageSkeleton } from "../components/PageSkeleton";
|
||||||
|
import { EntityRow } from "../components/EntityRow";
|
||||||
|
import { cn } from "../lib/utils";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import {
|
||||||
|
ArrowUpRight,
|
||||||
|
BookOpen,
|
||||||
|
Boxes,
|
||||||
|
FolderInput,
|
||||||
|
RefreshCw,
|
||||||
|
ShieldAlert,
|
||||||
|
ShieldCheck,
|
||||||
|
TerminalSquare,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
function stripFrontmatter(markdown: string) {
|
||||||
|
const normalized = markdown.replace(/\r\n/g, "\n");
|
||||||
|
if (!normalized.startsWith("---\n")) return normalized.trim();
|
||||||
|
const closing = normalized.indexOf("\n---\n", 4);
|
||||||
|
if (closing < 0) return normalized.trim();
|
||||||
|
return normalized.slice(closing + 5).trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function trustTone(trustLevel: CompanySkillTrustLevel) {
|
||||||
|
switch (trustLevel) {
|
||||||
|
case "markdown_only":
|
||||||
|
return "bg-emerald-500/10 text-emerald-700 dark:text-emerald-300";
|
||||||
|
case "assets":
|
||||||
|
return "bg-amber-500/10 text-amber-700 dark:text-amber-300";
|
||||||
|
case "scripts_executables":
|
||||||
|
return "bg-red-500/10 text-red-700 dark:text-red-300";
|
||||||
|
default:
|
||||||
|
return "bg-muted text-muted-foreground";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function trustLabel(trustLevel: CompanySkillTrustLevel) {
|
||||||
|
switch (trustLevel) {
|
||||||
|
case "markdown_only":
|
||||||
|
return "Markdown only";
|
||||||
|
case "assets":
|
||||||
|
return "Assets";
|
||||||
|
case "scripts_executables":
|
||||||
|
return "Scripts";
|
||||||
|
default:
|
||||||
|
return trustLevel;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function compatibilityLabel(detail: CompanySkillDetail | CompanySkillListItem) {
|
||||||
|
switch (detail.compatibility) {
|
||||||
|
case "compatible":
|
||||||
|
return "Compatible";
|
||||||
|
case "unknown":
|
||||||
|
return "Unknown";
|
||||||
|
case "invalid":
|
||||||
|
return "Invalid";
|
||||||
|
default:
|
||||||
|
return detail.compatibility;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function SkillListItem({
|
||||||
|
skill,
|
||||||
|
selected,
|
||||||
|
}: {
|
||||||
|
skill: CompanySkillListItem;
|
||||||
|
selected: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
to={`/skills/${skill.id}`}
|
||||||
|
className={cn(
|
||||||
|
"block rounded-xl border p-3 no-underline transition-colors",
|
||||||
|
selected
|
||||||
|
? "border-primary bg-primary/5"
|
||||||
|
: "border-border/70 bg-card hover:border-border hover:bg-accent/20",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="font-medium text-sm text-foreground">{skill.name}</span>
|
||||||
|
<span className="rounded-full border border-border/70 px-2 py-0.5 text-[10px] uppercase tracking-wide text-muted-foreground">
|
||||||
|
{skill.slug}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{skill.description && (
|
||||||
|
<p className="mt-1 line-clamp-2 text-xs text-muted-foreground">
|
||||||
|
{skill.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span className={cn("shrink-0 rounded-full px-2 py-1 text-[10px] font-medium", trustTone(skill.trustLevel))}>
|
||||||
|
{trustLabel(skill.trustLevel)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 flex items-center justify-between gap-3 text-[11px] text-muted-foreground">
|
||||||
|
<span>{skill.attachedAgentCount} agent{skill.attachedAgentCount === 1 ? "" : "s"}</span>
|
||||||
|
<span>{skill.fileInventory.length} file{skill.fileInventory.length === 1 ? "" : "s"}</span>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SkillDetailPanel({
|
||||||
|
detail,
|
||||||
|
isLoading,
|
||||||
|
}: {
|
||||||
|
detail: CompanySkillDetail | null | undefined;
|
||||||
|
isLoading: boolean;
|
||||||
|
}) {
|
||||||
|
if (isLoading) {
|
||||||
|
return <PageSkeleton variant="detail" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!detail) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-2xl border border-dashed border-border bg-card/40 p-8">
|
||||||
|
<div className="max-w-md space-y-3">
|
||||||
|
<div className="inline-flex h-11 w-11 items-center justify-center rounded-2xl bg-accent text-accent-foreground">
|
||||||
|
<BookOpen className="h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold">Select a skill</h2>
|
||||||
|
<p className="mt-1 text-sm text-muted-foreground">
|
||||||
|
Review its markdown, inspect files, and see which agents have it attached.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const markdownBody = stripFrontmatter(detail.markdown);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-5">
|
||||||
|
<section className="overflow-hidden rounded-2xl border border-border bg-card">
|
||||||
|
<div className="border-b border-border bg-[linear-gradient(135deg,rgba(24,24,27,0.02),rgba(24,24,27,0.06))] px-5 py-4 dark:bg-[linear-gradient(135deg,rgba(255,255,255,0.03),rgba(255,255,255,0.06))]">
|
||||||
|
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<h2 className="text-xl font-semibold">{detail.name}</h2>
|
||||||
|
<span className="rounded-full border border-border/70 px-2 py-0.5 text-[10px] uppercase tracking-wide text-muted-foreground">
|
||||||
|
{detail.slug}
|
||||||
|
</span>
|
||||||
|
<span className={cn("rounded-full px-2 py-0.5 text-[10px] font-medium", trustTone(detail.trustLevel))}>
|
||||||
|
{trustLabel(detail.trustLevel)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{detail.description && (
|
||||||
|
<p className="mt-2 max-w-2xl text-sm text-muted-foreground">{detail.description}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="grid shrink-0 gap-1 text-right text-[11px] text-muted-foreground">
|
||||||
|
<span>{compatibilityLabel(detail)}</span>
|
||||||
|
<span>{detail.attachedAgentCount} attached agent{detail.attachedAgentCount === 1 ? "" : "s"}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-5 px-5 py-5 lg:grid-cols-[minmax(0,1fr)_18rem]">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="mb-3 flex items-center justify-between gap-3">
|
||||||
|
<h3 className="text-sm font-medium">SKILL.md</h3>
|
||||||
|
{detail.sourceLocator && (
|
||||||
|
<a
|
||||||
|
href={detail.sourceLocator}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
Open source
|
||||||
|
<ArrowUpRight className="h-3.5 w-3.5" />
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="rounded-xl border border-border/70 bg-background px-4 py-4">
|
||||||
|
<MarkdownBody>{markdownBody}</MarkdownBody>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<section className="rounded-xl border border-border/70 bg-background px-4 py-4">
|
||||||
|
<h3 className="text-sm font-medium">Inventory</h3>
|
||||||
|
<div className="mt-3 space-y-2">
|
||||||
|
{detail.fileInventory.map((entry) => (
|
||||||
|
<div key={`${entry.kind}:${entry.path}`} className="flex items-center justify-between gap-3 text-xs">
|
||||||
|
<span className="truncate font-mono text-muted-foreground">{entry.path}</span>
|
||||||
|
<span className="rounded-full border border-border/70 px-2 py-0.5 uppercase tracking-wide text-[10px] text-muted-foreground">
|
||||||
|
{entry.kind}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="rounded-xl border border-border/70 bg-background px-4 py-4">
|
||||||
|
<h3 className="text-sm font-medium">Used By Agents</h3>
|
||||||
|
{detail.usedByAgents.length === 0 ? (
|
||||||
|
<p className="mt-3 text-sm text-muted-foreground">No agents are currently attached to this skill.</p>
|
||||||
|
) : (
|
||||||
|
<div className="mt-3 space-y-2">
|
||||||
|
{detail.usedByAgents.map((agent) => (
|
||||||
|
<EntityRow
|
||||||
|
key={agent.id}
|
||||||
|
title={agent.name}
|
||||||
|
subtitle={agent.adapterType}
|
||||||
|
to={`/agents/${agent.urlKey}/skills`}
|
||||||
|
trailing={agent.actualState ? (
|
||||||
|
<span className="rounded-full border border-border/70 px-2 py-0.5 text-[10px] uppercase tracking-wide text-muted-foreground">
|
||||||
|
{agent.actualState}
|
||||||
|
</span>
|
||||||
|
) : undefined}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CompanySkills() {
|
||||||
|
const { skillId } = useParams<{ skillId?: string }>();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const { selectedCompanyId } = useCompany();
|
||||||
|
const { setBreadcrumbs } = useBreadcrumbs();
|
||||||
|
const { pushToast } = useToast();
|
||||||
|
const [source, setSource] = useState("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setBreadcrumbs([
|
||||||
|
{ label: "Skills", href: "/skills" },
|
||||||
|
...(skillId ? [{ label: "Detail" }] : []),
|
||||||
|
]);
|
||||||
|
}, [setBreadcrumbs, skillId]);
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: skills,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
} = useQuery({
|
||||||
|
queryKey: queryKeys.companySkills.list(selectedCompanyId ?? ""),
|
||||||
|
queryFn: () => companySkillsApi.list(selectedCompanyId!),
|
||||||
|
enabled: Boolean(selectedCompanyId),
|
||||||
|
});
|
||||||
|
|
||||||
|
const selectedSkillId = useMemo(() => {
|
||||||
|
if (!skillId) return skills?.[0]?.id ?? null;
|
||||||
|
return skillId;
|
||||||
|
}, [skillId, skills]);
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: detail,
|
||||||
|
isLoading: detailLoading,
|
||||||
|
} = useQuery({
|
||||||
|
queryKey: queryKeys.companySkills.detail(selectedCompanyId ?? "", selectedSkillId ?? ""),
|
||||||
|
queryFn: () => companySkillsApi.detail(selectedCompanyId!, selectedSkillId!),
|
||||||
|
enabled: Boolean(selectedCompanyId && selectedSkillId),
|
||||||
|
});
|
||||||
|
|
||||||
|
const importSkill = useMutation({
|
||||||
|
mutationFn: (importSource: string) => companySkillsApi.importFromSource(selectedCompanyId!, importSource),
|
||||||
|
onSuccess: async (result) => {
|
||||||
|
await queryClient.invalidateQueries({ queryKey: queryKeys.companySkills.list(selectedCompanyId!) });
|
||||||
|
if (result.imported[0]) {
|
||||||
|
navigate(`/skills/${result.imported[0].id}`);
|
||||||
|
}
|
||||||
|
pushToast({
|
||||||
|
tone: "success",
|
||||||
|
title: "Skills imported",
|
||||||
|
body: `${result.imported.length} skill${result.imported.length === 1 ? "" : "s"} added to the company library.`,
|
||||||
|
});
|
||||||
|
if (result.warnings[0]) {
|
||||||
|
pushToast({
|
||||||
|
tone: "warn",
|
||||||
|
title: "Import warnings",
|
||||||
|
body: result.warnings[0],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
setSource("");
|
||||||
|
},
|
||||||
|
onError: (importError) => {
|
||||||
|
pushToast({
|
||||||
|
tone: "error",
|
||||||
|
title: "Skill import failed",
|
||||||
|
body: importError instanceof Error ? importError.message : "Failed to import skill source.",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!selectedCompanyId) {
|
||||||
|
return <EmptyState icon={Boxes} message="Select a company to manage skills." />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-5">
|
||||||
|
<section className="overflow-hidden rounded-2xl border border-border bg-card">
|
||||||
|
<div className="border-b border-border bg-[radial-gradient(circle_at_top_left,rgba(16,185,129,0.10),transparent_38%),radial-gradient(circle_at_bottom_right,rgba(59,130,246,0.10),transparent_40%)] px-5 py-5">
|
||||||
|
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
|
||||||
|
<div className="max-w-2xl">
|
||||||
|
<div className="mb-2 inline-flex items-center gap-2 rounded-full border border-border/70 bg-background/70 px-3 py-1 text-[11px] uppercase tracking-[0.18em] text-muted-foreground">
|
||||||
|
<Boxes className="h-3.5 w-3.5" />
|
||||||
|
Company skill library
|
||||||
|
</div>
|
||||||
|
<h1 className="text-2xl font-semibold tracking-tight">Manage reusable skills once, attach them anywhere.</h1>
|
||||||
|
<p className="mt-2 text-sm text-muted-foreground">
|
||||||
|
Import `SKILL.md` packages from local paths, GitHub repos, or direct URLs. Agents attach by skill shortname, while adapters decide how those skills are installed or mounted.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2 text-xs text-muted-foreground sm:grid-cols-3">
|
||||||
|
<div className="rounded-xl border border-border/70 bg-background/70 px-3 py-3">
|
||||||
|
<div className="flex items-center gap-2 font-medium text-foreground">
|
||||||
|
<ShieldCheck className="h-3.5 w-3.5 text-emerald-600 dark:text-emerald-400" />
|
||||||
|
Markdown-first
|
||||||
|
</div>
|
||||||
|
<p className="mt-1">`skills.sh` compatible packages stay readable and repo-native.</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-xl border border-border/70 bg-background/70 px-3 py-3">
|
||||||
|
<div className="flex items-center gap-2 font-medium text-foreground">
|
||||||
|
<FolderInput className="h-3.5 w-3.5 text-blue-600 dark:text-blue-400" />
|
||||||
|
GitHub aware
|
||||||
|
</div>
|
||||||
|
<p className="mt-1">Import a repo, a subtree, or a single skill file without a registry.</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-xl border border-border/70 bg-background/70 px-3 py-3">
|
||||||
|
<div className="flex items-center gap-2 font-medium text-foreground">
|
||||||
|
<ShieldAlert className="h-3.5 w-3.5 text-amber-600 dark:text-amber-400" />
|
||||||
|
Trust surfaced
|
||||||
|
</div>
|
||||||
|
<p className="mt-1">Scripts and executable bundles stay visible instead of being hidden in setup.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-t border-border px-5 py-4">
|
||||||
|
<div className="flex flex-col gap-3 lg:flex-row lg:items-center">
|
||||||
|
<div className="flex-1">
|
||||||
|
<Input
|
||||||
|
value={source}
|
||||||
|
onChange={(event) => setSource(event.target.value)}
|
||||||
|
placeholder="Local path, GitHub repo/tree/blob URL, or direct SKILL.md URL"
|
||||||
|
className="h-10"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => queryClient.invalidateQueries({ queryKey: queryKeys.companySkills.list(selectedCompanyId) })}
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
<RefreshCw className="mr-1.5 h-3.5 w-3.5" />
|
||||||
|
Refresh
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={() => importSkill.mutate(source.trim())}
|
||||||
|
disabled={importSkill.isPending || source.trim().length === 0}
|
||||||
|
>
|
||||||
|
{importSkill.isPending ? "Importing..." : "Import skill"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{error && <p className="text-sm text-destructive">{error.message}</p>}
|
||||||
|
|
||||||
|
{!isLoading && (skills?.length ?? 0) === 0 ? (
|
||||||
|
<EmptyState
|
||||||
|
icon={TerminalSquare}
|
||||||
|
message="No company skills yet."
|
||||||
|
action="Import your first skill"
|
||||||
|
onAction={() => {
|
||||||
|
const trimmed = source.trim();
|
||||||
|
if (trimmed) importSkill.mutate(trimmed);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="grid gap-5 xl:grid-cols-[22rem_minmax(0,1fr)]">
|
||||||
|
<section className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-sm font-medium">Library</h2>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{skills?.length ?? 0} tracked skill{(skills?.length ?? 0) === 1 ? "" : "s"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{isLoading ? (
|
||||||
|
<PageSkeleton variant="list" />
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{(skills ?? []).map((skill) => (
|
||||||
|
<SkillListItem
|
||||||
|
key={skill.id}
|
||||||
|
skill={skill}
|
||||||
|
selected={skill.id === selectedSkillId}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<SkillDetailPanel detail={detail} isLoading={detailLoading} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue