feat: add project-level skill scanning and Projects sidebar filter

Scan all directories under a configurable home folder for project-level
skills (.claude/skills, .codex/skills, .cursor/skills, etc.) and display
them grouped by project in a new "Projects" sidebar section.

- Add projectScanEnabled and projectsHomeDir settings
- Scan ~/project/.tool/skills for each installed tool using the tool's
  own path config (correct tool attribution)
- Add Projects section to sidebar with per-project filtering
- Show absolute file path in frontmatter detail view
- Skip .Trash, Library, and other system dirs to avoid EPERM crashes
- Try/catch per-project scan so one failure doesn't kill the rest

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Michael Leydon 2026-03-28 22:31:46 -04:00 committed by Railly
parent 7bd89a3856
commit fcd77ef70e
8 changed files with 172 additions and 9 deletions

File diff suppressed because one or more lines are too long

View file

@ -60,7 +60,7 @@ export default class AgentfilesPlugin extends Plugin {
this.watcher = new SkillWatcher(this.settings.watchDebounceMs, () =>
this.refreshStore()
);
this.watcher.watchPaths(getWatchPaths());
this.watcher.watchPaths(getWatchPaths(this.settings));
}
stopWatcher(): void {

View file

@ -5,11 +5,12 @@ import {
realpathSync,
statSync,
} from "fs";
import { join, basename, extname } from "path";
import { join, basename, extname, relative } from "path";
import { homedir } from "os";
import { parseYaml } from "obsidian";
import { createHash } from "crypto";
import { TOOL_CONFIGS } from "./tool-configs";
import type { SkillItem, SkillPath, SkillType, ChopsSettings } from "./types";
import type { SkillItem, SkillPath, SkillType, ChopsSettings, ToolConfig } from "./types";
const IGNORED_FILES = new Set([
"readme.md",
@ -211,6 +212,64 @@ function scanProjectSkills(projectRoot: string, toolId: string): SkillItem[] {
return results;
}
function getProjectsHomeDir(settings: ChopsSettings): string {
return settings.projectsHomeDir || homedir();
}
function scanToolProjectPaths(
projectRoot: string,
tool: ToolConfig
): SkillItem[] {
const home = homedir();
const results: SkillItem[] = [];
for (const sp of [...tool.paths, ...tool.agentPaths]) {
const rel = relative(home, sp.baseDir);
if (rel.startsWith("..") || rel.startsWith("/")) continue;
const projectPath = join(projectRoot, rel);
if (!existsSync(projectPath)) continue;
try {
results.push(...scanPath({ ...sp, baseDir: projectPath }, tool.id));
} catch { /* permission errors, broken symlinks, etc */ }
}
return results;
}
function scanProjectRoots(settings: ChopsSettings): { items: SkillItem[]; toolId: string }[] {
const homeDir = getProjectsHomeDir(settings);
if (!existsSync(homeDir)) return [];
const results: { items: SkillItem[]; toolId: string }[] = [];
try {
const SKIP_DIRS = new Set(["node_modules", ".Trash", "Library", "Applications", "Music", "Movies", "Pictures", "Public"]);
for (const entry of readdirSync(homeDir, { withFileTypes: true })) {
if (!entry.isDirectory() && !entry.isSymbolicLink()) continue;
if (SKIP_DIRS.has(entry.name)) continue;
const projectPath = join(homeDir, entry.name);
for (const tool of TOOL_CONFIGS) {
if (!tool.isInstalled()) continue;
const toolSettings = settings.tools[tool.id];
if (toolSettings && !toolSettings.enabled) continue;
const items = scanToolProjectPaths(projectPath, tool);
if (items.length > 0) {
results.push({ items, toolId: tool.id });
}
}
}
} catch { /* permission errors, etc */ }
return results;
}
export function getProjectName(filePath: string, projectsHomeDir: string): string {
const homeDir = projectsHomeDir || homedir();
if (!filePath.startsWith(homeDir + "/")) return "global";
const rest = filePath.slice(homeDir.length + 1);
const parts = rest.split("/");
if (parts.length > 1 && !parts[0].startsWith(".")) {
return parts[0];
}
return "global";
}
export function scanAll(settings: ChopsSettings): Map<string, SkillItem> {
const items = new Map<string, SkillItem>();
const nameMap = new Map<string, string>();
@ -263,6 +322,14 @@ export function scanAll(settings: ChopsSettings): Map<string, SkillItem> {
}
}
if (settings.projectScanEnabled) {
for (const { items: projectItems, toolId } of scanProjectRoots(settings)) {
for (const item of projectItems) {
addItem(item, toolId);
}
}
}
return items;
}
@ -270,7 +337,7 @@ export function getInstalledTools(): string[] {
return TOOL_CONFIGS.filter((t) => t.isInstalled()).map((t) => t.id);
}
export function getWatchPaths(): string[] {
export function getWatchPaths(settings?: ChopsSettings): string[] {
const paths: string[] = [];
for (const tool of TOOL_CONFIGS) {
if (!tool.isInstalled()) continue;
@ -280,5 +347,19 @@ export function getWatchPaths(): string[] {
}
}
}
if (settings?.projectScanEnabled) {
const homeDir = getProjectsHomeDir(settings);
const SKIP_DIRS = new Set(["node_modules", ".Trash", "Library", "Applications", "Music", "Movies", "Pictures", "Public"]);
try {
for (const entry of readdirSync(homeDir, { withFileTypes: true })) {
if ((!entry.isDirectory() && !entry.isSymbolicLink()) || SKIP_DIRS.has(entry.name)) continue;
const projectPath = join(homeDir, entry.name);
for (const dir of [".claude/skills", ".claude/commands", ".claude/agents", ".cursor/skills", ".codex/skills"]) {
const fullPath = join(projectPath, dir);
if (existsSync(fullPath)) paths.push(fullPath);
}
}
} catch { /* permission errors */ }
}
return paths;
}

View file

@ -44,6 +44,41 @@ export class AgentfilesSettingTab extends PluginSettingTab {
})
);
new Setting(containerEl).setName("Project scanning").setHeading();
new Setting(containerEl)
.setName("Enable project scanning")
.setDesc(
"Scan all directories under the projects home folder for project-level skills"
)
.addToggle((toggle) =>
toggle
.setValue(this.plugin.settings.projectScanEnabled)
.onChange(async (value) => {
this.plugin.settings.projectScanEnabled = value;
await this.plugin.saveSettings();
this.plugin.refreshStore();
this.plugin.restartWatcher();
})
);
new Setting(containerEl)
.setName("Projects home directory")
.setDesc(
"Root directory to scan for project-level skills. Leave empty for home directory (~)."
)
.addText((text) =>
text
.setPlaceholder("~")
.setValue(this.plugin.settings.projectsHomeDir)
.onChange(async (value) => {
this.plugin.settings.projectsHomeDir = value;
await this.plugin.saveSettings();
this.plugin.refreshStore();
this.plugin.restartWatcher();
})
);
new Setting(containerEl).setName("Tools").setHeading();
for (const tool of TOOL_CONFIGS) {

View file

@ -1,12 +1,13 @@
import { Events } from "obsidian";
import type { SkillItem, SidebarFilter, ChopsSettings } from "./types";
import { scanAll } from "./scanner";
import { scanAll, getProjectName } from "./scanner";
import { getSkillkitStats, isSkillkitAvailable } from "./skillkit";
export class SkillStore extends Events {
private items: Map<string, SkillItem> = new Map();
private _filter: SidebarFilter = { kind: "all" };
private _searchQuery = "";
private _projectsHomeDir = "";
get filter(): SidebarFilter {
return this._filter;
@ -40,6 +41,11 @@ export class SkillStore extends Events {
i.collections.includes(this._filter.name)
);
break;
case "project":
result = result.filter(
(i) => getProjectName(i.filePath, this._projectsHomeDir) === this._filter.project
);
break;
}
if (this._searchQuery) {
@ -64,6 +70,7 @@ export class SkillStore extends Events {
}
refresh(settings: ChopsSettings): void {
this._projectsHomeDir = settings.projectsHomeDir;
this.items = scanAll(settings);
this.enrichWithSkillkit();
this.trigger("updated");
@ -132,4 +139,13 @@ export class SkillStore extends Events {
}
return counts;
}
getProjectCounts(): Map<string, number> {
const counts = new Map<string, number>();
for (const item of this.items.values()) {
const project = getProjectName(item.filePath, this._projectsHomeDir);
counts.set(project, (counts.get(project) || 0) + 1);
}
return counts;
}
}

View file

@ -47,7 +47,8 @@ export type SidebarFilter =
| { kind: "favorites" }
| { kind: "tool"; toolId: string }
| { kind: "type"; type: SkillType }
| { kind: "collection"; name: string };
| { kind: "collection"; name: string }
| { kind: "project"; project: string };
export interface ChopsSettings {
tools: Record<string, { enabled: boolean; customPaths: string[] }>;
@ -56,6 +57,8 @@ export interface ChopsSettings {
favorites: string[];
collections: Record<string, string[]>;
customScanPaths: string[];
projectScanEnabled: boolean;
projectsHomeDir: string;
}
export const DEFAULT_SETTINGS: ChopsSettings = {
@ -65,4 +68,6 @@ export const DEFAULT_SETTINGS: ChopsSettings = {
favorites: [],
collections: {},
customScanPaths: [],
projectScanEnabled: true,
projectsHomeDir: "",
};

View file

@ -163,10 +163,16 @@ export class DetailPanel {
private renderFrontmatter(container: HTMLElement, item: SkillItem): void {
const keys = Object.keys(item.frontmatter);
if (keys.length === 0) return;
const section = container.createDiv("as-frontmatter");
if (item.filePath) {
const pathProp = section.createDiv("as-fm-prop");
pathProp.createSpan({ cls: "as-fm-key", text: "path" });
pathProp.createSpan({ cls: "as-fm-value", text: item.filePath });
}
if (keys.length === 0 && !item.filePath) return;
for (const key of keys) {
const value = item.frontmatter[key];
if (value === undefined || value === null) continue;

View file

@ -28,6 +28,7 @@ export class SidebarPanel {
this.renderLibrarySection();
this.renderTypeSection();
this.renderToolSection();
this.renderProjectSection();
this.renderCollectionSection();
if (!this.store.hasSkillkit) {
@ -123,6 +124,23 @@ export class SidebarPanel {
}
}
private renderProjectSection(): void {
const projectCounts = this.store.getProjectCounts();
if (projectCounts.size === 0) return;
const items: { label: string; icon: string; filter: SidebarFilter; count: number }[] = [];
for (const [project, count] of projectCounts) {
items.push({
label: project,
icon: "folder-git-2",
filter: { kind: "project", project },
count,
});
}
items.sort((a, b) => a.label.localeCompare(b.label));
this.renderSection("Projects", items);
}
private renderCollectionSection(): void {
const section = this.containerEl.createDiv("as-sidebar-section");
section.createDiv({ cls: "as-sidebar-title", text: "Collections" });
@ -226,6 +244,8 @@ export class SidebarPanel {
return current.type === filter.type;
if (current.kind === "collection" && filter.kind === "collection")
return current.name === filter.name;
if (current.kind === "project" && filter.kind === "project")
return current.project === filter.project;
return true;
}
}