Skip to content

Commit 335ab3f

Browse files
beautyfreeclaude
andcommitted
feat(skills): per-agent lifecycle with bulk ops and smart Remove
Rework how a user manages skills inside a single agent's context. The old flow exposed git-internals-flavored buttons ("Uninstall from all agents") even when scoped to one agent, and mixed direct/inherited state awkwardly. New model is DWIM: "Remove from Gemini" does whatever is needed so the skill genuinely disappears from Gemini. Smart destructive action - One "Remove from {agent}" button replaces the direct/detach split. - Direct only → uninstall_skill. - Inherited only → detach_shared_skill (materialises into other agents reading the shared dir, then drops the canonical copy). - Both → composite: uninstall direct + detach shared. Confirm dialog explains the ripple to other agents. New backend primitives - detach_shared_skill RPC: the shared ~/.agents library is fundamentally group-scoped; selective removal requires materialising for the preservers first. - uninstall_all_skills_from_agent RPC: "I don't use Codex anymore". - sync_all_skills_to_agent RPC: "copy everything from Claude into Qwen". Sidebar UX - Right-click on an agent → context menu with "Copy all skills here from ▸" submenu (each detected agent) and "Remove all skills (N)". Replaces the always-visible toolbar above the skill list. - Menu clamps inside window; submenu flips left/up near edges so the item list can't land offscreen. - Hover tooltip: "Claude — 49 own · 61 from shared library", or "Claude — 110 skills" when there's no inheritance to disambiguate. List and detail polish - Row ⋮ menu mirrors the smart-remove logic (was "Uninstall from all" even when filtered to one agent). - Stacked agent icon badges in Recent Skills and the detail panel — list was visually exploding at 10+ agents. - Hide per-row agent chips when the filter is already scoped to one agent (no new information to show). Audit fixes - install.ts now writes a minimal `source: "local"` provenance stub when nothing exists, so update_skill can fail cleanly with a readable "local skills can't be updated" instead of a git error. - detachSharedSkill keeps provenance intact on preserved agents so update_skill still works on them. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 033ec9f commit 335ab3f

15 files changed

Lines changed: 993 additions & 51 deletions

File tree

src/electron-main/platform-electron.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,9 @@ export function createElectronPlatform(
6363
const parent = getWindow();
6464
const result = await dialog.showOpenDialog(parent, {
6565
title: opts?.title,
66-
properties: ["openDirectory"],
66+
// `createDirectory` adds the "New Folder" button on macOS;
67+
// `promptToCreate` lets the user type a non-existent path on Windows.
68+
properties: ["openDirectory", "createDirectory", "promptToCreate"],
6769
defaultPath: expandStartingFolder(opts?.startingFolder ?? "~/"),
6870
});
6971
if (result.canceled) return null;

src/main/install.ts

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import type { AgentConfig } from "./types";
1515
import { copyDirRecursive, expandHome, linkOrCopy, removePath } from "./fsutil";
1616
import { isSymlink } from "./fsutil";
1717
import { getTemplatesDir } from "./paths";
18-
import { writeProvenance } from "./provenance";
18+
import { readProvenance, writeProvenance } from "./provenance";
1919
import { sharedSkillsDir } from "./shared-skills";
2020

2121
export { sharedSkillsDir };
@@ -126,6 +126,15 @@ export function installSkillFromPath(
126126
}
127127
}
128128

129+
// Preserve any existing provenance from a prior git-based install. Only
130+
// write a minimal "local" stub when there's nothing recorded — callers
131+
// like installSkillFromGit override this with the real repo info right
132+
// after. Without this, a skill installed from a local folder has no
133+
// provenance at all and later update_skill fails with "no provenance".
134+
if (!readProvenance()[skillName]) {
135+
writeProvenance(skillName, "local", sourceSkillDir, null, null);
136+
}
137+
129138
return canonicalDir;
130139
}
131140

