Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 61 additions & 19 deletions apps/code/src/main/services/agent/discover-plugins.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import * as os from "node:os";
import * as path from "node:path";
import type { SdkPluginConfig } from "@anthropic-ai/claude-agent-sdk";
import { logger } from "../../utils/logger";
import { parseSkillFrontmatter } from "./parse-skill-frontmatter";
import type { SkillInfo, SkillSource } from "./skill-schemas";

const log = logger.scope("discover-plugins");

Expand Down Expand Up @@ -49,6 +51,11 @@ async function discoverUserSkills(
}

async function discoverMarketplacePlugins(): Promise<SdkPluginConfig[]> {
const paths = await getMarketplaceInstallPaths();
return paths.map((p) => ({ type: "local" as const, path: p }));
}

export async function getMarketplaceInstallPaths(): Promise<string[]> {
const installedPath = path.join(
os.homedir(),
".claude",
Expand All @@ -64,16 +71,16 @@ async function discoverMarketplacePlugins(): Promise<SdkPluginConfig[]> {
return [];
}

const configs: SdkPluginConfig[] = [];
const paths: string[] = [];
for (const entries of Object.values(data.plugins)) {
if (!Array.isArray(entries)) continue;
for (const entry of entries) {
if (entry.installPath && fs.existsSync(entry.installPath)) {
configs.push({ type: "local", path: entry.installPath });
paths.push(entry.installPath);
}
}
}
return configs;
return paths;
} catch {
return [];
}
Expand All @@ -98,29 +105,32 @@ async function discoverRepoSkills(
);
}

async function findSkillDirs(sourceSkillsDir: string): Promise<string[]> {
if (!fs.existsSync(sourceSkillsDir)) {
return [];
}

const entries = await fs.promises.readdir(sourceSkillsDir, {
withFileTypes: true,
});

return entries
.filter(
(e) =>
(e.isDirectory() || e.isSymbolicLink()) &&
fs.existsSync(path.join(sourceSkillsDir, e.name, "SKILL.md")),
)
.map((e) => e.name);
}

async function buildSyntheticPlugin(
sourceSkillsDir: string,
pluginDir: string,
name: string,
description: string,
): Promise<SdkPluginConfig[]> {
try {
if (!fs.existsSync(sourceSkillsDir)) {
return [];
}

const entries = await fs.promises.readdir(sourceSkillsDir, {
withFileTypes: true,
});

const skillDirs = entries
.filter(
(e) =>
(e.isDirectory() || e.isSymbolicLink()) &&
fs.existsSync(path.join(sourceSkillsDir, e.name, "SKILL.md")),
)
.map((e) => e.name);

const skillDirs = await findSkillDirs(sourceSkillsDir);
if (skillDirs.length === 0) {
return [];
}
Expand Down Expand Up @@ -172,3 +182,35 @@ async function buildSyntheticPlugin(
return [];
}
}

export async function readSkillMetadataFromDir(
skillsDir: string,
source: SkillSource,
repoName?: string,
): Promise<SkillInfo[]> {
const skillNames = await findSkillDirs(skillsDir);
if (skillNames.length === 0) return [];

const results = await Promise.all(
skillNames.map(async (skillName) => {
const skillPath = path.join(skillsDir, skillName);
try {
const content = await fs.promises.readFile(
path.join(skillPath, "SKILL.md"),
"utf-8",
);
const frontmatter = parseSkillFrontmatter(content);
return {
name: frontmatter?.name ?? skillName,
description: frontmatter?.description ?? "",
source,
path: skillPath,
...(repoName ? { repoName } : {}),
} satisfies SkillInfo;
} catch {
return null;
}
}),
);
return results.filter((r): r is SkillInfo => r !== null);
}
72 changes: 72 additions & 0 deletions apps/code/src/main/services/agent/parse-skill-frontmatter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
/**
* Parses YAML frontmatter from a SKILL.md file.
* Extracts `name` and `description` fields.
*
* Handles:
* - Simple values: `name: my-skill`
* - Quoted strings: `description: 'Some text'` or `description: "Some text"`
* - Multi-line folded: `description: >-\n line1\n line2`
*/
export function parseSkillFrontmatter(
content: string,
): { name: string; description: string } | null {
const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
if (!match) return null;

const yaml = match[1];
const name = extractYamlValue(yaml, "name");
if (!name) return null;

const description = extractYamlValue(yaml, "description") ?? "";
return { name, description };
}

function extractYamlValue(yaml: string, key: string): string | null {
const lines = yaml.split("\n");

for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const keyPattern = new RegExp(`^${key}:\\s*(.*)$`);
const match = line.match(keyPattern);
if (!match) continue;

const rawValue = match[1].trim();

// Multi-line folded scalar (>- or >)
if (rawValue === ">-" || rawValue === ">") {
return collectIndentedLines(lines, i + 1).join(" ");
}

// Multi-line literal scalar (|- or |)
if (rawValue === "|-" || rawValue === "|") {
return collectIndentedLines(lines, i + 1).join("\n");
}

// Quoted string (single or double)
if (
(rawValue.startsWith("'") && rawValue.endsWith("'")) ||
(rawValue.startsWith('"') && rawValue.endsWith('"'))
) {
return rawValue.slice(1, -1);
}

// Plain scalar
return rawValue;
}

return null;
}

