forked from farhoodlabs/paperclip
Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 27db0d3c67 | |||
| 9e30b72b27 | |||
| 7b12d907cc | |||
| d1d592d793 | |||
| 3dfb859676 |
@@ -812,6 +812,7 @@ export {
|
|||||||
companySkillDetailSchema,
|
companySkillDetailSchema,
|
||||||
companySkillUpdateStatusSchema,
|
companySkillUpdateStatusSchema,
|
||||||
companySkillImportSchema,
|
companySkillImportSchema,
|
||||||
|
companySkillUpdateAuthSchema,
|
||||||
companySkillProjectScanRequestSchema,
|
companySkillProjectScanRequestSchema,
|
||||||
companySkillProjectScanSkippedSchema,
|
companySkillProjectScanSkippedSchema,
|
||||||
companySkillProjectScanConflictSchema,
|
companySkillProjectScanConflictSchema,
|
||||||
|
|||||||
@@ -68,6 +68,11 @@ export const companySkillUpdateStatusSchema = z.object({
|
|||||||
|
|
||||||
export const companySkillImportSchema = z.object({
|
export const companySkillImportSchema = z.object({
|
||||||
source: z.string().min(1),
|
source: z.string().min(1),
|
||||||
|
authToken: z.string().min(1).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const companySkillUpdateAuthSchema = z.object({
|
||||||
|
authToken: z.string().min(1).nullable(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const companySkillProjectScanRequestSchema = z.object({
|
export const companySkillProjectScanRequestSchema = z.object({
|
||||||
@@ -135,3 +140,4 @@ export type CompanySkillImport = z.infer<typeof companySkillImportSchema>;
|
|||||||
export type CompanySkillProjectScan = z.infer<typeof companySkillProjectScanRequestSchema>;
|
export type CompanySkillProjectScan = z.infer<typeof companySkillProjectScanRequestSchema>;
|
||||||
export type CompanySkillCreate = z.infer<typeof companySkillCreateSchema>;
|
export type CompanySkillCreate = z.infer<typeof companySkillCreateSchema>;
|
||||||
export type CompanySkillFileUpdate = z.infer<typeof companySkillFileUpdateSchema>;
|
export type CompanySkillFileUpdate = z.infer<typeof companySkillFileUpdateSchema>;
|
||||||
|
export type CompanySkillUpdateAuth = z.infer<typeof companySkillUpdateAuthSchema>;
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ export {
|
|||||||
companySkillDetailSchema,
|
companySkillDetailSchema,
|
||||||
companySkillUpdateStatusSchema,
|
companySkillUpdateStatusSchema,
|
||||||
companySkillImportSchema,
|
companySkillImportSchema,
|
||||||
|
companySkillUpdateAuthSchema,
|
||||||
companySkillProjectScanRequestSchema,
|
companySkillProjectScanRequestSchema,
|
||||||
companySkillProjectScanSkippedSchema,
|
companySkillProjectScanSkippedSchema,
|
||||||
companySkillProjectScanConflictSchema,
|
companySkillProjectScanConflictSchema,
|
||||||
@@ -74,6 +75,7 @@ export {
|
|||||||
type CompanySkillProjectScan,
|
type CompanySkillProjectScan,
|
||||||
type CompanySkillCreate,
|
type CompanySkillCreate,
|
||||||
type CompanySkillFileUpdate,
|
type CompanySkillFileUpdate,
|
||||||
|
type CompanySkillUpdateAuth,
|
||||||
} from "./company-skill.js";
|
} from "./company-skill.js";
|
||||||
export {
|
export {
|
||||||
agentSkillStateSchema,
|
agentSkillStateSchema,
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ const mockAccessService = vi.hoisted(() => ({
|
|||||||
const mockCompanySkillService = vi.hoisted(() => ({
|
const mockCompanySkillService = vi.hoisted(() => ({
|
||||||
importFromSource: vi.fn(),
|
importFromSource: vi.fn(),
|
||||||
deleteSkill: vi.fn(),
|
deleteSkill: vi.fn(),
|
||||||
|
updateSkillAuth: vi.fn(),
|
||||||
|
scanProjectWorkspaces: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const mockLogActivity = vi.hoisted(() => vi.fn());
|
const mockLogActivity = vi.hoisted(() => vi.fn());
|
||||||
@@ -97,6 +99,15 @@ describe("company skill mutation permissions", () => {
|
|||||||
slug: "find-skills",
|
slug: "find-skills",
|
||||||
name: "Find Skills",
|
name: "Find Skills",
|
||||||
});
|
});
|
||||||
|
mockCompanySkillService.scanProjectWorkspaces.mockResolvedValue({
|
||||||
|
scannedProjects: 1,
|
||||||
|
scannedWorkspaces: 2,
|
||||||
|
discovered: [],
|
||||||
|
imported: [],
|
||||||
|
updated: [],
|
||||||
|
conflicts: [],
|
||||||
|
warnings: [],
|
||||||
|
});
|
||||||
mockLogActivity.mockResolvedValue(undefined);
|
mockLogActivity.mockResolvedValue(undefined);
|
||||||
mockAccessService.canUser.mockResolvedValue(true);
|
mockAccessService.canUser.mockResolvedValue(true);
|
||||||
mockAccessService.hasPermission.mockResolvedValue(false);
|
mockAccessService.hasPermission.mockResolvedValue(false);
|
||||||
@@ -294,9 +305,120 @@ describe("company skill mutation permissions", () => {
|
|||||||
expect(mockCompanySkillService.importFromSource).toHaveBeenCalledWith(
|
expect(mockCompanySkillService.importFromSource).toHaveBeenCalledWith(
|
||||||
"company-1",
|
"company-1",
|
||||||
"https://github.com/vercel-labs/agent-browser",
|
"https://github.com/vercel-labs/agent-browser",
|
||||||
|
undefined,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("passes a PAT through skill import requests", async () => {
|
||||||
|
const res = await request(await createApp({
|
||||||
|
type: "board",
|
||||||
|
userId: "local-board",
|
||||||
|
companyIds: ["company-1"],
|
||||||
|
source: "local_implicit",
|
||||||
|
isInstanceAdmin: false,
|
||||||
|
}))
|
||||||
|
.post("/api/companies/company-1/skills/import")
|
||||||
|
.send({
|
||||||
|
source: "https://github.com/vercel-labs/agent-browser",
|
||||||
|
authToken: "ghp_private_token",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.status, JSON.stringify(res.body)).toBe(201);
|
||||||
|
expect(mockCompanySkillService.importFromSource).toHaveBeenCalledWith(
|
||||||
|
"company-1",
|
||||||
|
"https://github.com/vercel-labs/agent-browser",
|
||||||
|
"ghp_private_token",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("updates a skill auth token", async () => {
|
||||||
|
mockCompanySkillService.updateSkillAuth.mockResolvedValue({
|
||||||
|
id: "skill-1",
|
||||||
|
slug: "find-skills",
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await request(await createApp({
|
||||||
|
type: "board",
|
||||||
|
userId: "local-board",
|
||||||
|
companyIds: ["company-1"],
|
||||||
|
source: "local_implicit",
|
||||||
|
isInstanceAdmin: false,
|
||||||
|
}))
|
||||||
|
.patch("/api/companies/company-1/skills/skill-1/auth")
|
||||||
|
.send({ authToken: "ghp_private_token" });
|
||||||
|
|
||||||
|
expect(res.status, JSON.stringify(res.body)).toBe(200);
|
||||||
|
expect(mockCompanySkillService.updateSkillAuth).toHaveBeenCalledWith(
|
||||||
|
"company-1",
|
||||||
|
"skill-1",
|
||||||
|
"ghp_private_token",
|
||||||
|
);
|
||||||
|
expect(mockLogActivity).toHaveBeenCalledWith(
|
||||||
|
expect.anything(),
|
||||||
|
expect.objectContaining({
|
||||||
|
companyId: "company-1",
|
||||||
|
action: "company.skill_auth_updated",
|
||||||
|
entityType: "company_skill",
|
||||||
|
entityId: "skill-1",
|
||||||
|
details: { slug: "find-skills" },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears a skill auth token", async () => {
|
||||||
|
mockCompanySkillService.updateSkillAuth.mockResolvedValue({
|
||||||
|
id: "skill-1",
|
||||||
|
slug: "find-skills",
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await request(await createApp({
|
||||||
|
type: "board",
|
||||||
|
userId: "local-board",
|
||||||
|
companyIds: ["company-1"],
|
||||||
|
source: "local_implicit",
|
||||||
|
isInstanceAdmin: false,
|
||||||
|
}))
|
||||||
|
.patch("/api/companies/company-1/skills/skill-1/auth")
|
||||||
|
.send({ authToken: null });
|
||||||
|
|
||||||
|
expect(res.status, JSON.stringify(res.body)).toBe(200);
|
||||||
|
expect(mockCompanySkillService.updateSkillAuth).toHaveBeenCalledWith(
|
||||||
|
"company-1",
|
||||||
|
"skill-1",
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
expect(mockLogActivity).toHaveBeenCalledWith(
|
||||||
|
expect.anything(),
|
||||||
|
expect.objectContaining({
|
||||||
|
companyId: "company-1",
|
||||||
|
action: "company.skill_auth_removed",
|
||||||
|
entityType: "company_skill",
|
||||||
|
entityId: "skill-1",
|
||||||
|
details: { slug: "find-skills" },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows agents with canCreateAgents to scan project workspaces", async () => {
|
||||||
|
mockAgentService.getById.mockResolvedValue({
|
||||||
|
id: "agent-1",
|
||||||
|
companyId: "company-1",
|
||||||
|
permissions: { canCreateAgents: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await request(await createApp({
|
||||||
|
type: "agent",
|
||||||
|
agentId: "agent-1",
|
||||||
|
companyId: "company-1",
|
||||||
|
runId: "run-1",
|
||||||
|
}))
|
||||||
|
.post("/api/companies/company-1/skills/scan-projects")
|
||||||
|
.send({});
|
||||||
|
|
||||||
|
expect(res.status, JSON.stringify(res.body)).toBe(200);
|
||||||
|
expect(mockCompanySkillService.scanProjectWorkspaces).toHaveBeenCalledWith("company-1", {});
|
||||||
|
});
|
||||||
|
|
||||||
it("returns a blocking error when attempting to delete a skill still used by agents", async () => {
|
it("returns a blocking error when attempting to delete a skill still used by agents", async () => {
|
||||||
const { unprocessable } = await import("../errors.js");
|
const { unprocessable } = await import("../errors.js");
|
||||||
mockCompanySkillService.deleteSkill.mockImplementationOnce(async () => {
|
mockCompanySkillService.deleteSkill.mockImplementationOnce(async () => {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
companySkillCreateSchema,
|
companySkillCreateSchema,
|
||||||
companySkillFileUpdateSchema,
|
companySkillFileUpdateSchema,
|
||||||
companySkillImportSchema,
|
companySkillImportSchema,
|
||||||
|
companySkillUpdateAuthSchema,
|
||||||
companySkillProjectScanRequestSchema,
|
companySkillProjectScanRequestSchema,
|
||||||
} from "@paperclipai/shared";
|
} from "@paperclipai/shared";
|
||||||
import { trackSkillImported } from "@paperclipai/shared/telemetry";
|
import { trackSkillImported } from "@paperclipai/shared/telemetry";
|
||||||
@@ -194,7 +195,8 @@ export function companySkillRoutes(db: Db) {
|
|||||||
const companyId = req.params.companyId as string;
|
const companyId = req.params.companyId as string;
|
||||||
await assertCanMutateCompanySkills(req, companyId);
|
await assertCanMutateCompanySkills(req, companyId);
|
||||||
const source = String(req.body.source ?? "");
|
const source = String(req.body.source ?? "");
|
||||||
const result = await svc.importFromSource(companyId, source);
|
const authToken = typeof req.body.authToken === "string" ? req.body.authToken.trim() : undefined;
|
||||||
|
const result = await svc.importFromSource(companyId, source, authToken || undefined);
|
||||||
|
|
||||||
const actor = getActorInfo(req);
|
const actor = getActorInfo(req);
|
||||||
await logActivity(db, {
|
await logActivity(db, {
|
||||||
@@ -318,5 +320,38 @@ export function companySkillRoutes(db: Db) {
|
|||||||
res.json(result);
|
res.json(result);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
router.patch(
|
||||||
|
"/companies/:companyId/skills/:skillId/auth",
|
||||||
|
validate(companySkillUpdateAuthSchema),
|
||||||
|
async (req, res) => {
|
||||||
|
const companyId = req.params.companyId as string;
|
||||||
|
const skillId = req.params.skillId as string;
|
||||||
|
await assertCanMutateCompanySkills(req, companyId);
|
||||||
|
const authToken = req.body.authToken as string | null;
|
||||||
|
const result = await svc.updateSkillAuth(companyId, skillId, authToken);
|
||||||
|
if (!result) {
|
||||||
|
res.status(404).json({ error: "Skill not found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const actor = getActorInfo(req);
|
||||||
|
await logActivity(db, {
|
||||||
|
companyId,
|
||||||
|
actorType: actor.actorType,
|
||||||
|
actorId: actor.actorId,
|
||||||
|
agentId: actor.agentId,
|
||||||
|
runId: actor.runId,
|
||||||
|
action: authToken ? "company.skill_auth_updated" : "company.skill_auth_removed",
|
||||||
|
entityType: "company_skill",
|
||||||
|
entityId: result.id,
|
||||||
|
details: {
|
||||||
|
slug: result.slug,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json(result);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
return router;
|
return router;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { createHash } from "node:crypto";
|
|||||||
import { promises as fs } from "node:fs";
|
import { promises as fs } from "node:fs";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import { and, asc, eq } from "drizzle-orm";
|
import { and, asc, eq, sql } from "drizzle-orm";
|
||||||
import type { Db } from "@paperclipai/db";
|
import type { Db } from "@paperclipai/db";
|
||||||
import { companies, companySkills } from "@paperclipai/db";
|
import { companies, companySkills } from "@paperclipai/db";
|
||||||
import { readPaperclipSkillSyncPreference } from "@paperclipai/adapter-utils/server-utils";
|
import { readPaperclipSkillSyncPreference } from "@paperclipai/adapter-utils/server-utils";
|
||||||
@@ -32,6 +32,7 @@ import { notFound, unprocessable } from "../errors.js";
|
|||||||
import { ghFetch, gitHubApiBase, resolveRawGitHubUrl } from "./github-fetch.js";
|
import { ghFetch, gitHubApiBase, resolveRawGitHubUrl } from "./github-fetch.js";
|
||||||
import { agentService } from "./agents.js";
|
import { agentService } from "./agents.js";
|
||||||
import { projectService } from "./projects.js";
|
import { projectService } from "./projects.js";
|
||||||
|
import { secretService } from "./secrets.js";
|
||||||
|
|
||||||
type CompanySkillRow = typeof companySkills.$inferSelect;
|
type CompanySkillRow = typeof companySkills.$inferSelect;
|
||||||
type CompanySkillListDbRow = Pick<
|
type CompanySkillListDbRow = Pick<
|
||||||
@@ -540,20 +541,20 @@ function parseFrontmatterMarkdown(raw: string): { frontmatter: Record<string, un
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchText(url: string) {
|
async function fetchText(url: string, authToken?: string) {
|
||||||
const response = await ghFetch(url);
|
const response = await ghFetch(url, undefined, authToken);
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw unprocessable(`Failed to fetch ${url}: ${response.status}`);
|
throw unprocessable(`Failed to fetch ${url}: ${response.status}`);
|
||||||
}
|
}
|
||||||
return response.text();
|
return response.text();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchJson<T>(url: string): Promise<T> {
|
async function fetchJson<T>(url: string, authToken?: string): Promise<T> {
|
||||||
const response = await ghFetch(url, {
|
const response = await ghFetch(url, {
|
||||||
headers: {
|
headers: {
|
||||||
accept: "application/vnd.github+json",
|
accept: "application/vnd.github+json",
|
||||||
},
|
},
|
||||||
});
|
}, authToken);
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw unprocessable(`Failed to fetch ${url}: ${response.status}`);
|
throw unprocessable(`Failed to fetch ${url}: ${response.status}`);
|
||||||
}
|
}
|
||||||
@@ -561,16 +562,18 @@ async function fetchJson<T>(url: string): Promise<T> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
async function resolveGitHubDefaultBranch(owner: string, repo: string, apiBase: string) {
|
async function resolveGitHubDefaultBranch(owner: string, repo: string, apiBase: string, authToken?: string) {
|
||||||
const response = await fetchJson<{ default_branch?: string }>(
|
const response = await fetchJson<{ default_branch?: string }>(
|
||||||
`${apiBase}/repos/${owner}/${repo}`,
|
`${apiBase}/repos/${owner}/${repo}`,
|
||||||
|
authToken,
|
||||||
);
|
);
|
||||||
return asString(response.default_branch) ?? "main";
|
return asString(response.default_branch) ?? "main";
|
||||||
}
|
}
|
||||||
|
|
||||||
async function resolveGitHubCommitSha(owner: string, repo: string, ref: string, apiBase: string) {
|
async function resolveGitHubCommitSha(owner: string, repo: string, ref: string, apiBase: string, authToken?: string) {
|
||||||
const response = await fetchJson<{ sha?: string }>(
|
const response = await fetchJson<{ sha?: string }>(
|
||||||
`${apiBase}/repos/${owner}/${repo}/commits/${encodeURIComponent(ref)}`,
|
`${apiBase}/repos/${owner}/${repo}/commits/${encodeURIComponent(ref)}`,
|
||||||
|
authToken,
|
||||||
);
|
);
|
||||||
const sha = asString(response.sha);
|
const sha = asString(response.sha);
|
||||||
if (!sha) {
|
if (!sha) {
|
||||||
@@ -607,7 +610,7 @@ function parseGitHubSourceUrl(rawUrl: string) {
|
|||||||
return { hostname: url.hostname, owner, repo, ref, basePath, filePath, explicitRef };
|
return { hostname: url.hostname, owner, repo, ref, basePath, filePath, explicitRef };
|
||||||
}
|
}
|
||||||
|
|
||||||
async function resolveGitHubPinnedRef(parsed: ReturnType<typeof parseGitHubSourceUrl>) {
|
async function resolveGitHubPinnedRef(parsed: ReturnType<typeof parseGitHubSourceUrl>, authToken?: string) {
|
||||||
const apiBase = gitHubApiBase(parsed.hostname);
|
const apiBase = gitHubApiBase(parsed.hostname);
|
||||||
if (/^[0-9a-f]{40}$/i.test(parsed.ref.trim())) {
|
if (/^[0-9a-f]{40}$/i.test(parsed.ref.trim())) {
|
||||||
return {
|
return {
|
||||||
@@ -618,8 +621,8 @@ async function resolveGitHubPinnedRef(parsed: ReturnType<typeof parseGitHubSourc
|
|||||||
|
|
||||||
const trackingRef = parsed.explicitRef
|
const trackingRef = parsed.explicitRef
|
||||||
? parsed.ref
|
? parsed.ref
|
||||||
: await resolveGitHubDefaultBranch(parsed.owner, parsed.repo, apiBase);
|
: await resolveGitHubDefaultBranch(parsed.owner, parsed.repo, apiBase, authToken);
|
||||||
const pinnedRef = await resolveGitHubCommitSha(parsed.owner, parsed.repo, trackingRef, apiBase);
|
const pinnedRef = await resolveGitHubCommitSha(parsed.owner, parsed.repo, trackingRef, apiBase, authToken);
|
||||||
return { pinnedRef, trackingRef };
|
return { pinnedRef, trackingRef };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1050,6 +1053,7 @@ async function readUrlSkillImports(
|
|||||||
companyId: string,
|
companyId: string,
|
||||||
sourceUrl: string,
|
sourceUrl: string,
|
||||||
requestedSkillSlug: string | null = null,
|
requestedSkillSlug: string | null = null,
|
||||||
|
authToken?: string,
|
||||||
): Promise<{ skills: ImportedSkill[]; warnings: string[] }> {
|
): Promise<{ skills: ImportedSkill[]; warnings: string[] }> {
|
||||||
const url = sourceUrl.trim();
|
const url = sourceUrl.trim();
|
||||||
const warnings: string[] = [];
|
const warnings: string[] = [];
|
||||||
@@ -1064,10 +1068,11 @@ async function readUrlSkillImports(
|
|||||||
if (looksLikeRepoUrl) {
|
if (looksLikeRepoUrl) {
|
||||||
const parsed = parseGitHubSourceUrl(url);
|
const parsed = parseGitHubSourceUrl(url);
|
||||||
const apiBase = gitHubApiBase(parsed.hostname);
|
const apiBase = gitHubApiBase(parsed.hostname);
|
||||||
const { pinnedRef, trackingRef } = await resolveGitHubPinnedRef(parsed);
|
const { pinnedRef, trackingRef } = await resolveGitHubPinnedRef(parsed, authToken);
|
||||||
let ref = pinnedRef;
|
let ref = pinnedRef;
|
||||||
const tree = await fetchJson<{ tree?: Array<{ path: string; type: string }> }>(
|
const tree = await fetchJson<{ tree?: Array<{ path: string; type: string }> }>(
|
||||||
`${apiBase}/repos/${parsed.owner}/${parsed.repo}/git/trees/${ref}?recursive=1`,
|
`${apiBase}/repos/${parsed.owner}/${parsed.repo}/git/trees/${ref}?recursive=1`,
|
||||||
|
authToken,
|
||||||
).catch(() => {
|
).catch(() => {
|
||||||
throw unprocessable(`Failed to read GitHub tree for ${url}`);
|
throw unprocessable(`Failed to read GitHub tree for ${url}`);
|
||||||
});
|
});
|
||||||
@@ -1094,7 +1099,7 @@ async function readUrlSkillImports(
|
|||||||
const skills: ImportedSkill[] = [];
|
const skills: ImportedSkill[] = [];
|
||||||
for (const relativeSkillPath of skillPaths) {
|
for (const relativeSkillPath of skillPaths) {
|
||||||
const repoSkillPath = basePrefix ? `${basePrefix}${relativeSkillPath}` : relativeSkillPath;
|
const repoSkillPath = basePrefix ? `${basePrefix}${relativeSkillPath}` : relativeSkillPath;
|
||||||
const markdown = await fetchText(resolveRawGitHubUrl(parsed.hostname, parsed.owner, parsed.repo, ref, repoSkillPath));
|
const markdown = await fetchText(resolveRawGitHubUrl(parsed.hostname, parsed.owner, parsed.repo, ref, repoSkillPath), authToken);
|
||||||
const parsedMarkdown = parseFrontmatterMarkdown(markdown);
|
const parsedMarkdown = parseFrontmatterMarkdown(markdown);
|
||||||
const skillDir = path.posix.dirname(relativeSkillPath);
|
const skillDir = path.posix.dirname(relativeSkillPath);
|
||||||
const slug = deriveImportedSkillSlug(parsedMarkdown.frontmatter, path.posix.basename(skillDir));
|
const slug = deriveImportedSkillSlug(parsedMarkdown.frontmatter, path.posix.basename(skillDir));
|
||||||
@@ -1156,7 +1161,7 @@ async function readUrlSkillImports(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (url.startsWith("http://") || url.startsWith("https://")) {
|
if (url.startsWith("http://") || url.startsWith("https://")) {
|
||||||
const markdown = await fetchText(url);
|
const markdown = await fetchText(url, authToken);
|
||||||
const parsedMarkdown = parseFrontmatterMarkdown(markdown);
|
const parsedMarkdown = parseFrontmatterMarkdown(markdown);
|
||||||
const urlObj = new URL(url);
|
const urlObj = new URL(url);
|
||||||
const fileName = path.posix.basename(urlObj.pathname);
|
const fileName = path.posix.basename(urlObj.pathname);
|
||||||
@@ -1548,6 +1553,22 @@ function toCompanySkillListItem(skill: CompanySkillListRow, attachedAgentCount:
|
|||||||
export function companySkillService(db: Db) {
|
export function companySkillService(db: Db) {
|
||||||
const agents = agentService(db);
|
const agents = agentService(db);
|
||||||
const projects = projectService(db);
|
const projects = projectService(db);
|
||||||
|
const secretsSvc = secretService(db);
|
||||||
|
|
||||||
|
async function resolveSkillAuthToken(
|
||||||
|
companyId: string,
|
||||||
|
skill: { metadata: Record<string, unknown> | null },
|
||||||
|
): Promise<string | undefined> {
|
||||||
|
const meta = skill.metadata;
|
||||||
|
if (!meta) return undefined;
|
||||||
|
const secretId = typeof meta.sourceAuthSecretId === "string" ? meta.sourceAuthSecretId.trim() : "";
|
||||||
|
if (!secretId) return undefined;
|
||||||
|
try {
|
||||||
|
return await secretsSvc.resolveSecretValue(companyId, secretId, "latest");
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function ensureBundledSkills(companyId: string) {
|
async function ensureBundledSkills(companyId: string) {
|
||||||
for (const skillsRoot of resolveBundledSkillsRoot()) {
|
for (const skillsRoot of resolveBundledSkillsRoot()) {
|
||||||
@@ -1766,7 +1787,8 @@ export function companySkillService(db: Db) {
|
|||||||
|
|
||||||
const hostname = asString(metadata.hostname) || "github.com";
|
const hostname = asString(metadata.hostname) || "github.com";
|
||||||
const apiBase = gitHubApiBase(hostname);
|
const apiBase = gitHubApiBase(hostname);
|
||||||
const latestRef = await resolveGitHubCommitSha(owner, repo, trackingRef, apiBase);
|
const authToken = await resolveSkillAuthToken(companyId, skill);
|
||||||
|
const latestRef = await resolveGitHubCommitSha(owner, repo, trackingRef, apiBase, authToken);
|
||||||
return {
|
return {
|
||||||
supported: true,
|
supported: true,
|
||||||
reason: null,
|
reason: null,
|
||||||
@@ -1810,8 +1832,9 @@ export function companySkillService(db: Db) {
|
|||||||
if (!owner || !repo) {
|
if (!owner || !repo) {
|
||||||
throw unprocessable("Skill source metadata is incomplete.");
|
throw unprocessable("Skill source metadata is incomplete.");
|
||||||
}
|
}
|
||||||
|
const authToken = await resolveSkillAuthToken(companyId, skill);
|
||||||
const repoPath = normalizePortablePath(path.posix.join(repoSkillDir, normalizedPath));
|
const repoPath = normalizePortablePath(path.posix.join(repoSkillDir, normalizedPath));
|
||||||
content = await fetchText(resolveRawGitHubUrl(hostname, owner, repo, ref, repoPath));
|
content = await fetchText(resolveRawGitHubUrl(hostname, owner, repo, ref, repoPath), authToken);
|
||||||
} else if (skill.sourceType === "url") {
|
} else if (skill.sourceType === "url") {
|
||||||
if (normalizedPath !== "SKILL.md") {
|
if (normalizedPath !== "SKILL.md") {
|
||||||
throw notFound("This skill source only exposes SKILL.md");
|
throw notFound("This skill source only exposes SKILL.md");
|
||||||
@@ -1928,7 +1951,8 @@ export function companySkillService(db: Db) {
|
|||||||
throw unprocessable("Skill source locator is missing.");
|
throw unprocessable("Skill source locator is missing.");
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await readUrlSkillImports(companyId, skill.sourceLocator, skill.slug);
|
const authToken = await resolveSkillAuthToken(companyId, skill);
|
||||||
|
const result = await readUrlSkillImports(companyId, skill.sourceLocator, skill.slug, authToken);
|
||||||
const matching = result.skills.find((entry) => entry.key === skill.key) ?? result.skills[0] ?? null;
|
const matching = result.skills.find((entry) => entry.key === skill.key) ?? result.skills[0] ?? null;
|
||||||
if (!matching) {
|
if (!matching) {
|
||||||
throw unprocessable(`Skill ${skill.key} could not be re-imported from its source.`);
|
throw unprocessable(`Skill ${skill.key} could not be re-imported from its source.`);
|
||||||
@@ -2103,6 +2127,28 @@ export function companySkillService(db: Db) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const sourceLocators = new Set<string>();
|
||||||
|
for (const skill of acceptedSkills) {
|
||||||
|
if (skill.sourceType !== "github" && skill.sourceType !== "skills_sh") continue;
|
||||||
|
const locator = skill.sourceLocator ?? "";
|
||||||
|
if (locator) sourceLocators.add(locator);
|
||||||
|
}
|
||||||
|
for (const sourceLocator of sourceLocators) {
|
||||||
|
try {
|
||||||
|
const result = await readUrlSkillImports(companyId, sourceLocator, null);
|
||||||
|
for (const nextSkill of result.skills) {
|
||||||
|
if (acceptedSkills.some((s) => s.slug === nextSkill.slug)) continue;
|
||||||
|
const persisted = (await upsertImportedSkills(companyId, [nextSkill]))[0];
|
||||||
|
if (persisted) {
|
||||||
|
imported.push(persisted);
|
||||||
|
upsertAcceptedSkill(persisted);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
warnings.push(`Could not re-scan source ${sourceLocator} — skipping.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
scannedProjects: scannedProjectIds.size,
|
scannedProjects: scannedProjectIds.size,
|
||||||
scannedWorkspaces: scanTargets.length,
|
scannedWorkspaces: scanTargets.length,
|
||||||
@@ -2326,20 +2372,29 @@ export function companySkillService(db: Db) {
|
|||||||
const incomingOwner = asString(incomingMeta.owner);
|
const incomingOwner = asString(incomingMeta.owner);
|
||||||
const incomingRepo = asString(incomingMeta.repo);
|
const incomingRepo = asString(incomingMeta.repo);
|
||||||
const incomingKind = asString(incomingMeta.sourceKind);
|
const incomingKind = asString(incomingMeta.sourceKind);
|
||||||
|
// Bundled skills are sourced from the server image and re-upserted by
|
||||||
|
// ensureBundledSkills only. Never let a non-bundled import overwrite a
|
||||||
|
// bundled row, regardless of which org/repo it claims to be from.
|
||||||
if (
|
if (
|
||||||
existing
|
existing
|
||||||
&& existingMeta.sourceKind === "paperclip_bundled"
|
&& existingMeta.sourceKind === "paperclip_bundled"
|
||||||
&& incomingKind === "github"
|
&& incomingKind !== "paperclip_bundled"
|
||||||
&& incomingOwner === "paperclipai"
|
|
||||||
&& incomingRepo === "paperclip"
|
|
||||||
) {
|
) {
|
||||||
out.push(existing);
|
out.push(existing);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Preserve sourceAuthSecretId across re-imports of the same skill. Skip
|
||||||
|
// bundled rows: they should never carry a PAT reference, and preserving
|
||||||
|
// one across a bundled re-upsert would re-attach stale data.
|
||||||
const metadata = {
|
const metadata = {
|
||||||
...(skill.metadata ?? {}),
|
...(skill.metadata ?? {}),
|
||||||
skillKey: skill.key,
|
skillKey: skill.key,
|
||||||
|
...(existing?.metadata
|
||||||
|
&& existingMeta.sourceKind !== "paperclip_bundled"
|
||||||
|
&& typeof (existing.metadata as Record<string, unknown>).sourceAuthSecretId === "string"
|
||||||
|
? { sourceAuthSecretId: (existing.metadata as Record<string, unknown>).sourceAuthSecretId }
|
||||||
|
: {}),
|
||||||
};
|
};
|
||||||
const values = {
|
const values = {
|
||||||
companyId,
|
companyId,
|
||||||
@@ -2375,7 +2430,7 @@ export function companySkillService(db: Db) {
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function importFromSource(companyId: string, source: string): Promise<CompanySkillImportResult> {
|
async function importFromSource(companyId: string, source: string, authToken?: string): Promise<CompanySkillImportResult> {
|
||||||
await ensureSkillInventoryCurrent(companyId);
|
await ensureSkillInventoryCurrent(companyId);
|
||||||
const parsed = parseSkillImportSourceInput(source);
|
const parsed = parseSkillImportSourceInput(source);
|
||||||
const local = !/^https?:\/\//i.test(parsed.resolvedSource);
|
const local = !/^https?:\/\//i.test(parsed.resolvedSource);
|
||||||
@@ -2385,7 +2440,7 @@ export function companySkillService(db: Db) {
|
|||||||
.filter((skill) => !parsed.requestedSkillSlug || skill.slug === parsed.requestedSkillSlug),
|
.filter((skill) => !parsed.requestedSkillSlug || skill.slug === parsed.requestedSkillSlug),
|
||||||
warnings: parsed.warnings,
|
warnings: parsed.warnings,
|
||||||
}
|
}
|
||||||
: await readUrlSkillImports(companyId, parsed.resolvedSource, parsed.requestedSkillSlug)
|
: await readUrlSkillImports(companyId, parsed.resolvedSource, parsed.requestedSkillSlug, authToken)
|
||||||
.then((result) => ({
|
.then((result) => ({
|
||||||
skills: result.skills,
|
skills: result.skills,
|
||||||
warnings: [...parsed.warnings, ...result.warnings],
|
warnings: [...parsed.warnings, ...result.warnings],
|
||||||
@@ -2412,6 +2467,35 @@ export function companySkillService(db: Db) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
const imported = await upsertImportedSkills(companyId, filteredSkills);
|
const imported = await upsertImportedSkills(companyId, filteredSkills);
|
||||||
|
|
||||||
|
if (authToken && imported.length > 0) {
|
||||||
|
for (const skill of imported) {
|
||||||
|
const skillMeta = skill.metadata as Record<string, unknown> | null;
|
||||||
|
if (skillMeta?.sourceKind === "paperclip_bundled") continue;
|
||||||
|
const secretName = `skill-pat:${skill.id}`;
|
||||||
|
let secretId: string;
|
||||||
|
const existing = await secretsSvc.getByName(companyId, secretName);
|
||||||
|
if (existing) {
|
||||||
|
await secretsSvc.rotate(existing.id, { value: authToken });
|
||||||
|
secretId = existing.id;
|
||||||
|
} else {
|
||||||
|
const created = await secretsSvc.create(companyId, {
|
||||||
|
name: secretName,
|
||||||
|
provider: "local_encrypted",
|
||||||
|
value: authToken,
|
||||||
|
description: `GitHub PAT for skill ${skill.slug}`,
|
||||||
|
});
|
||||||
|
secretId = created.id;
|
||||||
|
}
|
||||||
|
const meta = (skill.metadata ?? {}) as Record<string, unknown>;
|
||||||
|
meta.sourceAuthSecretId = secretId;
|
||||||
|
await db
|
||||||
|
.update(companySkills)
|
||||||
|
.set({ metadata: meta, updatedAt: new Date() })
|
||||||
|
.where(and(eq(companySkills.id, skill.id), eq(companySkills.companyId, companyId)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return { imported, warnings };
|
return { imported, warnings };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2451,9 +2535,85 @@ export function companySkillService(db: Db) {
|
|||||||
// Clean up materialized runtime files
|
// Clean up materialized runtime files
|
||||||
await fs.rm(resolveRuntimeSkillMaterializedPath(companyId, skill), { recursive: true, force: true });
|
await fs.rm(resolveRuntimeSkillMaterializedPath(companyId, skill), { recursive: true, force: true });
|
||||||
|
|
||||||
|
const meta = skill.metadata as Record<string, unknown> | null;
|
||||||
|
const secretId = typeof meta?.sourceAuthSecretId === "string" ? meta.sourceAuthSecretId : null;
|
||||||
|
if (secretId) {
|
||||||
|
// Skip cleanup if another skill in the same company still references this
|
||||||
|
// secret. The deleted row is already gone from the table, so any result
|
||||||
|
// here is a sibling skill we shouldn't orphan.
|
||||||
|
const otherSkillRefs = await db
|
||||||
|
.select({ id: companySkills.id })
|
||||||
|
.from(companySkills)
|
||||||
|
.where(and(
|
||||||
|
eq(companySkills.companyId, companyId),
|
||||||
|
sql`${companySkills.metadata} ->> 'sourceAuthSecretId' = ${secretId}`,
|
||||||
|
))
|
||||||
|
.limit(1);
|
||||||
|
if (otherSkillRefs.length === 0) {
|
||||||
|
try {
|
||||||
|
await secretsSvc.remove(secretId);
|
||||||
|
} catch {
|
||||||
|
// Best-effort: don't fail the skill deletion if secret cleanup fails
|
||||||
|
// (typically blocked by an agent env binding still referencing it).
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return skill;
|
return skill;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function updateSkillAuth(
|
||||||
|
companyId: string,
|
||||||
|
skillId: string,
|
||||||
|
authToken: string | null,
|
||||||
|
): Promise<CompanySkill | null> {
|
||||||
|
const skill = await getById(companyId, skillId);
|
||||||
|
if (!skill) return null;
|
||||||
|
|
||||||
|
const meta = (skill.metadata ?? {}) as Record<string, unknown>;
|
||||||
|
if (meta.sourceKind === "paperclip_bundled") {
|
||||||
|
throw unprocessable("Cannot configure auth for bundled paperclip skills");
|
||||||
|
}
|
||||||
|
const existingSecretId = typeof meta.sourceAuthSecretId === "string" ? meta.sourceAuthSecretId : null;
|
||||||
|
|
||||||
|
if (authToken) {
|
||||||
|
const secretName = `skill-pat:${skill.id}`;
|
||||||
|
let secretId: string;
|
||||||
|
const existingSecret = existingSecretId
|
||||||
|
? await secretsSvc.getById(existingSecretId)
|
||||||
|
: await secretsSvc.getByName(companyId, secretName);
|
||||||
|
if (existingSecret) {
|
||||||
|
await secretsSvc.rotate(existingSecret.id, { value: authToken });
|
||||||
|
secretId = existingSecret.id;
|
||||||
|
} else {
|
||||||
|
const created = await secretsSvc.create(companyId, {
|
||||||
|
name: secretName,
|
||||||
|
provider: "local_encrypted",
|
||||||
|
value: authToken,
|
||||||
|
description: `GitHub PAT for skill ${skill.slug}`,
|
||||||
|
});
|
||||||
|
secretId = created.id;
|
||||||
|
}
|
||||||
|
meta.sourceAuthSecretId = secretId;
|
||||||
|
} else {
|
||||||
|
if (existingSecretId) {
|
||||||
|
try {
|
||||||
|
await secretsSvc.remove(existingSecretId);
|
||||||
|
} catch {
|
||||||
|
// Best-effort: don't fail the metadata update if secret deletion fails
|
||||||
|
}
|
||||||
|
}
|
||||||
|
delete meta.sourceAuthSecretId;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [updated] = await db
|
||||||
|
.update(companySkills)
|
||||||
|
.set({ metadata: meta, updatedAt: new Date() })
|
||||||
|
.where(and(eq(companySkills.id, skillId), eq(companySkills.companyId, companyId)))
|
||||||
|
.returning();
|
||||||
|
return updated ? toCompanySkill(updated) : null;
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
list,
|
list,
|
||||||
listFull,
|
listFull,
|
||||||
@@ -2470,6 +2630,7 @@ export function companySkillService(db: Db) {
|
|||||||
createLocalSkill,
|
createLocalSkill,
|
||||||
deleteSkill,
|
deleteSkill,
|
||||||
importFromSource,
|
importFromSource,
|
||||||
|
updateSkillAuth,
|
||||||
scanProjectWorkspaces,
|
scanProjectWorkspaces,
|
||||||
importPackageFiles,
|
importPackageFiles,
|
||||||
installUpdate,
|
installUpdate,
|
||||||
|
|||||||
@@ -16,9 +16,13 @@ export function resolveRawGitHubUrl(hostname: string, owner: string, repo: strin
|
|||||||
: `https://${hostname}/raw/${owner}/${repo}/${ref}/${p}`;
|
: `https://${hostname}/raw/${owner}/${repo}/${ref}/${p}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function ghFetch(url: string, init?: RequestInit): Promise<Response> {
|
export async function ghFetch(url: string, init?: RequestInit, authToken?: string): Promise<Response> {
|
||||||
|
const headers = new Headers(init?.headers);
|
||||||
|
if (authToken) {
|
||||||
|
headers.set("Authorization", `Bearer ${authToken}`);
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
return await fetch(url, init);
|
return await fetch(url, { ...init, headers, redirect: authToken ? "manual" : "follow" });
|
||||||
} catch {
|
} catch {
|
||||||
throw unprocessable(`Could not connect to ${new URL(url).hostname} — ensure the URL points to a GitHub or GitHub Enterprise instance`);
|
throw unprocessable(`Could not connect to ${new URL(url).hostname} — ensure the URL points to a GitHub or GitHub Enterprise instance`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,267 @@
|
|||||||
|
---
|
||||||
|
name: paperclip-dev
|
||||||
|
required: false
|
||||||
|
description: >
|
||||||
|
Develop and operate a local Paperclip instance — start and stop servers,
|
||||||
|
pull updates from master, run builds and tests, manage worktrees, back up
|
||||||
|
databases, and diagnose problems. Use whenever you need to work on the
|
||||||
|
Paperclip codebase itself or keep a running instance healthy.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Paperclip Dev
|
||||||
|
|
||||||
|
This skill covers the day-to-day workflows for developing and operating a local Paperclip instance. It assumes you are working inside the Paperclip repo checkout with `origin` pointing to `git@github.com:paperclipai/paperclip.git`.
|
||||||
|
|
||||||
|
> **OPEN SOURCE HYGIENE:** This repository is public-facing. Treat anything you push to `origin` as publishable. Never commit or push secrets, API keys, tokens, private logs, PII, customer data, or machine-local configuration that should stay private. Keep git history tidy as well: avoid pushing throwaway branches, noisy checkpoint commits, or speculative work that does not need to be shared upstream.
|
||||||
|
|
||||||
|
> **MANDATORY:** Before running any CLI command, building, testing, or managing worktrees, you MUST read `doc/DEVELOPING.md` in the Paperclip repo. It is the canonical reference for all `paperclipai` CLI commands, their options, build/test workflows, database operations, worktree management, and diagnostics. Do NOT guess at flags or options — read the doc first.
|
||||||
|
|
||||||
|
## Quick Command Reference
|
||||||
|
|
||||||
|
These are the most common commands. For full option tables and details, see `doc/DEVELOPING.md`.
|
||||||
|
|
||||||
|
| Task | Command |
|
||||||
|
|------|---------|
|
||||||
|
| Start server (first time or normal) | `npx paperclipai run` |
|
||||||
|
| Dev mode with hot reload | `pnpm dev` |
|
||||||
|
| Stop dev server | `pnpm dev:stop` |
|
||||||
|
| Build | `pnpm build` |
|
||||||
|
| Type-check | `pnpm typecheck` |
|
||||||
|
| Run tests | `pnpm test` |
|
||||||
|
| Run migrations | `pnpm db:migrate` |
|
||||||
|
| Regenerate Drizzle client | `pnpm db:generate` |
|
||||||
|
| Back up database | `npx paperclipai db:backup` |
|
||||||
|
| Health check | `npx paperclipai doctor --repair` |
|
||||||
|
| Print env vars | `npx paperclipai env` |
|
||||||
|
| Trigger agent heartbeat | `npx paperclipai heartbeat run --agent-id <id>` |
|
||||||
|
| Install agent skills locally | `npx paperclipai agent local-cli <agent> --company-id <id>` |
|
||||||
|
|
||||||
|
## Pulling from Master
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git fetch origin && git pull origin master
|
||||||
|
pnpm install && pnpm build
|
||||||
|
```
|
||||||
|
|
||||||
|
If schema changes landed, also run `pnpm db:generate && pnpm db:migrate`.
|
||||||
|
|
||||||
|
## Worktrees
|
||||||
|
|
||||||
|
Paperclip worktrees combine git worktrees with isolated Paperclip instances — each gets its own database, server port, and environment seeded from the primary instance.
|
||||||
|
|
||||||
|
> **MANDATORY:** Before creating or managing worktrees, you MUST read the "Worktree-local Instances" and "Worktree CLI Reference" sections in `doc/DEVELOPING.md`. That is the canonical reference for all worktree commands, their options, seed modes, and environment variables.
|
||||||
|
|
||||||
|
### When to Use Worktrees
|
||||||
|
|
||||||
|
- Starting a feature branch that needs its own Paperclip environment
|
||||||
|
- Running parallel agent work without cross-contaminating the primary instance
|
||||||
|
- Testing Paperclip changes in isolation before merging
|
||||||
|
|
||||||
|
### Command Overview
|
||||||
|
|
||||||
|
The CLI has two tiers (see `doc/DEVELOPING.md` for full option tables):
|
||||||
|
|
||||||
|
| Command | Purpose |
|
||||||
|
|---------|---------|
|
||||||
|
| `worktree:make <name>` | Create worktree + isolated instance in one step |
|
||||||
|
| `worktree:list` | List worktrees and their Paperclip status |
|
||||||
|
| `worktree:merge-history` | Preview/import issue history between worktrees |
|
||||||
|
| `worktree:cleanup <name>` | Remove worktree, branch, and instance data |
|
||||||
|
| `worktree init` | Bootstrap instance inside existing worktree |
|
||||||
|
| `worktree env` | Print shell exports for worktree instance |
|
||||||
|
| `worktree reseed` | Refresh worktree DB from another instance |
|
||||||
|
| `worktree repair` | Fix broken/missing worktree instance metadata |
|
||||||
|
|
||||||
|
### Typical Workflow
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Create a worktree for a feature
|
||||||
|
npx paperclipai worktree:make my-feature --start-point origin/main
|
||||||
|
|
||||||
|
# 2. Move into the worktree (path printed by worktree:make) and source the environment
|
||||||
|
cd <worktree-path>
|
||||||
|
eval "$(npx paperclipai worktree env)"
|
||||||
|
|
||||||
|
# 3. Start the isolated Paperclip server
|
||||||
|
npx paperclipai run
|
||||||
|
|
||||||
|
# 4. Do your work
|
||||||
|
|
||||||
|
# 5. When done, merge history back if needed
|
||||||
|
npx paperclipai worktree:merge-history --from paperclip-my-feature --to current --apply
|
||||||
|
|
||||||
|
# 6. Clean up
|
||||||
|
npx paperclipai worktree:cleanup my-feature
|
||||||
|
```
|
||||||
|
|
||||||
|
## Forks — Prefer Pushing to a User Fork
|
||||||
|
|
||||||
|
If the user has a personal fork of `paperclipai/paperclip` configured as a git remote, push your feature branches to **that fork** instead of creating branches on the main repo. This keeps the upstream branch list clean and matches the standard open-source contribution flow.
|
||||||
|
|
||||||
|
### Detect a fork remote
|
||||||
|
|
||||||
|
Before pushing or creating a PR, list remotes and check for one that points at a non-`paperclipai` GitHub fork:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git remote -v
|
||||||
|
```
|
||||||
|
|
||||||
|
Treat any remote whose URL points to `github.com:<user>/paperclip` (or `github.com/<user>/paperclip.git`) as the user's fork. Common names are `fork`, `<username>`, or `myfork`. The remote named `origin` or `upstream` that points at `paperclipai/paperclip` is the canonical upstream — do not push feature branches there if a fork exists.
|
||||||
|
|
||||||
|
### Pushing to the fork
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Push the current branch to the user's fork and set upstream
|
||||||
|
git push -u <fork-remote> HEAD
|
||||||
|
```
|
||||||
|
|
||||||
|
Then create the PR from the fork branch:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
gh pr create --repo paperclipai/paperclip --head <fork-owner>:<branch-name> ...
|
||||||
|
```
|
||||||
|
|
||||||
|
`gh pr create` usually figures out the head ref automatically when run from a branch tracking the fork; the explicit `--head <owner>:<branch>` form is the reliable fallback when it does not.
|
||||||
|
|
||||||
|
### When no fork exists
|
||||||
|
|
||||||
|
If `git remote -v` shows only `paperclipai/paperclip` remotes (no user fork), fall back to pushing branches to `origin` as before. Do NOT create a fork on the user's behalf — ask first.
|
||||||
|
|
||||||
|
### Keeping the fork up to date
|
||||||
|
|
||||||
|
The canonical remote that points at `paperclipai/paperclip` may be named `origin` **or** `upstream` depending on how the user set up the repo. Detect it the same way as in the "Detect a fork remote" step, then fetch and push from/with that remote so the sync works under either convention:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
UPSTREAM_REMOTE=$(git remote -v | awk '/paperclipai\/paperclip.*\(fetch\)/{print $1; exit}')
|
||||||
|
git fetch "$UPSTREAM_REMOTE"
|
||||||
|
git push <fork-remote> "${UPSTREAM_REMOTE}/master:master"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Pull Requests
|
||||||
|
|
||||||
|
> **MANDATORY PRE-FLIGHT:** Before creating ANY pull request, you MUST read the canonical source files listed below. Do NOT run `gh pr create` until you have read these files and verified your PR body matches every required section.
|
||||||
|
|
||||||
|
### Step 1 — Read the canonical files
|
||||||
|
|
||||||
|
You MUST read all three of these files before creating a PR:
|
||||||
|
|
||||||
|
1. **`.github/PULL_REQUEST_TEMPLATE.md`** — the required PR body structure
|
||||||
|
2. **`CONTRIBUTING.md`** — contribution conventions, PR requirements, and thinking-path examples
|
||||||
|
3. **`.github/workflows/pr.yml`** — CI checks that gate merge
|
||||||
|
|
||||||
|
### Step 2 — Validate your PR body against this checklist
|
||||||
|
|
||||||
|
After reading the template, verify your `--body` includes every one of these sections (names must match exactly):
|
||||||
|
|
||||||
|
- [ ] `## Thinking Path` — blockquote style, 5-8 reasoning steps
|
||||||
|
- [ ] `## What Changed` — bullet list of concrete changes
|
||||||
|
- [ ] `## Verification` — how a reviewer confirms this works
|
||||||
|
- [ ] `## Risks` — what could go wrong
|
||||||
|
- [ ] `## Model Used` — provider, model ID, version, capabilities
|
||||||
|
- [ ] `## Checklist` — copied from the template, items checked off
|
||||||
|
|
||||||
|
If any section is missing or empty, do NOT submit the PR. Go back and fill it in.
|
||||||
|
|
||||||
|
### Step 3 — Create the PR
|
||||||
|
|
||||||
|
Only after completing Steps 1 and 2, run `gh pr create`. Use the template contents as the structure for `--body` — do not write a freeform summary.
|
||||||
|
|
||||||
|
## Hard Rules — Do NOT Bypass
|
||||||
|
|
||||||
|
These rules exist because agents have caused real damage by improvising around CLI failures. Follow them exactly.
|
||||||
|
|
||||||
|
1. **CLI is the only interface to worktrees and databases.** All worktree and database operations MUST go through `npx paperclipai` / `pnpm paperclipai` commands. You MUST NOT:
|
||||||
|
- Run `pg_dump`, `pg_restore`, `psql`, `createdb`, `dropdb`, or any raw postgres commands
|
||||||
|
- Manually set `DATABASE_URL` to point a worktree server at another instance's database
|
||||||
|
- Run `rm -rf` on any `.paperclip/`, `.paperclip-worktrees/`, or `db/` directory
|
||||||
|
- Directly manipulate embedded postgres data directories
|
||||||
|
- Kill postgres processes by PID
|
||||||
|
|
||||||
|
2. **If a CLI command fails, stop and report.** Do NOT attempt workarounds. If `worktree:make`, `worktree reseed`, `worktree init`, `worktree:cleanup`, or any other `paperclipai` command fails:
|
||||||
|
- Report the exact error message in your task comment
|
||||||
|
- Set the task to `blocked`
|
||||||
|
- Suggest running `npx paperclipai doctor --repair` or recreating the worktree from scratch
|
||||||
|
- Do NOT try to manually replicate what the CLI does
|
||||||
|
|
||||||
|
3. **Never share databases between instances.** Each worktree instance gets its own isolated database. Never override `DATABASE_URL` to point one instance at another's database. This destroys isolation and can corrupt production data.
|
||||||
|
|
||||||
|
4. **Starting a dev server in a worktree requires setup first.** The correct sequence is:
|
||||||
|
```bash
|
||||||
|
# If the worktree already exists but has no running instance:
|
||||||
|
cd <worktree-path>
|
||||||
|
eval "$(npx paperclipai worktree env)"
|
||||||
|
pnpm install && pnpm build
|
||||||
|
npx paperclipai run # or pnpm dev
|
||||||
|
|
||||||
|
# If the worktree needs a fresh database:
|
||||||
|
npx paperclipai worktree reseed --seed-mode full
|
||||||
|
|
||||||
|
# If the worktree is broken beyond repair:
|
||||||
|
npx paperclipai worktree:cleanup <name>
|
||||||
|
npx paperclipai worktree:make <name> --seed-mode full
|
||||||
|
```
|
||||||
|
If any step fails, follow rule 2 — stop and report.
|
||||||
|
|
||||||
|
5. **Seeding is a CLI operation.** When asked to seed a worktree database from the main instance, use `worktree reseed` or recreate with `worktree:make --seed-mode full`. Read `doc/DEVELOPING.md` for the full option tables. Never attempt manual database copying.
|
||||||
|
|
||||||
|
## Persistent Dev Servers (for Manual Testing)
|
||||||
|
|
||||||
|
When an agent needs to start a dev server that outlives the current heartbeat — for example, so a human or QA agent can manually test against it — the server process **must** be launched in a detached session. A process started directly from a heartbeat shell is killed when the heartbeat exits.
|
||||||
|
|
||||||
|
### Use `tmux` for persistent servers
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. cd into the worktree (or main repo) and source the environment
|
||||||
|
cd <worktree-path>
|
||||||
|
eval "$(npx paperclipai worktree env)" # skip if using the primary instance
|
||||||
|
|
||||||
|
# 2. Start the dev server in a named, detached tmux session
|
||||||
|
tmux new-session -d -s <session-name> 'pnpm dev'
|
||||||
|
|
||||||
|
# Example with a descriptive name:
|
||||||
|
tmux new-session -d -s auth-fix-3102 'pnpm dev'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Managing the session
|
||||||
|
|
||||||
|
| Task | Command |
|
||||||
|
|------|---------|
|
||||||
|
| Check if the session is alive | `tmux has-session -t <session-name> 2>/dev/null && echo running` |
|
||||||
|
| View server output | `tmux capture-pane -t <session-name> -p` |
|
||||||
|
| Kill the session | `tmux kill-session -t <session-name>` |
|
||||||
|
| List all tmux sessions | `tmux list-sessions` |
|
||||||
|
|
||||||
|
### Verifying the server is reachable
|
||||||
|
|
||||||
|
After launching, confirm the port is listening before reporting success:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Wait briefly for startup, then verify
|
||||||
|
sleep 3
|
||||||
|
curl -sf http://127.0.0.1:<port>/api/health && echo "Server is up"
|
||||||
|
lsof -nP -iTCP:<port> -sTCP:LISTEN
|
||||||
|
```
|
||||||
|
|
||||||
|
### Key rules
|
||||||
|
|
||||||
|
1. **Always use `tmux` (or equivalent)** when a dev server needs to stay running after the heartbeat ends. A server started directly from the agent shell will die when the heartbeat exits, even if it appeared healthy moments before.
|
||||||
|
2. **Name the session descriptively** — include the worktree name and port (e.g., `auth-fix-3102`).
|
||||||
|
3. **Verify the server is listening** before reporting the URL to anyone.
|
||||||
|
4. **Do not use `nohup` or `&` alone** — these are unreliable for agent shells that may have their entire process group killed.
|
||||||
|
5. **Clean up when done** — kill the tmux session when the testing is complete.
|
||||||
|
|
||||||
|
## Common Mistakes
|
||||||
|
|
||||||
|
| Mistake | Fix |
|
||||||
|
|---------|-----|
|
||||||
|
| Server won't start | Run `npx paperclipai doctor --repair` to diagnose and auto-fix |
|
||||||
|
| Forgetting to source worktree env | Run `eval "$(npx paperclipai worktree env)"` after cd-ing into the worktree |
|
||||||
|
| Stale dependencies after pull | Run `pnpm install && pnpm build` after pulling |
|
||||||
|
| Schema out of date after pull | Run `pnpm db:generate && pnpm db:migrate` |
|
||||||
|
| Reseeding while target DB is running | Stop the target server first, or use `--allow-live-target` |
|
||||||
|
| Cleaning up with unmerged commits | Merge or push first, or use `--force` if intentionally discarding |
|
||||||
|
| Running agents against wrong instance | Verify `PAPERCLIP_API_URL` points to the correct port |
|
||||||
|
| CLI command fails | Do NOT work around it — report the error and block (see Hard Rules above) |
|
||||||
|
| Agent tries manual postgres operations | NEVER do this — all DB ops go through the CLI (see Hard Rules above) |
|
||||||
|
| Dev server dies between heartbeats | Launch in a detached `tmux` session — see "Persistent Dev Servers" above |
|
||||||
|
| Pushed feature branch to `paperclipai/paperclip` when a fork exists | Push to the user's fork remote instead — see "Forks" above |
|
||||||
@@ -36,10 +36,15 @@ export const companySkillsApi = {
|
|||||||
`/companies/${encodeURIComponent(companyId)}/skills`,
|
`/companies/${encodeURIComponent(companyId)}/skills`,
|
||||||
payload,
|
payload,
|
||||||
),
|
),
|
||||||
importFromSource: (companyId: string, source: string) =>
|
importFromSource: (companyId: string, source: string, authToken?: string) =>
|
||||||
api.post<CompanySkillImportResult>(
|
api.post<CompanySkillImportResult>(
|
||||||
`/companies/${encodeURIComponent(companyId)}/skills/import`,
|
`/companies/${encodeURIComponent(companyId)}/skills/import`,
|
||||||
{ source },
|
{ source, ...(authToken ? { authToken } : {}) },
|
||||||
|
),
|
||||||
|
updateAuth: (companyId: string, skillId: string, authToken: string | null) =>
|
||||||
|
api.patch<CompanySkill>(
|
||||||
|
`/companies/${encodeURIComponent(companyId)}/skills/${encodeURIComponent(skillId)}/auth`,
|
||||||
|
{ authToken },
|
||||||
),
|
),
|
||||||
scanProjects: (companyId: string, payload: CompanySkillProjectScanRequest = {}) =>
|
scanProjects: (companyId: string, payload: CompanySkillProjectScanRequest = {}) =>
|
||||||
api.post<CompanySkillProjectScanResult>(
|
api.post<CompanySkillProjectScanResult>(
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ import {
|
|||||||
RefreshCw,
|
RefreshCw,
|
||||||
Save,
|
Save,
|
||||||
Search,
|
Search,
|
||||||
|
ShieldCheck,
|
||||||
Trash2,
|
Trash2,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
@@ -487,6 +488,103 @@ function SkillList({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function SkillAuthSection({
|
||||||
|
companyId,
|
||||||
|
skillId,
|
||||||
|
hasAuth,
|
||||||
|
}: {
|
||||||
|
companyId: string;
|
||||||
|
skillId: string;
|
||||||
|
hasAuth: boolean;
|
||||||
|
}) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const { pushToast } = useToastActions();
|
||||||
|
const [editing, setEditing] = useState(false);
|
||||||
|
const [token, setToken] = useState("");
|
||||||
|
|
||||||
|
const updateAuth = useMutation({
|
||||||
|
mutationFn: (authToken: string | null) =>
|
||||||
|
companySkillsApi.updateAuth(companyId, skillId, authToken),
|
||||||
|
onSuccess: async () => {
|
||||||
|
await queryClient.invalidateQueries({ queryKey: queryKeys.companySkills.detail(companyId, skillId) });
|
||||||
|
setEditing(false);
|
||||||
|
setToken("");
|
||||||
|
pushToast({ tone: "success", title: "Auth updated" });
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
pushToast({
|
||||||
|
tone: "error",
|
||||||
|
title: "Failed to update auth",
|
||||||
|
body: error instanceof Error ? error.message : "Unknown error",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<span className="text-[11px] uppercase tracking-[0.18em] text-muted-foreground">Auth</span>
|
||||||
|
{!editing ? (
|
||||||
|
<>
|
||||||
|
{hasAuth ? (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setEditing(true)}
|
||||||
|
>
|
||||||
|
<ShieldCheck className="mr-1.5 h-3.5 w-3.5" />
|
||||||
|
PAT configured
|
||||||
|
</Button>
|
||||||
|
<button
|
||||||
|
className="inline-flex items-center text-muted-foreground/50 hover:text-destructive transition-colors"
|
||||||
|
onClick={() => updateAuth.mutate(null)}
|
||||||
|
disabled={updateAuth.isPending}
|
||||||
|
title="Remove PAT"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setEditing(true)}
|
||||||
|
>
|
||||||
|
Add PAT
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={token}
|
||||||
|
onChange={(e) => setToken(e.target.value)}
|
||||||
|
placeholder="GitHub Personal Access Token"
|
||||||
|
className="flex-1 min-w-[200px] rounded-md border border-border px-2 py-1 bg-transparent text-xs outline-none placeholder:text-muted-foreground/50"
|
||||||
|
autoComplete="off"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={() => updateAuth.mutate(token.trim())}
|
||||||
|
disabled={!token.trim() || updateAuth.isPending}
|
||||||
|
>
|
||||||
|
{updateAuth.isPending ? "Saving..." : "Save"}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => { setEditing(false); setToken(""); }}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function SkillPane({
|
function SkillPane({
|
||||||
loading,
|
loading,
|
||||||
detail,
|
detail,
|
||||||
@@ -614,6 +712,13 @@ function SkillPane({
|
|||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
{(detail.sourceType === "github" || detail.sourceType === "skills_sh") && (
|
||||||
|
<SkillAuthSection
|
||||||
|
companyId={detail.companyId}
|
||||||
|
skillId={detail.id}
|
||||||
|
hasAuth={Boolean((detail.metadata as Record<string, unknown> | null)?.sourceAuthSecretId)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{detail.sourceType === "github" && (
|
{detail.sourceType === "github" && (
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<span className="text-[11px] uppercase tracking-[0.18em] text-muted-foreground">Pin</span>
|
<span className="text-[11px] uppercase tracking-[0.18em] text-muted-foreground">Pin</span>
|
||||||
@@ -762,6 +867,7 @@ export function CompanySkills() {
|
|||||||
const { pushToast } = useToastActions();
|
const { pushToast } = useToastActions();
|
||||||
const [skillFilter, setSkillFilter] = useState("");
|
const [skillFilter, setSkillFilter] = useState("");
|
||||||
const [source, setSource] = useState("");
|
const [source, setSource] = useState("");
|
||||||
|
const [importAuthToken, setImportAuthToken] = useState("");
|
||||||
const [createOpen, setCreateOpen] = useState(false);
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
const [emptySourceHelpOpen, setEmptySourceHelpOpen] = useState(false);
|
const [emptySourceHelpOpen, setEmptySourceHelpOpen] = useState(false);
|
||||||
const [expandedSkillId, setExpandedSkillId] = useState<string | null>(null);
|
const [expandedSkillId, setExpandedSkillId] = useState<string | null>(null);
|
||||||
@@ -887,7 +993,8 @@ export function CompanySkills() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const importSkill = useMutation({
|
const importSkill = useMutation({
|
||||||
mutationFn: (importSource: string) => companySkillsApi.importFromSource(selectedCompanyId!, importSource),
|
mutationFn: ({ importSource, authToken }: { importSource: string; authToken?: string }) =>
|
||||||
|
companySkillsApi.importFromSource(selectedCompanyId!, importSource, authToken),
|
||||||
onSuccess: async (result) => {
|
onSuccess: async (result) => {
|
||||||
await queryClient.invalidateQueries({ queryKey: queryKeys.companySkills.list(selectedCompanyId!) });
|
await queryClient.invalidateQueries({ queryKey: queryKeys.companySkills.list(selectedCompanyId!) });
|
||||||
if (result.imported[0]) navigate(skillRoute(result.imported[0].id));
|
if (result.imported[0]) navigate(skillRoute(result.imported[0].id));
|
||||||
@@ -900,6 +1007,7 @@ export function CompanySkills() {
|
|||||||
pushToast({ tone: "warn", title: "Import warnings", body: result.warnings[0] });
|
pushToast({ tone: "warn", title: "Import warnings", body: result.warnings[0] });
|
||||||
}
|
}
|
||||||
setSource("");
|
setSource("");
|
||||||
|
setImportAuthToken("");
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
pushToast({
|
pushToast({
|
||||||
@@ -1073,7 +1181,8 @@ export function CompanySkills() {
|
|||||||
setEmptySourceHelpOpen(true);
|
setEmptySourceHelpOpen(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
importSkill.mutate(trimmedSource);
|
const token = importAuthToken.trim() || undefined;
|
||||||
|
importSkill.mutate({ importSource: trimmedSource, authToken: token });
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -1220,6 +1329,18 @@ export function CompanySkills() {
|
|||||||
{importSkill.isPending ? <RefreshCw className="h-4 w-4 animate-spin" /> : "Add"}
|
{importSkill.isPending ? <RefreshCw className="h-4 w-4 animate-spin" /> : "Add"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
{source.trim().length > 0 && /github\.com|^[a-zA-Z0-9_-]+\/[a-zA-Z0-9_.-]+/.test(source.trim()) && (
|
||||||
|
<div className="mt-1 flex items-center gap-2 border-b border-border pb-2">
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={importAuthToken}
|
||||||
|
onChange={(event) => setImportAuthToken(event.target.value)}
|
||||||
|
placeholder="GitHub PAT (optional, for private repos)"
|
||||||
|
className="w-full bg-transparent text-sm outline-none placeholder:text-muted-foreground"
|
||||||
|
autoComplete="off"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{scanStatusMessage && (
|
{scanStatusMessage && (
|
||||||
<p className="mt-3 text-xs text-muted-foreground">
|
<p className="mt-3 text-xs text-muted-foreground">
|
||||||
{scanStatusMessage}
|
{scanStatusMessage}
|
||||||
|
|||||||
Reference in New Issue
Block a user