Skip to content
Draft
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
13 changes: 12 additions & 1 deletion packages/types/src/marketplace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ export type McpInstallationMethod = z.infer<typeof mcpInstallationMethodSchema>
/**
* Component type validation
*/
export const marketplaceItemTypeSchema = z.enum(["mode", "mcp"] as const)
export const marketplaceItemTypeSchema = z.enum(["mode", "mcp", "command"] as const)

export type MarketplaceItemType = z.infer<typeof marketplaceItemTypeSchema>

Expand Down Expand Up @@ -61,6 +61,13 @@ export const mcpMarketplaceItemSchema = baseMarketplaceItemSchema.extend({

export type McpMarketplaceItem = z.infer<typeof mcpMarketplaceItemSchema>

export const commandMarketplaceItemSchema = baseMarketplaceItemSchema.extend({
content: z.string().min(1), // Markdown content for slash command
argumentHint: z.string().optional(),
})

export type CommandMarketplaceItem = z.infer<typeof commandMarketplaceItemSchema>

/**
* Unified marketplace item schema using discriminated union
*/
Expand All @@ -73,6 +80,10 @@ export const marketplaceItemSchema = z.discriminatedUnion("type", [
mcpMarketplaceItemSchema.extend({
type: z.literal("mcp"),
}),
// Command marketplace item
commandMarketplaceItemSchema.extend({
type: z.literal("command"),
}),
])

export type MarketplaceItem = z.infer<typeof marketplaceItemSchema>
Expand Down
29 changes: 29 additions & 0 deletions src/services/marketplace/MarketplaceManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { GlobalFileNames } from "../../shared/globalFileNames"
import { ensureSettingsDirectoryExists } from "../../utils/globalContext"
import { t } from "../../i18n"
import type { CustomModesManager } from "../../core/config/CustomModesManager"
import { getGlobalRooDirectory } from "../roo-config"

import { RemoteConfigLoader } from "./RemoteConfigLoader"
import { SimpleInstaller } from "./SimpleInstaller"
Expand Down Expand Up @@ -284,6 +285,20 @@ export class MarketplaceManager {
} catch (error) {
// File doesn't exist or can't be read, skip
}

// Check commands in .roo/commands
const projectCommandsDir = path.join(workspaceFolder.uri.fsPath, ".roo", "commands")
try {
const entries = await fs.readdir(projectCommandsDir, { withFileTypes: true })
for (const entry of entries) {
if (entry.isFile() && entry.name.toLowerCase().endsWith(".md")) {
const commandName = entry.name.slice(0, -3)
metadata[commandName] = { type: "command" }
}
}
} catch (error) {
// Directory doesn't exist or can't be read, skip
}
} catch (error) {
console.error("Error checking project installations:", error)
}
Expand Down Expand Up @@ -329,6 +344,20 @@ export class MarketplaceManager {
} catch (error) {
// File doesn't exist or can't be read, skip
}

// Check global commands
const globalCommandsDir = path.join(getGlobalRooDirectory(), "commands")
try {
const entries = await fs.readdir(globalCommandsDir, { withFileTypes: true })
for (const entry of entries) {
if (entry.isFile() && entry.name.toLowerCase().endsWith(".md")) {
const commandName = entry.name.slice(0, -3)
metadata[commandName] = { type: "command" }
}
}
} catch (error) {
// Directory doesn't exist or can't be read, skip
}
} catch (error) {
console.error("Error checking global installations:", error)
}
Expand Down
38 changes: 34 additions & 4 deletions src/services/marketplace/RemoteConfigLoader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,11 @@ import * as yaml from "yaml"
import { z } from "zod"

import {
type MarketplaceItem,
type MarketplaceItemType,
commandMarketplaceItemSchema,
modeMarketplaceItemSchema,
mcpMarketplaceItemSchema,
type MarketplaceItem,
type MarketplaceItemType,
} from "@roo-code/types"
import { getRooCodeApiUrl } from "@roo-code/cloud"

Expand All @@ -18,6 +19,10 @@ const mcpMarketplaceResponse = z.object({
items: z.array(mcpMarketplaceItemSchema),
})

const commandMarketplaceResponse = z.object({
items: z.array(commandMarketplaceItemSchema),
})

