Files
PaperClipAI/ui/src/lib/skill-create.ts
T
DottaandPaperclip 52aea90263 feat: organize skills with nested folders and My Skills (#9633)
## Thinking Path

> - Paperclip is the open source control plane people use to organize
and govern AI-agent companies
> - Company skills are durable resources that users browse, import,
assign, and maintain over time
> - A flat skill list plus tags does not provide a stable location or
hierarchy for personal, company, project-imported, and bundled skills
> - Folder paths need to be canonical, company-scoped, safe to move, and
preserved across re-imports without changing skill IDs
> - The `/skills` UI also needs traversal, breadcrumbs, move/create
flows, and a dedicated My Skills namespace that work on desktop and
mobile
> - This pull request adds the folder data model and APIs, reserved-root
lifecycle, project import behavior, and the folder-first skills
experience
> - The benefit is a predictable filesystem-like organization model
while tags remain available for cross-cutting classification

## Linked Issues or Issue Description

Refs #9619 — the reviewed folder foundation was intentionally closed and
folded into this combined feature PR.
Refs #9026 — earlier flat-folder attempt superseded by this integrated
implementation.
Refs #3281 — related skill organization proposal; this PR uses canonical
persisted folders rather than deriving groups from skill keys, and does
not add hidden-skill behavior.

**Feature request**

- **Problem:** Skills currently lack a canonical hierarchical location,
making personal skills, project imports, bundled skills, and
company-authored skills difficult to traverse and manage at scale.
- **Proposed behavior:** Add nested company-scoped folders with stable
paths, reserved My/Projects/Bundled roots, subtree queries, safe
move/create operations, and a folder-first `/skills` library UI.
- **Import behavior:** New project scans file skills under
`projects/<project-slug>`; later imports update content without
overriding a user-selected folder.
- **Alternatives considered:** Tags alone remain useful for
cross-cutting classification, but they do not provide canonical
location, nesting, reserved namespaces, or stable import placement.
- **Roadmap alignment:** Extends the completed Skills Manager and
Scheduled Routines capabilities without duplicating an active roadmap
item.

## What Changed

- Adds `folders` persistence for routine and skill folders, nested
canonical paths, parent/slug/system-key fields, migration backfills, and
reapply-safe migrations `0174`–`0175` after current master migrations.
- Adds company-scoped folder CRUD, cycle/depth/namespace validation,
reserved My/Projects/Bundled lifecycle, item moves, subtree filtering,
and folder paths on skill results.
- Preserves project-import placement: first import files into the
project folder, while re-import keeps user-owned placement and stable
skill IDs.
- Adds the `/skills` folder tree rail, tags facet, breadcrumbs,
subfolder browser, move/new-folder dialog, canonical detail location,
inline tag editing, and folder-aware Studio creation.
- Keeps bundled skills read-only even when their source metadata is
incomplete by detecting the reserved Bundled folder and hiding
selection/move actions.
- Extends routine folder UI and OpenAPI coverage, and adds regression
tests across migrations, services, routes, tree helpers, pages, and
Studio creation.

## Verification

- `pnpm exec vitest run
packages/db/src/nested-skill-folders-migration.test.ts
server/src/__tests__/folders-routes.test.ts
server/src/__tests__/folders-service.test.ts
server/src/__tests__/company-skills-service.test.ts
server/src/__tests__/routines-service.test.ts
ui/src/components/folders/FolderControls.test.tsx
ui/src/components/folders/SkillFolderTree.test.tsx
ui/src/components/folders/skill-folder-tree.test.ts
ui/src/pages/CompanySkills.test.tsx ui/src/pages/Routines.test.tsx
ui/src/pages/SkillStudio.test.tsx
ui/src/lib/company-skill-routes.test.ts ui/src/lib/skill-create.test.ts`
— 13 files, 192 tests passed.
- `pnpm exec vitest run ui/src/pages/CompanySkills.test.tsx
ui/src/components/folders/SkillFolderTree.test.tsx` — 2 files, 20 tests
passed after preserving the existing PR's bundled-skill fixes.
- `pnpm -r typecheck` — passed for all workspace packages.
- `pnpm test:run` — passed in an isolated CI-like environment with
inherited Paperclip runtime identity and static AWS credential variables
removed.
- `pnpm build` — production build passed for all workspace packages.
- Greptile iteration 2 — 5/5 confidence with zero unresolved threads on
commit `ff2d67aa71`.
- Latest-head GitHub checks — all success, neutral, or skipped; PR is
mergeable with a clean merge state.
- `pnpm check:token-gates` — reports nine existing `#9627` comment false
positives already present on `master`; this PR introduces no new token
violation.

## Risks

- **Migration/backfill:** `0174` creates the foundation and `0175` adds
nested/reserved semantics. Both are ordered after current master
migration `0173`, are covered by numbering/safety checks, and are
designed to be reapply-safe.
- **Reserved namespaces:** My, Projects, and Bundled roots are
service-managed. Regression coverage prevents namespace squatting,
cross-company folder use, bundled writes, cycles, and excessive depth.
- **Behavioral change:** Project scans choose a project folder only on
initial creation; existing skills deliberately retain their current
folder during refresh.
- **UI scope:** The folder rail applies to the Installed library;
Catalog retains the discovery-oriented category sidebar.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

- OpenAI `gpt-5.5` in Codex CLI, medium reasoning mode; runtime did not
expose a context-window value. Used repository/file tools, terminal
execution, Git/GitHub operations, test execution, and code editing.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-07-16 15:50:45 -05:00

138 lines
3.8 KiB
TypeScript

import type {
CompanySkillCreateRequest,
CompanySkillDetail,
CompanySkillSharingScope,
} from "@paperclipai/shared";
export const SKILL_CREATE_ACCENTS = [
"#6366f1", "#0ea5e9", "#10b981", "#f59e0b", "#ef4444",
"#8b5cf6", "#ec4899", "#14b8a6", "#f97316", "#22c55e",
"#3b82f6", "#a855f7",
];
export type SkillCreateDraft = {
name: string;
slug: string;
tagline: string;
description: string;
color: string;
categories: string[];
markdown: string;
sharingScope: Exclude<CompanySkillSharingScope, "public_link">;
forkedFromSkillId: string | null;
forkedFromName: string | null;
/** Destination folder for the new skill (null = Unfiled / top level). */
folderId: string | null;
};
export function normalizeSkillDraftSlug(value: string) {
return value
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 80);
}
export function splitCategoryDraft(value: string) {
const seen = new Set<string>();
const categories: string[] = [];
for (const entry of value.split(",")) {
const category = entry.trim().replace(/\s+/g, " ");
if (!category) continue;
const key = category.toLowerCase();
if (seen.has(key)) continue;
seen.add(key);
categories.push(category);
}
return categories;
}
export function defaultSkillMarkdown(name: string, tagline: string) {
const title = name.trim() || "New Skill";
const summary = tagline.trim() || "Describe when agents should use this skill.";
return [
"---",
`name: ${title}`,
`description: ${summary}`,
"---",
"",
`# ${title}`,
"",
summary,
"",
"## When To Use",
"",
"- Use this skill when the task needs its specialized workflow.",
"",
"## Workflow",
"",
"1. Inspect the task context.",
"2. Apply the workflow carefully.",
"3. Report what changed and how it was verified.",
"",
].join("\n");
}
export function skillAccentColor(key: string, explicit: string | null | undefined): string {
const trimmed = explicit?.trim();
if (trimmed) return trimmed;
let hash = 0;
for (let i = 0; i < key.length; i += 1) hash = (hash * 31 + key.charCodeAt(i)) >>> 0;
return SKILL_CREATE_ACCENTS[hash % SKILL_CREATE_ACCENTS.length]!;
}
export function buildBlankSkillDraft(): SkillCreateDraft {
return {
name: "",
slug: "",
tagline: "",
description: "",
color: SKILL_CREATE_ACCENTS[0]!,
categories: [],
markdown: defaultSkillMarkdown("", ""),
sharingScope: "company",
forkedFromSkillId: null,
forkedFromName: null,
folderId: null,
};
}
export function buildForkSkillDraft(skill: CompanySkillDetail): SkillCreateDraft {
const name = `${skill.name} Fork`;
const slug = normalizeSkillDraftSlug(`${skill.slug}-fork`);
return {
name,
slug,
tagline: skill.tagline ?? "",
description: skill.description ?? "",
color: skill.color ?? skillAccentColor(skill.key, null),
categories: skill.categories,
markdown: skill.markdown.replace(/^name:\s*.*$/m, `name: ${name}`),
sharingScope: "company",
forkedFromSkillId: skill.id,
forkedFromName: skill.name,
folderId: null,
};
}
export function skillCreateDraftToPayload(draft: SkillCreateDraft): CompanySkillCreateRequest {
const effectiveSlug = draft.slug.trim() || normalizeSkillDraftSlug(draft.name);
const effectiveMarkdown = draft.markdown.trim().length > 0
? draft.markdown
: defaultSkillMarkdown(draft.name, draft.tagline);
return {
name: draft.name.trim(),
slug: effectiveSlug || null,
description: draft.description.trim() || draft.tagline.trim() || null,
markdown: effectiveMarkdown,
color: draft.color,
tagline: draft.tagline.trim() || null,
categories: draft.categories,
sharingScope: draft.sharingScope,
forkedFromSkillId: draft.forkedFromSkillId,
folderId: draft.folderId ?? undefined,
};
}