Refine portability export behavior and skill plans
This commit is contained in:
@@ -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(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
399
doc/plans/2026-03-14-adapter-skill-sync-rollout.md
Normal file
399
doc/plans/2026-03-14-adapter-skill-sync-rollout.md
Normal 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 Claude’s 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 Pi’s 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.
|
||||||
@@ -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`
|
||||||
|
|
||||||
|
|||||||
@@ -133,6 +133,7 @@ export type {
|
|||||||
CompanyPortabilityEnvInput,
|
CompanyPortabilityEnvInput,
|
||||||
CompanyPortabilityCompanyManifestEntry,
|
CompanyPortabilityCompanyManifestEntry,
|
||||||
CompanyPortabilityAgentManifestEntry,
|
CompanyPortabilityAgentManifestEntry,
|
||||||
|
CompanyPortabilitySkillManifestEntry,
|
||||||
CompanyPortabilityProjectManifestEntry,
|
CompanyPortabilityProjectManifestEntry,
|
||||||
CompanyPortabilityIssueManifestEntry,
|
CompanyPortabilityIssueManifestEntry,
|
||||||
CompanyPortabilityManifest,
|
CompanyPortabilityManifest,
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -90,6 +90,7 @@ export type {
|
|||||||
CompanyPortabilityEnvInput,
|
CompanyPortabilityEnvInput,
|
||||||
CompanyPortabilityCompanyManifestEntry,
|
CompanyPortabilityCompanyManifestEntry,
|
||||||
CompanyPortabilityAgentManifestEntry,
|
CompanyPortabilityAgentManifestEntry,
|
||||||
|
CompanyPortabilitySkillManifestEntry,
|
||||||
CompanyPortabilityProjectManifestEntry,
|
CompanyPortabilityProjectManifestEntry,
|
||||||
CompanyPortabilityIssueManifestEntry,
|
CompanyPortabilityIssueManifestEntry,
|
||||||
CompanyPortabilityManifest,
|
CompanyPortabilityManifest,
|
||||||
|
|||||||
@@ -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>;
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ export {
|
|||||||
portabilityEnvInputSchema,
|
portabilityEnvInputSchema,
|
||||||
portabilityCompanyManifestEntrySchema,
|
portabilityCompanyManifestEntrySchema,
|
||||||
portabilityAgentManifestEntrySchema,
|
portabilityAgentManifestEntrySchema,
|
||||||
|
portabilitySkillManifestEntrySchema,
|
||||||
portabilityManifestSchema,
|
portabilityManifestSchema,
|
||||||
portabilitySourceSchema,
|
portabilitySourceSchema,
|
||||||
portabilityTargetSchema,
|
portabilityTargetSchema,
|
||||||
|
|||||||
@@ -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"],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user