Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 43 additions & 12 deletions app/(dashboard)/skills/_components/skills-library.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import {
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { enableGlobalSkill } from '@/lib/actions/skill'
import { enableGlobalSkill, uninstallGlobalSkill } from '@/lib/actions/skill'
import { getSkillCatalog } from '@/lib/skills/catalog'

type SkillsLibraryProps = {
Expand All @@ -24,25 +24,45 @@ type SkillsLibraryProps = {

export function SkillsLibrary({ enabledSkillIds }: SkillsLibraryProps) {
const router = useRouter()
const [pendingSkillId, setPendingSkillId] = useState<string | null>(null)
const [pendingOperation, setPendingOperation] = useState<{
skillId: string
type: 'enable' | 'uninstall'
} | null>(null)
const [isPending, startTransition] = useTransition()
const catalog = getSkillCatalog()
const enabledSkillSet = new Set(enabledSkillIds)

const handleEnable = (skillId: string) => {
startTransition(async () => {
setPendingSkillId(skillId)
setPendingOperation({ skillId, type: 'enable' })

const result = await enableGlobalSkill(skillId)
if (!result.success) {
toast.error(result.error)
setPendingSkillId(null)
setPendingOperation(null)
return
}

toast.success('Global skill enabled. Install tasks will fan out across your projects.')
router.refresh()
setPendingSkillId(null)
setPendingOperation(null)
})
}

const handleUninstall = (skillId: string) => {
startTransition(async () => {
setPendingOperation({ skillId, type: 'uninstall' })

const result = await uninstallGlobalSkill(skillId)
if (!result.success) {
toast.error(result.error)
setPendingOperation(null)
return
}

toast.success('Global skill removed. Uninstall tasks will converge existing projects.')
router.refresh()
setPendingOperation(null)
})
}

Expand All @@ -55,17 +75,19 @@ export function SkillsLibrary({ enabledSkillIds }: SkillsLibraryProps) {
<div className="space-y-2">
<h1 className="text-3xl font-display font-bold text-white">Skills</h1>
<p className="max-w-3xl text-sm leading-6 text-muted-foreground">
Enabling a skill here creates global desired state for the user. Existing projects get
`INSTALL_SKILL` tasks immediately, and new projects inherit the same skill when they
are created.
Skills here define global desired state for the user. Enabling fans out
`INSTALL_SKILL` tasks to existing projects and future projects inherit the skill.
Uninstalling removes the global desired state and fans out `UNINSTALL_SKILL` work
without auto-starting stopped sandboxes.
</p>
</div>
</div>

<div className="grid gap-4 md:grid-cols-2">
{catalog.map((skill) => {
const isEnabled = enabledSkillSet.has(skill.skillId)
const isLoading = isPending && pendingSkillId === skill.skillId
const isLoading = isPending && pendingOperation?.skillId === skill.skillId
const isUninstalling = isLoading && pendingOperation?.type === 'uninstall'

return (
<Card key={skill.skillId} className="border-border/80 bg-card/70">
Expand Down Expand Up @@ -108,10 +130,19 @@ export function SkillsLibrary({ enabledSkillIds }: SkillsLibraryProps) {
</Button>

<Button
onClick={() => handleEnable(skill.skillId)}
disabled={isEnabled || isLoading}
variant={isEnabled ? 'destructive' : 'default'}
onClick={() =>
isEnabled ? handleUninstall(skill.skillId) : handleEnable(skill.skillId)
}
disabled={isLoading}
>
{isEnabled ? 'Enabled' : isLoading ? 'Enabling...' : 'Enable Skill'}
{isEnabled
? isUninstalling
? 'Uninstalling...'
: 'Uninstall Skill'
: isLoading
? 'Enabling...'
: 'Enable Skill'}
</Button>
</CardFooter>
</Card>
Expand Down
2 changes: 1 addition & 1 deletion app/(dashboard)/skills/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { SkillsLibrary } from './_components/skills-library'

export const metadata = {
title: 'Skills | Fulling',
description: 'Enable global skills that will be installed across your projects.',
description: 'Manage global skills that install into or uninstall from your projects.',
}

export default async function SkillsPage() {
Expand Down
5 changes: 3 additions & 2 deletions docs/prds/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ Recommended split:
- One PRD per feature or workflow
- Use stable kebab-case file names
- Prefer names like `import-project-control-flow.md`
- Related PRDs may be grouped under a feature directory such as `skills/`

## Suggested PRD structure

Expand All @@ -59,5 +60,5 @@ Each PRD should usually include:
## Current PRDs

- [Import Project Control Flow](./import-project-control-flow.md)
- [Global Skill Enablement Control Flow](./global-skill-enablement-control-flow.md)
- [Uninstall Skill Control Flow](./uninstall-skill-control-flow.md)
- [Skills / Global Skill Enablement Control Flow](./skills/global-skill-enablement-control-flow.md)
- [Skills / Uninstall Skill Control Flow](./skills/uninstall-skill-control-flow.md)
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Global Skill Enablement Control Flow

Status: Draft
Status: Implemented

## Goal

Expand Down Expand Up @@ -36,6 +36,12 @@ This document does not define:
- auto-starting stopped projects only to install skills
- detailed project-level skill UI beyond required status semantics

Current phase note:

- the skill directory is a static local catalog
- users do not create custom skills from the UI in this phase
- the catalog is not yet backed by a remote marketplace or external source

## User Intent

When a user enables a skill from the global Skills tab, the system receives three
Expand Down Expand Up @@ -95,6 +101,10 @@ control plane has persisted installation work for that project.
For a currently running project, this normally means an `INSTALL_SKILL` task can
proceed immediately.

For the current implementation, the control plane should also proactively trigger
task evaluation for projects that are already `RUNNING`, rather than waiting only
for the next periodic reconcile cycle.

For a stopped or otherwise non-runnable project, this means an `INSTALL_SKILL`
task may remain pending until prerequisites are satisfied.

Expand Down Expand Up @@ -221,6 +231,7 @@ This allows valid combinations such as:
For the current phase of the product:

- the global Skills tab should reflect user-level enablement state
- the global Skills tab is backed by a static local catalog in this phase
- enabling a skill should return quickly after durable state is created
- the UI should not wait for all projects to finish installation before showing the skill as enabled
- stopped projects should not be presented as immediate installation failures
Expand Down Expand Up @@ -291,6 +302,8 @@ For the current phase of the product:
- project install tasks should receive `installCommand` in their payload
- install execution should consume the command from task payload rather than
inferring installation behavior dynamically at runtime
- `installCommand` must be non-interactive so sandbox execution can complete
without user input
- the current phase does not define editing an existing enabled skill's
`installCommand`

Expand All @@ -311,15 +324,22 @@ This PRD does not define:
Current implementation should preserve this product contract:

- the user action enables a skill at the global user scope, not the single-project scope
- the current Skills page reads from a static local catalog, not a user-authored
or remote marketplace-backed catalog
- `UserSkill` is the durable source of truth for the enabled skill and its `installCommand`
- project installation is asynchronous and should run through `ProjectTask`
- each `INSTALL_SKILL` task should contain an execution snapshot of the `installCommand`
- install execution should happen only when a project's sandbox is `RUNNING`
- `installCommand` should be written in non-interactive form
- sandbox lifecycle state remains separate from skill installation state
- future project creation should consult globally enabled skills and create install work automatically
- already-`RUNNING` projects should be triggered immediately after enablement so
installation does not rely only on cron pickup

Current codebase note:

- `ProjectTaskType` already reserves `INSTALL_SKILL` and `UNINSTALL_SKILL`
- task prerequisite evaluation already matches the desired sandbox `RUNNING` gate
- the install-skill executor and global enabled-skill persistence model are not yet implemented
- `UserSkill` persistence and `INSTALL_SKILL` fan-out are implemented
- task prerequisite evaluation matches the desired sandbox `RUNNING` gate
- import projects additionally wait for successful repository clone before skill installation
- the current catalog entry uses a non-interactive command form:
`npx -y skills add https://github.com/anthropics/skills --skill frontend-design -y`
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Uninstall Skill Control Flow

Status: Draft
Status: Implemented

## Goal

Expand Down Expand Up @@ -39,6 +39,12 @@ This document does not define:
- auto-starting stopped projects only to uninstall skills
- bulk operator tooling for failed uninstalls

Current phase note:

- the skill directory is a static local catalog
- users do not create custom skills from the UI in this phase
- the catalog is not yet backed by a remote marketplace or external source

## User Intent

When a user uninstalls a skill from the global Skills tab, the system receives
Expand Down Expand Up @@ -101,6 +107,10 @@ skill indefinitely.
For a currently running project, this normally means an `UNINSTALL_SKILL` task
can proceed immediately.

For the current implementation, the control plane should also proactively trigger
task evaluation for projects that are already `RUNNING`, rather than waiting only
for the next periodic reconcile cycle.

For a stopped or otherwise non-runnable project, this means uninstall work may
remain pending until prerequisites are satisfied.

Expand Down Expand Up @@ -253,6 +263,7 @@ For the current phase of the product:

- the global Skills tab should stop showing the skill as enabled once durable
uninstall state is created
- the global Skills tab is backed by a static local catalog in this phase
- the UI should not wait for all projects to finish uninstall before reflecting
the global uninstall
- stopped projects should not be presented as immediate uninstall failures
Expand Down Expand Up @@ -296,6 +307,7 @@ This currently implies the need for:
- `userSkillId`
- `skillId`
- `installCommand`
- `uninstallCommand`
- task result or error data that records project-level uninstall outcome

If uninstall fails for one project, the database must still clearly reflect:
Expand All @@ -321,9 +333,11 @@ For the current phase of the product:

- each globally enabled skill is represented by a `UserSkill` record that includes
`installCommand`
- the static catalog also defines the `uninstallCommand` used for removal
- uninstall removes that global `UserSkill` desired state
- historical uninstall and install tasks may still retain `installCommand` in their
payload as execution snapshots
- uninstall tasks should receive `uninstallCommand` in their payload as an execution snapshot
- uninstall execution should rely on task payload and task semantics rather than
attempting to rebuild prior install intent from mutable runtime state

Expand All @@ -344,6 +358,8 @@ This PRD does not define:
Current implementation should preserve this product contract:

- the user action removes a skill at the global user scope, not the single-project scope
- the current Skills page reads from a static local catalog, not a user-authored
or remote marketplace-backed catalog
- the removed global skill record is the `UserSkill` source of truth that previously
held the skill's `installCommand`
- project uninstall is asynchronous and should run through `ProjectTask`
Expand All @@ -358,6 +374,10 @@ Current implementation should preserve this product contract:
Current codebase note:

- `ProjectTaskType` already reserves `INSTALL_SKILL` and `UNINSTALL_SKILL`
- enable-side `UserSkill` persistence and `INSTALL_SKILL` fan-out are implemented
- task prerequisite evaluation already matches the desired sandbox `RUNNING` gate
- uninstall executor, task supersession rules, and global enabled-skill persistence
model are not yet implemented
- global uninstall removes the `UserSkill` desired state and fans out uninstall work
- pending and waiting install tasks for the same skill are cancelled when uninstall is accepted
- stale install work is prevented from winning over newer uninstall intent during task reconcile
- projects that are already `RUNNING` are triggered immediately after uninstall fan-out
- uninstall executor, uninstall UI entry, and global uninstall control flow are implemented
21 changes: 17 additions & 4 deletions lib/actions/skill.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,11 @@
'use server'

import type { UserSkill } from '@prisma/client'

import { auth } from '@/lib/auth'
import { enableGlobalSkillCommand } from '@/lib/platform/control/commands/skill'
import { enableGlobalSkillCommand, uninstallGlobalSkillCommand } from '@/lib/platform/control/commands/skill'

import type { ActionResult } from './types'

export async function enableGlobalSkill(skillId: string): Promise<ActionResult<UserSkill>> {
export async function enableGlobalSkill(skillId: string): Promise<ActionResult<{ skillId: string }>> {
const session = await auth()

if (!session) {
Expand All @@ -19,3 +17,18 @@ export async function enableGlobalSkill(skillId: string): Promise<ActionResult<U
skillId,
})
}

export async function uninstallGlobalSkill(
skillId: string
): Promise<ActionResult<{ skillId: string }>> {
const session = await auth()

if (!session) {
return { success: false, error: 'Unauthorized' }
}

return uninstallGlobalSkillCommand({
userId: session.user.id,
skillId,
})
}
3 changes: 3 additions & 0 deletions lib/jobs/project-task/executors/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type { ProjectTaskWithRelations } from '@/lib/repo/project-task'

import { type ProjectTaskExecutorResult, runCloneRepositoryTask } from './clone-repository'
import { runInstallSkillTask } from './install-skill'
import { runUninstallSkillTask } from './uninstall-skill'

export async function runProjectTaskExecutor(
task: ProjectTaskWithRelations
Expand All @@ -13,6 +14,8 @@ export async function runProjectTaskExecutor(
return runCloneRepositoryTask(task)
case 'INSTALL_SKILL':
return runInstallSkillTask(task)
case 'UNINSTALL_SKILL':
return runUninstallSkillTask(task)
default:
return {
success: false,
Expand Down
Loading
Loading