Skip to content

Commit 40995c4

Browse files
beautyfreeclaude
andcommitted
feat(projects): add project-scope installs with folder tree sidebar
Addresses the core pain: skills installed globally are invisible to Claude Code on iPhone / remote agents that only see the repo. Installing at the project scope now writes into <project>/.agents/skills/<name>/ (the canonical location from vercel-labs/skills) and symlinks into each detected non-universal agent's own dir (.claude/skills, .factory/skills, …). Universal agents (Codex, Cursor, Copilot CLI, Cline, etc.) read the canonical path directly, so one install covers every agent the user has detected. Backend (src/main/projects.ts + new RPCs): - list/add/remove projects with optional `group` (folder) and persistent `project_folders` registry - install flow with sanitizeSkillName + isPathSafe, symlink-per-agent - install_skill_to_project / install_repo_skill_to_project / install_marketplace_skill_to_project, uninstall_project_skill, set_project_group, add/rename/remove_project_folder Frontend: - New Projects page with resizable sidebar, folder tree (create via inline VS Code-style draft row, right-click rename/delete, drag projects into folders or to top level, collapsible sections) - Import scope step in the existing wizard (User / Project) + new InstallToProjectPicker surfaced from SkillsManager, Marketplace detail, and the Projects page empty state ("Copy from installed", "Import from Git", "Browse Marketplace") - ProjectSkillDetailModal with frontmatter-aware preview and editor for skills living inside a project - Sidebar projects counter, compact agent-icon chips on skill cards - Deleting a folder unregisters its projects (explicit confirmation — files on disk are untouched) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent ada55ec commit 40995c4

15 files changed

Lines changed: 2181 additions & 49 deletions

src/main/projects.ts