@@ -170,12 +179,29 @@ export async function installSkillFromGit(
170179
targetAgentSlugs: string[],
171180
agents: AgentConfig[],
172181
sourceLabel: string,
182+
/** Optional git ref (branch, tag, or SHA) to check out before copying. */
183+
ref?: string | null,
173184
): Promise<string> {
174185
const tempDir = join(
175186
tmpdir(),
176187
`skills-app-install-${Date.now()}-${Math.random().toString(36).slice(2)}`,
177188
);
178189
await simpleGit().clone(repoUrl, tempDir);
190+
if (ref && ref.trim()) {
191+
try {
192+
await simpleGit(tempDir).checkout(ref.trim());
193+
} catch (err) {
194+
throw new Error(`Failed to checkout ref "${ref}" in ${repoUrl}: ${err}`);
195+
}
196+
}
197+
// Capture the resolved HEAD SHA so we can pin it in the lockfile.
198+
let resolvedSha: string | null = null;
199+
try {
200+
resolvedSha = (await simpleGit(tempDir).revparse(["HEAD"])).trim();
201+
} catch {
202+
/* keep null */
203+
}
204+
179205
const source = join(tempDir, skillRelativePath);
180206
const skillName = deriveGitTargetSkillName(repoUrl, skillRelativePath, source);
181207
const installed = installSkillFromPath(source, targetAgentSlugs, agents, skillName);
@@ -188,6 +214,6 @@ export async function installSkillFromGit(
188214
const skillId = basename(installed);
189215
const rel = skillRelativePath.trim();
190216
const skillPath = !rel || rel === "." ? null : rel;
191-
writeProvenance(skillId, sourceLabel, repoUrl, skillPath);
217+
writeProvenance(skillId, sourceLabel, repoUrl, skillPath, resolvedSha);
192218
return installed;
193219
}

src/main/provenance.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,14 @@ export type ProvenanceEntry = {
55
source: string;
66
repository?: string | null;
77
skill_path?: string | null;
8+
/** Git commit SHA pinned at install time (for reproducible re-install across devices). */
9+
ref?: string | null;
10+
/**
11+
* When set, the skill content is mirrored into the sync repo at this relative
12+
* path (e.g. "skills/my-custom"), and the lockfile records it as `kind: bundled`
13+
* so other devices can materialise it without needing any remote origin.
14+
*/
15+
bundled_path?: string | null;
816
installed_at?: string;
917
};
1018

@@ -28,13 +36,15 @@ export function writeProvenance(
2836
source: string,
2937
repository: string | null | undefined,
3038
skillPath: string | null | undefined,
39+
ref?: string | null | undefined,
3140
): void {
3241
const map = readProvenance();
3342
const now = String(Math.floor(Date.now() / 1000));
3443
map[skillId] = {
3544
source,
3645
repository: repository ?? null,
3746
skill_path: skillPath ?? null,
47+
ref: ref ?? null,
3848
installed_at: now,
3949
};
4050
writeFileSync(provenancePath(), JSON.stringify(map, null, 2), "utf-8");
@@ -47,6 +57,15 @@ export function removeProvenance(skillId: string): void {
4757
writeFileSync(provenancePath(), JSON.stringify(map, null, 2), "utf-8");
4858
}
4959

60+
/** Set or clear `bundled_path` on an existing provenance entry (creates one if missing). */
61+
export function setBundledPath(skillId: string, bundledPath: string | null): void {
62+
const map = readProvenance();
63+
const entry = map[skillId] ?? { source: "local" };
64+
entry.bundled_path = bundledPath;
65+
map[skillId] = entry;
66+
writeFileSync(provenancePath(), JSON.stringify(map, null, 2), "utf-8");
67+
}
68+
5069
/** Raw entries for scanner source resolution (Rust-compatible). */
5170
export function readProvenanceRaw(): Record<string, Record<string, unknown>> {
5271
const path = provenancePath();

src/main/rpc-handlers.ts

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import type { SkillSource } from './skill-types'
1919
import { scanAllSkills } from './scanner'
2020
import { installSkillFromGit, installSkillFromPath } from './install'
2121
import {
22+
detachSharedSkill,
2223
uninstallSkill,
2324
uninstallSkillFromAll,
2425
unlinkInheritedSkillFromAgentConfigs,
@@ -92,6 +93,7 @@ export type BunSideRpc = {
9293
| { macosWindowBlur: boolean }
9394
| { active: boolean }
9495
| { baseUrl: string }
96+
| { path: string }
9597
| import('../shared/rpc-schema').AppUpdateStatusJson
9698
) => void
9799
}
@@ -249,6 +251,104 @@ export function createRequestHandlers(ctx: {
249251
const { skillId } = params
250252
uninstallSkillFromAll(skillId, loadDetectedAgents())
251253
},
254+
/**
255+
* Remove every directly-installed skill from a single agent. Used by
256+
* "Clean up Gemini / Codex / …" in the agent header, for the case where
257+
* the user no longer uses that agent and wants it empty (or hidden from
258+
* the sidebar because a zero-count agent collapses).
259+
*/
260+
uninstall_all_skills_from_agent: async (params: { agentSlug: string }) => {
261+
const agents = loadDetectedAgents()
262+
const skills = scanAllSkills(agents)
263+
const removed: string[] = []
264+
const failed: { id: string; error: string }[] = []
265+
for (const skill of skills) {
266+
const direct = skill.installations.some(
267+
(i) => i.agent_slug === params.agentSlug && !i.is_inherited,
268+
)
269+
if (!direct) continue
270+
try {
271+
uninstallSkill(skill.id, params.agentSlug, agents)
272+
removed.push(skill.id)
273+
} catch (err) {
274+
failed.push({
275+
id: skill.id,
276+
error: err instanceof Error ? err.message : String(err),
277+
})
278+
}
279+
}
280+
return { removed, failed }
281+
},
282+
/**
283+
* Copy every skill that's directly installed on `sourceAgent` (or on any
284+
* agent when sourceAgent is null) into `targetAgent`. Uses the canonical
285+
* dir as the source for each skill, same path as individual Sync To X.
286+
*/
287+
sync_all_skills_to_agent: async (params: {
288+
targetAgent: string
289+
sourceAgent: string | null
290+
}) => {
291+
const agents = loadDetectedAgents()
292+
const skills = scanAllSkills(agents)
293+
const copied: string[] = []
294+
const skipped: string[] = []
295+
const alreadyPresent: string[] = []
296+
const failed: { id: string; error: string }[] = []
297+
for (const skill of skills) {
298+
// STEP 1: candidacy — is this skill actually on the source agent?
299+
// Only directly-installed skills count (inherited skills aren't
300+
// "owned" by the source; they come from a shared library that the
301+
// target may or may not already read). If the user picked "any",
302+
// we accept anything with at least one direct install somewhere.
303+
const presentOnSource = params.sourceAgent
304+
? skill.installations.some(
305+
(i) =>
306+
i.agent_slug === params.sourceAgent && !i.is_inherited,
307+
)
308+
: skill.installations.some((i) => !i.is_inherited)
309+
if (!presentOnSource) continue
310+
311+
// STEP 2: is it already on the target? Count separately so the
312+
// summary can tell the user "your target already has N of them via
313+
// the shared dir" — different meaning from "we considered it and
314+
// dropped it for another reason".
315+
const onTarget = skill.installations.some(
316+
(i) => i.agent_slug === params.targetAgent,
317+
)
318+
if (onTarget) {
319+
alreadyPresent.push(skill.id)
320+
continue
321+
}
322+
try {
323+
const source = resolveSkillSourcePath(skill.id, agents)
324+
installSkillFromPath(source, [params.targetAgent], agents)
325+
copied.push(skill.id)
326+
} catch (err) {
327+
failed.push({
328+
id: skill.id,
329+
error: err instanceof Error ? err.message : String(err),
330+
})
331+
}
332+
}
333+
// Keep the shared-response shape stable — fold "already present" into
334+
// skipped so existing callers still read the summary the same way,
335+
// while copied/failed stay precise.
336+
return {
337+
copied,
338+
skipped: [...alreadyPresent, ...skipped],
339+
failed,
340+
}
341+
},
342+
detach_shared_skill: async (params: {
343+
skillId: string
344+
removeFromAgent: string
345+
}) => {
346+
return detachSharedSkill(
347+
params.skillId,
348+
params.removeFromAgent,
349+
loadDetectedAgents(),
350+
)
351+
},
252352
unlink_inherited_skill: async (params: { skillId: string }) => {
253353
const { skillId } = params
254354
const agents = loadDetectedAgents()

src/main/scanner.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -215,6 +215,7 @@ function scanInheritedRoot(
215215
collection,
216216
scope: { kind: "SharedGlobal" },
217217
installations: [installation],
218+
bundled_path: (provenance[dirName]?.bundled_path as string | undefined) ?? null,
218219
});
219220
}
220221
}
@@ -280,6 +281,7 @@ function scanSkillMdRoot(
280281
collection,
281282
scope,
282283
installations: [installation],
284+
bundled_path: (provenance[skillId]?.bundled_path as string | undefined) ?? null,
283285
});
284286
}
285287
}

