mirror of
https://github.com/railly/agentfiles.git
synced 2026-07-22 14:30:25 +00:00
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:
parent
7bd89a3856
commit
fcd77ef70e
8 changed files with 172 additions and 9 deletions
2
main.js
2
main.js
File diff suppressed because one or more lines are too long
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
18
src/store.ts
18
src/store.ts
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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: "",
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue