Skip to content

Commit 5cfc59a

Browse files
authored
feat: add global skill uninstall flow (#153)
1 parent 67c34d3 commit 5cfc59a

14 files changed

Lines changed: 547 additions & 32 deletions

File tree

app/(dashboard)/skills/_components/skills-library.tsx

Lines changed: 43 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import {
1515
CardHeader,
1616
CardTitle,
1717
} from '@/components/ui/card'
18-
import { enableGlobalSkill } from '@/lib/actions/skill'
18+
import { enableGlobalSkill, uninstallGlobalSkill } from '@/lib/actions/skill'
1919
import { getSkillCatalog } from '@/lib/skills/catalog'
2020

2121
type SkillsLibraryProps = {
@@ -24,25 +24,45 @@ type SkillsLibraryProps = {
2424

2525
export function SkillsLibrary({ enabledSkillIds }: SkillsLibraryProps) {
2626
const router = useRouter()
27-
const [pendingSkillId, setPendingSkillId] = useState<string | null>(null)
27+
const [pendingOperation, setPendingOperation] = useState<{
28+
skillId: string
29+
type: 'enable' | 'uninstall'
30+
} | null>(null)
2831
const [isPending, startTransition] = useTransition()
2932
const catalog = getSkillCatalog()
3033
const enabledSkillSet = new Set(enabledSkillIds)
3134

3235
const handleEnable = (skillId: string) => {
3336
startTransition(async () => {
34-
setPendingSkillId(skillId)
37+
setPendingOperation({ skillId, type: 'enable' })
3538

3639
const result = await enableGlobalSkill(skillId)
3740
if (!result.success) {
3841
toast.error(result.error)
39-
setPendingSkillId(null)
42+
setPendingOperation(null)
4043
return
4144
}
4245

4346
toast.success('Global skill enabled. Install tasks will fan out across your projects.')
4447
router.refresh()
45-
setPendingSkillId(null)
48+
setPendingOperation(null)
49+
})
50+
}
51+
52+
const handleUninstall = (skillId: string) => {
53+
startTransition(async () => {
54+
setPendingOperation({ skillId, type: 'uninstall' })
55+
56+
const result = await uninstallGlobalSkill(skillId)
57+
if (!result.success) {
58+
toast.error(result.error)
59+
setPendingOperation(null)
60+
return
61+
}
62+
63+
toast.success('Global skill removed. Uninstall tasks will converge existing projects.')
64+
router.refresh()
65+
setPendingOperation(null)
4666
})
4767
}
4868

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

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

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

110132
<Button
111-
onClick={() => handleEnable(skill.skillId)}
112-
disabled={isEnabled || isLoading}
133+
variant={isEnabled ? 'destructive' : 'default'}
134+
onClick={() =>
135+
isEnabled ? handleUninstall(skill.skillId) : handleEnable(skill.skillId)
136+
}
137+
disabled={isLoading}
113138
>
114-
{isEnabled ? 'Enabled' : isLoading ? 'Enabling...' : 'Enable Skill'}
139+
{isEnabled
140+
? isUninstalling
141+
? 'Uninstalling...'
142+
: 'Uninstall Skill'
143+
: isLoading
144+
? 'Enabling...'
145+
: 'Enable Skill'}
115146
</Button>
116147
</CardFooter>
117148
</Card>

app/(dashboard)/skills/page.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import { SkillsLibrary } from './_components/skills-library'
77

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

1313
export default async function SkillsPage() {

docs/prds/README.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ Recommended split:
4141
- One PRD per feature or workflow
4242
- Use stable kebab-case file names
4343
- Prefer names like `import-project-control-flow.md`
44+
- Related PRDs may be grouped under a feature directory such as `skills/`
4445

4546
## Suggested PRD structure
4647

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

6162
- [Import Project Control Flow](./import-project-control-flow.md)
62-
- [Global Skill Enablement Control Flow](./global-skill-enablement-control-flow.md)
63-
- [Uninstall Skill Control Flow](./uninstall-skill-control-flow.md)
63+
- [Skills / Global Skill Enablement Control Flow](./skills/global-skill-enablement-control-flow.md)
64+
- [Skills / Uninstall Skill Control Flow](./skills/uninstall-skill-control-flow.md)

docs/prds/global-skill-enablement-control-flow.md renamed to docs/prds/skills/global-skill-enablement-control-flow.md

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Global Skill Enablement Control Flow
22

3-
Status: Draft
3+
Status: Implemented
44

55
## Goal
66

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

39+
Current phase note:
40+
41+
- the skill directory is a static local catalog
42+
- users do not create custom skills from the UI in this phase
43+
- the catalog is not yet backed by a remote marketplace or external source
44+
3945
## User Intent
4046

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

104+
For the current implementation, the control plane should also proactively trigger
105+
task evaluation for projects that are already `RUNNING`, rather than waiting only
106+
for the next periodic reconcile cycle.
107+
98108
For a stopped or otherwise non-runnable project, this means an `INSTALL_SKILL`
99109
task may remain pending until prerequisites are satisfied.
100110

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

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

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

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

321339
Current codebase note:
322340

323-
- `ProjectTaskType` already reserves `INSTALL_SKILL` and `UNINSTALL_SKILL`
324-
- task prerequisite evaluation already matches the desired sandbox `RUNNING` gate
325-
- the install-skill executor and global enabled-skill persistence model are not yet implemented
341+
- `UserSkill` persistence and `INSTALL_SKILL` fan-out are implemented
342+
- task prerequisite evaluation matches the desired sandbox `RUNNING` gate
343+
- import projects additionally wait for successful repository clone before skill installation
344+
- the current catalog entry uses a non-interactive command form:
345+
`npx -y skills add https://github.com/anthropics/skills --skill frontend-design -y`

docs/prds/uninstall-skill-control-flow.md renamed to docs/prds/skills/uninstall-skill-control-flow.md

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Uninstall Skill Control Flow
22

3-
Status: Draft
3+
Status: Implemented
44

55
## Goal
66

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

42+
Current phase note:
43+
44+
- the skill directory is a static local catalog
45+
- users do not create custom skills from the UI in this phase
46+
- the catalog is not yet backed by a remote marketplace or external source
47+
4248
## User Intent
4349

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

110+
For the current implementation, the control plane should also proactively trigger
111+
task evaluation for projects that are already `RUNNING`, rather than waiting only
112+
for the next periodic reconcile cycle.
113+
104114
For a stopped or otherwise non-runnable project, this means uninstall work may
105115
remain pending until prerequisites are satisfied.
106116

@@ -253,6 +263,7 @@ For the current phase of the product:
253263

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

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

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

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

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

360376
- `ProjectTaskType` already reserves `INSTALL_SKILL` and `UNINSTALL_SKILL`
377+
- enable-side `UserSkill` persistence and `INSTALL_SKILL` fan-out are implemented
361378
- task prerequisite evaluation already matches the desired sandbox `RUNNING` gate
362-
- uninstall executor, task supersession rules, and global enabled-skill persistence
363-
model are not yet implemented
379+
- global uninstall removes the `UserSkill` desired state and fans out uninstall work
380+
- pending and waiting install tasks for the same skill are cancelled when uninstall is accepted
381+
- stale install work is prevented from winning over newer uninstall intent during task reconcile
382+
- projects that are already `RUNNING` are triggered immediately after uninstall fan-out
383+
- uninstall executor, uninstall UI entry, and global uninstall control flow are implemented

lib/actions/skill.ts

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,11 @@
11
'use server'
22

3-
import type { UserSkill } from '@prisma/client'
4-
53
import { auth } from '@/lib/auth'
6-
import { enableGlobalSkillCommand } from '@/lib/platform/control/commands/skill'
4+
import { enableGlobalSkillCommand, uninstallGlobalSkillCommand } from '@/lib/platform/control/commands/skill'
75

86
import type { ActionResult } from './types'
97

10-
export async function enableGlobalSkill(skillId: string): Promise<ActionResult<UserSkill>> {
8+
export async function enableGlobalSkill(skillId: string): Promise<ActionResult<{ skillId: string }>> {
119
const session = await auth()
1210

1311
if (!session) {
@@ -19,3 +17,18 @@ export async function enableGlobalSkill(skillId: string): Promise<ActionResult<U
1917
skillId,
2018
})
2119
}
20+
21+
export async function uninstallGlobalSkill(
22+
skillId: string
23+
): Promise<ActionResult<{ skillId: string }>> {
24+
const session = await auth()
25+
26+
if (!session) {
27+
return { success: false, error: 'Unauthorized' }
28+
}
29+
30+
return uninstallGlobalSkillCommand({
31+
userId: session.user.id,
32+
skillId,
33+
})
34+
}

lib/jobs/project-task/executors/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import type { ProjectTaskWithRelations } from '@/lib/repo/project-task'
44

55
import { type ProjectTaskExecutorResult, runCloneRepositoryTask } from './clone-repository'
66
import { runInstallSkillTask } from './install-skill'
7+
import { runUninstallSkillTask } from './uninstall-skill'
78

89
export async function runProjectTaskExecutor(
910
task: ProjectTaskWithRelations
@@ -13,6 +14,8 @@ export async function runProjectTaskExecutor(
1314
return runCloneRepositoryTask(task)
1415
case 'INSTALL_SKILL':
1516
return runInstallSkillTask(task)
17+
case 'UNINSTALL_SKILL':
18+
return runUninstallSkillTask(task)
1619
default:
1720
return {
1821
success: false,

0 commit comments

Comments
 (0)