export class RemoteConfigLoader {
private apiBaseUrl: string
private cache: Map<string, { data: MarketplaceItem[]; timestamp: number }> = new Map()
Expand All @@ -32,10 +37,11 @@ export class RemoteConfigLoader {

const modesPromise = this.fetchModes()
const mcpsPromise = hideMarketplaceMcps ? Promise.resolve([]) : this.fetchMcps()
const commandsPromise = this.fetchCommands()

const [modes, mcps] = await Promise.all([modesPromise, mcpsPromise])
const [modes, mcps, commands] = await Promise.all([modesPromise, mcpsPromise, commandsPromise])

items.push(...modes, ...mcps)
items.push(...modes, ...mcps, ...commands)
return items
}

Expand Down Expand Up @@ -83,6 +89,30 @@ export class RemoteConfigLoader {
return items
}

private async fetchCommands(): Promise<MarketplaceItem[]> {
const cacheKey = "commands"
const cached = this.getFromCache(cacheKey)

if (cached) {
return cached
}

const data = await this.fetchWithRetry<string>(`${this.apiBaseUrl}/api/marketplace/commands`)

// The commands endpoint returns Markdown list (yaml frontmatter + markdown body per item)
// The test server sample is plain Markdown with YAML frontmatter; parse as plain text list.
const yamlData = yaml.parse(data)
const validated = commandMarketplaceResponse.parse(yamlData)

const items: MarketplaceItem[] = validated.items.map((item) => ({
type: "command" as const,
...item,
}))

this.setCache(cacheKey, items)
return items
}

private async fetchWithRetry<T>(url: string, maxRetries = 3): Promise<T> {
let lastError: Error

Expand Down
49 changes: 49 additions & 0 deletions src/services/marketplace/SimpleInstaller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import type { MarketplaceItem, MarketplaceItemType, InstallMarketplaceItemOption
import { GlobalFileNames } from "../../shared/globalFileNames"
import { ensureSettingsDirectoryExists } from "../../utils/globalContext"
import type { CustomModesManager } from "../../core/config/CustomModesManager"
import { getGlobalRooDirectory, getProjectRooDirectoryForCwd } from "../roo-config"

export interface InstallOptions extends InstallMarketplaceItemOptions {
target: "project" | "global"
Expand All @@ -26,11 +27,30 @@ export class SimpleInstaller {
return await this.installMode(item, target)
case "mcp":
return await this.installMcp(item, target, options)
case "command":
return await this.installCommand(item, target)
default:
throw new Error(`Unsupported item type: ${(item as any).type}`)
}
}

private async installCommand(
item: MarketplaceItem,
target: "project" | "global",
): Promise<{ filePath: string; line?: number }> {
if (!item.content || Array.isArray(item.content)) {
throw new Error("Command item missing content")
}

const dirPath = await this.getCommandDirPath(target)
await fs.mkdir(dirPath, { recursive: true })

const filePath = path.join(dirPath, `${item.id}.md`)
await fs.writeFile(filePath, item.content, "utf-8")

return { filePath }
}

private async installMode(
item: MarketplaceItem,
target: "project" | "global",
Expand Down Expand Up @@ -288,11 +308,28 @@ export class SimpleInstaller {
case "mcp":
await this.removeMcp(item, target)
break
case "command":
await this.removeCommand(item, target)
break
default:
throw new Error(`Unsupported item type: ${(item as any).type}`)
}
}

private async removeCommand(item: MarketplaceItem, target: "project" | "global"): Promise<void> {
const dirPath = await this.getCommandDirPath(target)
const filePath = path.join(dirPath, `${item.id}.md`)

try {
await fs.unlink(filePath)
} catch (error: any) {
if (error.code === "ENOENT") {
return
}
throw error
}
}

private async removeMode(item: MarketplaceItem, target: "project" | "global"): Promise<void> {
if (!this.customModesManager) {
throw new Error("CustomModesManager is not available")
Expand Down Expand Up @@ -381,4 +418,16 @@ export class SimpleInstaller {
return path.join(globalSettingsPath, GlobalFileNames.mcpSettings)
}
}

private async getCommandDirPath(target: "project" | "global"): Promise<string> {
if (target === "project") {
const workspaceFolder = vscode.workspace.workspaceFolders?.[0]
if (!workspaceFolder) {
throw new Error("No workspace folder found")
}
return path.join(getProjectRooDirectoryForCwd(workspaceFolder.uri.fsPath), "commands")
}

return path.join(getGlobalRooDirectory(), "commands")
}
}
Loading
Loading