Refine portability export behavior and skill plans

This commit is contained in:
Dotta
2026-03-14 18:59:26 -05:00
parent 7e43020a28
commit b2c0f3f9a5
13 changed files with 1126 additions and 12 deletions

View File

@@ -1,6 +1,7 @@
import { Command } from "commander"; import { Command } from "commander";
import { mkdir, readdir, readFile, stat, writeFile } from "node:fs/promises"; import { mkdir, readdir, readFile, stat, writeFile } from "node:fs/promises";
import path from "node:path"; import path from "node:path";
import * as p from "@clack/prompts";
import type { import type {
Company, Company,
CompanyPortabilityExportResult, CompanyPortabilityExportResult,
@@ -35,6 +36,7 @@ interface CompanyExportOptions extends BaseClientOptions {
projects?: string; projects?: string;
issues?: string; issues?: string;
projectIssues?: string; projectIssues?: string;
expandReferencedSkills?: boolean;
} }
interface CompanyImportOptions extends BaseClientOptions { interface CompanyImportOptions extends BaseClientOptions {
@@ -137,6 +139,31 @@ async function writeExportToFolder(outDir: string, exported: CompanyPortabilityE
} }
} }
async function confirmOverwriteExportDirectory(outDir: string): Promise<void> {
const root = path.resolve(outDir);
const stats = await stat(root).catch(() => null);
if (!stats) return;
if (!stats.isDirectory()) {
throw new Error(`Export output path ${root} exists and is not a directory.`);
}
const entries = await readdir(root);
if (entries.length === 0) return;
if (!process.stdin.isTTY || !process.stdout.isTTY) {
throw new Error(`Export output directory ${root} already contains files. Re-run interactively or choose an empty directory.`);
}
const confirmed = await p.confirm({
message: `Overwrite existing files in ${root}?`,
initialValue: false,
});
if (p.isCancel(confirmed) || !confirmed) {
throw new Error("Export cancelled.");
}
}
function matchesPrefix(company: Company, selector: string): boolean { function matchesPrefix(company: Company, selector: string): boolean {
return company.issuePrefix.toUpperCase() === selector.toUpperCase(); return company.issuePrefix.toUpperCase() === selector.toUpperCase();
} }
@@ -278,6 +305,7 @@ export function registerCompanyCommands(program: Command): void {
.option("--projects <values>", "Comma-separated project shortnames/ids to export") .option("--projects <values>", "Comma-separated project shortnames/ids to export")
.option("--issues <values>", "Comma-separated issue identifiers/ids to export") .option("--issues <values>", "Comma-separated issue identifiers/ids to export")
.option("--project-issues <values>", "Comma-separated project shortnames/ids whose issues should be exported") .option("--project-issues <values>", "Comma-separated project shortnames/ids whose issues should be exported")
.option("--expand-referenced-skills", "Vendor skill contents instead of exporting upstream references", false)
.action(async (companyId: string, opts: CompanyExportOptions) => { .action(async (companyId: string, opts: CompanyExportOptions) => {
try { try {
const ctx = resolveCommandContext(opts); const ctx = resolveCommandContext(opts);
@@ -289,11 +317,13 @@ export function registerCompanyCommands(program: Command): void {
projects: parseCsvValues(opts.projects), projects: parseCsvValues(opts.projects),
issues: parseCsvValues(opts.issues), issues: parseCsvValues(opts.issues),
projectIssues: parseCsvValues(opts.projectIssues), projectIssues: parseCsvValues(opts.projectIssues),
expandReferencedSkills: Boolean(opts.expandReferencedSkills),
}, },
); );
if (!exported) { if (!exported) {
throw new Error("Export request returned no data"); throw new Error("Export request returned no data");
} }
await confirmOverwriteExportDirectory(opts.out!);
await writeExportToFolder(opts.out!, exported); await writeExportToFolder(opts.out!, exported);
printOutput( printOutput(
{ {

View File

@@ -24,6 +24,10 @@ The normative package format draft lives in:
This plan is about implementation and rollout inside Paperclip. This plan is about implementation and rollout inside Paperclip.
Adapter-wide skill rollout details live in:
- `doc/plans/2026-03-14-adapter-skill-sync-rollout.md`
## 2. Executive Summary ## 2. Executive Summary
Paperclip already has portability primitives in the repo: Paperclip already has portability primitives in the repo:

View File

@@ -0,0 +1,399 @@
# 2026-03-14 Adapter Skill Sync Rollout
Status: Proposed
Date: 2026-03-14
Audience: Product and engineering
Related:
- `doc/plans/2026-03-14-skills-ui-product-plan.md`
- `doc/plans/2026-03-13-company-import-export-v2.md`
- `docs/companies/companies-spec.md`
## 1. Purpose
This document defines the rollout plan for adapter-wide skill support in Paperclip.
The goal is not just “show a skills tab.” The goal is:
- every adapter has a deliberate skill-sync truth model
- the UI tells the truth for that adapter
- Paperclip stores desired skill state consistently even when the adapter cannot fully reconcile it
- unsupported adapters degrade clearly and safely
## 2. Current Adapter Matrix
Paperclip currently has these adapters:
- `claude_local`
- `codex_local`
- `cursor_local`
- `gemini_local`
- `opencode_local`
- `pi_local`
- `openclaw_gateway`
The current skill API supports:
- `unsupported`
- `persistent`
- `ephemeral`
Current implementation state:
- `codex_local`: implemented, `persistent`
- `claude_local`: implemented, `ephemeral`
- `cursor_local`: not yet implemented, but technically suited to `persistent`
- `gemini_local`: not yet implemented, but technically suited to `persistent`
- `pi_local`: not yet implemented, but technically suited to `persistent`
- `opencode_local`: not yet implemented; likely `persistent`, but with special handling because it currently injects into Claudes shared skills home
- `openclaw_gateway`: not yet implemented; blocked on gateway protocol support, so `unsupported` for now
## 3. Product Principles
1. Desired skills live in Paperclip for every adapter.
2. Adapters may expose different truth models, and the UI must reflect that honestly.
3. Persistent adapters should read and reconcile actual installed state.
4. Ephemeral adapters should report effective runtime state, not pretend they own a persistent install.
5. Shared-home adapters need stronger safeguards than isolated-home adapters.
6. Gateway or cloud adapters must not fake local filesystem sync.
## 4. Adapter Classification
### 4.1 Persistent local-home adapters
These adapters have a stable local skills directory that Paperclip can read and manage.
Candidates:
- `codex_local`
- `cursor_local`
- `gemini_local`
- `pi_local`
- `opencode_local` with caveats
Expected UX:
- show actual installed skills
- show managed vs external skills
- support `sync`
- support stale removal
- preserve unknown external skills
### 4.2 Ephemeral mount adapters
These adapters do not have a meaningful Paperclip-owned persistent install state.
Current adapter:
- `claude_local`
Expected UX:
- show desired Paperclip skills
- show any discoverable external dirs if available
- say “mounted on next run” instead of “installed”
- do not imply a persistent adapter-owned install state
### 4.3 Unsupported / remote adapters
These adapters cannot support skill sync without new external capabilities.
Current adapter:
- `openclaw_gateway`
Expected UX:
- company skill library still works
- agent attachment UI still works at the desired-state level
- actual adapter state is `unsupported`
- sync button is disabled or replaced with explanatory text
## 5. Per-Adapter Plan
### 5.1 Codex Local
Target mode:
- `persistent`
Current state:
- already implemented
Requirements to finish:
- keep as reference implementation
- tighten tests around external custom skills and stale removal
- ensure imported company skills can be attached and synced without manual path work
Success criteria:
- list installed managed and external skills
- sync desired skills into `CODEX_HOME/skills`
- preserve external user-managed skills
### 5.2 Claude Local
Target mode:
- `ephemeral`
Current state:
- already implemented
Requirements to finish:
- polish status language in UI
- clearly distinguish “desired” from “mounted on next run”
- optionally surface configured external skill dirs if Claude exposes them
Success criteria:
- desired skills stored in Paperclip
- selected skills mounted per run
- no misleading “installed” language
### 5.3 Cursor Local
Target mode:
- `persistent`
Technical basis:
- runtime already injects Paperclip skills into `~/.cursor/skills`
Implementation work:
1. Add `listSkills` for Cursor.
2. Add `syncSkills` for Cursor.
3. Reuse the same managed-symlink pattern as Codex.
4. Distinguish:
- managed Paperclip skills
- external skills already present
- missing desired skills
- stale managed skills
Testing:
- unit tests for discovery
- unit tests for sync and stale removal
- verify shared auth/session setup is not disturbed
Success criteria:
- Cursor agents show real installed state
- syncing from the agent Skills tab works
### 5.4 Gemini Local
Target mode:
- `persistent`
Technical basis:
- runtime already injects Paperclip skills into `~/.gemini/skills`
Implementation work:
1. Add `listSkills` for Gemini.
2. Add `syncSkills` for Gemini.
3. Reuse managed-symlink conventions from Codex/Cursor.
4. Verify auth remains untouched while skills are reconciled.
Potential caveat:
- if Gemini treats that skills directory as shared user state, the UI should warn before removing stale managed skills
Success criteria:
- Gemini agents can reconcile desired vs actual skill state
### 5.5 Pi Local
Target mode:
- `persistent`
Technical basis:
- runtime already injects Paperclip skills into `~/.pi/agent/skills`
Implementation work:
1. Add `listSkills` for Pi.
2. Add `syncSkills` for Pi.
3. Reuse managed-symlink helpers.
4. Verify session-file behavior remains independent from skill sync.
Success criteria:
- Pi agents expose actual installed skill state
- Paperclip can sync desired skills into Pis persistent home
### 5.6 OpenCode Local
Target mode:
- `persistent`
Special case:
- OpenCode currently injects Paperclip skills into `~/.claude/skills`
This is product-risky because:
- it shares state with Claude
- Paperclip may accidentally imply the skills belong only to OpenCode when the home is shared
Plan:
Phase 1:
- implement `listSkills` and `syncSkills`
- treat it as `persistent`
- explicitly label the home as shared in UI copy
- only remove stale managed Paperclip skills that are clearly marked as Paperclip-managed
Phase 2:
- investigate whether OpenCode supports its own isolated skills home
- if yes, migrate to an adapter-specific home and remove the shared-home caveat
Success criteria:
- OpenCode agents show real state
- shared-home risk is visible and bounded
### 5.7 OpenClaw Gateway
Target mode:
- `unsupported` until gateway protocol support exists
Required external work:
- gateway API to list installed/available skills
- gateway API to install/remove or otherwise reconcile skills
- gateway metadata for whether state is persistent or ephemeral
Until then:
- Paperclip stores desired skills only
- UI shows unsupported actual state
- no fake sync implementation
Future target:
- likely a fourth truth model eventually, such as remote-managed persistent state
- for now, keep the current API and treat gateway as unsupported
## 6. API Plan
## 6.1 Keep the current minimal adapter API
Near-term adapter contract remains:
- `listSkills(ctx)`
- `syncSkills(ctx, desiredSkills)`
This is enough for all local adapters.
## 6.2 Optional extension points
Add only if needed after the first broad rollout:
- `skillHomeLabel`
- `sharedHome: boolean`
- `supportsExternalDiscovery: boolean`
- `supportsDestructiveSync: boolean`
These should be optional metadata additions to the snapshot, not required new adapter methods.
## 7. UI Plan
The company-level skill library can stay adapter-neutral.
The agent-level Skills tab must become adapter-aware by copy and status:
- `persistent`: installed / missing / stale / external
- `ephemeral`: mounted on next run / external / desired only
- `unsupported`: desired only, adapter cannot report actual state
Additional UI requirement for shared-home adapters:
- show a small warning that the adapter uses a shared user skills home
- avoid destructive wording unless Paperclip can prove a skill is Paperclip-managed
## 8. Rollout Phases
### Phase 1: Finish the local filesystem family
Ship:
- `cursor_local`
- `gemini_local`
- `pi_local`
Rationale:
- these are the closest to Codex in architecture
- they already inject into stable local skill homes
### Phase 2: OpenCode shared-home support
Ship:
- `opencode_local`
Rationale:
- technically feasible now
- needs slightly more careful product language because of the shared Claude skills home
### Phase 3: Gateway support decision
Decide:
- keep `openclaw_gateway` unsupported for V1
- or extend the gateway protocol for remote skill management
My recommendation:
- do not block V1 on gateway support
- keep it explicitly unsupported until the remote protocol exists
## 9. Definition Of Done
Adapter-wide skill support is ready when all are true:
1. Every adapter has an explicit truth model:
- `persistent`
- `ephemeral`
- `unsupported`
2. The UI copy matches that truth model.
3. All local persistent adapters implement:
- `listSkills`
- `syncSkills`
4. Tests cover:
- desired-state storage
- actual-state discovery
- managed vs external distinctions
- stale managed-skill cleanup where supported
5. `openclaw_gateway` is either:
- explicitly unsupported with clean UX
- or backed by a real remote skill API
## 10. Recommendation
The recommended immediate order is:
1. `cursor_local`
2. `gemini_local`
3. `pi_local`
4. `opencode_local`
5. defer `openclaw_gateway`
That gets Paperclip from “skills work for Codex and Claude” to “skills work for the whole local-adapter family,” which is the meaningful V1 milestone.

View File

@@ -5,6 +5,7 @@ Date: 2026-03-14
Audience: Product and engineering Audience: Product and engineering
Related: Related:
- `doc/plans/2026-03-13-company-import-export-v2.md` - `doc/plans/2026-03-13-company-import-export-v2.md`
- `doc/plans/2026-03-14-adapter-skill-sync-rollout.md`
- `docs/companies/companies-spec.md` - `docs/companies/companies-spec.md`
- `ui/src/pages/AgentDetail.tsx` - `ui/src/pages/AgentDetail.tsx`

View File

@@ -133,6 +133,7 @@ export type {
CompanyPortabilityEnvInput, CompanyPortabilityEnvInput,
CompanyPortabilityCompanyManifestEntry, CompanyPortabilityCompanyManifestEntry,
CompanyPortabilityAgentManifestEntry, CompanyPortabilityAgentManifestEntry,
CompanyPortabilitySkillManifestEntry,
CompanyPortabilityProjectManifestEntry, CompanyPortabilityProjectManifestEntry,
CompanyPortabilityIssueManifestEntry, CompanyPortabilityIssueManifestEntry,
CompanyPortabilityManifest, CompanyPortabilityManifest,

View File

@@ -59,6 +59,7 @@ export interface CompanyPortabilityAgentManifestEntry {
slug: string; slug: string;
name: string; name: string;
path: string; path: string;
skills: string[];
role: string; role: string;
title: string | null; title: string | null;
icon: string | null; icon: string | null;
@@ -72,6 +73,23 @@ export interface CompanyPortabilityAgentManifestEntry {
metadata: Record<string, unknown> | null; metadata: Record<string, unknown> | null;
} }
export interface CompanyPortabilitySkillManifestEntry {
slug: string;
name: string;
path: string;
description: string | null;
sourceType: string;
sourceLocator: string | null;
sourceRef: string | null;
trustLevel: string | null;
compatibility: string | null;
metadata: Record<string, unknown> | null;
fileInventory: Array<{
path: string;
kind: string;
}>;
}
export interface CompanyPortabilityManifest { export interface CompanyPortabilityManifest {
schemaVersion: number; schemaVersion: number;
generatedAt: string; generatedAt: string;
@@ -82,6 +100,7 @@ export interface CompanyPortabilityManifest {
includes: CompanyPortabilityInclude; includes: CompanyPortabilityInclude;
company: CompanyPortabilityCompanyManifestEntry | null; company: CompanyPortabilityCompanyManifestEntry | null;
agents: CompanyPortabilityAgentManifestEntry[]; agents: CompanyPortabilityAgentManifestEntry[];
skills: CompanyPortabilitySkillManifestEntry[];
projects: CompanyPortabilityProjectManifestEntry[]; projects: CompanyPortabilityProjectManifestEntry[];
issues: CompanyPortabilityIssueManifestEntry[]; issues: CompanyPortabilityIssueManifestEntry[];
envInputs: CompanyPortabilityEnvInput[]; envInputs: CompanyPortabilityEnvInput[];
@@ -196,4 +215,5 @@ export interface CompanyPortabilityExportRequest {
projects?: string[]; projects?: string[];
issues?: string[]; issues?: string[];
projectIssues?: string[]; projectIssues?: string[];
expandReferencedSkills?: boolean;
} }

View File

@@ -90,6 +90,7 @@ export type {
CompanyPortabilityEnvInput, CompanyPortabilityEnvInput,
CompanyPortabilityCompanyManifestEntry, CompanyPortabilityCompanyManifestEntry,
CompanyPortabilityAgentManifestEntry, CompanyPortabilityAgentManifestEntry,
CompanyPortabilitySkillManifestEntry,
CompanyPortabilityProjectManifestEntry, CompanyPortabilityProjectManifestEntry,
CompanyPortabilityIssueManifestEntry, CompanyPortabilityIssueManifestEntry,
CompanyPortabilityManifest, CompanyPortabilityManifest,

View File

@@ -31,6 +31,7 @@ export const portabilityAgentManifestEntrySchema = z.object({
slug: z.string().min(1), slug: z.string().min(1),
name: z.string().min(1), name: z.string().min(1),
path: z.string().min(1), path: z.string().min(1),
skills: z.array(z.string().min(1)).default([]),
role: z.string().min(1), role: z.string().min(1),
title: z.string().nullable(), title: z.string().nullable(),
icon: z.string().nullable(), icon: z.string().nullable(),
@@ -44,6 +45,23 @@ export const portabilityAgentManifestEntrySchema = z.object({
metadata: z.record(z.unknown()).nullable(), metadata: z.record(z.unknown()).nullable(),
}); });
export const portabilitySkillManifestEntrySchema = z.object({
slug: z.string().min(1),
name: z.string().min(1),
path: z.string().min(1),
description: z.string().nullable(),
sourceType: z.string().min(1),
sourceLocator: z.string().nullable(),
sourceRef: z.string().nullable(),
trustLevel: z.string().nullable(),
compatibility: z.string().nullable(),
metadata: z.record(z.unknown()).nullable(),
fileInventory: z.array(z.object({
path: z.string().min(1),
kind: z.string().min(1),
})).default([]),
});
export const portabilityProjectManifestEntrySchema = z.object({ export const portabilityProjectManifestEntrySchema = z.object({
slug: z.string().min(1), slug: z.string().min(1),
name: z.string().min(1), name: z.string().min(1),
@@ -93,6 +111,7 @@ export const portabilityManifestSchema = z.object({
}), }),
company: portabilityCompanyManifestEntrySchema.nullable(), company: portabilityCompanyManifestEntrySchema.nullable(),
agents: z.array(portabilityAgentManifestEntrySchema), agents: z.array(portabilityAgentManifestEntrySchema),
skills: z.array(portabilitySkillManifestEntrySchema).default([]),
projects: z.array(portabilityProjectManifestEntrySchema).default([]), projects: z.array(portabilityProjectManifestEntrySchema).default([]),
issues: z.array(portabilityIssueManifestEntrySchema).default([]), issues: z.array(portabilityIssueManifestEntrySchema).default([]),
envInputs: z.array(portabilityEnvInputSchema).default([]), envInputs: z.array(portabilityEnvInputSchema).default([]),
@@ -137,6 +156,7 @@ export const companyPortabilityExportSchema = z.object({
projects: z.array(z.string().min(1)).optional(), projects: z.array(z.string().min(1)).optional(),
issues: z.array(z.string().min(1)).optional(), issues: z.array(z.string().min(1)).optional(),
projectIssues: z.array(z.string().min(1)).optional(), projectIssues: z.array(z.string().min(1)).optional(),
expandReferencedSkills: z.boolean().optional(),
}); });
export type CompanyPortabilityExport = z.infer<typeof companyPortabilityExportSchema>; export type CompanyPortabilityExport = z.infer<typeof companyPortabilityExportSchema>;

View File

@@ -36,6 +36,7 @@ export {
portabilityEnvInputSchema, portabilityEnvInputSchema,
portabilityCompanyManifestEntrySchema, portabilityCompanyManifestEntrySchema,
portabilityAgentManifestEntrySchema, portabilityAgentManifestEntrySchema,
portabilitySkillManifestEntrySchema,
portabilityManifestSchema, portabilityManifestSchema,
portabilitySourceSchema, portabilitySourceSchema,
portabilityTargetSchema, portabilityTargetSchema,

View File

@@ -29,6 +29,12 @@ const issueSvc = {
create: vi.fn(), create: vi.fn(),
}; };
const companySkillSvc = {
list: vi.fn(),
readFile: vi.fn(),
importPackageFiles: vi.fn(),
};
vi.mock("../services/companies.js", () => ({ vi.mock("../services/companies.js", () => ({
companyService: () => companySvc, companyService: () => companySvc,
})); }));
@@ -49,6 +55,10 @@ vi.mock("../services/issues.js", () => ({
issueService: () => issueSvc, issueService: () => issueSvc,
})); }));
vi.mock("../services/company-skills.js", () => ({
companySkillService: () => companySkillSvc,
}));
const { companyPortabilityService } = await import("../services/company-portability.js"); const { companyPortabilityService } = await import("../services/company-portability.js");
describe("company portability", () => { describe("company portability", () => {
@@ -74,6 +84,9 @@ describe("company portability", () => {
adapterType: "claude_local", adapterType: "claude_local",
adapterConfig: { adapterConfig: {
promptTemplate: "You are ClaudeCoder.", promptTemplate: "You are ClaudeCoder.",
paperclipSkillSync: {
desiredSkills: ["paperclip"],
},
instructionsFilePath: "/tmp/ignored.md", instructionsFilePath: "/tmp/ignored.md",
cwd: "/tmp/ignored", cwd: "/tmp/ignored",
command: "/Users/dotta/.local/bin/claude", command: "/Users/dotta/.local/bin/claude",
@@ -106,14 +119,113 @@ describe("company portability", () => {
}, },
metadata: null, metadata: null,
}, },
{
id: "agent-2",
name: "CMO",
status: "idle",
role: "cmo",
title: "Chief Marketing Officer",
icon: "globe",
reportsTo: null,
capabilities: "Owns marketing",
adapterType: "claude_local",
adapterConfig: {
promptTemplate: "You are CMO.",
},
runtimeConfig: {
heartbeat: {
intervalSec: 3600,
},
},
budgetMonthlyCents: 0,
permissions: {
canCreateAgents: false,
},
metadata: null,
},
]); ]);
projectSvc.list.mockResolvedValue([]); projectSvc.list.mockResolvedValue([]);
issueSvc.list.mockResolvedValue([]); issueSvc.list.mockResolvedValue([]);
issueSvc.getById.mockResolvedValue(null); issueSvc.getById.mockResolvedValue(null);
issueSvc.getByIdentifier.mockResolvedValue(null); issueSvc.getByIdentifier.mockResolvedValue(null);
companySkillSvc.list.mockResolvedValue([
{
id: "skill-1",
companyId: "company-1",
slug: "paperclip",
name: "paperclip",
description: "Paperclip coordination skill",
markdown: "---\nname: paperclip\ndescription: Paperclip coordination skill\n---\n\n# Paperclip\n",
sourceType: "github",
sourceLocator: "https://github.com/paperclipai/paperclip/tree/master/skills/paperclip",
sourceRef: "0123456789abcdef0123456789abcdef01234567",
trustLevel: "markdown_only",
compatibility: "compatible",
fileInventory: [
{ path: "SKILL.md", kind: "skill" },
{ path: "references/api.md", kind: "reference" },
],
metadata: {
sourceKind: "github",
owner: "paperclipai",
repo: "paperclip",
ref: "0123456789abcdef0123456789abcdef01234567",
trackingRef: "master",
repoSkillDir: "skills/paperclip",
},
},
{
id: "skill-2",
companyId: "company-1",
slug: "company-playbook",
name: "company-playbook",
description: "Internal company skill",
markdown: "---\nname: company-playbook\ndescription: Internal company skill\n---\n\n# Company Playbook\n",
sourceType: "local_path",
sourceLocator: "/tmp/company-playbook",
sourceRef: null,
trustLevel: "markdown_only",
compatibility: "compatible",
fileInventory: [
{ path: "SKILL.md", kind: "skill" },
{ path: "references/checklist.md", kind: "reference" },
],
metadata: {
sourceKind: "local_path",
},
},
]);
companySkillSvc.readFile.mockImplementation(async (_companyId: string, skillId: string, relativePath: string) => {
if (skillId === "skill-2") {
return {
skillId,
path: relativePath,
kind: relativePath === "SKILL.md" ? "skill" : "reference",
content: relativePath === "SKILL.md"
? "---\nname: company-playbook\ndescription: Internal company skill\n---\n\n# Company Playbook\n"
: "# Checklist\n",
language: "markdown",
markdown: true,
editable: true,
};
}
return {
skillId,
path: relativePath,
kind: relativePath === "SKILL.md" ? "skill" : "reference",
content: relativePath === "SKILL.md"
? "---\nname: paperclip\ndescription: Paperclip coordination skill\n---\n\n# Paperclip\n"
: "# API\n",
language: "markdown",
markdown: true,
editable: false,
};
});
companySkillSvc.importPackageFiles.mockResolvedValue([]);
}); });
it("exports a clean base package with sanitized Paperclip extension data", async () => { it("exports referenced skills as stubs by default with sanitized Paperclip extension data", async () => {
const portability = companyPortabilityService({} as any); const portability = companyPortabilityService({} as any);
const exported = await portability.exportBundle("company-1", { const exported = await portability.exportBundle("company-1", {
@@ -128,6 +240,14 @@ describe("company portability", () => {
expect(exported.files["COMPANY.md"]).toContain('name: "Paperclip"'); expect(exported.files["COMPANY.md"]).toContain('name: "Paperclip"');
expect(exported.files["COMPANY.md"]).toContain('schema: "agentcompanies/v1"'); expect(exported.files["COMPANY.md"]).toContain('schema: "agentcompanies/v1"');
expect(exported.files["agents/claudecoder/AGENTS.md"]).toContain("You are ClaudeCoder."); expect(exported.files["agents/claudecoder/AGENTS.md"]).toContain("You are ClaudeCoder.");
expect(exported.files["agents/claudecoder/AGENTS.md"]).toContain("skills:");
expect(exported.files["agents/claudecoder/AGENTS.md"]).toContain('- "paperclip"');
expect(exported.files["agents/cmo/AGENTS.md"]).not.toContain("skills:");
expect(exported.files["skills/paperclip/SKILL.md"]).toContain("metadata:");
expect(exported.files["skills/paperclip/SKILL.md"]).toContain('kind: "github-dir"');
expect(exported.files["skills/paperclip/references/api.md"]).toBeUndefined();
expect(exported.files["skills/company-playbook/SKILL.md"]).toContain("# Company Playbook");
expect(exported.files["skills/company-playbook/references/checklist.md"]).toContain("# Checklist");
const extension = exported.files[".paperclip.yaml"]; const extension = exported.files[".paperclip.yaml"];
expect(extension).toContain('schema: "paperclip/v1"'); expect(extension).toContain('schema: "paperclip/v1"');
@@ -147,6 +267,24 @@ describe("company portability", () => {
expect(exported.warnings).toContain("Agent claudecoder PATH override was omitted from export because it is system-dependent."); expect(exported.warnings).toContain("Agent claudecoder PATH override was omitted from export because it is system-dependent.");
}); });
it("expands referenced skills when requested", async () => {
const portability = companyPortabilityService({} as any);
const exported = await portability.exportBundle("company-1", {
include: {
company: true,
agents: true,
projects: false,
issues: false,
},
expandReferencedSkills: true,
});
expect(exported.files["skills/paperclip/SKILL.md"]).toContain("# Paperclip");
expect(exported.files["skills/paperclip/SKILL.md"]).toContain("metadata:");
expect(exported.files["skills/paperclip/references/api.md"]).toContain("# API");
});
it("reads env inputs back from .paperclip.yaml during preview import", async () => { it("reads env inputs back from .paperclip.yaml during preview import", async () => {
const portability = companyPortabilityService({} as any); const portability = companyPortabilityService({} as any);
@@ -201,4 +339,58 @@ describe("company portability", () => {
}, },
]); ]);
}); });
it("imports packaged skills and restores desired skill refs on agents", async () => {
const portability = companyPortabilityService({} as any);
companySvc.create.mockResolvedValue({
id: "company-imported",
name: "Imported Paperclip",
});
accessSvc.ensureMembership.mockResolvedValue(undefined);
agentSvc.create.mockResolvedValue({
id: "agent-created",
name: "ClaudeCoder",
});
const exported = await portability.exportBundle("company-1", {
include: {
company: true,
agents: true,
projects: false,
issues: false,
},
});
agentSvc.list.mockResolvedValue([]);
await portability.importBundle({
source: {
type: "inline",
rootPath: exported.rootPath,
files: exported.files,
},
include: {
company: true,
agents: true,
projects: false,
issues: false,
},
target: {
mode: "new_company",
newCompanyName: "Imported Paperclip",
},
agents: "all",
collisionStrategy: "rename",
}, "user-1");
expect(companySkillSvc.importPackageFiles).toHaveBeenCalledWith("company-imported", exported.files);
expect(agentSvc.create).toHaveBeenCalledWith("company-imported", expect.objectContaining({
adapterConfig: expect.objectContaining({
paperclipSkillSync: {
desiredSkills: ["paperclip"],
},
}),
}));
});
}); });

View File

@@ -1,4 +1,5 @@
import { promises as fs } from "node:fs"; import { promises as fs } from "node:fs";
import { execFileSync } from "node:child_process";
import path from "node:path"; import path from "node:path";
import type { Db } from "@paperclipai/db"; import type { Db } from "@paperclipai/db";
import type { import type {
@@ -16,6 +17,8 @@ import type {
CompanyPortabilityPreviewResult, CompanyPortabilityPreviewResult,
CompanyPortabilityProjectManifestEntry, CompanyPortabilityProjectManifestEntry,
CompanyPortabilityIssueManifestEntry, CompanyPortabilityIssueManifestEntry,
CompanyPortabilitySkillManifestEntry,
CompanySkill,
} from "@paperclipai/shared"; } from "@paperclipai/shared";
import { import {
ISSUE_PRIORITIES, ISSUE_PRIORITIES,
@@ -24,9 +27,14 @@ import {
deriveProjectUrlKey, deriveProjectUrlKey,
normalizeAgentUrlKey, normalizeAgentUrlKey,
} from "@paperclipai/shared"; } from "@paperclipai/shared";
import {
readPaperclipSkillSyncPreference,
writePaperclipSkillSyncPreference,
} from "@paperclipai/adapter-utils/server-utils";
import { notFound, unprocessable } from "../errors.js"; import { notFound, unprocessable } from "../errors.js";
import { accessService } from "./access.js"; import { accessService } from "./access.js";
import { agentService } from "./agents.js"; import { agentService } from "./agents.js";
import { companySkillService } from "./company-skills.js";
import { companyService } from "./companies.js"; import { companyService } from "./companies.js";
import { issueService } from "./issues.js"; import { issueService } from "./issues.js";
import { projectService } from "./projects.js"; import { projectService } from "./projects.js";
@@ -475,6 +483,7 @@ const YAML_KEY_PRIORITY = [
"kind", "kind",
"slug", "slug",
"reportsTo", "reportsTo",
"skills",
"owner", "owner",
"assignee", "assignee",
"project", "project",
@@ -594,6 +603,93 @@ function buildMarkdown(frontmatter: Record<string, unknown>, body: string) {
return `${renderFrontmatter(frontmatter)}\n${cleanBody}\n`; return `${renderFrontmatter(frontmatter)}\n${cleanBody}\n`;
} }
function buildSkillSourceEntry(skill: CompanySkill) {
const metadata = isPlainRecord(skill.metadata) ? skill.metadata : null;
if (asString(metadata?.sourceKind) === "paperclip_bundled") {
let commit: string | null = null;
try {
const resolved = execFileSync("git", ["rev-parse", "HEAD"], {
cwd: process.cwd(),
encoding: "utf8",
}).trim();
commit = resolved || null;
} catch {
commit = null;
}
return {
kind: "github-dir",
repo: "paperclipai/paperclip",
path: `skills/${skill.slug}`,
commit,
trackingRef: "master",
url: `https://github.com/paperclipai/paperclip/tree/master/skills/${skill.slug}`,
};
}
if (skill.sourceType === "github") {
const owner = asString(metadata?.owner);
const repo = asString(metadata?.repo);
const repoSkillDir = asString(metadata?.repoSkillDir);
if (!owner || !repo || !repoSkillDir) return null;
return {
kind: "github-dir",
repo: `${owner}/${repo}`,
path: repoSkillDir,
commit: skill.sourceRef ?? null,
trackingRef: asString(metadata?.trackingRef),
url: skill.sourceLocator,
};
}
if (skill.sourceType === "url" && skill.sourceLocator) {
return {
kind: "url",
url: skill.sourceLocator,
};
}
return null;
}
function shouldReferenceSkillOnExport(skill: CompanySkill, expandReferencedSkills: boolean) {
if (expandReferencedSkills) return false;
const metadata = isPlainRecord(skill.metadata) ? skill.metadata : null;
if (asString(metadata?.sourceKind) === "paperclip_bundled") return true;
return skill.sourceType === "github" || skill.sourceType === "url";
}
function buildReferencedSkillMarkdown(skill: CompanySkill) {
const sourceEntry = buildSkillSourceEntry(skill);
const frontmatter: Record<string, unknown> = {
name: skill.name,
description: skill.description ?? null,
};
if (sourceEntry) {
frontmatter.metadata = {
sources: [sourceEntry],
};
}
return buildMarkdown(frontmatter, "");
}
function withSkillSourceMetadata(skill: CompanySkill, markdown: string) {
const sourceEntry = buildSkillSourceEntry(skill);
if (!sourceEntry) return markdown;
const parsed = parseFrontmatterMarkdown(markdown);
const metadata = isPlainRecord(parsed.frontmatter.metadata)
? { ...parsed.frontmatter.metadata }
: {};
const existingSources = Array.isArray(metadata.sources)
? metadata.sources.filter((entry) => isPlainRecord(entry))
: [];
metadata.sources = [...existingSources, sourceEntry];
const frontmatter = {
...parsed.frontmatter,
metadata,
};
return buildMarkdown(frontmatter, parsed.body);
}
function renderCompanyAgentsSection(agentSummaries: Array<{ slug: string; name: string }>) { function renderCompanyAgentsSection(agentSummaries: Array<{ slug: string; name: string }>) {
const lines = ["# Agents", ""]; const lines = ["# Agents", ""];
if (agentSummaries.length === 0) { if (agentSummaries.length === 0) {
@@ -854,6 +950,17 @@ function readAgentEnvInputs(
}); });
} }
function readAgentSkillRefs(frontmatter: Record<string, unknown>) {
const skills = frontmatter.skills;
if (!Array.isArray(skills)) return [];
return Array.from(new Set(
skills
.filter((entry): entry is string => typeof entry === "string")
.map((entry) => normalizeAgentUrlKey(entry) ?? entry.trim())
.filter(Boolean),
));
}
function buildManifestFromPackageFiles( function buildManifestFromPackageFiles(
files: Record<string, string>, files: Record<string, string>,
opts?: { sourceLabel?: { companyId: string; companyName: string } | null }, opts?: { sourceLabel?: { companyId: string; companyName: string } | null },
@@ -898,6 +1005,9 @@ function buildManifestFromPackageFiles(
const referencedTaskPaths = includeEntries const referencedTaskPaths = includeEntries
.map((entry) => resolvePortablePath(resolvedCompanyPath, entry.path)) .map((entry) => resolvePortablePath(resolvedCompanyPath, entry.path))
.filter((entry) => entry.endsWith("/TASK.md") || entry === "TASK.md"); .filter((entry) => entry.endsWith("/TASK.md") || entry === "TASK.md");
const referencedSkillPaths = includeEntries
.map((entry) => resolvePortablePath(resolvedCompanyPath, entry.path))
.filter((entry) => entry.endsWith("/SKILL.md") || entry === "SKILL.md");
const discoveredAgentPaths = Object.keys(normalizedFiles).filter( const discoveredAgentPaths = Object.keys(normalizedFiles).filter(
(entry) => entry.endsWith("/AGENTS.md") || entry === "AGENTS.md", (entry) => entry.endsWith("/AGENTS.md") || entry === "AGENTS.md",
); );
@@ -907,9 +1017,13 @@ function buildManifestFromPackageFiles(
const discoveredTaskPaths = Object.keys(normalizedFiles).filter( const discoveredTaskPaths = Object.keys(normalizedFiles).filter(
(entry) => entry.endsWith("/TASK.md") || entry === "TASK.md", (entry) => entry.endsWith("/TASK.md") || entry === "TASK.md",
); );
const discoveredSkillPaths = Object.keys(normalizedFiles).filter(
(entry) => entry.endsWith("/SKILL.md") || entry === "SKILL.md",
);
const agentPaths = Array.from(new Set([...referencedAgentPaths, ...discoveredAgentPaths])).sort(); const agentPaths = Array.from(new Set([...referencedAgentPaths, ...discoveredAgentPaths])).sort();
const projectPaths = Array.from(new Set([...referencedProjectPaths, ...discoveredProjectPaths])).sort(); const projectPaths = Array.from(new Set([...referencedProjectPaths, ...discoveredProjectPaths])).sort();
const taskPaths = Array.from(new Set([...referencedTaskPaths, ...discoveredTaskPaths])).sort(); const taskPaths = Array.from(new Set([...referencedTaskPaths, ...discoveredTaskPaths])).sort();
const skillPaths = Array.from(new Set([...referencedSkillPaths, ...discoveredSkillPaths])).sort();
const manifest: CompanyPortabilityManifest = { const manifest: CompanyPortabilityManifest = {
schemaVersion: 3, schemaVersion: 3,
@@ -932,6 +1046,7 @@ function buildManifestFromPackageFiles(
: readCompanyApprovalDefault(companyFrontmatter), : readCompanyApprovalDefault(companyFrontmatter),
}, },
agents: [], agents: [],
skills: [],
projects: [], projects: [],
issues: [], issues: [],
envInputs: [], envInputs: [],
@@ -963,6 +1078,7 @@ function buildManifestFromPackageFiles(
slug, slug,
name: asString(frontmatter.name) ?? title ?? slug, name: asString(frontmatter.name) ?? title ?? slug,
path: agentPath, path: agentPath,
skills: readAgentSkillRefs(frontmatter),
role: asString(extension.role) ?? "agent", role: asString(extension.role) ?? "agent",
title, title,
icon: asString(extension.icon), icon: asString(extension.icon),
@@ -986,6 +1102,89 @@ function buildManifestFromPackageFiles(
} }
} }
for (const skillPath of skillPaths) {
const markdownRaw = normalizedFiles[skillPath];
if (typeof markdownRaw !== "string") {
warnings.push(`Referenced skill file is missing from package: ${skillPath}`);
continue;
}
const skillDoc = parseFrontmatterMarkdown(markdownRaw);
const frontmatter = skillDoc.frontmatter;
const skillDir = path.posix.dirname(skillPath);
const fallbackSlug = normalizeAgentUrlKey(path.posix.basename(skillDir)) ?? "skill";
const slug = asString(frontmatter.slug) ?? normalizeAgentUrlKey(asString(frontmatter.name) ?? "") ?? fallbackSlug;
const inventory = Object.keys(normalizedFiles)
.filter((entry) => entry === skillPath || entry.startsWith(`${skillDir}/`))
.map((entry) => ({
path: entry === skillPath ? "SKILL.md" : entry.slice(skillDir.length + 1),
kind: entry === skillPath
? "skill"
: entry.startsWith(`${skillDir}/references/`)
? "reference"
: entry.startsWith(`${skillDir}/scripts/`)
? "script"
: entry.startsWith(`${skillDir}/assets/`)
? "asset"
: entry.endsWith(".md")
? "markdown"
: "other",
}));
const metadata = isPlainRecord(frontmatter.metadata) ? frontmatter.metadata : null;
const sources = metadata && Array.isArray(metadata.sources) ? metadata.sources : [];
const primarySource = sources.find((entry) => isPlainRecord(entry)) as Record<string, unknown> | undefined;
const sourceKind = asString(primarySource?.kind);
let sourceType = "catalog";
let sourceLocator: string | null = null;
let sourceRef: string | null = null;
let normalizedMetadata: Record<string, unknown> | null = null;
if (sourceKind === "github-dir" || sourceKind === "github-file") {
const repo = asString(primarySource?.repo);
const repoPath = asString(primarySource?.path);
const commit = asString(primarySource?.commit);
const trackingRef = asString(primarySource?.trackingRef);
const [owner, repoName] = (repo ?? "").split("/");
sourceType = "github";
sourceLocator = asString(primarySource?.url)
?? (repo ? `https://github.com/${repo}${repoPath ? `/tree/${trackingRef ?? commit ?? "main"}/${repoPath}` : ""}` : null);
sourceRef = commit;
normalizedMetadata = owner && repoName
? {
sourceKind: "github",
owner,
repo: repoName,
ref: commit,
trackingRef,
repoSkillDir: repoPath ?? `skills/${slug}`,
}
: null;
} else if (sourceKind === "url") {
sourceType = "url";
sourceLocator = asString(primarySource?.url) ?? asString(primarySource?.rawUrl);
normalizedMetadata = {
sourceKind: "url",
};
} else if (metadata) {
normalizedMetadata = {
sourceKind: "catalog",
};
}
manifest.skills.push({
slug,
name: asString(frontmatter.name) ?? slug,
path: skillPath,
description: asString(frontmatter.description),
sourceType,
sourceLocator,
sourceRef,
trustLevel: null,
compatibility: "compatible",
metadata: normalizedMetadata,
fileInventory: inventory,
});
}
for (const projectPath of projectPaths) { for (const projectPath of projectPaths) {
const markdownRaw = normalizedFiles[projectPath]; const markdownRaw = normalizedFiles[projectPath];
if (typeof markdownRaw !== "string") { if (typeof markdownRaw !== "string") {
@@ -1163,6 +1362,7 @@ export function companyPortabilityService(db: Db) {
const access = accessService(db); const access = accessService(db);
const projects = projectService(db); const projects = projectService(db);
const issues = issueService(db); const issues = issueService(db);
const companySkills = companySkillService(db);
async function resolveSource(source: CompanyPortabilityPreview["source"]): Promise<ResolvedSource> { async function resolveSource(source: CompanyPortabilityPreview["source"]): Promise<ResolvedSource> {
if (source.type === "inline") { if (source.type === "inline") {
@@ -1246,6 +1446,7 @@ export function companyPortabilityService(db: Db) {
const relative = basePrefix ? entry.slice(basePrefix.length) : entry; const relative = basePrefix ? entry.slice(basePrefix.length) : entry;
return ( return (
relative.endsWith(".md") || relative.endsWith(".md") ||
relative.startsWith("skills/") ||
relative === ".paperclip.yaml" || relative === ".paperclip.yaml" ||
relative === ".paperclip.yml" relative === ".paperclip.yml"
); );
@@ -1296,6 +1497,7 @@ export function companyPortabilityService(db: Db) {
const allAgentRows = include.agents ? await agents.list(companyId, { includeTerminated: true }) : []; const allAgentRows = include.agents ? await agents.list(companyId, { includeTerminated: true }) : [];
const agentRows = allAgentRows.filter((agent) => agent.status !== "terminated"); const agentRows = allAgentRows.filter((agent) => agent.status !== "terminated");
const companySkillRows = await companySkills.list(companyId);
if (include.agents) { if (include.agents) {
const skipped = allAgentRows.length - agentRows.length; const skipped = allAgentRows.length - agentRows.length;
if (skipped > 0) { if (skipped > 0) {
@@ -1399,7 +1601,7 @@ export function companyPortabilityService(db: Db) {
const projectSlugById = new Map<string, string>(); const projectSlugById = new Map<string, string>();
const usedProjectSlugs = new Set<string>(); const usedProjectSlugs = new Set<string>();
for (const project of selectedProjectRows) { for (const project of selectedProjectRows) {
const baseSlug = deriveProjectUrlKey(project.name, project.id); const baseSlug = deriveProjectUrlKey(project.name, project.name);
projectSlugById.set(project.id, uniqueSlug(baseSlug, usedProjectSlugs)); projectSlugById.set(project.id, uniqueSlug(baseSlug, usedProjectSlugs));
} }
@@ -1431,6 +1633,22 @@ export function companyPortabilityService(db: Db) {
const paperclipProjectsOut: Record<string, Record<string, unknown>> = {}; const paperclipProjectsOut: Record<string, Record<string, unknown>> = {};
const paperclipTasksOut: Record<string, Record<string, unknown>> = {}; const paperclipTasksOut: Record<string, Record<string, unknown>> = {};
for (const skill of companySkillRows) {
if (shouldReferenceSkillOnExport(skill, Boolean(input.expandReferencedSkills))) {
files[`skills/${skill.slug}/SKILL.md`] = buildReferencedSkillMarkdown(skill);
continue;
}
for (const inventoryEntry of skill.fileInventory) {
const fileDetail = await companySkills.readFile(companyId, skill.id, inventoryEntry.path).catch(() => null);
if (!fileDetail) continue;
const filePath = `skills/${skill.slug}/${inventoryEntry.path}`;
files[filePath] = inventoryEntry.path === "SKILL.md"
? withSkillSourceMetadata(skill, fileDetail.content)
: fileDetail.content;
}
}
if (include.agents) { if (include.agents) {
for (const agent of agentRows) { for (const agent of agentRows) {
const slug = idToSlug.get(agent.id)!; const slug = idToSlug.get(agent.id)!;
@@ -1467,6 +1685,9 @@ export function companyPortabilityService(db: Db) {
.filter((inputValue) => inputValue.agentSlug === slug), .filter((inputValue) => inputValue.agentSlug === slug),
); );
const reportsToSlug = agent.reportsTo ? (idToSlug.get(agent.reportsTo) ?? null) : null; const reportsToSlug = agent.reportsTo ? (idToSlug.get(agent.reportsTo) ?? null) : null;
const desiredSkills = readPaperclipSkillSyncPreference(
(agent.adapterConfig as Record<string, unknown>) ?? {},
).desiredSkills;
const commandValue = asString(portableAdapterConfig.command); const commandValue = asString(portableAdapterConfig.command);
if (commandValue && isAbsoluteCommand(commandValue)) { if (commandValue && isAbsoluteCommand(commandValue)) {
@@ -1475,11 +1696,12 @@ export function companyPortabilityService(db: Db) {
} }
files[agentPath] = buildMarkdown( files[agentPath] = buildMarkdown(
{ stripEmptyValues({
name: agent.name, name: agent.name,
title: agent.title ?? null, title: agent.title ?? null,
reportsTo: reportsToSlug, reportsTo: reportsToSlug,
}, skills: desiredSkills.length > 0 ? desiredSkills : undefined,
}) as Record<string, unknown>,
instructions.body, instructions.body,
); );
@@ -1627,6 +1849,8 @@ export function companyPortabilityService(db: Db) {
warnings.push("No agents selected for import."); warnings.push("No agents selected for import.");
} }
const availableSkillSlugs = new Set(source.manifest.skills.map((skill) => skill.slug));
for (const agent of selectedAgents) { for (const agent of selectedAgents) {
const filePath = ensureMarkdownPath(agent.path); const filePath = ensureMarkdownPath(agent.path);
const markdown = source.files[filePath]; const markdown = source.files[filePath];
@@ -1638,6 +1862,11 @@ export function companyPortabilityService(db: Db) {
if (parsed.frontmatter.kind && parsed.frontmatter.kind !== "agent") { if (parsed.frontmatter.kind && parsed.frontmatter.kind !== "agent") {
warnings.push(`Agent markdown ${filePath} does not declare kind: agent in frontmatter.`); warnings.push(`Agent markdown ${filePath} does not declare kind: agent in frontmatter.`);
} }
for (const skillSlug of agent.skills) {
if (!availableSkillSlugs.has(skillSlug)) {
warnings.push(`Agent ${agent.slug} references skill ${skillSlug}, but that skill is not present in the package.`);
}
}
} }
if (include.projects) { if (include.projects) {
@@ -1912,6 +2141,8 @@ export function companyPortabilityService(db: Db) {
existingProjectSlugToId.set(existing.urlKey, existing.id); existingProjectSlugToId.set(existing.urlKey, existing.id);
} }
await companySkills.importPackageFiles(targetCompany.id, plan.source.files);
if (include.agents) { if (include.agents) {
for (const planAgent of plan.preview.plan.agentPlans) { for (const planAgent of plan.preview.plan.agentPlans) {
const manifestAgent = plan.selectedAgents.find((agent) => agent.slug === planAgent.slug); const manifestAgent = plan.selectedAgents.find((agent) => agent.slug === planAgent.slug);
@@ -1936,6 +2167,11 @@ export function companyPortabilityService(db: Db) {
...manifestAgent.adapterConfig, ...manifestAgent.adapterConfig,
promptTemplate: markdown.body || asString((manifestAgent.adapterConfig as Record<string, unknown>).promptTemplate) || "", promptTemplate: markdown.body || asString((manifestAgent.adapterConfig as Record<string, unknown>).promptTemplate) || "",
} as Record<string, unknown>; } as Record<string, unknown>;
const desiredSkills = manifestAgent.skills ?? [];
const adapterConfigWithSkills = writePaperclipSkillSyncPreference(
adapterConfig,
desiredSkills,
);
delete adapterConfig.instructionsFilePath; delete adapterConfig.instructionsFilePath;
const patch = { const patch = {
name: planAgent.plannedName, name: planAgent.plannedName,
@@ -1945,7 +2181,7 @@ export function companyPortabilityService(db: Db) {
capabilities: manifestAgent.capabilities, capabilities: manifestAgent.capabilities,
reportsTo: null, reportsTo: null,
adapterType: manifestAgent.adapterType, adapterType: manifestAgent.adapterType,
adapterConfig, adapterConfig: adapterConfigWithSkills,
runtimeConfig: manifestAgent.runtimeConfig, runtimeConfig: manifestAgent.runtimeConfig,
budgetMonthlyCents: manifestAgent.budgetMonthlyCents, budgetMonthlyCents: manifestAgent.budgetMonthlyCents,
permissions: manifestAgent.permissions, permissions: manifestAgent.permissions,

View File

@@ -34,6 +34,7 @@ type ImportedSkill = {
name: string; name: string;
description: string | null; description: string | null;
markdown: string; markdown: string;
packageDir?: string | null;
sourceType: CompanySkillSourceType; sourceType: CompanySkillSourceType;
sourceLocator: string | null; sourceLocator: string | null;
sourceRef: string | null; sourceRef: string | null;
@@ -72,6 +73,16 @@ function normalizePortablePath(input: string) {
return input.replace(/\\/g, "/").replace(/^\.\/+/, "").replace(/^\/+/, ""); return input.replace(/\\/g, "/").replace(/^\.\/+/, "").replace(/^\/+/, "");
} }
function normalizePackageFileMap(files: Record<string, string>) {
const out: Record<string, string> = {};
for (const [rawPath, content] of Object.entries(files)) {
const nextPath = normalizePortablePath(rawPath);
if (!nextPath) continue;
out[nextPath] = content;
}
return out;
}
function normalizeSkillSlug(value: string | null | undefined) { function normalizeSkillSlug(value: string | null | undefined) {
return value ? normalizeAgentUrlKey(value) ?? null : null; return value ? normalizeAgentUrlKey(value) ?? null : null;
} }
@@ -399,6 +410,111 @@ function deriveImportedSkillSlug(frontmatter: Record<string, unknown>, fallback:
return normalizeSkillSlug(asString(frontmatter.name)) ?? normalizeAgentUrlKey(fallback) ?? "skill"; return normalizeSkillSlug(asString(frontmatter.name)) ?? normalizeAgentUrlKey(fallback) ?? "skill";
} }
function deriveImportedSkillSource(
frontmatter: Record<string, unknown>,
fallbackSlug: string,
): Pick<ImportedSkill, "sourceType" | "sourceLocator" | "sourceRef" | "metadata"> {
const metadata = isPlainRecord(frontmatter.metadata) ? frontmatter.metadata : null;
const rawSources = metadata && Array.isArray(metadata.sources) ? metadata.sources : [];
const sourceEntry = rawSources.find((entry) => isPlainRecord(entry)) as Record<string, unknown> | undefined;
const kind = asString(sourceEntry?.kind);
if (kind === "github-dir" || kind === "github-file") {
const repo = asString(sourceEntry?.repo);
const repoPath = asString(sourceEntry?.path);
const commit = asString(sourceEntry?.commit);
const trackingRef = asString(sourceEntry?.trackingRef);
const url = asString(sourceEntry?.url)
?? (repo
? `https://github.com/${repo}${repoPath ? `/tree/${trackingRef ?? commit ?? "main"}/${repoPath}` : ""}`
: null);
const [owner, repoName] = (repo ?? "").split("/");
if (repo && owner && repoName) {
return {
sourceType: "github",
sourceLocator: url,
sourceRef: commit,
metadata: {
sourceKind: "github",
owner,
repo: repoName,
ref: commit,
trackingRef,
repoSkillDir: repoPath ?? `skills/${fallbackSlug}`,
},
};
}
}
if (kind === "url") {
const url = asString(sourceEntry?.url) ?? asString(sourceEntry?.rawUrl);
if (url) {
return {
sourceType: "url",
sourceLocator: url,
sourceRef: null,
metadata: {
sourceKind: "url",
},
};
}
}
return {
sourceType: "catalog",
sourceLocator: null,
sourceRef: null,
metadata: {
sourceKind: "catalog",
},
};
}
function readInlineSkillImports(files: Record<string, string>): ImportedSkill[] {
const normalizedFiles = normalizePackageFileMap(files);
const skillPaths = Object.keys(normalizedFiles).filter(
(entry) => path.posix.basename(entry).toLowerCase() === "skill.md",
);
const imports: ImportedSkill[] = [];
for (const skillPath of skillPaths) {
const dir = path.posix.dirname(skillPath);
const skillDir = dir === "." ? "" : dir;
const slugFallback = path.posix.basename(skillDir || path.posix.dirname(skillPath));
const markdown = normalizedFiles[skillPath]!;
const parsed = parseFrontmatterMarkdown(markdown);
const slug = deriveImportedSkillSlug(parsed.frontmatter, slugFallback);
const source = deriveImportedSkillSource(parsed.frontmatter, slug);
const inventory = Object.keys(normalizedFiles)
.filter((entry) => entry === skillPath || (skillDir ? entry.startsWith(`${skillDir}/`) : false))
.map((entry) => {
const relative = entry === skillPath ? "SKILL.md" : entry.slice(skillDir.length + 1);
return {
path: normalizePortablePath(relative),
kind: classifyInventoryKind(relative),
};
})
.sort((left, right) => left.path.localeCompare(right.path));
imports.push({
slug,
name: asString(parsed.frontmatter.name) ?? slug,
description: asString(parsed.frontmatter.description),
markdown,
packageDir: skillDir,
sourceType: source.sourceType,
sourceLocator: source.sourceLocator,
sourceRef: source.sourceRef,
trustLevel: deriveTrustLevel(inventory),
compatibility: "compatible",
fileInventory: inventory,
metadata: source.metadata,
});
}
return imports;
}
async function walkLocalFiles(root: string, current: string, out: string[]) { async function walkLocalFiles(root: string, current: string, out: string[]) {
const entries = await fs.readdir(current, { withFileTypes: true }); const entries = await fs.readdir(current, { withFileTypes: true });
for (const entry of entries) { for (const entry of entries) {
@@ -432,6 +548,7 @@ async function readLocalSkillImports(sourcePath: string): Promise<ImportedSkill[
name: asString(parsed.frontmatter.name) ?? slug, name: asString(parsed.frontmatter.name) ?? slug,
description: asString(parsed.frontmatter.description), description: asString(parsed.frontmatter.description),
markdown, markdown,
packageDir: path.dirname(resolvedPath),
sourceType: "local_path", sourceType: "local_path",
sourceLocator: path.dirname(resolvedPath), sourceLocator: path.dirname(resolvedPath),
sourceRef: null, sourceRef: null,
@@ -471,6 +588,7 @@ async function readLocalSkillImports(sourcePath: string): Promise<ImportedSkill[
name: asString(parsed.frontmatter.name) ?? slug, name: asString(parsed.frontmatter.name) ?? slug,
description: asString(parsed.frontmatter.description), description: asString(parsed.frontmatter.description),
markdown, markdown,
packageDir: path.join(root, skillDir),
sourceType: "local_path", sourceType: "local_path",
sourceLocator: path.join(root, skillDir), sourceLocator: path.join(root, skillDir),
sourceRef: null, sourceRef: null,
@@ -633,7 +751,7 @@ function getSkillMeta(skill: CompanySkill): SkillSourceMeta {
} }
function normalizeSkillDirectory(skill: CompanySkill) { function normalizeSkillDirectory(skill: CompanySkill) {
if (skill.sourceType !== "local_path" || !skill.sourceLocator) return null; if ((skill.sourceType !== "local_path" && skill.sourceType !== "catalog") || !skill.sourceLocator) return null;
const resolved = path.resolve(skill.sourceLocator); const resolved = path.resolve(skill.sourceLocator);
if (path.basename(resolved).toLowerCase() === "skill.md") { if (path.basename(resolved).toLowerCase() === "skill.md") {
return path.dirname(resolved); return path.dirname(resolved);
@@ -921,10 +1039,15 @@ export function companySkillService(db: Db) {
const source = deriveSkillSourceInfo(skill); const source = deriveSkillSourceInfo(skill);
let content = ""; let content = "";
if (skill.sourceType === "local_path") { if (skill.sourceType === "local_path" || skill.sourceType === "catalog") {
const absolutePath = resolveLocalSkillFilePath(skill, normalizedPath); const absolutePath = resolveLocalSkillFilePath(skill, normalizedPath);
if (!absolutePath) throw notFound("Skill file not found"); if (absolutePath) {
content = await fs.readFile(absolutePath, "utf8"); content = await fs.readFile(absolutePath, "utf8");
} else if (normalizedPath === "SKILL.md") {
content = skill.markdown;
} else {
throw notFound("Skill file not found");
}
} else if (skill.sourceType === "github") { } else if (skill.sourceType === "github") {
const metadata = getSkillMeta(skill); const metadata = getSkillMeta(skill);
const owner = asString(metadata.owner); const owner = asString(metadata.owner);
@@ -1061,10 +1184,69 @@ export function companySkillService(db: Db) {
return imported[0] ?? null; return imported[0] ?? null;
} }
async function materializeCatalogSkillFiles(
companyId: string,
skill: ImportedSkill,
normalizedFiles: Record<string, string>,
) {
const packageDir = skill.packageDir ? normalizePortablePath(skill.packageDir) : null;
if (!packageDir) return null;
const catalogRoot = path.resolve(resolveManagedSkillsRoot(companyId), "__catalog__");
const skillDir = path.resolve(catalogRoot, skill.slug);
await fs.rm(skillDir, { recursive: true, force: true });
await fs.mkdir(skillDir, { recursive: true });
for (const entry of skill.fileInventory) {
const sourcePath = entry.path === "SKILL.md"
? `${packageDir}/SKILL.md`
: `${packageDir}/${entry.path}`;
const content = normalizedFiles[sourcePath];
if (typeof content !== "string") continue;
const targetPath = path.resolve(skillDir, entry.path);
await fs.mkdir(path.dirname(targetPath), { recursive: true });
await fs.writeFile(targetPath, content, "utf8");
}
return skillDir;
}
async function importPackageFiles(companyId: string, files: Record<string, string>): Promise<CompanySkill[]> {
await ensureBundledSkills(companyId);
const normalizedFiles = normalizePackageFileMap(files);
const importedSkills = readInlineSkillImports(normalizedFiles);
if (importedSkills.length === 0) return [];
for (const skill of importedSkills) {
if (skill.sourceType !== "catalog") continue;
const materializedDir = await materializeCatalogSkillFiles(companyId, skill, normalizedFiles);
if (materializedDir) {
skill.sourceLocator = materializedDir;
}
}
return upsertImportedSkills(companyId, importedSkills);
}
async function upsertImportedSkills(companyId: string, imported: ImportedSkill[]): Promise<CompanySkill[]> { async function upsertImportedSkills(companyId: string, imported: ImportedSkill[]): Promise<CompanySkill[]> {
const out: CompanySkill[] = []; const out: CompanySkill[] = [];
for (const skill of imported) { for (const skill of imported) {
const existing = await getBySlug(companyId, skill.slug); const existing = await getBySlug(companyId, skill.slug);
const existingMeta = existing ? getSkillMeta(existing) : {};
const incomingMeta = skill.metadata && isPlainRecord(skill.metadata) ? skill.metadata : {};
const incomingOwner = asString(incomingMeta.owner);
const incomingRepo = asString(incomingMeta.repo);
const incomingKind = asString(incomingMeta.sourceKind);
if (
existing
&& existingMeta.sourceKind === "paperclip_bundled"
&& incomingKind === "github"
&& incomingOwner === "paperclipai"
&& incomingRepo === "paperclip"
) {
out.push(existing);
continue;
}
const values = { const values = {
companyId, companyId,
slug: skill.slug, slug: skill.slug,
@@ -1137,6 +1319,7 @@ export function companySkillService(db: Db) {
updateFile, updateFile,
createLocalSkill, createLocalSkill,
importFromSource, importFromSource,
importPackageFiles,
installUpdate, installUpdate,
}; };
} }

View File

@@ -81,6 +81,27 @@ function stripFrontmatter(markdown: string) {
return normalized.slice(closing + 5).trim(); return normalized.slice(closing + 5).trim();
} }
function splitFrontmatter(markdown: string): { frontmatter: string | null; body: string } {
const normalized = markdown.replace(/\r\n/g, "\n");
if (!normalized.startsWith("---\n")) {
return { frontmatter: null, body: normalized };
}
const closing = normalized.indexOf("\n---\n", 4);
if (closing < 0) {
return { frontmatter: null, body: normalized };
}
return {
frontmatter: normalized.slice(4, closing).trim(),
body: normalized.slice(closing + 5).trimStart(),
};
}
function mergeFrontmatter(markdown: string, body: string) {
const parsed = splitFrontmatter(markdown);
if (!parsed.frontmatter) return body;
return ["---", parsed.frontmatter, "---", "", body].join("\n");
}
function buildTree(entries: CompanySkillFileInventoryEntry[]) { function buildTree(entries: CompanySkillFileInventoryEntry[]) {
const root: SkillTreeNode = { name: "", path: null, kind: "dir", children: [] }; const root: SkillTreeNode = { name: "", path: null, kind: "dir", children: [] };
@@ -778,7 +799,7 @@ export function CompanySkills() {
useEffect(() => { useEffect(() => {
if (fileQuery.data) { if (fileQuery.data) {
setDisplayedFile(fileQuery.data); setDisplayedFile(fileQuery.data);
setDraft(fileQuery.data.content); setDraft(fileQuery.data.markdown ? splitFrontmatter(fileQuery.data.content).body : fileQuery.data.content);
} }
}, [fileQuery.data]); }, [fileQuery.data]);
@@ -837,14 +858,19 @@ export function CompanySkills() {
}); });
const saveFile = useMutation({ const saveFile = useMutation({
mutationFn: () => companySkillsApi.updateFile(selectedCompanyId!, selectedSkillId!, selectedPath, draft), mutationFn: () => companySkillsApi.updateFile(
selectedCompanyId!,
selectedSkillId!,
selectedPath,
activeFile?.markdown ? mergeFrontmatter(activeFile.content, draft) : draft,
),
onSuccess: async (result) => { onSuccess: async (result) => {
await Promise.all([ await Promise.all([
queryClient.invalidateQueries({ queryKey: queryKeys.companySkills.list(selectedCompanyId!) }), queryClient.invalidateQueries({ queryKey: queryKeys.companySkills.list(selectedCompanyId!) }),
queryClient.invalidateQueries({ queryKey: queryKeys.companySkills.detail(selectedCompanyId!, selectedSkillId!) }), queryClient.invalidateQueries({ queryKey: queryKeys.companySkills.detail(selectedCompanyId!, selectedSkillId!) }),
queryClient.invalidateQueries({ queryKey: queryKeys.companySkills.file(selectedCompanyId!, selectedSkillId!, selectedPath) }), queryClient.invalidateQueries({ queryKey: queryKeys.companySkills.file(selectedCompanyId!, selectedSkillId!, selectedPath) }),
]); ]);
setDraft(result.content); setDraft(result.markdown ? splitFrontmatter(result.content).body : result.content);
setEditMode(false); setEditMode(false);
pushToast({ pushToast({
tone: "success", tone: "success",