diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index f87961f0..412bf68f 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -68,13 +68,18 @@ export type { CompanySkillSourceType, CompanySkillTrustLevel, CompanySkillCompatibility, + CompanySkillSourceBadge, CompanySkillFileInventoryEntry, CompanySkill, CompanySkillListItem, CompanySkillUsageAgent, CompanySkillDetail, + CompanySkillUpdateStatus, CompanySkillImportRequest, CompanySkillImportResult, + CompanySkillCreateRequest, + CompanySkillFileDetail, + CompanySkillFileUpdateRequest, AgentSkillSyncMode, AgentSkillState, AgentSkillEntry, @@ -251,12 +256,17 @@ export { companySkillSourceTypeSchema, companySkillTrustLevelSchema, companySkillCompatibilitySchema, + companySkillSourceBadgeSchema, companySkillFileInventoryEntrySchema, companySkillSchema, companySkillListItemSchema, companySkillUsageAgentSchema, companySkillDetailSchema, + companySkillUpdateStatusSchema, companySkillImportSchema, + companySkillCreateSchema, + companySkillFileDetailSchema, + companySkillFileUpdateSchema, portabilityIncludeSchema, portabilityEnvInputSchema, portabilityCompanyManifestEntrySchema, diff --git a/packages/shared/src/types/company-skill.ts b/packages/shared/src/types/company-skill.ts index 152dc67e..4476d1b2 100644 --- a/packages/shared/src/types/company-skill.ts +++ b/packages/shared/src/types/company-skill.ts @@ -4,6 +4,8 @@ export type CompanySkillTrustLevel = "markdown_only" | "assets" | "scripts_execu export type CompanySkillCompatibility = "compatible" | "unknown" | "invalid"; +export type CompanySkillSourceBadge = "paperclip" | "github" | "local" | "url" | "catalog"; + export interface CompanySkillFileInventoryEntry { path: string; kind: "skill" | "markdown" | "reference" | "script" | "asset" | "other"; @@ -29,6 +31,10 @@ export interface CompanySkill { export interface CompanySkillListItem extends CompanySkill { attachedAgentCount: number; + editable: boolean; + editableReason: string | null; + sourceLabel: string | null; + sourceBadge: CompanySkillSourceBadge; } export interface CompanySkillUsageAgent { @@ -43,6 +49,19 @@ export interface CompanySkillUsageAgent { export interface CompanySkillDetail extends CompanySkill { attachedAgentCount: number; usedByAgents: CompanySkillUsageAgent[]; + editable: boolean; + editableReason: string | null; + sourceLabel: string | null; + sourceBadge: CompanySkillSourceBadge; +} + +export interface CompanySkillUpdateStatus { + supported: boolean; + reason: string | null; + trackingRef: string | null; + currentRef: string | null; + latestRef: string | null; + hasUpdate: boolean; } export interface CompanySkillImportRequest { @@ -53,3 +72,25 @@ export interface CompanySkillImportResult { imported: CompanySkill[]; warnings: string[]; } + +export interface CompanySkillCreateRequest { + name: string; + slug?: string | null; + description?: string | null; + markdown?: string | null; +} + +export interface CompanySkillFileDetail { + skillId: string; + path: string; + kind: CompanySkillFileInventoryEntry["kind"]; + content: string; + language: string | null; + markdown: boolean; + editable: boolean; +} + +export interface CompanySkillFileUpdateRequest { + path: string; + content: string; +} diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index 84e9864f..283dade4 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -3,13 +3,18 @@ export type { CompanySkillSourceType, CompanySkillTrustLevel, CompanySkillCompatibility, + CompanySkillSourceBadge, CompanySkillFileInventoryEntry, CompanySkill, CompanySkillListItem, CompanySkillUsageAgent, CompanySkillDetail, + CompanySkillUpdateStatus, CompanySkillImportRequest, CompanySkillImportResult, + CompanySkillCreateRequest, + CompanySkillFileDetail, + CompanySkillFileUpdateRequest, } from "./company-skill.js"; export type { AgentSkillSyncMode, diff --git a/packages/shared/src/validators/company-skill.ts b/packages/shared/src/validators/company-skill.ts index 422d25a0..a2983982 100644 --- a/packages/shared/src/validators/company-skill.ts +++ b/packages/shared/src/validators/company-skill.ts @@ -3,6 +3,7 @@ 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 companySkillSourceBadgeSchema = z.enum(["paperclip", "github", "local", "url", "catalog"]); export const companySkillFileInventoryEntrySchema = z.object({ path: z.string().min(1), @@ -29,6 +30,10 @@ export const companySkillSchema = z.object({ export const companySkillListItemSchema = companySkillSchema.extend({ attachedAgentCount: z.number().int().nonnegative(), + editable: z.boolean(), + editableReason: z.string().nullable(), + sourceLabel: z.string().nullable(), + sourceBadge: companySkillSourceBadgeSchema, }); export const companySkillUsageAgentSchema = z.object({ @@ -43,10 +48,47 @@ export const companySkillUsageAgentSchema = z.object({ export const companySkillDetailSchema = companySkillSchema.extend({ attachedAgentCount: z.number().int().nonnegative(), usedByAgents: z.array(companySkillUsageAgentSchema).default([]), + editable: z.boolean(), + editableReason: z.string().nullable(), + sourceLabel: z.string().nullable(), + sourceBadge: companySkillSourceBadgeSchema, +}); + +export const companySkillUpdateStatusSchema = z.object({ + supported: z.boolean(), + reason: z.string().nullable(), + trackingRef: z.string().nullable(), + currentRef: z.string().nullable(), + latestRef: z.string().nullable(), + hasUpdate: z.boolean(), }); export const companySkillImportSchema = z.object({ source: z.string().min(1), }); +export const companySkillCreateSchema = z.object({ + name: z.string().min(1), + slug: z.string().min(1).nullable().optional(), + description: z.string().nullable().optional(), + markdown: z.string().nullable().optional(), +}); + +export const companySkillFileDetailSchema = z.object({ + skillId: z.string().uuid(), + path: z.string().min(1), + kind: z.enum(["skill", "markdown", "reference", "script", "asset", "other"]), + content: z.string(), + language: z.string().nullable(), + markdown: z.boolean(), + editable: z.boolean(), +}); + +export const companySkillFileUpdateSchema = z.object({ + path: z.string().min(1), + content: z.string(), +}); + export type CompanySkillImport = z.infer; +export type CompanySkillCreate = z.infer; +export type CompanySkillFileUpdate = z.infer; diff --git a/packages/shared/src/validators/index.ts b/packages/shared/src/validators/index.ts index f65256bd..ae7a8745 100644 --- a/packages/shared/src/validators/index.ts +++ b/packages/shared/src/validators/index.ts @@ -8,13 +8,20 @@ export { companySkillSourceTypeSchema, companySkillTrustLevelSchema, companySkillCompatibilitySchema, + companySkillSourceBadgeSchema, companySkillFileInventoryEntrySchema, companySkillSchema, companySkillListItemSchema, companySkillUsageAgentSchema, companySkillDetailSchema, + companySkillUpdateStatusSchema, companySkillImportSchema, + companySkillCreateSchema, + companySkillFileDetailSchema, + companySkillFileUpdateSchema, type CompanySkillImport, + type CompanySkillCreate, + type CompanySkillFileUpdate, } from "./company-skill.js"; export { agentSkillStateSchema, diff --git a/server/src/__tests__/company-skills.test.ts b/server/src/__tests__/company-skills.test.ts index eb744b9b..d092abd9 100644 --- a/server/src/__tests__/company-skills.test.ts +++ b/server/src/__tests__/company-skills.test.ts @@ -9,7 +9,7 @@ describe("company skill import source parsing", () => { expect(parsed.resolvedSource).toBe("https://github.com/vercel-labs/skills"); expect(parsed.requestedSkillSlug).toBe("find-skills"); - expect(parsed.warnings[0]).toContain("skills.sh command"); + expect(parsed.warnings).toEqual([]); }); it("parses owner/repo/skill shorthand as a GitHub repo plus requested skill", () => { @@ -18,4 +18,13 @@ describe("company skill import source parsing", () => { expect(parsed.resolvedSource).toBe("https://github.com/vercel-labs/skills"); expect(parsed.requestedSkillSlug).toBe("find-skills"); }); + + it("parses skills.sh commands whose requested skill differs from the folder name", () => { + const parsed = parseSkillImportSourceInput( + "npx skills add https://github.com/remotion-dev/skills --skill remotion-best-practices", + ); + + expect(parsed.resolvedSource).toBe("https://github.com/remotion-dev/skills"); + expect(parsed.requestedSkillSlug).toBe("remotion-best-practices"); + }); }); diff --git a/server/src/routes/company-skills.ts b/server/src/routes/company-skills.ts index 5219a97a..1fee1a06 100644 --- a/server/src/routes/company-skills.ts +++ b/server/src/routes/company-skills.ts @@ -1,6 +1,10 @@ import { Router } from "express"; import type { Db } from "@paperclipai/db"; -import { companySkillImportSchema } from "@paperclipai/shared"; +import { + companySkillCreateSchema, + companySkillFileUpdateSchema, + companySkillImportSchema, +} from "@paperclipai/shared"; import { validate } from "../middleware/validate.js"; import { companySkillService, logActivity } from "../services/index.js"; import { assertCompanyAccess, getActorInfo } from "./authz.js"; @@ -28,6 +32,93 @@ export function companySkillRoutes(db: Db) { res.json(result); }); + router.get("/companies/:companyId/skills/:skillId/update-status", async (req, res) => { + const companyId = req.params.companyId as string; + const skillId = req.params.skillId as string; + assertCompanyAccess(req, companyId); + const result = await svc.updateStatus(companyId, skillId); + if (!result) { + res.status(404).json({ error: "Skill not found" }); + return; + } + res.json(result); + }); + + router.get("/companies/:companyId/skills/:skillId/files", async (req, res) => { + const companyId = req.params.companyId as string; + const skillId = req.params.skillId as string; + const relativePath = String(req.query.path ?? "SKILL.md"); + assertCompanyAccess(req, companyId); + const result = await svc.readFile(companyId, skillId, relativePath); + if (!result) { + res.status(404).json({ error: "Skill not found" }); + return; + } + res.json(result); + }); + + router.post( + "/companies/:companyId/skills", + validate(companySkillCreateSchema), + async (req, res) => { + const companyId = req.params.companyId as string; + assertCompanyAccess(req, companyId); + const result = await svc.createLocalSkill(companyId, req.body); + + const actor = getActorInfo(req); + await logActivity(db, { + companyId, + actorType: actor.actorType, + actorId: actor.actorId, + agentId: actor.agentId, + runId: actor.runId, + action: "company.skill_created", + entityType: "company_skill", + entityId: result.id, + details: { + slug: result.slug, + name: result.name, + }, + }); + + res.status(201).json(result); + }, + ); + + router.patch( + "/companies/:companyId/skills/:skillId/files", + validate(companySkillFileUpdateSchema), + async (req, res) => { + const companyId = req.params.companyId as string; + const skillId = req.params.skillId as string; + assertCompanyAccess(req, companyId); + const result = await svc.updateFile( + companyId, + skillId, + String(req.body.path ?? ""), + String(req.body.content ?? ""), + ); + + const actor = getActorInfo(req); + await logActivity(db, { + companyId, + actorType: actor.actorType, + actorId: actor.actorId, + agentId: actor.agentId, + runId: actor.runId, + action: "company.skill_file_updated", + entityType: "company_skill", + entityId: skillId, + details: { + path: result.path, + markdown: result.markdown, + }, + }); + + res.json(result); + }, + ); + router.post( "/companies/:companyId/skills/import", validate(companySkillImportSchema), @@ -59,5 +150,34 @@ export function companySkillRoutes(db: Db) { }, ); + router.post("/companies/:companyId/skills/:skillId/install-update", async (req, res) => { + const companyId = req.params.companyId as string; + const skillId = req.params.skillId as string; + assertCompanyAccess(req, companyId); + const result = await svc.installUpdate(companyId, skillId); + if (!result) { + res.status(404).json({ error: "Skill not found" }); + return; + } + + const actor = getActorInfo(req); + await logActivity(db, { + companyId, + actorType: actor.actorType, + actorId: actor.actorId, + agentId: actor.agentId, + runId: actor.runId, + action: "company.skill_update_installed", + entityType: "company_skill", + entityId: result.id, + details: { + slug: result.slug, + sourceRef: result.sourceRef, + }, + }); + + res.json(result); + }); + return router; } diff --git a/server/src/services/company-skills.ts b/server/src/services/company-skills.ts index d67ec738..ccc30933 100644 --- a/server/src/services/company-skills.ts +++ b/server/src/services/company-skills.ts @@ -6,18 +6,23 @@ import type { Db } from "@paperclipai/db"; import { companySkills } from "@paperclipai/db"; import type { CompanySkill, + CompanySkillCreateRequest, CompanySkillCompatibility, CompanySkillDetail, + CompanySkillFileDetail, CompanySkillFileInventoryEntry, CompanySkillImportResult, CompanySkillListItem, + CompanySkillSourceBadge, CompanySkillSourceType, CompanySkillTrustLevel, + CompanySkillUpdateStatus, CompanySkillUsageAgent, } from "@paperclipai/shared"; import { normalizeAgentUrlKey } from "@paperclipai/shared"; import { readPaperclipSkillSyncPreference } from "@paperclipai/adapter-utils/server-utils"; import { findServerAdapter } from "../adapters/index.js"; +import { resolvePaperclipInstanceRoot } from "../home-paths.js"; import { notFound, unprocessable } from "../errors.js"; import { agentService } from "./agents.js"; import { secretService } from "./secrets.js"; @@ -44,6 +49,15 @@ type ParsedSkillImportSource = { warnings: string[]; }; +type SkillSourceMeta = { + sourceKind?: string; + owner?: string; + repo?: string; + ref?: string; + trackingRef?: string; + repoSkillDir?: string; +}; + function asString(value: unknown): string | null { if (typeof value !== "string") return null; const trimmed = value.trim(); @@ -233,6 +247,24 @@ async function fetchJson(url: string): Promise { return response.json() as Promise; } +async function resolveGitHubDefaultBranch(owner: string, repo: string) { + const response = await fetchJson<{ default_branch?: string }>( + `https://api.github.com/repos/${owner}/${repo}`, + ); + return asString(response.default_branch) ?? "main"; +} + +async function resolveGitHubCommitSha(owner: string, repo: string, ref: string) { + const response = await fetchJson<{ sha?: string }>( + `https://api.github.com/repos/${owner}/${repo}/commits/${encodeURIComponent(ref)}`, + ); + const sha = asString(response.sha); + if (!sha) { + throw unprocessable(`Failed to resolve GitHub ref ${ref}`); + } + return sha; +} + function parseGitHubSourceUrl(rawUrl: string) { const url = new URL(rawUrl); if (url.hostname !== "github.com") { @@ -247,15 +279,33 @@ function parseGitHubSourceUrl(rawUrl: string) { let ref = "main"; let basePath = ""; let filePath: string | null = null; + let explicitRef = false; if (parts[2] === "tree") { ref = parts[3] ?? "main"; basePath = parts.slice(4).join("/"); + explicitRef = true; } else if (parts[2] === "blob") { ref = parts[3] ?? "main"; filePath = parts.slice(4).join("/"); basePath = filePath ? path.posix.dirname(filePath) : ""; + explicitRef = true; } - return { owner, repo, ref, basePath, filePath }; + return { owner, repo, ref, basePath, filePath, explicitRef }; +} + +async function resolveGitHubPinnedRef(parsed: ReturnType) { + if (/^[0-9a-f]{40}$/i.test(parsed.ref.trim())) { + return { + pinnedRef: parsed.ref, + trackingRef: parsed.explicitRef ? parsed.ref : null, + }; + } + + const trackingRef = parsed.explicitRef + ? parsed.ref + : await resolveGitHubDefaultBranch(parsed.owner, parsed.repo); + const pinnedRef = await resolveGitHubCommitSha(parsed.owner, parsed.repo, trackingRef); + return { pinnedRef, trackingRef }; } function resolveRawGitHubUrl(owner: string, repo: string, ref: string, filePath: string) { @@ -298,7 +348,6 @@ export function parseSkillImportSourceInput(rawInput: string): ParsedSkillImport requestedSkillSlug = normalizeSkillSlug(token.slice("--skill=".length)); } } - warnings.push("Parsed a skills.sh command. Paperclip imports the referenced skill package without executing shell input."); } } @@ -346,6 +395,10 @@ function matchesRequestedSkill(relativeSkillPath: string, requestedSkillSlug: st return normalizeSkillSlug(path.posix.basename(skillDir)) === requestedSkillSlug; } +function deriveImportedSkillSlug(frontmatter: Record, fallback: string) { + return normalizeSkillSlug(asString(frontmatter.name)) ?? normalizeAgentUrlKey(fallback) ?? "skill"; +} + async function walkLocalFiles(root: string, current: string, out: string[]) { const entries = await fs.readdir(current, { withFileTypes: true }); for (const entry of entries) { @@ -370,7 +423,7 @@ async function readLocalSkillImports(sourcePath: string): Promise entry === skillPath || entry.startsWith(`${skillDir}/`)) .map((entry) => { @@ -419,12 +472,12 @@ async function readLocalSkillImports(sourcePath: string): Promise }>( `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`, - ); - } + ).catch(() => { throw unprocessable(`Failed to read GitHub tree for ${url}`); }); const allPaths = (tree.tree ?? []) @@ -468,13 +512,11 @@ async function readUrlSkillImports( ? relativePaths.filter((entry) => entry === path.posix.relative(parsed.basePath || ".", parsed.filePath!)) : relativePaths; const skillPaths = filteredPaths.filter( - (entry) => path.posix.basename(entry).toLowerCase() === "skill.md" && matchesRequestedSkill(entry, requestedSkillSlug), + (entry) => path.posix.basename(entry).toLowerCase() === "skill.md", ); if (skillPaths.length === 0) { throw unprocessable( - requestedSkillSlug - ? `Skill ${requestedSkillSlug} was not found in the provided GitHub source.` - : "No SKILL.md files were found in the provided GitHub source.", + "No SKILL.md files were found in the provided GitHub source.", ); } const skills: ImportedSkill[] = []; @@ -483,7 +525,10 @@ async function readUrlSkillImports( 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 slug = deriveImportedSkillSlug(parsedMarkdown.frontmatter, path.posix.basename(skillDir)); + if (requestedSkillSlug && !matchesRequestedSkill(relativeSkillPath, requestedSkillSlug) && slug !== requestedSkillSlug) { + continue; + } const inventory = filteredPaths .filter((entry) => entry === relativeSkillPath || entry.startsWith(`${skillDir}/`)) .map((entry) => ({ @@ -502,9 +547,23 @@ async function readUrlSkillImports( trustLevel: deriveTrustLevel(inventory), compatibility: "compatible", fileInventory: inventory, - metadata: null, + metadata: { + sourceKind: "github", + owner: parsed.owner, + repo: parsed.repo, + ref: ref, + trackingRef, + repoSkillDir: basePrefix ? `${basePrefix}${skillDir}` : skillDir, + }, }); } + if (skills.length === 0) { + throw unprocessable( + requestedSkillSlug + ? `Skill ${requestedSkillSlug} was not found in the provided GitHub source.` + : "No SKILL.md files were found in the provided GitHub source.", + ); + } return { skills, warnings }; } @@ -513,7 +572,7 @@ async function readUrlSkillImports( 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 slug = deriveImportedSkillSlug(parsedMarkdown.frontmatter, fileName.replace(/\.md$/i, "")); const inventory: CompanySkillFileInventoryEntry[] = [{ path: "SKILL.md", kind: "skill" }]; return { skills: [{ @@ -527,7 +586,9 @@ async function readUrlSkillImports( trustLevel: deriveTrustLevel(inventory), compatibility: "compatible", fileInventory: inventory, - metadata: null, + metadata: { + sourceKind: "url", + }, }], warnings, }; @@ -567,6 +628,131 @@ function serializeFileInventory( })); } +function getSkillMeta(skill: CompanySkill): SkillSourceMeta { + return isPlainRecord(skill.metadata) ? skill.metadata as SkillSourceMeta : {}; +} + +function normalizeSkillDirectory(skill: CompanySkill) { + if (skill.sourceType !== "local_path" || !skill.sourceLocator) return null; + const resolved = path.resolve(skill.sourceLocator); + if (path.basename(resolved).toLowerCase() === "skill.md") { + return path.dirname(resolved); + } + return resolved; +} + +function resolveManagedSkillsRoot(companyId: string) { + return path.resolve(resolvePaperclipInstanceRoot(), "skills", companyId); +} + +function resolveLocalSkillFilePath(skill: CompanySkill, relativePath: string) { + const normalized = normalizePortablePath(relativePath); + const skillDir = normalizeSkillDirectory(skill); + if (skillDir) { + return path.resolve(skillDir, normalized); + } + + if (!skill.sourceLocator) return null; + const fallbackRoot = path.resolve(skill.sourceLocator); + const directPath = path.resolve(fallbackRoot, normalized); + return directPath; +} + +function inferLanguageFromPath(filePath: string) { + const fileName = path.posix.basename(filePath).toLowerCase(); + if (fileName === "skill.md" || fileName.endsWith(".md")) return "markdown"; + if (fileName.endsWith(".ts")) return "typescript"; + if (fileName.endsWith(".tsx")) return "tsx"; + if (fileName.endsWith(".js")) return "javascript"; + if (fileName.endsWith(".jsx")) return "jsx"; + if (fileName.endsWith(".json")) return "json"; + if (fileName.endsWith(".yml") || fileName.endsWith(".yaml")) return "yaml"; + if (fileName.endsWith(".sh")) return "bash"; + if (fileName.endsWith(".py")) return "python"; + if (fileName.endsWith(".html")) return "html"; + if (fileName.endsWith(".css")) return "css"; + return null; +} + +function isMarkdownPath(filePath: string) { + const fileName = path.posix.basename(filePath).toLowerCase(); + return fileName === "skill.md" || fileName.endsWith(".md"); +} + +function deriveSkillSourceInfo(skill: CompanySkill): { + editable: boolean; + editableReason: string | null; + sourceLabel: string | null; + sourceBadge: CompanySkillSourceBadge; +} { + const metadata = getSkillMeta(skill); + const localSkillDir = normalizeSkillDirectory(skill); + if (metadata.sourceKind === "paperclip_bundled") { + return { + editable: false, + editableReason: "Bundled Paperclip skills are read-only.", + sourceLabel: "Paperclip bundled", + sourceBadge: "paperclip", + }; + } + + if (skill.sourceType === "github") { + const owner = asString(metadata.owner) ?? null; + const repo = asString(metadata.repo) ?? null; + return { + editable: false, + editableReason: "Remote GitHub skills are read-only. Fork or import locally to edit them.", + sourceLabel: owner && repo ? `${owner}/${repo}` : skill.sourceLocator, + sourceBadge: "github", + }; + } + + if (skill.sourceType === "url") { + return { + editable: false, + editableReason: "URL-based skills are read-only. Save them locally to edit them.", + sourceLabel: skill.sourceLocator, + sourceBadge: "url", + }; + } + + if (skill.sourceType === "local_path") { + const managedRoot = resolveManagedSkillsRoot(skill.companyId); + if (localSkillDir && localSkillDir.startsWith(managedRoot)) { + return { + editable: true, + editableReason: null, + sourceLabel: "Paperclip workspace", + sourceBadge: "paperclip", + }; + } + + return { + editable: true, + editableReason: null, + sourceLabel: skill.sourceLocator, + sourceBadge: "local", + }; + } + + return { + editable: false, + editableReason: "This skill source is read-only.", + sourceLabel: skill.sourceLocator, + sourceBadge: "catalog", + }; +} + +function enrichSkill(skill: CompanySkill, attachedAgentCount: number, usedByAgents: CompanySkillUsageAgent[] = []) { + const source = deriveSkillSourceInfo(skill); + return { + ...skill, + attachedAgentCount, + usedByAgents, + ...source, + }; +} + export function companySkillService(db: Db) { const agents = agentService(db); const secretsSvc = secretService(db); @@ -575,7 +761,15 @@ export function companySkillService(db: Db) { for (const skillsRoot of resolveBundledSkillsRoot()) { const stats = await fs.stat(skillsRoot).catch(() => null); if (!stats?.isDirectory()) continue; - const bundledSkills = await readLocalSkillImports(skillsRoot).catch(() => [] as ImportedSkill[]); + const bundledSkills = await readLocalSkillImports(skillsRoot) + .then((skills) => skills.map((skill) => ({ + ...skill, + metadata: { + ...(skill.metadata ?? {}), + sourceKind: "paperclip_bundled", + }, + }))) + .catch(() => [] as ImportedSkill[]); if (bundledSkills.length === 0) continue; return upsertImportedSkills(companyId, bundledSkills); } @@ -596,10 +790,7 @@ export function companySkillService(db: Db) { const preference = readPaperclipSkillSyncPreference(agent.adapterConfig as Record); return preference.desiredSkills.includes(skill.slug); }).length; - return { - ...skill, - attachedAgentCount, - }; + return enrichSkill(skill, attachedAgentCount); }); } @@ -671,13 +862,205 @@ export function companySkillService(db: Db) { const skill = await getById(id); if (!skill || skill.companyId !== companyId) return null; const usedByAgents = await usage(companyId, skill.slug); + return enrichSkill(skill, usedByAgents.length, usedByAgents); + } + + async function updateStatus(companyId: string, skillId: string): Promise { + await ensureBundledSkills(companyId); + const skill = await getById(skillId); + if (!skill || skill.companyId !== companyId) return null; + + if (skill.sourceType !== "github") { + return { + supported: false, + reason: "Only GitHub-managed skills support update checks.", + trackingRef: null, + currentRef: skill.sourceRef ?? null, + latestRef: null, + hasUpdate: false, + }; + } + + const metadata = getSkillMeta(skill); + const owner = asString(metadata.owner); + const repo = asString(metadata.repo); + const trackingRef = asString(metadata.trackingRef) ?? asString(metadata.ref); + if (!owner || !repo || !trackingRef) { + return { + supported: false, + reason: "This GitHub skill does not have enough metadata to track updates.", + trackingRef: trackingRef ?? null, + currentRef: skill.sourceRef ?? null, + latestRef: null, + hasUpdate: false, + }; + } + + const latestRef = await resolveGitHubCommitSha(owner, repo, trackingRef); return { - ...skill, - attachedAgentCount: usedByAgents.length, - usedByAgents, + supported: true, + reason: null, + trackingRef, + currentRef: skill.sourceRef ?? null, + latestRef, + hasUpdate: latestRef !== (skill.sourceRef ?? null), }; } + async function readFile(companyId: string, skillId: string, relativePath: string): Promise { + await ensureBundledSkills(companyId); + const skill = await getById(skillId); + if (!skill || skill.companyId !== companyId) return null; + + const normalizedPath = normalizePortablePath(relativePath || "SKILL.md"); + const fileEntry = skill.fileInventory.find((entry) => entry.path === normalizedPath); + if (!fileEntry) { + throw notFound("Skill file not found"); + } + + const source = deriveSkillSourceInfo(skill); + let content = ""; + + if (skill.sourceType === "local_path") { + const absolutePath = resolveLocalSkillFilePath(skill, normalizedPath); + if (!absolutePath) throw notFound("Skill file not found"); + content = await fs.readFile(absolutePath, "utf8"); + } else if (skill.sourceType === "github") { + const metadata = getSkillMeta(skill); + const owner = asString(metadata.owner); + const repo = asString(metadata.repo); + const ref = skill.sourceRef ?? asString(metadata.ref) ?? "main"; + const repoSkillDir = normalizePortablePath(asString(metadata.repoSkillDir) ?? skill.slug); + if (!owner || !repo) { + throw unprocessable("Skill source metadata is incomplete."); + } + const repoPath = normalizePortablePath(path.posix.join(repoSkillDir, normalizedPath)); + content = await fetchText(resolveRawGitHubUrl(owner, repo, ref, repoPath)); + } else if (skill.sourceType === "url") { + if (normalizedPath !== "SKILL.md") { + throw notFound("This skill source only exposes SKILL.md"); + } + content = skill.markdown; + } else { + throw unprocessable("Unsupported skill source."); + } + + return { + skillId: skill.id, + path: normalizedPath, + kind: fileEntry.kind, + content, + language: inferLanguageFromPath(normalizedPath), + markdown: isMarkdownPath(normalizedPath), + editable: source.editable, + }; + } + + async function createLocalSkill(companyId: string, input: CompanySkillCreateRequest): Promise { + const slug = normalizeSkillSlug(input.slug ?? input.name) ?? "skill"; + const managedRoot = resolveManagedSkillsRoot(companyId); + const skillDir = path.resolve(managedRoot, slug); + const skillFilePath = path.resolve(skillDir, "SKILL.md"); + + await fs.mkdir(skillDir, { recursive: true }); + + const markdown = (input.markdown?.trim().length + ? input.markdown + : [ + "---", + `name: ${input.name}`, + ...(input.description?.trim() ? [`description: ${input.description.trim()}`] : []), + "---", + "", + `# ${input.name}`, + "", + input.description?.trim() ? input.description.trim() : "Describe what this skill does.", + "", + ].join("\n")); + + await fs.writeFile(skillFilePath, markdown, "utf8"); + + const parsed = parseFrontmatterMarkdown(markdown); + const imported = await upsertImportedSkills(companyId, [{ + slug, + name: asString(parsed.frontmatter.name) ?? input.name, + description: asString(parsed.frontmatter.description) ?? input.description?.trim() ?? null, + markdown, + sourceType: "local_path", + sourceLocator: skillDir, + sourceRef: null, + trustLevel: "markdown_only", + compatibility: "compatible", + fileInventory: [{ path: "SKILL.md", kind: "skill" }], + metadata: { sourceKind: "managed_local" }, + }]); + + return imported[0]!; + } + + async function updateFile(companyId: string, skillId: string, relativePath: string, content: string): Promise { + await ensureBundledSkills(companyId); + const skill = await getById(skillId); + if (!skill || skill.companyId !== companyId) throw notFound("Skill not found"); + + const source = deriveSkillSourceInfo(skill); + if (!source.editable || skill.sourceType !== "local_path") { + throw unprocessable(source.editableReason ?? "This skill cannot be edited."); + } + + const normalizedPath = normalizePortablePath(relativePath); + const absolutePath = resolveLocalSkillFilePath(skill, normalizedPath); + if (!absolutePath) throw notFound("Skill file not found"); + + await fs.mkdir(path.dirname(absolutePath), { recursive: true }); + await fs.writeFile(absolutePath, content, "utf8"); + + if (normalizedPath === "SKILL.md") { + const parsed = parseFrontmatterMarkdown(content); + await db + .update(companySkills) + .set({ + name: asString(parsed.frontmatter.name) ?? skill.name, + description: asString(parsed.frontmatter.description) ?? skill.description, + markdown: content, + updatedAt: new Date(), + }) + .where(eq(companySkills.id, skill.id)); + } else { + await db + .update(companySkills) + .set({ updatedAt: new Date() }) + .where(eq(companySkills.id, skill.id)); + } + + const detail = await readFile(companyId, skillId, normalizedPath); + if (!detail) throw notFound("Skill file not found"); + return detail; + } + + async function installUpdate(companyId: string, skillId: string): Promise { + await ensureBundledSkills(companyId); + const skill = await getById(skillId); + if (!skill || skill.companyId !== companyId) return null; + + const status = await updateStatus(companyId, skillId); + if (!status?.supported) { + throw unprocessable(status?.reason ?? "This skill does not support updates."); + } + if (!skill.sourceLocator) { + throw unprocessable("Skill source locator is missing."); + } + + const result = await readUrlSkillImports(skill.sourceLocator, skill.slug); + const matching = result.skills.find((entry) => entry.slug === skill.slug) ?? result.skills[0] ?? null; + if (!matching) { + throw unprocessable(`Skill ${skill.slug} could not be re-imported from its source.`); + } + + const imported = await upsertImportedSkills(companyId, [matching]); + return imported[0] ?? null; + } + async function upsertImportedSkills(companyId: string, imported: ImportedSkill[]): Promise { const out: CompanySkill[] = []; for (const skill of imported) { @@ -749,6 +1132,11 @@ export function companySkillService(db: Db) { getById, getBySlug, detail, + updateStatus, + readFile, + updateFile, + createLocalSkill, importFromSource, + installUpdate, }; } diff --git a/ui/src/App.tsx b/ui/src/App.tsx index be6e467d..005b3001 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -112,8 +112,7 @@ function boardRoutes() { } /> } /> } /> - } /> - } /> + } /> } /> } /> } /> @@ -305,8 +304,7 @@ export function App() { } /> } /> } /> - } /> - } /> + } /> } /> } /> } /> diff --git a/ui/src/api/companySkills.ts b/ui/src/api/companySkills.ts index f29896a1..417dbe8f 100644 --- a/ui/src/api/companySkills.ts +++ b/ui/src/api/companySkills.ts @@ -1,7 +1,11 @@ import type { + CompanySkill, + CompanySkillCreateRequest, CompanySkillDetail, + CompanySkillFileDetail, CompanySkillImportResult, CompanySkillListItem, + CompanySkillUpdateStatus, } from "@paperclipai/shared"; import { api } from "./client"; @@ -12,9 +16,32 @@ export const companySkillsApi = { api.get( `/companies/${encodeURIComponent(companyId)}/skills/${encodeURIComponent(skillId)}`, ), + updateStatus: (companyId: string, skillId: string) => + api.get( + `/companies/${encodeURIComponent(companyId)}/skills/${encodeURIComponent(skillId)}/update-status`, + ), + file: (companyId: string, skillId: string, relativePath: string) => + api.get( + `/companies/${encodeURIComponent(companyId)}/skills/${encodeURIComponent(skillId)}/files?path=${encodeURIComponent(relativePath)}`, + ), + updateFile: (companyId: string, skillId: string, path: string, content: string) => + api.patch( + `/companies/${encodeURIComponent(companyId)}/skills/${encodeURIComponent(skillId)}/files`, + { path, content }, + ), + create: (companyId: string, payload: CompanySkillCreateRequest) => + api.post( + `/companies/${encodeURIComponent(companyId)}/skills`, + payload, + ), importFromSource: (companyId: string, source: string) => api.post( `/companies/${encodeURIComponent(companyId)}/skills/import`, { source }, ), + installUpdate: (companyId: string, skillId: string) => + api.post( + `/companies/${encodeURIComponent(companyId)}/skills/${encodeURIComponent(skillId)}/install-update`, + {}, + ), }; diff --git a/ui/src/hooks/useCompanyPageMemory.test.ts b/ui/src/hooks/useCompanyPageMemory.test.ts index a64c60b8..4f669015 100644 --- a/ui/src/hooks/useCompanyPageMemory.test.ts +++ b/ui/src/hooks/useCompanyPageMemory.test.ts @@ -39,6 +39,16 @@ describe("getRememberedPathOwnerCompanyId", () => { }), ).toBe("pap"); }); + + it("treats unprefixed skills routes as board routes instead of company prefixes", () => { + expect( + getRememberedPathOwnerCompanyId({ + companies, + pathname: "/skills/skill-123/files/SKILL.md", + fallbackCompanyId: "pap", + }), + ).toBe("pap"); + }); }); describe("sanitizeRememberedPathForCompany", () => { @@ -68,4 +78,13 @@ describe("sanitizeRememberedPathForCompany", () => { }), ).toBe("/dashboard"); }); + + it("keeps remembered skills paths intact for the target company", () => { + expect( + sanitizeRememberedPathForCompany({ + path: "/skills/skill-123/files/SKILL.md", + companyPrefix: "PAP", + }), + ).toBe("/skills/skill-123/files/SKILL.md"); + }); }); diff --git a/ui/src/lib/company-routes.ts b/ui/src/lib/company-routes.ts index 736e3897..512a4e61 100644 --- a/ui/src/lib/company-routes.ts +++ b/ui/src/lib/company-routes.ts @@ -2,6 +2,7 @@ const BOARD_ROUTE_ROOTS = new Set([ "dashboard", "companies", "company", + "skills", "org", "agents", "projects", diff --git a/ui/src/lib/queryKeys.ts b/ui/src/lib/queryKeys.ts index dda0e694..5f94250d 100644 --- a/ui/src/lib/queryKeys.ts +++ b/ui/src/lib/queryKeys.ts @@ -7,6 +7,10 @@ export const queryKeys = { companySkills: { list: (companyId: string) => ["company-skills", companyId] as const, detail: (companyId: string, skillId: string) => ["company-skills", companyId, skillId] as const, + updateStatus: (companyId: string, skillId: string) => + ["company-skills", companyId, skillId, "update-status"] as const, + file: (companyId: string, skillId: string, relativePath: string) => + ["company-skills", companyId, skillId, "file", relativePath] as const, }, agents: { list: (companyId: string) => ["agents", companyId] as const, diff --git a/ui/src/pages/CompanySkills.tsx b/ui/src/pages/CompanySkills.tsx index 24030c4c..6843f390 100644 --- a/ui/src/pages/CompanySkills.tsx +++ b/ui/src/pages/CompanySkills.tsx @@ -1,10 +1,14 @@ -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useMemo, useState, type SVGProps } from "react"; import { Link, useNavigate, useParams } from "@/lib/router"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import type { + CompanySkillCreateRequest, CompanySkillDetail, + CompanySkillFileDetail, + CompanySkillFileInventoryEntry, CompanySkillListItem, - CompanySkillTrustLevel, + CompanySkillSourceBadge, + CompanySkillUpdateStatus, } from "@paperclipai/shared"; import { companySkillsApi } from "../api/companySkills"; import { useCompany } from "../context/CompanyContext"; @@ -13,22 +17,62 @@ import { useToast } from "../context/ToastContext"; import { queryKeys } from "../lib/queryKeys"; import { EmptyState } from "../components/EmptyState"; import { MarkdownBody } from "../components/MarkdownBody"; +import { MarkdownEditor } from "../components/MarkdownEditor"; import { PageSkeleton } from "../components/PageSkeleton"; -import { EntityRow } from "../components/EntityRow"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { cn } from "../lib/utils"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; +import { Textarea } from "@/components/ui/textarea"; import { - ArrowUpRight, - BookOpen, Boxes, - FolderInput, + ChevronDown, + ChevronRight, + Code2, + Eye, + FileCode2, + FileText, + Folder, + FolderOpen, + Github, + Link2, + ExternalLink, + Paperclip, + Pencil, + Plus, RefreshCw, - ShieldAlert, - ShieldCheck, - TerminalSquare, + Save, + Search, } from "lucide-react"; +type SkillTreeNode = { + name: string; + path: string | null; + kind: "dir" | "file"; + fileKind?: CompanySkillFileInventoryEntry["kind"]; + children: SkillTreeNode[]; +}; + +const SKILL_TREE_BASE_INDENT = 16; +const SKILL_TREE_STEP_INDENT = 24; +const SKILL_TREE_ROW_HEIGHT_CLASS = "min-h-9"; + +function VercelMark(props: SVGProps) { + return ( + + ); +} + function stripFrontmatter(markdown: string) { const normalized = markdown.replace(/\r\n/g, "\n"); if (!normalized.startsWith("---\n")) return normalized.trim(); @@ -37,280 +81,807 @@ function stripFrontmatter(markdown: string) { 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"; +function buildTree(entries: CompanySkillFileInventoryEntry[]) { + const root: SkillTreeNode = { name: "", path: null, kind: "dir", children: [] }; + + for (const entry of entries) { + const segments = entry.path.split("/").filter(Boolean); + let current = root; + let currentPath = ""; + for (const [index, segment] of segments.entries()) { + currentPath = currentPath ? `${currentPath}/${segment}` : segment; + const isLeaf = index === segments.length - 1; + let next = current.children.find((child) => child.name === segment); + if (!next) { + next = { + name: segment, + path: isLeaf ? entry.path : currentPath, + kind: isLeaf ? "file" : "dir", + fileKind: isLeaf ? entry.kind : undefined, + children: [], + }; + current.children.push(next); + } + current = next; + } + } + + function sortNode(node: SkillTreeNode) { + node.children.sort((left, right) => { + if (left.kind !== right.kind) return left.kind === "dir" ? -1 : 1; + if (left.name === "SKILL.md") return -1; + if (right.name === "SKILL.md") return 1; + return left.name.localeCompare(right.name); + }); + node.children.forEach(sortNode); + } + + sortNode(root); + return root.children; +} + +function sourceMeta(sourceBadge: CompanySkillSourceBadge, sourceLabel: string | null) { + const normalizedLabel = sourceLabel?.toLowerCase() ?? ""; + const isSkillsShManaged = + normalizedLabel.includes("skills.sh") || normalizedLabel.includes("vercel-labs/skills"); + + switch (sourceBadge) { + case "github": + return isSkillsShManaged + ? { icon: VercelMark, label: sourceLabel ?? "skills.sh", managedLabel: "skills.sh managed" } + : { icon: Github, label: sourceLabel ?? "GitHub", managedLabel: "GitHub managed" }; + case "url": + return { icon: Link2, label: sourceLabel ?? "URL", managedLabel: "URL managed" }; + case "local": + return { icon: Folder, label: sourceLabel ?? "Folder", managedLabel: "Folder managed" }; + case "paperclip": + return { icon: Paperclip, label: sourceLabel ?? "Paperclip", managedLabel: "Paperclip managed" }; default: - return "bg-muted text-muted-foreground"; + return { icon: Boxes, label: sourceLabel ?? "Catalog", managedLabel: "Catalog managed" }; } } -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 shortRef(ref: string | null | undefined) { + if (!ref) return null; + return ref.slice(0, 7); } -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 fileIcon(kind: CompanySkillFileInventoryEntry["kind"]) { + if (kind === "script" || kind === "reference") return FileCode2; + return FileText; } -function SkillListItem({ - skill, - selected, +function encodeSkillFilePath(filePath: string) { + return filePath.split("/").map((segment) => encodeURIComponent(segment)).join("/"); +} + +function decodeSkillFilePath(filePath: string | undefined) { + if (!filePath) return "SKILL.md"; + return filePath + .split("/") + .filter(Boolean) + .map((segment) => { + try { + return decodeURIComponent(segment); + } catch { + return segment; + } + }) + .join("/"); +} + +function parseSkillRoute(routePath: string | undefined) { + const segments = (routePath ?? "").split("/").filter(Boolean); + if (segments.length === 0) { + return { skillId: null, filePath: "SKILL.md" }; + } + + const [rawSkillId, rawMode, ...rest] = segments; + const skillId = rawSkillId ? decodeURIComponent(rawSkillId) : null; + if (!skillId) { + return { skillId: null, filePath: "SKILL.md" }; + } + + if (rawMode === "files") { + return { + skillId, + filePath: decodeSkillFilePath(rest.join("/")), + }; + } + + return { skillId, filePath: "SKILL.md" }; +} + +function skillRoute(skillId: string, filePath?: string | null) { + return filePath ? `/skills/${skillId}/files/${encodeSkillFilePath(filePath)}` : `/skills/${skillId}`; +} + +function parentDirectoryPaths(filePath: string) { + const segments = filePath.split("/").filter(Boolean); + const parents: string[] = []; + for (let index = 0; index < segments.length - 1; index += 1) { + parents.push(segments.slice(0, index + 1).join("/")); + } + return parents; +} + +function NewSkillForm({ + onCreate, + isPending, + onCancel, }: { - skill: CompanySkillListItem; - selected: boolean; + onCreate: (payload: CompanySkillCreateRequest) => void; + isPending: boolean; + onCancel: () => void; }) { + const [name, setName] = useState(""); + const [slug, setSlug] = useState(""); + const [description, setDescription] = useState(""); + return ( - -
-
-
- {skill.name} - - {skill.slug} - -
- {skill.description && ( -

- {skill.description} -

- )} +
+
+ setName(event.target.value)} + placeholder="Skill name" + className="h-9 rounded-none border-0 border-b border-border px-0 shadow-none focus-visible:ring-0" + /> + setSlug(event.target.value)} + placeholder="optional-shortname" + className="h-9 rounded-none border-0 border-b border-border px-0 shadow-none focus-visible:ring-0" + /> +