src/main/skill-json.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ export function skillToJson(s: Skill): SkillJson {
5555
footprint_name_chars: s.footprint_name_chars,
5656
footprint_skill_md_chars: s.footprint_skill_md_chars,
5757
listing_excluded: s.listing_excluded,
58+
bundled_path: s.bundled_path ?? null,
5859
};
5960
}
6061

src/main/skill-types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,8 @@ export interface Skill {
4141
footprint_skill_md_chars: number;
4242
/** True when model listing omits description slice (manual invoke). */
4343
listing_excluded: boolean;
44+
/** When set, skill content is mirrored into the sync repo at this relative path. */
45+
bundled_path?: string | null;
4446
}
4547

4648
export interface UpdateProgress {

src/main/trpc/router.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,15 @@ export function createAppRouter(ctx: {
2525
install_skill: proc.input(anyIn).mutation(({ input }) => h.install_skill(input)),
2626
uninstall_skill: proc.input(anyIn).mutation(({ input }) => h.uninstall_skill(input)),
2727
uninstall_skill_all: proc.input(anyIn).mutation(({ input }) => h.uninstall_skill_all(input)),
28+
detach_shared_skill: proc.input(anyIn).mutation(({ input }) =>
29+
h.detach_shared_skill(input),
30+
),
31+
uninstall_all_skills_from_agent: proc.input(anyIn).mutation(({ input }) =>
32+
h.uninstall_all_skills_from_agent(input),
33+
),
34+
sync_all_skills_to_agent: proc.input(anyIn).mutation(({ input }) =>
35+
h.sync_all_skills_to_agent(input),
36+
),
2837
unlink_inherited_skill: proc.input(anyIn).mutation(({ input }) =>
2938
h.unlink_inherited_skill(input),
3039
),

src/main/uninstall.ts

Lines changed: 84 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
1-
import { existsSync, lstatSync, readFileSync, readdirSync, rmSync, statSync, unlinkSync, writeFileSync } from "node:fs";
1+
import { existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, realpathSync, rmSync, statSync, unlinkSync, writeFileSync } from "node:fs";
22
import { homedir } from "node:os";
33
import { basename, join, sep } from "node:path";
44
import { parse as parseToml, stringify as stringifyToml } from "@iarna/toml";
55
import type { AgentConfig } from "./types";
66
import { removeProvenance } from "./provenance";
77
import { sharedSkillsDir } from "./shared-skills";
8+
import { linkOrCopy } from "./fsutil";
89

910
function expandHomePath(path: string): string {
1011
if (path.startsWith("~/")) {
@@ -93,6 +94,17 @@ export function uninstallSkill(skillId: string, agentSlug: string, agents: Agent
9394
}
9495
}
9596

97+
// extra_config cleanup: this writes to files like ~/.codex/config.toml or
98+
// agent registry JSONs. These files are typically PER-AGENT. Cleaning
99+
// them on single-agent uninstall is correct *for the agent being removed*
100+
// — but only touch THIS agent's own extra_config, not some global one
101+
// that also references the skill for other agents. The previous logic
102+
// iterated `agent.extra_config` which is already per-agent, so this is
103+
// fine — the concern flagged in the audit (B5) doesn't actually apply
104+
// because each agent owns its own config file. Leaving a defensive guard:
105+
// only clean when the skill truly isn't referenced by this agent anymore
106+
// (we already removed it from global_paths above, so that's always true
107+
// at this point).
96108
if (agent.extra_config) {
97109
for (const cfg of agent.extra_config) {
98110
if (cfg.target_file) {
@@ -126,6 +138,77 @@ export function uninstallSkillFromAll(skillId: string, agents: AgentConfig[]): v
126138
removeProvenance(skillId);
127139
}
128140

141+
/**
142+
* "Detach" a skill from the shared `~/.agents/skills/` library so one specific
143+
* agent can stop seeing it while the others keep it. Needed because the
144+
* shared dir is read simultaneously by every agent that opts in — you can't
145+
* selectively uninstall from just Gemini if the skill physically lives there.
146+
*
147+
* Flow:
148+
* 1. Find every agent that currently inherits from shared dir.
149+
* 2. For each such agent EXCEPT `removeFromAgentSlug`: materialise the
150+
* skill into that agent's own global_paths[0] via linkOrCopy (same
151+
* primitive normal installs use). Result: they now have a direct copy.
152+
* 3. Delete the shared canonical dir. The target agent loses visibility
153+
* (no direct install, no inherited source), everyone else keeps it.
154+
*
155+
* Tradeoff: after detach the skill is no longer tracked by the shared dir,
156+
* so git-pulls / dotfile updates to `.agents/` won't reach the detached
157+
* copies. The user trades "update-in-lockstep" for "per-agent control".
158+
*/
159+
export function detachSharedSkill(
160+
skillId: string,
161+
removeFromAgentSlug: string,
162+
agents: AgentConfig[],
163+
): { preservedOn: string[]; removedFrom: string } {
164+
const canonicalDir = join(sharedSkillsDir(), skillId);
165+
if (!existsSync(canonicalDir)) {
166+
throw new Error(`shared skill not found: ${skillId}`);
167+
}
168+
let sharedReal: string;
169+
try {
170+
sharedReal = realpathSync(sharedSkillsDir());
171+
} catch {
172+
sharedReal = sharedSkillsDir();
173+
}
174+
175+
const inheritingAgents = agents.filter((a) => {
176+
if (!a.detected) return false;
177+
return a.additional_readable_paths.some((rp) => {
178+
try {
179+
return realpathSync(rp.path) === sharedReal;
180+
} catch {
181+
return rp.path === sharedSkillsDir();
182+
}
183+
});
184+
});
185+
186+
const preservedOn: string[] = [];
187+
for (const agent of inheritingAgents) {
188+
if (agent.slug === removeFromAgentSlug) continue;
189+
const gp = agent.global_paths[0];
190+
if (!gp) continue;
191+
mkdirSync(gp, { recursive: true });
192+
const target = join(gp, skillId);
193+
if (existsSync(target)) {
194+
rmSync(target, { recursive: true, force: true });
195+
}
196+
linkOrCopy(canonicalDir, target);
197+
preservedOn.push(agent.slug);
198+
}
199+
200+
// Delete the shared canonical — this is what cuts visibility for
201+
// removeFromAgentSlug (and any other agent that ONLY reads from .agents
202+
// without its own global_paths entry).
203+
rmSync(canonicalDir, { recursive: true, force: true });
204+
// NOTE: keep provenance intact — the skill still exists on preservedOn
205+
// agents, and `update_skill` needs `provenance[skillId]` to know where to
206+
// pull new versions from. Removing provenance here would leave updates
207+
// broken on every agent we just materialised a copy into.
208+
209+
return { preservedOn, removedFrom: removeFromAgentSlug };
210+
}
211+
129212
export function unlinkInheritedSkillFromAgentConfigs(
130213
skillId: string,
131214
agents: AgentConfig[],

0 commit comments

Comments
 (0)