diff --git a/cli/src/commands/client/company.ts b/cli/src/commands/client/company.ts index bbbd2c7e..b725a451 100644 --- a/cli/src/commands/client/company.ts +++ b/cli/src/commands/client/company.ts @@ -1,6 +1,7 @@ import { Command } from "commander"; import { mkdir, readdir, readFile, stat, writeFile } from "node:fs/promises"; import path from "node:path"; +import * as p from "@clack/prompts"; import type { Company, CompanyPortabilityExportResult, @@ -35,6 +36,7 @@ interface CompanyExportOptions extends BaseClientOptions { projects?: string; issues?: string; projectIssues?: string; + expandReferencedSkills?: boolean; } interface CompanyImportOptions extends BaseClientOptions { @@ -137,6 +139,31 @@ async function writeExportToFolder(outDir: string, exported: CompanyPortabilityE } } +async function confirmOverwriteExportDirectory(outDir: string): Promise { + 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 { return company.issuePrefix.toUpperCase() === selector.toUpperCase(); } @@ -278,6 +305,7 @@ export function registerCompanyCommands(program: Command): void { .option("--projects ", "Comma-separated project shortnames/ids to export") .option("--issues ", "Comma-separated issue identifiers/ids to export") .option("--project-issues ", "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) => { try { const ctx = resolveCommandContext(opts); @@ -289,11 +317,13 @@ export function registerCompanyCommands(program: Command): void { projects: parseCsvValues(opts.projects), issues: parseCsvValues(opts.issues), projectIssues: parseCsvValues(opts.projectIssues), + expandReferencedSkills: Boolean(opts.expandReferencedSkills), }, ); if (!exported) { throw new Error("Export request returned no data"); } + await confirmOverwriteExportDirectory(opts.out!); await writeExportToFolder(opts.out!, exported); printOutput( { diff --git a/doc/plans/2026-03-13-company-import-export-v2.md b/doc/plans/2026-03-13-company-import-export-v2.md index c258d769..89d39d81 100644 --- a/doc/plans/2026-03-13-company-import-export-v2.md +++ b/doc/plans/2026-03-13-company-import-export-v2.md @@ -24,6 +24,10 @@ The normative package format draft lives in: 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 Paperclip already has portability primitives in the repo: diff --git a/doc/plans/2026-03-14-adapter-skill-sync-rollout.md b/doc/plans/2026-03-14-adapter-skill-sync-rollout.md new file mode 100644 index 00000000..e062b7dd --- /dev/null +++ b/doc/plans/2026-03-14-adapter-skill-sync-rollout.md @@ -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. diff --git a/doc/plans/2026-03-14-skills-ui-product-plan.md b/doc/plans/2026-03-14-skills-ui-product-plan.md index 1addb4bf..6df9eb05 100644 --- a/doc/plans/2026-03-14-skills-ui-product-plan.md +++ b/doc/plans/2026-03-14-skills-ui-product-plan.md @@ -5,6 +5,7 @@ Date: 2026-03-14 Audience: Product and engineering Related: - `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` - `ui/src/pages/AgentDetail.tsx` diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 412bf68f..a6c949d8 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -133,6 +133,7 @@ export type { CompanyPortabilityEnvInput, CompanyPortabilityCompanyManifestEntry, CompanyPortabilityAgentManifestEntry, + CompanyPortabilitySkillManifestEntry, CompanyPortabilityProjectManifestEntry, CompanyPortabilityIssueManifestEntry, CompanyPortabilityManifest, diff --git a/packages/shared/src/types/company-portability.ts b/packages/shared/src/types/company-portability.ts index 73c4d604..97318877 100644 --- a/packages/shared/src/types/company-portability.ts +++ b/packages/shared/src/types/company-portability.ts @@ -59,6 +59,7 @@ export interface CompanyPortabilityAgentManifestEntry { slug: string; name: string; path: string; + skills: string[]; role: string; title: string | null; icon: string | null; @@ -72,6 +73,23 @@ export interface CompanyPortabilityAgentManifestEntry { metadata: Record | 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 | null; + fileInventory: Array<{ + path: string; + kind: string; + }>; +} + export interface CompanyPortabilityManifest { schemaVersion: number; generatedAt: string; @@ -82,6 +100,7 @@ export interface CompanyPortabilityManifest { includes: CompanyPortabilityInclude; company: CompanyPortabilityCompanyManifestEntry | null; agents: CompanyPortabilityAgentManifestEntry[]; + skills: CompanyPortabilitySkillManifestEntry[]; projects: CompanyPortabilityProjectManifestEntry[]; issues: CompanyPortabilityIssueManifestEntry[]; envInputs: CompanyPortabilityEnvInput[]; @@ -196,4 +215,5 @@ export interface CompanyPortabilityExportRequest { projects?: string[]; issues?: string[]; projectIssues?: string[]; + expandReferencedSkills?: boolean; } diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index 283dade4..631ab41e 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -90,6 +90,7 @@ export type { CompanyPortabilityEnvInput, CompanyPortabilityCompanyManifestEntry, CompanyPortabilityAgentManifestEntry, + CompanyPortabilitySkillManifestEntry, CompanyPortabilityProjectManifestEntry, CompanyPortabilityIssueManifestEntry, CompanyPortabilityManifest, diff --git a/packages/shared/src/validators/company-portability.ts b/packages/shared/src/validators/company-portability.ts index fa0d9f6f..c4f20a51 100644 --- a/packages/shared/src/validators/company-portability.ts +++ b/packages/shared/src/validators/company-portability.ts @@ -31,6 +31,7 @@ export const portabilityAgentManifestEntrySchema = z.object({ slug: z.string().min(1), name: z.string().min(1), path: z.string().min(1), + skills: z.array(z.string().min(1)).default([]), role: z.string().min(1), title: z.string().nullable(), icon: z.string().nullable(), @@ -44,6 +45,23 @@ export const portabilityAgentManifestEntrySchema = z.object({ 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({ slug: z.string().min(1), name: z.string().min(1), @@ -93,6 +111,7 @@ export const portabilityManifestSchema = z.object({ }), company: portabilityCompanyManifestEntrySchema.nullable(), agents: z.array(portabilityAgentManifestEntrySchema), + skills: z.array(portabilitySkillManifestEntrySchema).default([]), projects: z.array(portabilityProjectManifestEntrySchema).default([]), issues: z.array(portabilityIssueManifestEntrySchema).default([]), envInputs: z.array(portabilityEnvInputSchema).default([]), @@ -137,6 +156,7 @@ export const companyPortabilityExportSchema = z.object({ projects: z.array(z.string().min(1)).optional(), issues: z.array(z.string().min(1)).optional(), projectIssues: z.array(z.string().min(1)).optional(), + expandReferencedSkills: z.boolean().optional(), }); export type CompanyPortabilityExport = z.infer; diff --git a/packages/shared/src/validators/index.ts b/packages/shared/src/validators/index.ts index ae7a8745..8ff4df27 100644 --- a/packages/shared/src/validators/index.ts +++ b/packages/shared/src/validators/index.ts @@ -36,6 +36,7 @@ export { portabilityEnvInputSchema, portabilityCompanyManifestEntrySchema, portabilityAgentManifestEntrySchema, + portabilitySkillManifestEntrySchema, portabilityManifestSchema, portabilitySourceSchema, portabilityTargetSchema, diff --git a/server/src/__tests__/company-portability.test.ts b/server/src/__tests__/company-portability.test.ts index fe08a474..14220532 100644 --- a/server/src/__tests__/company-portability.test.ts +++ b/server/src/__tests__/company-portability.test.ts @@ -29,6 +29,12 @@ const issueSvc = { create: vi.fn(), }; +const companySkillSvc = { + list: vi.fn(), + readFile: vi.fn(), + importPackageFiles: vi.fn(), +}; + vi.mock("../services/companies.js", () => ({ companyService: () => companySvc, })); @@ -49,6 +55,10 @@ vi.mock("../services/issues.js", () => ({ issueService: () => issueSvc, })); +vi.mock("../services/company-skills.js", () => ({ + companySkillService: () => companySkillSvc, +})); + const { companyPortabilityService } = await import("../services/company-portability.js"); describe("company portability", () => { @@ -74,6 +84,9 @@ describe("company portability", () => { adapterType: "claude_local", adapterConfig: { promptTemplate: "You are ClaudeCoder.", + paperclipSkillSync: { + desiredSkills: ["paperclip"], + }, instructionsFilePath: "/tmp/ignored.md", cwd: "/tmp/ignored", command: "/Users/dotta/.local/bin/claude", @@ -106,14 +119,113 @@ describe("company portability", () => { }, 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([]); issueSvc.list.mockResolvedValue([]); issueSvc.getById.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 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('schema: "agentcompanies/v1"'); 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"]; 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."); }); + 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 () => { 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"], + }, + }), + })); + }); }); diff --git a/server/src/services/company-portability.ts b/server/src/services/company-portability.ts index f0db416c..cdc220d0 100644 --- a/server/src/services/company-portability.ts +++ b/server/src/services/company-portability.ts @@ -1,4 +1,5 @@ import { promises as fs } from "node:fs"; +import { execFileSync } from "node:child_process"; import path from "node:path"; import type { Db } from "@paperclipai/db"; import type { @@ -16,6 +17,8 @@ import type { CompanyPortabilityPreviewResult, CompanyPortabilityProjectManifestEntry, CompanyPortabilityIssueManifestEntry, + CompanyPortabilitySkillManifestEntry, + CompanySkill, } from "@paperclipai/shared"; import { ISSUE_PRIORITIES, @@ -24,9 +27,14 @@ import { deriveProjectUrlKey, normalizeAgentUrlKey, } from "@paperclipai/shared"; +import { + readPaperclipSkillSyncPreference, + writePaperclipSkillSyncPreference, +} from "@paperclipai/adapter-utils/server-utils"; import { notFound, unprocessable } from "../errors.js"; import { accessService } from "./access.js"; import { agentService } from "./agents.js"; +import { companySkillService } from "./company-skills.js"; import { companyService } from "./companies.js"; import { issueService } from "./issues.js"; import { projectService } from "./projects.js"; @@ -475,6 +483,7 @@ const YAML_KEY_PRIORITY = [ "kind", "slug", "reportsTo", + "skills", "owner", "assignee", "project", @@ -594,6 +603,93 @@ function buildMarkdown(frontmatter: Record, body: string) { 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 = { + 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 }>) { const lines = ["# Agents", ""]; if (agentSummaries.length === 0) { @@ -854,6 +950,17 @@ function readAgentEnvInputs( }); } +function readAgentSkillRefs(frontmatter: Record) { + 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( files: Record, opts?: { sourceLabel?: { companyId: string; companyName: string } | null }, @@ -898,6 +1005,9 @@ function buildManifestFromPackageFiles( const referencedTaskPaths = includeEntries .map((entry) => resolvePortablePath(resolvedCompanyPath, entry.path)) .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( (entry) => entry.endsWith("/AGENTS.md") || entry === "AGENTS.md", ); @@ -907,9 +1017,13 @@ function buildManifestFromPackageFiles( const discoveredTaskPaths = Object.keys(normalizedFiles).filter( (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 projectPaths = Array.from(new Set([...referencedProjectPaths, ...discoveredProjectPaths])).sort(); const taskPaths = Array.from(new Set([...referencedTaskPaths, ...discoveredTaskPaths])).sort(); + const skillPaths = Array.from(new Set([...referencedSkillPaths, ...discoveredSkillPaths])).sort(); const manifest: CompanyPortabilityManifest = { schemaVersion: 3, @@ -932,6 +1046,7 @@ function buildManifestFromPackageFiles( : readCompanyApprovalDefault(companyFrontmatter), }, agents: [], + skills: [], projects: [], issues: [], envInputs: [], @@ -963,6 +1078,7 @@ function buildManifestFromPackageFiles( slug, name: asString(frontmatter.name) ?? title ?? slug, path: agentPath, + skills: readAgentSkillRefs(frontmatter), role: asString(extension.role) ?? "agent", title, 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 | undefined; + const sourceKind = asString(primarySource?.kind); + let sourceType = "catalog"; + let sourceLocator: string | null = null; + let sourceRef: string | null = null; + let normalizedMetadata: Record | 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) { const markdownRaw = normalizedFiles[projectPath]; if (typeof markdownRaw !== "string") { @@ -1163,6 +1362,7 @@ export function companyPortabilityService(db: Db) { const access = accessService(db); const projects = projectService(db); const issues = issueService(db); + const companySkills = companySkillService(db); async function resolveSource(source: CompanyPortabilityPreview["source"]): Promise { if (source.type === "inline") { @@ -1246,6 +1446,7 @@ export function companyPortabilityService(db: Db) { const relative = basePrefix ? entry.slice(basePrefix.length) : entry; return ( relative.endsWith(".md") || + relative.startsWith("skills/") || relative === ".paperclip.yaml" || relative === ".paperclip.yml" ); @@ -1296,6 +1497,7 @@ export function companyPortabilityService(db: Db) { const allAgentRows = include.agents ? await agents.list(companyId, { includeTerminated: true }) : []; const agentRows = allAgentRows.filter((agent) => agent.status !== "terminated"); + const companySkillRows = await companySkills.list(companyId); if (include.agents) { const skipped = allAgentRows.length - agentRows.length; if (skipped > 0) { @@ -1399,7 +1601,7 @@ export function companyPortabilityService(db: Db) { const projectSlugById = new Map(); const usedProjectSlugs = new Set(); 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)); } @@ -1431,6 +1633,22 @@ export function companyPortabilityService(db: Db) { const paperclipProjectsOut: Record> = {}; const paperclipTasksOut: Record> = {}; + 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) { for (const agent of agentRows) { const slug = idToSlug.get(agent.id)!; @@ -1467,6 +1685,9 @@ export function companyPortabilityService(db: Db) { .filter((inputValue) => inputValue.agentSlug === slug), ); const reportsToSlug = agent.reportsTo ? (idToSlug.get(agent.reportsTo) ?? null) : null; + const desiredSkills = readPaperclipSkillSyncPreference( + (agent.adapterConfig as Record) ?? {}, + ).desiredSkills; const commandValue = asString(portableAdapterConfig.command); if (commandValue && isAbsoluteCommand(commandValue)) { @@ -1475,11 +1696,12 @@ export function companyPortabilityService(db: Db) { } files[agentPath] = buildMarkdown( - { + stripEmptyValues({ name: agent.name, title: agent.title ?? null, reportsTo: reportsToSlug, - }, + skills: desiredSkills.length > 0 ? desiredSkills : undefined, + }) as Record, instructions.body, ); @@ -1627,6 +1849,8 @@ export function companyPortabilityService(db: Db) { warnings.push("No agents selected for import."); } + const availableSkillSlugs = new Set(source.manifest.skills.map((skill) => skill.slug)); + for (const agent of selectedAgents) { const filePath = ensureMarkdownPath(agent.path); const markdown = source.files[filePath]; @@ -1638,6 +1862,11 @@ export function companyPortabilityService(db: Db) { if (parsed.frontmatter.kind && parsed.frontmatter.kind !== "agent") { 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) { @@ -1912,6 +2141,8 @@ export function companyPortabilityService(db: Db) { existingProjectSlugToId.set(existing.urlKey, existing.id); } + await companySkills.importPackageFiles(targetCompany.id, plan.source.files); + if (include.agents) { for (const planAgent of plan.preview.plan.agentPlans) { const manifestAgent = plan.selectedAgents.find((agent) => agent.slug === planAgent.slug); @@ -1936,6 +2167,11 @@ export function companyPortabilityService(db: Db) { ...manifestAgent.adapterConfig, promptTemplate: markdown.body || asString((manifestAgent.adapterConfig as Record).promptTemplate) || "", } as Record; + const desiredSkills = manifestAgent.skills ?? []; + const adapterConfigWithSkills = writePaperclipSkillSyncPreference( + adapterConfig, + desiredSkills, + ); delete adapterConfig.instructionsFilePath; const patch = { name: planAgent.plannedName, @@ -1945,7 +2181,7 @@ export function companyPortabilityService(db: Db) { capabilities: manifestAgent.capabilities, reportsTo: null, adapterType: manifestAgent.adapterType, - adapterConfig, + adapterConfig: adapterConfigWithSkills, runtimeConfig: manifestAgent.runtimeConfig, budgetMonthlyCents: manifestAgent.budgetMonthlyCents, permissions: manifestAgent.permissions, diff --git a/server/src/services/company-skills.ts b/server/src/services/company-skills.ts index ccc30933..d0b6db69 100644 --- a/server/src/services/company-skills.ts +++ b/server/src/services/company-skills.ts @@ -34,6 +34,7 @@ type ImportedSkill = { name: string; description: string | null; markdown: string; + packageDir?: string | null; sourceType: CompanySkillSourceType; sourceLocator: string | null; sourceRef: string | null; @@ -72,6 +73,16 @@ function normalizePortablePath(input: string) { return input.replace(/\\/g, "/").replace(/^\.\/+/, "").replace(/^\/+/, ""); } +function normalizePackageFileMap(files: Record) { + const out: Record = {}; + 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) { return value ? normalizeAgentUrlKey(value) ?? null : null; } @@ -399,6 +410,111 @@ function deriveImportedSkillSlug(frontmatter: Record, fallback: return normalizeSkillSlug(asString(frontmatter.name)) ?? normalizeAgentUrlKey(fallback) ?? "skill"; } +function deriveImportedSkillSource( + frontmatter: Record, + fallbackSlug: string, +): Pick { + 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 | 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): 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[]) { const entries = await fs.readdir(current, { withFileTypes: true }); for (const entry of entries) { @@ -432,6 +548,7 @@ async function readLocalSkillImports(sourcePath: string): Promise, + ) { + 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): Promise { + 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 { const out: CompanySkill[] = []; for (const skill of imported) { 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 = { companyId, slug: skill.slug, @@ -1137,6 +1319,7 @@ export function companySkillService(db: Db) { updateFile, createLocalSkill, importFromSource, + importPackageFiles, installUpdate, }; } diff --git a/ui/src/pages/CompanySkills.tsx b/ui/src/pages/CompanySkills.tsx index 6843f390..09cde440 100644 --- a/ui/src/pages/CompanySkills.tsx +++ b/ui/src/pages/CompanySkills.tsx @@ -81,6 +81,27 @@ function stripFrontmatter(markdown: string) { 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[]) { const root: SkillTreeNode = { name: "", path: null, kind: "dir", children: [] }; @@ -778,7 +799,7 @@ export function CompanySkills() { useEffect(() => { if (fileQuery.data) { setDisplayedFile(fileQuery.data); - setDraft(fileQuery.data.content); + setDraft(fileQuery.data.markdown ? splitFrontmatter(fileQuery.data.content).body : fileQuery.data.content); } }, [fileQuery.data]); @@ -837,14 +858,19 @@ export function CompanySkills() { }); 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) => { await Promise.all([ queryClient.invalidateQueries({ queryKey: queryKeys.companySkills.list(selectedCompanyId!) }), queryClient.invalidateQueries({ queryKey: queryKeys.companySkills.detail(selectedCompanyId!, selectedSkillId!) }), queryClient.invalidateQueries({ queryKey: queryKeys.companySkills.file(selectedCompanyId!, selectedSkillId!, selectedPath) }), ]); - setDraft(result.content); + setDraft(result.markdown ? splitFrontmatter(result.content).body : result.content); setEditMode(false); pushToast({ tone: "success",