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
This commit is contained in:
@@ -14,6 +14,7 @@
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./package.json": "./package.json",
|
||||
"./types": "./src/types.ts",
|
||||
"./catalog.json": "./generated/catalog.json"
|
||||
},
|
||||
@@ -24,6 +25,7 @@
|
||||
"types": "./dist/src/index.d.ts",
|
||||
"import": "./dist/src/index.js"
|
||||
},
|
||||
"./package.json": "./package.json",
|
||||
"./types": {
|
||||
"types": "./dist/src/types.d.ts",
|
||||
"import": "./dist/src/types.js"
|
||||
|
||||
@@ -58,6 +58,7 @@
|
||||
"@paperclipai/db": "workspace:*",
|
||||
"@paperclipai/plugin-sdk": "workspace:*",
|
||||
"@paperclipai/shared": "workspace:*",
|
||||
"@paperclipai/skills-catalog": "workspace:*",
|
||||
"ajv": "^8.20.0",
|
||||
"ajv-formats": "^3.0.1",
|
||||
"better-auth": "1.4.18",
|
||||
|
||||
@@ -31,7 +31,7 @@ const mockCompanySkillService = vi.hoisted(() => ({
|
||||
}));
|
||||
|
||||
const mockCatalogService = vi.hoisted(() => ({
|
||||
listCatalogSkills: vi.fn(),
|
||||
listCatalogSkillsOrEmpty: vi.fn(),
|
||||
getCatalogSkillOrThrow: vi.fn(),
|
||||
readCatalogSkillFile: vi.fn(),
|
||||
}));
|
||||
@@ -115,6 +115,7 @@ describe("company skill mutation permissions", () => {
|
||||
imported: [],
|
||||
warnings: [],
|
||||
});
|
||||
mockCatalogService.listCatalogSkillsOrEmpty.mockReturnValue([]);
|
||||
mockCompanySkillService.list.mockResolvedValue([]);
|
||||
mockCompanySkillService.categoryCounts.mockResolvedValue([]);
|
||||
mockCompanySkillService.detail.mockResolvedValue(null);
|
||||
@@ -260,7 +261,7 @@ describe("company skill mutation permissions", () => {
|
||||
slug: "find-skills",
|
||||
name: "Find Skills",
|
||||
});
|
||||
mockCatalogService.listCatalogSkills.mockReturnValue([]);
|
||||
mockCatalogService.listCatalogSkillsOrEmpty.mockReturnValue([]);
|
||||
mockCatalogService.getCatalogSkillOrThrow.mockReturnValue({
|
||||
id: "paperclipai:bundled:software-development:review",
|
||||
key: "paperclipai/bundled/software-development/review",
|
||||
@@ -312,7 +313,7 @@ describe("company skill mutation permissions", () => {
|
||||
});
|
||||
|
||||
it("serves catalog listing without mutating company skills", async () => {
|
||||
mockCatalogService.listCatalogSkills.mockReturnValue([
|
||||
mockCatalogService.listCatalogSkillsOrEmpty.mockReturnValue([
|
||||
{
|
||||
id: "paperclipai:bundled:software-development:review",
|
||||
key: "paperclipai/bundled/software-development/review",
|
||||
@@ -344,7 +345,7 @@ describe("company skill mutation permissions", () => {
|
||||
.get("/api/skills/catalog?kind=bundled&q=review");
|
||||
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(200);
|
||||
expect(mockCatalogService.listCatalogSkills).toHaveBeenCalledWith({ kind: "bundled", q: "review" });
|
||||
expect(mockCatalogService.listCatalogSkillsOrEmpty).toHaveBeenCalledWith({ kind: "bundled", q: "review" });
|
||||
expect(mockCompanySkillService.importFromSource).not.toHaveBeenCalled();
|
||||
expect(mockCompanySkillService.installFromCatalog).not.toHaveBeenCalled();
|
||||
expect(mockLogActivity).not.toHaveBeenCalled();
|
||||
@@ -360,7 +361,7 @@ describe("company skill mutation permissions", () => {
|
||||
expect(list.status, JSON.stringify(list.body)).toBe(401);
|
||||
expect(detail.status, JSON.stringify(detail.body)).toBe(401);
|
||||
expect(file.status, JSON.stringify(file.body)).toBe(401);
|
||||
expect(mockCatalogService.listCatalogSkills).not.toHaveBeenCalled();
|
||||
expect(mockCatalogService.listCatalogSkillsOrEmpty).not.toHaveBeenCalled();
|
||||
expect(mockCatalogService.getCatalogSkillOrThrow).not.toHaveBeenCalled();
|
||||
expect(mockCatalogService.readCatalogSkillFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -1030,8 +1030,15 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
|
||||
})
|
||||
);
|
||||
expect(issue?.executionRunId).toBe(retryRun?.id ?? null);
|
||||
|
||||
const checkoutReleasedIssue = await waitForValue(async () =>
|
||||
db.select().from(issues).where(eq(issues.id, issueId)).then((rows) => {
|
||||
const row = rows[0] ?? null;
|
||||
return row?.checkoutRunId === null ? row : null;
|
||||
})
|
||||
);
|
||||
// Terminal run cleanup releases the checkout lock so future checkout 409s only mean a live owner exists.
|
||||
expect(issue?.checkoutRunId).toBeNull();
|
||||
expect(checkoutReleasedIssue?.checkoutRunId).toBeNull();
|
||||
});
|
||||
|
||||
it("releases active environment leases when an orphaned run is reaped", async () => {
|
||||
@@ -1519,8 +1526,11 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
|
||||
});
|
||||
expect(recoveryAction?.nextAction).toContain("Bind the missing secret");
|
||||
|
||||
const comments = await db.select().from(issueComments).where(eq(issueComments.issueId, issueId));
|
||||
expect(comments.some((comment) => comment.body.includes("secret/env bindings are missing"))).toBe(true);
|
||||
const configurationComment = await waitForValue(async () => {
|
||||
const rows = await db.select().from(issueComments).where(eq(issueComments.issueId, issueId));
|
||||
return rows.find((comment) => comment.body.includes("secret/env bindings are missing")) ?? null;
|
||||
});
|
||||
expect(configurationComment).toBeTruthy();
|
||||
});
|
||||
|
||||
it("queues one finish-handoff wake when a successful run leaves in-progress work without a next action", async () => {
|
||||
|
||||
@@ -6,6 +6,8 @@ const mockExistsSync = vi.hoisted(() => vi.fn());
|
||||
const mockReadFileSync = vi.hoisted(() => vi.fn());
|
||||
const mockStatSync = vi.hoisted(() => vi.fn());
|
||||
const mockReadFile = vi.hoisted(() => vi.fn());
|
||||
const mockRequireResolve = vi.hoisted(() => vi.fn());
|
||||
const mockLoggerWarn = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.doMock("node:fs", async () => {
|
||||
const actual = await vi.importActual<typeof import("node:fs")>("node:fs");
|
||||
@@ -21,6 +23,22 @@ vi.doMock("node:fs", async () => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.doMock("node:module", async () => {
|
||||
const actual = await vi.importActual<typeof import("node:module")>("node:module");
|
||||
return {
|
||||
...actual,
|
||||
createRequire: () => ({
|
||||
resolve: mockRequireResolve,
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
vi.doMock("../middleware/logger.js", () => ({
|
||||
logger: {
|
||||
warn: mockLoggerWarn,
|
||||
},
|
||||
}));
|
||||
|
||||
function catalogSkill(slug: string, name = slug): CatalogSkill {
|
||||
return {
|
||||
id: `paperclipai:bundled:software-development:${slug}`,
|
||||
@@ -74,6 +92,15 @@ describe("skills catalog service", () => {
|
||||
size: Buffer.byteLength(manifestJson),
|
||||
}));
|
||||
mockReadFile.mockImplementation(async (filePath: string) => `content:${filePath}`);
|
||||
mockRequireResolve.mockImplementation((specifier: string) => {
|
||||
if (specifier === "@paperclipai/skills-catalog/package.json") {
|
||||
return "/published/node_modules/@paperclipai/skills-catalog/package.json";
|
||||
}
|
||||
if (specifier === "@paperclipai/skills-catalog/catalog.json") {
|
||||
return "/published/node_modules/@paperclipai/skills-catalog/generated/catalog.json";
|
||||
}
|
||||
throw new Error(`Unexpected specifier: ${specifier}`);
|
||||
});
|
||||
});
|
||||
|
||||
it("caches and reloads the generated catalog manifest when it changes", async () => {
|
||||
@@ -150,4 +177,59 @@ describe("skills catalog service", () => {
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(mockReadFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("resolves the manifest and bundled skill files from the published package layout", async () => {
|
||||
const publishedMarkdown = "---\nname: published\n---\n\n# Published\n";
|
||||
const publishedSkill = catalogSkill("published-skill", "Published Skill");
|
||||
publishedSkill.files = [
|
||||
{
|
||||
path: "SKILL.md",
|
||||
kind: "skill",
|
||||
sizeBytes: Buffer.byteLength(publishedMarkdown),
|
||||
sha256: sha256(publishedMarkdown),
|
||||
},
|
||||
];
|
||||
manifestJson = manifest([publishedSkill], "0.3.2");
|
||||
mockReadFile.mockImplementationOnce(async () => Buffer.from(publishedMarkdown));
|
||||
const service = await import("../services/skills-catalog.js");
|
||||
|
||||
expect(service.getCatalogPackageMetadata()).toEqual({
|
||||
packageName: "@paperclipai/skills-catalog",
|
||||
packageVersion: "0.3.2",
|
||||
});
|
||||
await expect(service.readCatalogSkillFile(publishedSkill.id, "SKILL.md")).resolves.toMatchObject({
|
||||
catalogSkillId: publishedSkill.id,
|
||||
path: "SKILL.md",
|
||||
content: publishedMarkdown,
|
||||
markdown: true,
|
||||
});
|
||||
expect(mockReadFileSync).toHaveBeenCalledWith(
|
||||
"/published/node_modules/@paperclipai/skills-catalog/generated/catalog.json",
|
||||
"utf8",
|
||||
);
|
||||
expect(mockReadFile).toHaveBeenCalledWith(
|
||||
"/published/node_modules/@paperclipai/skills-catalog/catalog/bundled/software-development/published-skill/SKILL.md",
|
||||
);
|
||||
expect(mockRequireResolve).toHaveBeenCalledWith("@paperclipai/skills-catalog/package.json");
|
||||
expect(mockRequireResolve).toHaveBeenCalledWith("@paperclipai/skills-catalog/catalog.json");
|
||||
});
|
||||
|
||||
it("returns an empty list, caches package-resolution failure, and logs only once when the manifest cannot be resolved", async () => {
|
||||
mockRequireResolve.mockImplementation(() => {
|
||||
const error = new Error("not found") as Error & { code?: string };
|
||||
error.code = "ERR_MODULE_NOT_FOUND";
|
||||
throw error;
|
||||
});
|
||||
mockExistsSync.mockReturnValue(false);
|
||||
const service = await import("../services/skills-catalog.js");
|
||||
|
||||
expect(service.listCatalogSkillsOrEmpty()).toEqual([]);
|
||||
expect(service.listCatalogSkillsOrEmpty()).toEqual([]);
|
||||
expect(mockRequireResolve).toHaveBeenCalledTimes(1);
|
||||
expect(mockLoggerWarn).toHaveBeenCalledWith(
|
||||
{ err: expect.any(service.CatalogManifestUnavailableError) },
|
||||
"skills catalog manifest unavailable; returning empty catalog",
|
||||
);
|
||||
expect(mockLoggerWarn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -19,7 +19,11 @@ import {
|
||||
import { trackSkillImported } from "@paperclipai/shared/telemetry";
|
||||
import { validate } from "../middleware/validate.js";
|
||||
import { accessService, agentService, companySkillService, logActivity } from "../services/index.js";
|
||||
import { getCatalogSkillOrThrow, listCatalogSkills, readCatalogSkillFile } from "../services/skills-catalog.js";
|
||||
import {
|
||||
getCatalogSkillOrThrow,
|
||||
listCatalogSkillsOrEmpty,
|
||||
readCatalogSkillFile,
|
||||
} from "../services/skills-catalog.js";
|
||||
import { forbidden } from "../errors.js";
|
||||
import { assertAuthenticated, assertCompanyAccess, getActorInfo } from "./authz.js";
|
||||
import { getTelemetryClient } from "../telemetry.js";
|
||||
@@ -121,7 +125,7 @@ export function companySkillRoutes(db: Db) {
|
||||
category: firstQueryString(req.query.category),
|
||||
q: firstQueryString(req.query.q),
|
||||
});
|
||||
res.json(listCatalogSkills(query));
|
||||
res.json(listCatalogSkillsOrEmpty(query));
|
||||
});
|
||||
|
||||
router.get("/skills/catalog/:catalogId/files", async (req, res) => {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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 {
|
||||
@@ -10,6 +11,7 @@ import type {
|
||||
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";
|
||||
|
||||
@@ -21,30 +23,122 @@ interface CatalogManifestFile {
|
||||
|
||||
const serviceDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
const repoRoot = path.resolve(serviceDir, "../../..");
|
||||
const catalogPackageRoot = path.join(repoRoot, "packages/skills-catalog");
|
||||
const catalogManifestPath = path.join(catalogPackageRoot, "generated/catalog.json");
|
||||
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;
|
||||
|
||||
function loadCatalogManifest(): CatalogManifestFile {
|
||||
if (!existsSync(catalogManifestPath)) {
|
||||
throw new Error(
|
||||
`Skills catalog manifest not found at ${catalogManifestPath}. Run pnpm --filter @paperclipai/skills-catalog build:manifest.`,
|
||||
);
|
||||
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;
|
||||
}
|
||||
return JSON.parse(readFileSync(catalogManifestPath, "utf8")) as CatalogManifestFile;
|
||||
}
|
||||
|
||||
function getCatalogManifest() {
|
||||
if (!existsSync(catalogManifestPath)) {
|
||||
throw new Error(
|
||||
`Skills catalog manifest not found at ${catalogManifestPath}. Run pnpm --filter @paperclipai/skills-catalog build:manifest.`,
|
||||
);
|
||||
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;
|
||||
}
|
||||
const stats = statSync(catalogManifestPath);
|
||||
if (
|
||||
cachedCatalogManifest &&
|
||||
cachedCatalogManifest.mtimeMs === stats.mtimeMs &&
|
||||
@@ -53,7 +147,7 @@ function getCatalogManifest() {
|
||||
return cachedCatalogManifest.manifest;
|
||||
}
|
||||
|
||||
const manifest = loadCatalogManifest();
|
||||
const manifest = loadCatalogManifest(manifestPath);
|
||||
cachedCatalogManifest = {
|
||||
manifest,
|
||||
mtimeMs: stats.mtimeMs,
|
||||
@@ -93,7 +187,7 @@ function inferLanguageFromPath(filePath: string) {
|
||||
}
|
||||
|
||||
function resolveCatalogPackageRoot() {
|
||||
return catalogPackageRoot;
|
||||
return resolveCatalogPaths().packageRoot;
|
||||
}
|
||||
|
||||
function sourceRootPath(source: CatalogSkillSource) {
|
||||
@@ -173,6 +267,23 @@ export function listCatalogSkills(query: CatalogSkillListQuery = {}): CatalogSki
|
||||
.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 };
|
||||
|
||||
Reference in New Issue
Block a user