Files
PaperClipAI/server/src/services/skills-catalog.ts
T
Devin Foley 5e086cb828 fix(server): resolve published skills catalog package root and fallback (#8327)
## Thinking Path

> - Paperclip is the open source control plane people use to run and
supervise AI-agent companies.
> - The skills system is part of that core operator experience because
agents and humans both depend on the catalog-backed Skills Manager
surfaces.
> - In source checkouts, the server can find the catalog manifest and
bundled skill files through monorepo-relative paths, but published
installs do not preserve that layout.
> - That mismatch makes `GET /api/skills/catalog` fail in npm/pnpm
installs even though the catalog package itself is present.
> - The server therefore needs to resolve the catalog from the published
`@paperclipai/skills-catalog` package first, while still keeping a
monorepo fallback for local development.
> - This pull request makes the published package the primary resolution
path, uses the same resolved package root for bundled skill file reads,
and degrades the list route safely when the manifest is unavailable.
> - The benefit is that Skills Manager catalog reads behave correctly in
packaged installs instead of only in repo-local development layouts.

## Linked Issues or Issue Description

Fixes #8316
Refs #7281
Refs #7313
Refs #7350
Refs #7860
Refs #8223
Refs #8227

## What Changed

- Added `@paperclipai/skills-catalog` as a runtime dependency of
`@paperclipai/server`.
- Exported `./package.json` from `@paperclipai/skills-catalog` so the
server can resolve the published package root directly.
- Updated `server/src/services/skills-catalog.ts` to resolve the
manifest and package root from the published package first, with the
monorepo path retained only as a development fallback.
- Applied that resolved package root to bundled catalog file reads so
manifest lookup and skill-file reads use the same published layout.
- Added `listCatalogSkillsOrEmpty()` so `GET /api/skills/catalog`
returns `[]` and logs a warning when the manifest is unavailable instead
of surfacing a 500.
- Added targeted server tests for published-package resolution and
missing-manifest fallback handling.

## Verification

- `pnpm --filter @paperclipai/server exec vitest run
src/__tests__/skills-catalog-service.test.ts
src/__tests__/company-skills-routes.test.ts`
- `pnpm --filter @paperclipai/server build`
- Packaging smoke:
- pack `@paperclipai/shared` and `@paperclipai/skills-catalog` from this
checkout
  - mount those packed artifacts under `server/dist/node_modules`
  - import `server/dist/services/skills-catalog.js`
- verify a bundled catalog `SKILL.md` resolves and reads successfully
from the packed package layout

## Risks

- Low risk: the change is narrowly scoped to catalog package resolution
and fallback behavior.
- The new `./package.json` export slightly broadens the catalog
package's public surface, so reviewers should confirm that is an
acceptable runtime contract.
- The empty-array fallback intentionally changes failure mode for a
missing manifest from `500` to a warning + empty payload, which is safer
for packaged installs but could hide packaging regressions if logs are
not monitored.

## Model Used

- OpenAI Codex via Paperclip `codex_local` (GPT-5-based coding agent;
exact backend model ID/context window not exposed in this harness), with
tool use, shell execution, git, and local test/build verification.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [ ] If this change affects the UI, I have included before/after
screenshots
- [ ] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge
2026-06-19 09:14:41 -07:00

356 lines
12 KiB
TypeScript

import { createHash } from "node:crypto";
import { existsSync, readFileSync, statSync } from "node:fs";
import { promises as fs } from "node:fs";
import { createRequire } from "node:module";
import path from "node:path";
import { fileURLToPath } from "node:url";
import type {
CatalogSkill,
CatalogSkillFileDetail,
CatalogSkillListQuery,
CatalogSkillSource,
} from "@paperclipai/shared";
import { HttpError, conflict, notFound, unprocessable } from "../errors.js";
import { logger } from "../middleware/logger.js";
import { ghFetch, resolveRawGitHubUrl } from "./github-fetch.js";
import { normalizePortablePath } from "./portable-path.js";
interface CatalogManifestFile {
packageName: string;
packageVersion: string;
skills: CatalogSkill[];
}
const serviceDir = path.dirname(fileURLToPath(import.meta.url));
const repoRoot = path.resolve(serviceDir, "../../..");
const require = createRequire(import.meta.url);
const catalogPackageName = "@paperclipai/skills-catalog";
const catalogPackageJsonSpecifier = `${catalogPackageName}/package.json`;
const catalogManifestSpecifier = `${catalogPackageName}/catalog.json`;
const devCatalogPackageRoot = path.join(repoRoot, "packages/skills-catalog");
const devCatalogManifestPath = path.join(devCatalogPackageRoot, "generated/catalog.json");
let cachedCatalogManifest: {
manifest: CatalogManifestFile;
mtimeMs: number;
size: number;
} | null = null;
let cachedCatalogPaths:
| {
packageRoot: string;
manifestPath: string;
}
| false
| null = null;
let cachedCatalogPathsError: CatalogManifestUnavailableError | null = null;
let loggedCatalogUnavailableWarning = false;
export class CatalogManifestUnavailableError extends Error {
constructor(message: string, options?: { cause?: unknown }) {
super(message);
this.name = "CatalogManifestUnavailableError";
if (options && "cause" in options) {
Object.defineProperty(this, "cause", {
value: options.cause,
enumerable: false,
configurable: true,
writable: true,
});
}
}
}
export function isCatalogManifestUnavailableError(error: unknown): error is CatalogManifestUnavailableError {
return error instanceof CatalogManifestUnavailableError;
}
function manifestUnavailableMessage(manifestPath: string) {
return `Skills catalog manifest not found at ${manifestPath}. Run pnpm --filter @paperclipai/skills-catalog build:manifest.`;
}
function packageResolutionFailureMessage() {
return `Skills catalog package could not be resolved from ${catalogPackageJsonSpecifier} and ${catalogManifestSpecifier}.`;
}
function isMissingFileError(error: unknown): error is NodeJS.ErrnoException {
return error instanceof Error && "code" in error && (error as NodeJS.ErrnoException).code === "ENOENT";
}
function resolvePublishedCatalogPaths() {
return {
packageRoot: path.dirname(require.resolve(catalogPackageJsonSpecifier)),
manifestPath: require.resolve(catalogManifestSpecifier),
};
}
function resolveDevCatalogPaths() {
if (!existsSync(devCatalogManifestPath)) return null;
return {
packageRoot: devCatalogPackageRoot,
manifestPath: devCatalogManifestPath,
};
}
function resolveCatalogPaths() {
if (cachedCatalogPaths === false && cachedCatalogPathsError) {
throw cachedCatalogPathsError;
}
if (cachedCatalogPaths) {
return cachedCatalogPaths;
}
try {
cachedCatalogPaths = resolvePublishedCatalogPaths();
cachedCatalogPathsError = null;
return cachedCatalogPaths;
} catch (publishedError) {
const devPaths = resolveDevCatalogPaths();
if (devPaths) {
cachedCatalogPaths = devPaths;
cachedCatalogPathsError = null;
return cachedCatalogPaths;
}
cachedCatalogPathsError = new CatalogManifestUnavailableError(packageResolutionFailureMessage(), { cause: publishedError });
cachedCatalogPaths = false;
throw cachedCatalogPathsError;
}
}
function loadCatalogManifest(manifestPath: string): CatalogManifestFile {
try {
return JSON.parse(readFileSync(manifestPath, "utf8")) as CatalogManifestFile;
} catch (error) {
if (isMissingFileError(error)) {
throw new CatalogManifestUnavailableError(manifestUnavailableMessage(manifestPath), { cause: error });
}
throw error;
}
}
function getCatalogManifest() {
const { manifestPath } = resolveCatalogPaths();
if (!existsSync(manifestPath)) {
throw new CatalogManifestUnavailableError(manifestUnavailableMessage(manifestPath));
}
let stats: ReturnType<typeof statSync>;
try {
stats = statSync(manifestPath);
} catch (error) {
if (isMissingFileError(error)) {
throw new CatalogManifestUnavailableError(manifestUnavailableMessage(manifestPath), { cause: error });
}
throw error;
}
if (
cachedCatalogManifest &&
cachedCatalogManifest.mtimeMs === stats.mtimeMs &&
cachedCatalogManifest.size === stats.size
) {
return cachedCatalogManifest.manifest;
}
const manifest = loadCatalogManifest(manifestPath);
cachedCatalogManifest = {
manifest,
mtimeMs: stats.mtimeMs,
size: stats.size,
};
return manifest;
}
function getCatalogSkills() {
const catalogManifest = getCatalogManifest();
return catalogManifest.skills.map((skill) => ({
...skill,
packageName: catalogManifest.packageName,
packageVersion: catalogManifest.packageVersion,
}));
}
function isMarkdownPath(filePath: string) {
const fileName = path.posix.basename(filePath).toLowerCase();
return fileName === "skill.md" || fileName.endsWith(".md");
}
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 resolveCatalogPackageRoot() {
return resolveCatalogPaths().packageRoot;
}
function sourceRootPath(source: CatalogSkillSource) {
return source.path ? normalizePortablePath(source.path) : "";
}
function resolveCatalogSourcePath(source: CatalogSkillSource, relativePath: string) {
const sourceRoot = sourceRootPath(source);
return sourceRoot ? `${sourceRoot}/${relativePath}` : relativePath;
}
async function fetchCatalogSourceFile(
skill: CatalogSkill,
relativePath: string,
): Promise<Buffer> {
const source = skill.source;
if (!source) {
const packageRoot = resolveCatalogPackageRoot();
const absolutePath = path.resolve(packageRoot, skill.path, relativePath);
const skillRoot = path.resolve(packageRoot, skill.path);
if (absolutePath !== skillRoot && !absolutePath.startsWith(`${skillRoot}${path.sep}`)) {
throw notFound("Catalog skill file not found");
}
return fs.readFile(absolutePath);
}
if (source.type !== "github") {
throw unprocessable(`Unsupported catalog source type: ${(source as { type: string }).type}`);
}
const sourcePath = resolveCatalogSourcePath(source, relativePath);
const url = resolveRawGitHubUrl(source.hostname, source.owner, source.repo, source.commit, sourcePath);
const response = await ghFetch(url);
if (!response.ok) {
throw unprocessable(`Failed to fetch pinned catalog file ${sourcePath} from ${source.owner}/${source.repo}@${source.commit}: HTTP ${response.status}`);
}
return Buffer.from(await response.arrayBuffer());
}
async function readCatalogFileBytes(
skill: CatalogSkill,
relativePath: string,
): Promise<Buffer> {
const fileEntry = skill.files.find((entry) => entry.path === relativePath);
if (!fileEntry) {
throw notFound("Catalog skill file not found");
}
const bytes = await fetchCatalogSourceFile(skill, relativePath);
const actualSha = createHash("sha256").update(bytes).digest("hex");
if (actualSha !== fileEntry.sha256) {
throw unprocessable(`Pinned catalog file hash mismatch for ${skill.id}:${relativePath}.`);
}
return bytes;
}
function searchText(skill: CatalogSkill) {
return [
skill.id,
skill.key,
skill.slug,
skill.name,
skill.description,
skill.category,
skill.kind,
...skill.recommendedForRoles,
...skill.tags,
].join("\n").toLowerCase();
}
export function listCatalogSkills(query: CatalogSkillListQuery = {}): CatalogSkill[] {
const normalizedQuery = query.q?.trim().toLowerCase() ?? "";
return getCatalogSkills()
.filter((skill) => !query.kind || skill.kind === query.kind)
.filter((skill) => !query.category || skill.category === query.category)
.filter((skill) => !normalizedQuery || searchText(skill).includes(normalizedQuery))
.sort((left, right) => left.name.localeCompare(right.name) || left.key.localeCompare(right.key));
}
export function listCatalogSkillsOrEmpty(query: CatalogSkillListQuery = {}): CatalogSkill[] {
try {
const skills = listCatalogSkills(query);
loggedCatalogUnavailableWarning = false;
return skills;
} catch (error) {
if (!isCatalogManifestUnavailableError(error)) {
throw error;
}
if (!loggedCatalogUnavailableWarning) {
logger.warn({ err: error }, "skills catalog manifest unavailable; returning empty catalog");
loggedCatalogUnavailableWarning = true;
}
return [];
}
}
export function resolveCatalogSkillReference(reference: string): { skill: CatalogSkill | null; ambiguous: boolean } {
const trimmed = reference.trim();
if (!trimmed) return { skill: null, ambiguous: false };
const catalogSkills = getCatalogSkills();
const exact = catalogSkills.find((skill) => skill.id === trimmed || skill.key === trimmed);
if (exact) return { skill: exact, ambiguous: false };
const slugMatches = catalogSkills.filter((skill) => skill.slug === trimmed);
if (slugMatches.length === 1) return { skill: slugMatches[0]!, ambiguous: false };
if (slugMatches.length > 1) return { skill: null, ambiguous: true };
return { skill: null, ambiguous: false };
}
export function getCatalogSkillOrThrow(reference: string): CatalogSkill {
const result = resolveCatalogSkillReference(reference);
if (result.ambiguous) {
throw conflict(`Catalog skill slug "${reference}" is ambiguous. Use an id or key.`);
}
if (!result.skill) {
throw notFound("Catalog skill not found");
}
return result.skill;
}
export async function readCatalogSkillFile(
reference: string,
relativePath = "SKILL.md",
): Promise<CatalogSkillFileDetail> {
const skill = getCatalogSkillOrThrow(reference);
const normalizedPath = normalizePortablePath(relativePath || "SKILL.md");
const fileEntry = skill.files.find((entry) => entry.path === normalizedPath);
if (!fileEntry) {
throw notFound("Catalog skill file not found");
}
if (fileEntry.kind === "asset") {
throw new HttpError(415, "Catalog asset previews are not supported.");
}
const content = (await readCatalogFileBytes(skill, normalizedPath)).toString("utf8");
return {
catalogSkillId: skill.id,
path: normalizedPath,
kind: fileEntry.kind,
content,
language: inferLanguageFromPath(normalizedPath),
markdown: isMarkdownPath(normalizedPath),
};
}
export async function copyCatalogSkillFile(reference: string, relativePath: string, targetPath: string): Promise<void> {
const skill = getCatalogSkillOrThrow(reference);
const normalizedPath = normalizePortablePath(relativePath || "SKILL.md");
const fileEntry = skill.files.find((entry) => entry.path === normalizedPath);
if (!fileEntry) {
throw notFound("Catalog skill file not found");
}
await fs.writeFile(targetPath, await readCatalogFileBytes(skill, normalizedPath));
}
export function getCatalogPackageMetadata() {
const catalogManifest = getCatalogManifest();
return {
packageName: catalogManifest.packageName,
packageVersion: catalogManifest.packageVersion,
};
}