function collectIndentedLines(lines: string[], startIndex: number): string[] {
const result: string[] = [];
for (let i = startIndex; i < lines.length; i++) {
const line = lines[i];
// Continuation lines must be indented
if (line.match(/^\s+\S/)) {
result.push(line.trim());
} else {
break;
}
}
return result;
}
15 changes: 15 additions & 0 deletions apps/code/src/main/services/agent/skill-schemas.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { z } from "zod";

export type { SkillInfo, SkillSource } from "@shared/types/skills";

export const skillSource = z.enum(["bundled", "user", "repo", "marketplace"]);

export const skillInfo = z.object({
name: z.string(),
description: z.string(),
source: skillSource,
path: z.string(),
repoName: z.string().optional(),
});

export const listSkillsOutput = z.array(skillInfo);
2 changes: 2 additions & 0 deletions apps/code/src/main/trpc/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import { osRouter } from "./routers/os";
import { processTrackingRouter } from "./routers/process-tracking";
import { secureStoreRouter } from "./routers/secure-store";
import { shellRouter } from "./routers/shell";
import { skillsRouter } from "./routers/skills";
import { sleepRouter } from "./routers/sleep";
import { suspensionRouter } from "./routers/suspension.js";
import { uiRouter } from "./routers/ui";
Expand Down Expand Up @@ -59,6 +60,7 @@ export const trpcRouter = router({
suspension: suspensionRouter,
secureStore: secureStoreRouter,
shell: shellRouter,
skills: skillsRouter,
ui: uiRouter,
updates: updatesRouter,
deepLink: deepLinkRouter,
Expand Down
46 changes: 46 additions & 0 deletions apps/code/src/main/trpc/routers/skills.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import * as os from "node:os";
import * as path from "node:path";
import { container } from "../../di/container";
import { MAIN_TOKENS } from "../../di/tokens";
import {
getMarketplaceInstallPaths,
readSkillMetadataFromDir,
} from "../../services/agent/discover-plugins";
import { listSkillsOutput } from "../../services/agent/skill-schemas";
import type { FoldersService } from "../../services/folders/service";
import type { PosthogPluginService } from "../../services/posthog-plugin/service";
import { publicProcedure, router } from "../trpc";

const getPluginService = () =>
container.get<PosthogPluginService>(MAIN_TOKENS.PosthogPluginService);

const getFoldersService = () =>
container.get<FoldersService>(MAIN_TOKENS.FoldersService);

export const skillsRouter = router({
list: publicProcedure.output(listSkillsOutput).query(async () => {
const pluginPath = getPluginService().getPluginPath();
const folders = await getFoldersService().getFolders();
const marketplacePaths = await getMarketplaceInstallPaths();

const results = await Promise.all([
readSkillMetadataFromDir(path.join(pluginPath, "skills"), "bundled"),
readSkillMetadataFromDir(
path.join(os.homedir(), ".claude", "skills"),
"user",
),
...folders.map((f) =>
readSkillMetadataFromDir(
path.join(f.path, ".claude", "skills"),
"repo",
f.name,
),
),
...marketplacePaths.map((p) =>
readSkillMetadataFromDir(path.join(p, "skills"), "marketplace"),
),
]);

return results.flat();
}),
});
3 changes: 3 additions & 0 deletions apps/code/src/renderer/components/MainLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { RightSidebar, RightSidebarContent } from "@features/right-sidebar";
import { FolderSettingsView } from "@features/settings/components/FolderSettingsView";
import { SettingsDialog } from "@features/settings/components/SettingsDialog";
import { MainSidebar } from "@features/sidebar/components/MainSidebar";
import { SkillsView } from "@features/skills/components/SkillsView";
import { TaskDetail } from "@features/task-detail/components/TaskDetail";
import { TaskInput } from "@features/task-detail/components/TaskInput";
import { useTasks } from "@features/tasks/hooks/useTasks";
Expand Down Expand Up @@ -78,6 +79,8 @@ export function MainLayout() {
{view.type === "archived" && <ArchivedTasksView />}

{view.type === "command-center" && <CommandCenterView />}

{view.type === "skills" && <SkillsView />}
</Box>

{view.type === "task-detail" && view.data && (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { useSidebarData } from "../hooks/useSidebarData";
import { useTaskViewed } from "../hooks/useTaskViewed";
import { CommandCenterItem } from "./items/CommandCenterItem";
import { InboxItem, NewTaskItem } from "./items/HomeItem";
import { SkillsItem } from "./items/SkillsItem";
import { SidebarItem } from "./SidebarItem";
import { TaskListView } from "./TaskListView";

Expand All @@ -27,6 +28,7 @@ function SidebarMenuComponent() {
navigateToTaskInput,
navigateToInbox,
navigateToCommandCenter,
navigateToSkills,
} = useNavigationStore();

const { data: allTasks = [] } = useTasks();
Expand Down Expand Up @@ -88,6 +90,10 @@ function SidebarMenuComponent() {
navigateToCommandCenter();
};

const handleSkillsClick = () => {
navigateToSkills();
};

const handleTaskClick = (taskId: string) => {
const task = taskMap.get(taskId);
if (task) {
Expand Down Expand Up @@ -188,14 +194,21 @@ function SidebarMenuComponent() {
/>
</Box>

<Box mb="2">
<Box mb="1">
<CommandCenterItem
isActive={sidebarData.isCommandCenterActive}
onClick={handleCommandCenterClick}
activeCount={commandCenterActiveCount}
/>
</Box>

<Box mb="2">
<SkillsItem
isActive={sidebarData.isSkillsActive}
onClick={handleSkillsClick}
/>
</Box>

{sidebarData.isLoading ? (
<SidebarItem
depth={0}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { Lightning } from "@phosphor-icons/react";
import { SidebarItem } from "../SidebarItem";

interface SkillsItemProps {
isActive: boolean;
onClick: () => void;
}

export function SkillsItem({ isActive, onClick }: SkillsItemProps) {
return (
<SidebarItem
depth={0}
icon={<Lightning size={16} weight={isActive ? "fill" : "regular"} />}
label="Skills"
isActive={isActive}
onClick={onClick}
/>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ export interface SidebarData {
isHomeActive: boolean;
isInboxActive: boolean;
isCommandCenterActive: boolean;
isSkillsActive: boolean;
isLoading: boolean;
activeTaskId: string | null;
pinnedTasks: TaskData[];
Expand All @@ -64,7 +65,8 @@ interface ViewState {
| "folder-settings"
| "inbox"
| "archived"
| "command-center";
| "command-center"
| "skills";
data?: Task;
}

Expand Down Expand Up @@ -168,6 +170,7 @@ export function useSidebarData({
const isHomeActive = activeView.type === "task-input";
const isInboxActive = activeView.type === "inbox";
const isCommandCenterActive = activeView.type === "command-center";
const isSkillsActive = activeView.type === "skills";

const activeTaskId =
activeView.type === "task-detail" && activeView.data
Expand Down Expand Up @@ -276,6 +279,7 @@ export function useSidebarData({
isHomeActive,
isInboxActive,
isCommandCenterActive,
isSkillsActive,
isLoading,
activeTaskId,
pinnedTasks,
Expand Down
Loading
Loading