Lines changed: 340 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,340 @@
1+
import { existsSync, lstatSync, mkdirSync, readdirSync, rmSync, statSync } from "node:fs";
2+
import { tmpdir } from "node:os";
3+
import { basename, join, normalize, resolve, sep } from "node:path";
4+
import simpleGit from "simple-git";
5+
import type { ProjectEntryJson, ProjectSkillJson } from "../shared/rpc-schema";
6+
import { copyDirRecursive, linkOrCopy, removePath } from "./fsutil";
7+
import { parseSkillMdFile } from "./parser";
8+
import { readSettings, writeSettings } from "./settings";
9+
import type { MarketplaceSkill } from "./marketplace-types";
10+
import { resolveRepoPath } from "./repos";
11+
import { discoverSkillDirs } from "./scanner";
12+
import { detectAgents, loadAgentConfigs } from "./registry";
13+
import { getAgentsDir } from "./paths";
14+
import type { AgentConfig } from "./types";
15+
16+
/** Canonical skills dir for a project — mirrors vercel-labs/skills `.agents/skills` convention. */
17+
const UNIVERSAL_REL = ".agents/skills";
18+
export const UNIVERSAL_PROJECT_SKILLS_DIR = UNIVERSAL_REL;
19+
20+
export function projectCanonicalSkillsDir(projectPath: string): string {
21+
return join(projectPath, UNIVERSAL_REL);
22+
}
23+
24+
/**
25+
* Sanitize a skill directory name: kebab-case, strip path separators, prevent hidden files.
26+
* Ported from vercel-labs/skills.
27+
*/
28+
export function sanitizeSkillName(raw: string): string {
29+
const sanitized = raw
30+
.toLowerCase()
31+
.replace(/[^a-z0-9._]+/g, "-")
32+
.replace(/^[.\-]+|[.\-]+$/g, "");
33+
return sanitized.substring(0, 255) || "unnamed-skill";
34+
}
35+
36+
/** Verify `target` is inside `base` (after normalize/resolve). */
37+
function isPathSafe(base: string, target: string): boolean {
38+
const b = normalize(resolve(base));
39+
const t = normalize(resolve(target));
40+
return t === b || t.startsWith(b + sep);
41+
}
42+
43+
function nowIso(): string {
44+
return new Date().toISOString();
45+
}
46+
47+
function loadDetectedAgents(): AgentConfig[] {
48+
return detectAgents(loadAgentConfigs(getAgentsDir()));
49+
}
50+
51+
// ─── Projects settings ──────────────────────────────────────────────────────
52+
53+
export function listProjects(): ProjectEntryJson[] {
54+
const s = readSettings();
55+
return s.projects ?? [];
56+
}
57+
58+
export function addProject(path: string): ProjectEntryJson {
59+
if (!existsSync(path)) throw new Error(`path does not exist: ${path}`);
60+
const st = statSync(path);
61+
if (!st.isDirectory()) throw new Error(`path is not a directory: ${path}`);
62+
63+
const s = readSettings();
64+
const projects = s.projects ?? [];
65+
const existing = projects.find((p) => p.path === path);
66+
if (existing) {
67+
existing.last_used_at = nowIso();
68+
writeSettings({ ...s, projects });
69+
return existing;
70+
}
71+
const entry: ProjectEntryJson = {
72+
path,
73+
name: basename(path),
74+
added_at: nowIso(),
75+
last_used_at: nowIso(),
76+
};
77+
writeSettings({ ...s, projects: [...projects, entry] });
78+
return entry;
79+
}
80+
81+
export function removeProject(path: string): void {
82+
const s = readSettings();
83+
const projects = (s.projects ?? []).filter((p) => p.path !== path);
84+
writeSettings({ ...s, projects });
85+
}
86+
87+
// ─── Folder registry ───────────────────────────────────────────────────────
88+
89+
export function listProjectFolders(): string[] {
90+
const s = readSettings();
91+
return s.project_folders ?? [];
92+
}
93+
94+
export function addProjectFolder(name: string): string[] {
95+
const trimmed = name.trim();
96+
if (!trimmed) throw new Error("folder name is required");
97+
const s = readSettings();
98+
const folders = s.project_folders ?? [];
99+
if (folders.some((f) => f.toLowerCase() === trimmed.toLowerCase())) {
100+
throw new Error(`folder "${trimmed}" already exists`);
101+
}
102+
const next = [...folders, trimmed].sort((a, b) =>
103+
a.toLowerCase().localeCompare(b.toLowerCase()),
104+
);
105+
writeSettings({ ...s, project_folders: next });
106+
return next;
107+
}
108+
109+
export function removeProjectFolder(name: string): string[] {
110+
const s = readSettings();
111+
const folders = (s.project_folders ?? []).filter((f) => f !== name);
112+
// Unregister every project that lived inside this folder. Files on disk are untouched.
113+
const projects = (s.projects ?? []).filter((p) => p.group !== name);
114+
writeSettings({ ...s, project_folders: folders, projects });
115+
return folders;
116+
}
117+
118+
export function renameProjectFolder(from: string, to: string): string[] {
119+
const trimmed = to.trim();
120+
if (!trimmed) throw new Error("new folder name is required");
121+
const s = readSettings();
122+
const folders = s.project_folders ?? [];
123+
if (!folders.includes(from)) throw new Error(`folder not found: ${from}`);
124+
const next = folders
125+
.map((f) => (f === from ? trimmed : f))
126+
.filter((f, i, arr) => arr.indexOf(f) === i)
127+
.sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase()));
128+
const projects = (s.projects ?? []).map((p) =>
129+
p.group === from ? { ...p, group: trimmed } : p,
130+
);
131+
writeSettings({ ...s, project_folders: next, projects });
132+
return next;
133+
}
134+
135+
export function setProjectGroup(path: string, group: string | null): ProjectEntryJson {
136+
const s = readSettings();
137+
const projects = s.projects ?? [];
138+
const p = projects.find((x) => x.path === path);
139+
if (!p) throw new Error(`project not found: ${path}`);
140+
const normalized = group == null || group.trim() === "" ? null : group.trim();
141+
p.group = normalized;
142+
143+
// Auto-register a new folder name so it persists even if no project currently lives in it.
144+
let folders = s.project_folders ?? [];
145+
if (normalized && !folders.includes(normalized)) {
146+
folders = [...folders, normalized].sort((a, b) =>
147+
a.toLowerCase().localeCompare(b.toLowerCase()),
148+
);
149+
}
150+
writeSettings({ ...s, projects, project_folders: folders });
151+
return p;
152+
}
153+
154+
export function touchProject(path: string): void {
155+
const s = readSettings();
156+
const projects = s.projects ?? [];
157+
const p = projects.find((x) => x.path === path);
158+
if (!p) return;
159+
p.last_used_at = nowIso();
160+
writeSettings({ ...s, projects });
161+
}
162+
163+
// ─── Listing project skills ─────────────────────────────────────────────────
164+
165+
export function listProjectSkills(projectPath: string): ProjectSkillJson[] {
166+
const root = projectCanonicalSkillsDir(projectPath);
167+
if (!existsSync(root)) return [];
168+
const out: ProjectSkillJson[] = [];
169+
let entries: string[];
170+
try {
171+
entries = readdirSync(root);
172+
} catch {
173+
return [];
174+
}
175+
for (const name of entries) {
176+
const dir = join(root, name);
177+
let st;
178+
try {
179+
st = statSync(dir);
180+
} catch {
181+
continue;
182+
}
183+
if (!st.isDirectory()) continue;
184+
const skillMd = join(dir, "SKILL.md");
185+
if (!existsSync(skillMd)) continue;
186+
let parsed;
187+
try {
188+
parsed = parseSkillMdFile(skillMd);
189+
} catch {
190+
continue;
191+
}
192+
out.push({
193+
id: name,
194+
name: parsed.name ?? name,
195+
description: parsed.description ?? null,
196+
path: dir,
197+
});
198+
}
199+
out.sort((a, b) => a.name.toLowerCase().localeCompare(b.name.toLowerCase()));
200+
return out;
201+
}
202+
203+
// ─── Install ────────────────────────────────────────────────────────────────
204+
205+
/**
206+
* Install a skill into a project.
207+
*
208+
* Model (inspired by vercel-labs/skills):
209+
* 1. Copy skill into canonical `<project>/.agents/skills/<name>/`.
210+
* 2. For every detected non-universal agent (whose `project_skills_dir` differs from
211+
* `.agents/skills`), create a symlink from that agent's project dir to canonical —
212+
* so Claude Code (`.claude/skills`), Kilo (`.kilocode/skills`), Factory (`.factory/skills`),
213+
* etc. see the skill without a second copy.
214+
* 3. Universal agents (Codex, Cursor, Copilot, Cline, …) read `.agents/skills` natively —
215+
* no symlink needed.
216+
*/
217+
export function installSkillToProjectFromPath(
218+
sourceSkillDir: string,
219+
projectPath: string,
220+
targetSkillName?: string,
221+
): string {
222+
if (!existsSync(sourceSkillDir)) {
223+
throw new Error(`source skill directory not found: ${sourceSkillDir}`);
224+
}
225+
const canonicalRoot = projectCanonicalSkillsDir(projectPath);
226+
mkdirSync(canonicalRoot, { recursive: true });
227+
228+
const name = sanitizeSkillName(
229+
targetSkillName ?? basename(sourceSkillDir) ?? "skill",
230+
);
231+
const canonical = join(canonicalRoot, name);
232+
if (!isPathSafe(canonicalRoot, canonical)) {
233+
throw new Error(`unsafe skill name: ${name}`);
234+
}
235+
236+
if (existsSync(canonical)) rmSync(canonical, { recursive: true, force: true });
237+
copyDirRecursive(sourceSkillDir, canonical);
238+
239+
// Materialise the skill for every detected non-universal agent via a symlink.
240+
const agents = loadDetectedAgents();
241+
for (const agent of agents) {
242+
if (!agent.detected) continue;
243+
const rel = agent.project_skills_dir;
244+
if (!rel || rel === UNIVERSAL_REL) continue; // universal → reads canonical directly
245+
const agentRoot = join(projectPath, rel);
246+
const agentLink = join(agentRoot, name);
247+
if (!isPathSafe(projectPath, agentLink)) continue;
248+
try {
249+
mkdirSync(agentRoot, { recursive: true });
250+
if (existsSync(agentLink) || isSymlinkLoose(agentLink)) {
251+
removePath(agentLink);
252+
}
253+
linkOrCopy(canonical, agentLink);
254+
} catch (err) {
255+
console.warn(`project install: failed to link ${agent.slug}${agentLink}:`, err);
256+
}
257+
}
258+
259+
touchProject(projectPath);
260+
return canonical;
261+
}
262+
263+
function isSymlinkLoose(p: string): boolean {
264+
try {
265+
return lstatSync(p).isSymbolicLink();
266+
} catch {
267+
return false;
268+
}
269+
}
270+
271+
export async function installSkillToProjectFromGit(
272+
repoUrl: string,
273+
skillRelativePath: string,
274+
projectPath: string,
275+
): Promise<string> {
276+
const tempDir = join(
277+
tmpdir(),
278+
`skills-app-project-install-${Date.now()}-${Math.random().toString(36).slice(2)}`,
279+
);
280+
await simpleGit().clone(repoUrl, tempDir);
281+
try {
282+
const source = join(tempDir, skillRelativePath);
283+
const rel = skillRelativePath.trim();
284+
const nameBase =
285+
!rel || rel === "."
286+
? (repoUrl.trim().replace(/\/$/, "").split("/").pop() ?? "skill").replace(/\.git$/, "")
287+
: basename(rel);
288+
return installSkillToProjectFromPath(source, projectPath, nameBase);
289+
} finally {
290+
try {
291+
rmSync(tempDir, { recursive: true, force: true });
292+
} catch {
293+
/* ignore */
294+
}
295+
}
296+
}
297+
298+
export function installRepoSkillToProject(
299+
repoIdParam: string,
300+
skillId: string,
301+
projectPath: string,
302+
): string {
303+
const localPath = resolveRepoPath(repoIdParam);
304+
if (!existsSync(localPath)) throw new Error("Repository not found locally");
305+
const candidates = discoverSkillDirs(localPath);
306+
const skillPath = candidates.find((c) => basename(c.dir) === skillId)?.dir;
307+
if (!skillPath) throw new Error(`Skill '${skillId}' not found in repository`);
308+
return installSkillToProjectFromPath(skillPath, projectPath, skillId);
309+
}
310+
311+
export async function installMarketplaceSkillToProject(
312+
skill: MarketplaceSkill,
313+
projectPath: string,
314+
): Promise<string> {
315+
const repo = skill.repository?.trim();
316+
if (!repo) throw new Error("marketplace skill has no repository url");
317+
return installSkillToProjectFromGit(repo, ".", projectPath);
318+
}
319+
320+
// ─── Uninstall ──────────────────────────────────────────────────────────────
321+
322+
/**
323+
* Remove canonical skill directory plus every agent-specific symlink that points at it.
324+
*/
325+
export function uninstallProjectSkill(projectPath: string, skillId: string): void {
326+
const canonical = join(projectCanonicalSkillsDir(projectPath), skillId);
327+
if (existsSync(canonical) || isSymlinkLoose(canonical)) removePath(canonical);
328+
329+
const agents = loadDetectedAgents();
330+
for (const agent of agents) {
331+
const rel = agent.project_skills_dir;
332+
if (!rel || rel === UNIVERSAL_REL) continue;
333+
const link = join(projectPath, rel, skillId);
334+
if (!isPathSafe(projectPath, link)) continue;
335+
if (existsSync(link) || isSymlinkLoose(link)) {
336+
removePath(link);
337+
}
338+
}
339+
}
340+

0 commit comments

Comments
 (0)