diff --git a/src/commands.ts b/src/commands.ts index d38425454..15746227e 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -218,6 +218,7 @@ import exit from './commands/exit/index.js' import exportCommand from './commands/export/index.js' import model from './commands/model/index.js' import tag from './commands/tag/index.js' +import favorite from './commands/favorite/index.js' import outputStyle from './commands/output-style/index.js' import remoteEnv from './commands/remote-env/index.js' import upgrade from './commands/upgrade/index.js' @@ -347,6 +348,7 @@ const COMMANDS = memoize((): Command[] => [ statusline, stickers, tag, + favorite, theme, feedback, review, diff --git a/src/commands/favorite/favorite.ts b/src/commands/favorite/favorite.ts new file mode 100644 index 000000000..739d12e74 --- /dev/null +++ b/src/commands/favorite/favorite.ts @@ -0,0 +1,178 @@ +import type { LocalCommandCall, LocalCommandResult } from '../../types/command.js' +import { + listFavoriteItems, + downloadFavoriteItem, + loadFavoriteItem, + unloadFavoriteItem, + uninstallFavoriteItem, + viewFavoriteItem, + type FavoriteItemType, +} from '../../costrict/favorite/favorite.js' + +function pad(value: string, width: number) { + return value.length >= width ? value : value + ' '.repeat(width - value.length) +} + +function parseFavoriteArgs(args: string) { + const tokens = args.trim().split(/\s+/) + const [command = '', idOrFlag, ...rest] = tokens + let id = '' + let format: 'table' | 'json' = 'table' + let type: FavoriteItemType | undefined + + const remaining = [idOrFlag, ...rest].filter((item): item is string => Boolean(item)) + for (let i = 0; i < remaining.length; i++) { + const token = remaining[i] + if (token === '--format' && remaining[i + 1]) { + const value = remaining[++i] + if (value === 'table' || value === 'json') format = value + continue + } + if (token.startsWith('--format=')) { + const value = token.slice('--format='.length) + if (value === 'table' || value === 'json') format = value + continue + } + if (token === '--type' && remaining[i + 1]) { + const value = remaining[++i] + if (value === 'skill' || value === 'agent' || value === 'command' || value === 'mcp') type = value as FavoriteItemType + continue + } + if (token.startsWith('--type=')) { + const value = token.slice('--type='.length) + if (value === 'skill' || value === 'agent' || value === 'command' || value === 'mcp') type = value as FavoriteItemType + continue + } + if (!id && !token.startsWith('-')) { + id = token + } + } + + return { command, id, format, type } +} + +async function printFavoriteList(format: 'table' | 'json', type?: FavoriteItemType): Promise { + const items = await listFavoriteItems(type) + if (format === 'json') { + return JSON.stringify(items, null, 2) + } + + if (!items.length) { + return 'No cloud favorites found' + } + + const statusWidth = Math.max('Status'.length, ...items.map((item) => item.status.length)) + const typeWidth = Math.max('Type'.length, ...items.map((item) => item.itemType.length)) + const slugWidth = Math.max('Slug'.length, ...items.map((item) => item.slug.length)) + const nameWidth = Math.max('Name'.length, ...items.map((item) => item.name.length)) + + const lines: string[] = [ + `${pad('Status', statusWidth)} ${pad('Type', typeWidth)} ${pad('Slug', slugWidth)} ${pad('Name', nameWidth)} Description`, + ] + for (const item of items) { + lines.push( + `${pad(item.status, statusWidth)} ${pad(item.itemType, typeWidth)} ${pad(item.slug, slugWidth)} ${pad(item.name, nameWidth)} ${item.description}`, + ) + } + return lines.join('\n') +} + +async function printFavoriteView(id: string, format: 'table' | 'json'): Promise { + const item = await viewFavoriteItem(id) + if (format === 'json') { + return JSON.stringify(item, null, 2) + } + + const lines: string[] = [ + `Name: ${item.name}`, + `Slug: ${item.slug}`, + `ID: ${item.id}`, + `Type: ${item.itemType}`, + `Status: ${item.status}`, + `Favorites: ${item.favoriteCount ?? 0}`, + ] + if (item.version) lines.push(`Version: ${item.version}`) + if (item.localPath) lines.push(`Local path: ${item.localPath}`) + lines.push('', item.description || '(no description)') + return lines.join('\n') +} + +async function runFavoriteAction(action: 'download' | 'load' | 'unload' | 'uninstall', id: string): Promise { + switch (action) { + case 'download': { + const item = await downloadFavoriteItem(id) + return `downloaded ${item.slug}` + } + case 'load': { + const item = await loadFavoriteItem(id) + return `loaded ${item.slug} as active` + } + case 'unload': { + const item = await unloadFavoriteItem(id) + return `unloaded ${item.slug}` + } + case 'uninstall': { + const item = await uninstallFavoriteItem(id) + return `uninstalled ${item.slug}` + } + } +} + +const HELP_TEXT = `Usage: /favorite [args...] + +Manage CoStrict cloud favorite items (skills, agents, commands, MCPs). + +Commands: + list List cloud favorite items + view Show favorite item details + download Download favorite item to local storage + load Enable a downloaded favorite item + unload Disable a favorite item + uninstall Remove a local favorite item + +Options: + --type skill|agent|command|mcp Filter by item type + --format table|json Output format (default: table) + +Examples: + /favorite list + /favorite list --type skill + /favorite view my-skill + /favorite download my-skill + /favorite load my-skill + /favorite unload my-skill +` + +export const call: LocalCommandCall = async (args): Promise => { + const parsed = parseFavoriteArgs(args) + + if (!parsed.command || parsed.command === 'help' || parsed.command === '--help' || parsed.command === '-h') { + return { type: 'text', value: HELP_TEXT } + } + + if (parsed.command === 'list') { + const output = await printFavoriteList(parsed.format, parsed.type) + return { type: 'text', value: output } + } + + if (!parsed.id) { + return { type: 'text', value: `Error: favorite id/slug is required\n\n${HELP_TEXT}` } + } + + if (parsed.command === 'view') { + const output = await printFavoriteView(parsed.id, parsed.format) + return { type: 'text', value: output } + } + + if (['download', 'load', 'unload', 'uninstall'].includes(parsed.command)) { + try { + const output = await runFavoriteAction(parsed.command as 'download' | 'load' | 'unload' | 'uninstall', parsed.id) + return { type: 'text', value: output } + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + return { type: 'text', value: `Error: ${message}` } + } + } + + return { type: 'text', value: `Unknown command: ${parsed.command}\n\n${HELP_TEXT}` } +} diff --git a/src/commands/favorite/index.ts b/src/commands/favorite/index.ts new file mode 100644 index 000000000..50190d612 --- /dev/null +++ b/src/commands/favorite/index.ts @@ -0,0 +1,11 @@ +import type { Command } from '../../commands.js' + +const favorite = { + type: 'local', + name: 'favorite', + description: 'Manage CoStrict cloud favorite items (skills, agents, commands, MCPs)', + supportsNonInteractive: true, + load: () => import('./favorite.js'), +} satisfies Command + +export default favorite diff --git a/src/costrict/favorite/favorite.ts b/src/costrict/favorite/favorite.ts new file mode 100644 index 000000000..c36445039 --- /dev/null +++ b/src/costrict/favorite/favorite.ts @@ -0,0 +1,664 @@ +import path from 'node:path' +import { mkdir, readFile, readdir, rm, writeFile, copyFile } from 'node:fs/promises' +import { createCoStrictFetch } from '../provider/fetch.js' +import { getCoStrictBaseURL } from '../provider/auth.js' +import { getClaudeConfigHomeDir } from '../../utils/envUtils.js' +import { saveGlobalConfig, getGlobalConfig } from '../../utils/config.js' +import { clearSkillCaches } from '../../skills/loadSkillsDir.js' +import { parseFrontmatter } from '../../utils/frontmatterParser.js' +import type { McpServerConfig } from '../../services/mcp/types.js' + +const FAVORITE_PAGE_SIZE = 100 +const FAVORITE_MAX_PAGES = 20 + +export type FavoriteItemType = 'skill' | 'agent' | 'command' | 'mcp' + +type FavoriteLifecycle = 'downloaded' | 'active' | 'unloaded' + +type FavoriteStateRecord = { + id: string + slug: string + name: string + itemType: FavoriteItemType + localPath: string + lifecycle: FavoriteLifecycle + installedAt: string + updatedAt: string +} + +type FavoriteState = { + items: Record +} + +export type FavoriteItem = { + id: string + slug: string + name: string + description: string + itemType: FavoriteItemType + content: string + category?: string + version?: string + favoriteCount?: number + favorited?: boolean + createdBy?: string + createdAt?: string + updatedAt?: string +} + +export type FavoriteStatus = 'Cloud' | 'Downloaded' | 'Active' | 'Unloaded' + +export type FavoriteItemWithStatus = FavoriteItem & { + status: FavoriteStatus + localPath?: string +} + +type RemoteListResponse = { + items?: Array<{ id: string } & Record> + hasMore?: boolean +} + +const STORE_TYPE_MAP: Record = { + skill: 'skill', + subagent: 'agent', + command: 'command', + mcp: 'mcp', +} + +const LOCAL_TO_STORE_TYPE: Record = { + skill: 'skill', + agent: 'subagent', + command: 'command', + mcp: 'mcp', +} + +function favoriteRoot() { + return path.join(getClaudeConfigHomeDir(), 'favorites') +} + +function favoriteTypeRoot(itemType: FavoriteItemType) { + switch (itemType) { + case 'skill': + return path.join(favoriteRoot(), 'skills') + case 'agent': + return path.join(favoriteRoot(), 'agents') + case 'command': + return path.join(favoriteRoot(), 'commands') + case 'mcp': + return path.join(favoriteRoot(), 'mcps') + } +} + +function favoriteStatePath() { + return path.join(favoriteRoot(), 'state.json') +} + +function skillsDir() { + return path.join(getClaudeConfigHomeDir(), 'skills') +} + +async function readState(): Promise { + try { + const text = await readFile(favoriteStatePath(), 'utf-8') + return JSON.parse(text) as FavoriteState + } catch { + return { items: {} } + } +} + +async function writeState(state: FavoriteState) { + await mkdir(favoriteRoot(), { recursive: true }) + await writeFile(favoriteStatePath(), JSON.stringify(state, null, 2) + '\n') +} + +async function mutateState(fn: (state: FavoriteState) => void | Promise) { + const state = await readState() + await fn(state) + await writeState(state) +} + +async function listRemoteCandidates(storeType?: string, extraParams?: Record) { + const baseUrl = getCoStrictBaseURL() + const costrictFetch = createCoStrictFetch() + const result: Array<{ id: string } & Record> = [] + + for (let page = 1; page <= FAVORITE_MAX_PAGES; page++) { + const params = new URLSearchParams({ + page: String(page), + pageSize: String(FAVORITE_PAGE_SIZE), + }) + if (storeType) params.set('type', storeType) + if (extraParams) { + for (const [key, value] of Object.entries(extraParams)) { + params.set(key, value) + } + } + const url = `${baseUrl}/api/items?${params.toString()}` + const response = await costrictFetch(url) + if (!response.ok) { + const text = await response.text().catch(() => '') + throw new Error(`Request failed: ${response.status} ${text}`) + } + const data = (await response.json()) as RemoteListResponse + const items = data.items ?? [] + result.push(...items) + if (!data.hasMore || items.length === 0) break + } + + return result +} + +function parseFavoriteListItem(data: Record): FavoriteItem | undefined { + const storeType = String(data.itemType ?? '') + const localType = STORE_TYPE_MAP[storeType] + if (!localType) return undefined + + return { + id: String(data.id), + slug: String(data.slug ?? data.id), + name: String(data.name ?? data.slug ?? data.id), + description: String(data.description ?? ''), + itemType: localType, + content: '', + category: typeof data.category === 'string' ? data.category : undefined, + version: typeof data.version === 'string' ? data.version : undefined, + favoriteCount: typeof data.favoriteCount === 'number' ? data.favoriteCount : undefined, + favorited: typeof data.favorited === 'boolean' ? data.favorited : undefined, + createdBy: typeof data.createdBy === 'string' ? data.createdBy : undefined, + createdAt: typeof data.createdAt === 'string' ? data.createdAt : undefined, + updatedAt: typeof data.updatedAt === 'string' ? data.updatedAt : undefined, + } +} + +async function getRemoteItem(id: string): Promise { + const baseUrl = getCoStrictBaseURL() + const costrictFetch = createCoStrictFetch() + const response = await costrictFetch(`${baseUrl}/api/items/${id}`) + if (!response.ok) { + const text = await response.text().catch(() => '') + throw new Error(`Request failed: ${response.status} ${text}`) + } + const data = (await response.json()) as Record + const storeType = String(data.itemType ?? '') + const localType = STORE_TYPE_MAP[storeType] + if (!localType) { + throw new Error(`Unsupported item type: ${storeType} (${String(data.slug ?? id)})`) + } + return { + id: String(data.id), + slug: String(data.slug), + name: String(data.name), + description: String(data.description ?? ''), + itemType: localType, + content: String(data.content ?? ''), + category: typeof data.category === 'string' ? data.category : undefined, + version: typeof data.version === 'string' ? data.version : undefined, + favoriteCount: typeof data.favoriteCount === 'number' ? data.favoriteCount : undefined, + favorited: typeof data.favorited === 'boolean' ? data.favorited : undefined, + createdBy: typeof data.createdBy === 'string' ? data.createdBy : undefined, + createdAt: typeof data.createdAt === 'string' ? data.createdAt : undefined, + updatedAt: typeof data.updatedAt === 'string' ? data.updatedAt : undefined, + } +} + +async function getActiveSkillSlugs(): Promise> { + const sDir = skillsDir() + const result = new Set() + try { + const entries = await readdir(sDir, { withFileTypes: true }) + for (const entry of entries) { + if (entry.isDirectory() || entry.isSymbolicLink()) { + result.add(entry.name) + } + } + } catch { + // skills dir doesn't exist + } + return result +} + +async function getActiveAgentNames(): Promise> { + const cfg = getGlobalConfig() + return new Set(Object.keys(cfg.agents ?? {})) +} + +async function getActiveCommandNames(): Promise> { + const cfg = getGlobalConfig() + return new Set(Object.keys(cfg.commands ?? {})) +} + +async function getActiveMcpNames(): Promise> { + const cfg = getGlobalConfig() + return new Set(Object.keys(cfg.mcpServers ?? {})) +} + +function deriveStatus( + state: FavoriteStateRecord | undefined, + activeSkillSlugs: Set, + activeAgentNames: Set, + activeCommandNames: Set, + activeMcpNames: Set, +): FavoriteStatus { + if (!state) return 'Cloud' + switch (state.itemType) { + case 'skill': + if (activeSkillSlugs.has(state.slug)) return 'Active' + break + case 'agent': + if (activeAgentNames.has(state.slug)) return 'Active' + break + case 'command': + if (activeCommandNames.has(state.slug)) return 'Active' + break + case 'mcp': + if (activeMcpNames.has(state.slug)) return 'Active' + break + } + switch (state.lifecycle) { + case 'unloaded': + return 'Unloaded' + default: + return 'Downloaded' + } +} + +async function persistInstalledItem(item: FavoriteItem) { + const typeRoot = favoriteTypeRoot(item.itemType) + const dir = path.join(typeRoot, item.slug) + await mkdir(dir, { recursive: true }) + + switch (item.itemType) { + case 'skill': + await writeFile(path.join(dir, 'SKILL.md'), item.content) + break + case 'agent': + await writeFile(path.join(dir, `${item.slug}.md`), item.content) + break + case 'command': + await writeFile(path.join(dir, `${item.slug}.md`), item.content) + break + case 'mcp': { + const mcpDestPath = path.join(dir, 'mcp.json') + try { + const mcpConfig = JSON.parse(item.content) + await writeFile(mcpDestPath, JSON.stringify(mcpConfig, null, 2) + '\n') + } catch { + await writeFile(mcpDestPath, item.content) + } + break + } + } + + await writeFile( + path.join(dir, 'item.json'), + JSON.stringify( + { + id: item.id, + slug: item.slug, + name: item.name, + description: item.description, + itemType: item.itemType, + category: item.category, + version: item.version, + favoriteCount: item.favoriteCount, + updatedAt: item.updatedAt, + }, + null, + 2, + ) + '\n', + ) + + await mutateState((state) => { + state.items[item.slug] = { + id: item.id, + slug: item.slug, + name: item.name, + itemType: item.itemType, + localPath: dir, + lifecycle: state.items[item.slug]?.lifecycle ?? 'downloaded', + installedAt: state.items[item.slug]?.installedAt ?? new Date().toISOString(), + updatedAt: new Date().toISOString(), + } + }) + + return dir +} + +async function hasUsableLocalContent(hit: FavoriteStateRecord): Promise { + const contentPath = (() => { + switch (hit.itemType) { + case 'skill': + return path.join(hit.localPath, 'SKILL.md') + case 'agent': + return path.join(hit.localPath, `${hit.slug}.md`) + case 'command': + return path.join(hit.localPath, `${hit.slug}.md`) + case 'mcp': + return path.join(hit.localPath, 'mcp.json') + } + })() + try { + const { size } = await import('node:fs/promises').then((m) => m.stat(contentPath)) + return size > 0 + } catch { + return false + } +} + +async function ensureInstalled(slugOrId: string): Promise { + const state = await readState() + const hit = state.items[slugOrId] ?? Object.values(state.items).find((item) => item.id === slugOrId) + if (hit && (await hasUsableLocalContent(hit))) return hit + + let remote = hit ? await getRemoteItem(hit.id).catch(() => undefined) : undefined + if (!remote) { + const favorites = await listFavoriteItems() + const listed = favorites.find((f) => f.slug === slugOrId || f.id === slugOrId) + if (!listed) throw new Error(`Favorite item not found: ${slugOrId}`) + remote = await getRemoteItem(listed.id) + } + + const localPath = await persistInstalledItem(remote) + return { + id: remote.id, + slug: remote.slug, + name: remote.name, + itemType: remote.itemType, + localPath, + lifecycle: 'downloaded', + installedAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + } +} + +function convertMcpConfig(config: Record): Record | undefined { + if (typeof config.type === 'string' && (config.type === 'local' || config.type === 'remote')) { + return config + } + if (config.mcpServers && typeof config.mcpServers === 'object' && !Array.isArray(config.mcpServers)) { + const servers = Object.entries(config.mcpServers as Record) + if (servers.length === 0) return undefined + const [, server] = servers[0] + if (server && typeof server === 'object') { + return convertSingleMcpServer(server as Record) + } + return undefined + } + return convertSingleMcpServer(config) +} + +function convertSingleMcpServer(server: Record): Record | undefined { + const command = server.command + const args = server.args + if (!command && !args) return undefined + const cmdArray: string[] = [] + if (typeof command === 'string') { + cmdArray.push(command) + } else if (Array.isArray(command)) { + for (const c of command) { + if (typeof c === 'string') cmdArray.push(c) + } + } + if (Array.isArray(args)) { + for (const arg of args) { + if (typeof arg === 'string') cmdArray.push(arg) + } + } + if (cmdArray.length === 0) return undefined + const result: Record = { + type: 'local', + command: cmdArray, + } + if (typeof server.environment === 'object' && server.environment !== null) { + result.environment = server.environment + } + return result +} + +async function copyDir(src: string, dest: string) { + await mkdir(dest, { recursive: true }) + const entries = await readdir(src, { withFileTypes: true }) + for (const entry of entries) { + const srcPath = path.join(src, entry.name) + const destPath = path.join(dest, entry.name) + if (entry.isDirectory()) { + await copyDir(srcPath, destPath) + } else { + await copyFile(srcPath, destPath) + } + } +} + +async function addItemToConfig(item: FavoriteItem, localPath: string) { + switch (item.itemType) { + case 'skill': { + const destDir = path.join(skillsDir(), item.slug) + await mkdir(skillsDir(), { recursive: true }) + await copyDir(localPath, destDir) + clearSkillCaches() + break + } + case 'agent': { + const mdPath = path.join(localPath, `${item.slug}.md`) + const content = await readFile(mdPath, 'utf-8') + const { frontmatter, content: markdownContent } = parseFrontmatter(content, mdPath) + saveGlobalConfig((cfg) => ({ + ...cfg, + agents: { + ...(cfg.agents ?? {}), + [item.slug]: { + ...(frontmatter as Record), + prompt: markdownContent.trim(), + }, + }, + })) + break + } + case 'command': { + const mdPath = path.join(localPath, `${item.slug}.md`) + const content = await readFile(mdPath, 'utf-8') + const { frontmatter, content: markdownContent } = parseFrontmatter(content, mdPath) + saveGlobalConfig((cfg) => ({ + ...cfg, + commands: { + ...(cfg.commands ?? {}), + [item.slug]: { + ...(frontmatter as Record), + template: markdownContent.trim(), + }, + }, + })) + break + } + case 'mcp': { + const mcpJsonPath = path.join(localPath, 'mcp.json') + const configJson = JSON.parse(await readFile(mcpJsonPath, 'utf-8')) as Record + const converted = convertMcpConfig(configJson) + if (!converted) { + throw new Error( + `Unable to recognize MCP configuration format: ${item.slug}\n\n` + + `Configuration file: ${mcpJsonPath}\n` + + `Supported formats: opencode native, VS Code / Claude Desktop style, or simplified { command, args }`, + ) + } + saveGlobalConfig((cfg) => ({ + ...cfg, + mcpServers: { + ...(cfg.mcpServers ?? {}), + [item.slug]: converted as unknown as McpServerConfig, + }, + })) + break + } + } +} + +async function removeItemFromConfig(itemType: FavoriteItemType, slug: string, _localPath: string) { + switch (itemType) { + case 'skill': { + const destDir = path.join(skillsDir(), slug) + await rm(destDir, { recursive: true, force: true }) + clearSkillCaches() + break + } + case 'agent': { + saveGlobalConfig((cfg) => { + const agents = { ...(cfg.agents ?? {}) } + delete agents[slug] + return { ...cfg, agents } + }) + break + } + case 'command': { + saveGlobalConfig((cfg) => { + const commands = { ...(cfg.commands ?? {}) } + delete commands[slug] + return { ...cfg, commands } + }) + break + } + case 'mcp': { + saveGlobalConfig((cfg) => { + const mcpServers = { ...(cfg.mcpServers ?? {}) } + delete mcpServers[slug] + return { ...cfg, mcpServers } + }) + break + } + } +} + +async function readItemForConfig(installed: FavoriteStateRecord): Promise { + const itemMetaPath = path.join(installed.localPath, 'item.json') + let itemMeta: Record = {} + try { + const text = await readFile(itemMetaPath, 'utf-8') + itemMeta = JSON.parse(text) as Record + } catch { + // ignore + } + return { + id: installed.id, + slug: installed.slug, + name: installed.name, + description: String(itemMeta.description ?? ''), + itemType: installed.itemType, + content: '', + } +} + +export async function listFavoriteItems(type?: FavoriteItemType): Promise { + const storeTypes = type ? [LOCAL_TO_STORE_TYPE[type]] : Object.values(LOCAL_TO_STORE_TYPE) + const candidatePages = await Promise.all( + [...new Set(storeTypes)].map(async (st) => + listRemoteCandidates(st, { favorited: 'true' }).catch((error) => { + console.warn('failed to fetch remote favorite candidates', { type: st, error }) + return [] + }), + ), + ) + const candidates = candidatePages.flat() + + const [activeSkillSlugs, activeAgentNames, activeCommandNames, activeMcpNames, state] = await Promise.all([ + getActiveSkillSlugs(), + getActiveAgentNames(), + getActiveCommandNames(), + getActiveMcpNames(), + readState(), + ]) + + const seen = new Set() + const result: FavoriteItemWithStatus[] = [] + + for (const candidate of candidates) { + const item = parseFavoriteListItem(candidate) + if (!item) continue + if (!item?.favorited) continue + if (seen.has(item.slug)) continue + seen.add(item.slug) + const local = state.items[item.slug] + result.push({ + ...item, + status: deriveStatus(local, activeSkillSlugs, activeAgentNames, activeCommandNames, activeMcpNames), + localPath: local?.localPath, + }) + } + + if (result.length === 0) { + for (const [slug, record] of Object.entries(state.items)) { + if (type && record.itemType !== type) continue + if (seen.has(slug)) continue + seen.add(slug) + result.push({ + id: record.id, + slug: record.slug, + name: record.name, + description: '', + itemType: record.itemType, + content: '', + status: deriveStatus(record, activeSkillSlugs, activeAgentNames, activeCommandNames, activeMcpNames), + localPath: record.localPath, + }) + } + } + + return result.sort((a, b) => a.name.localeCompare(b.name)) +} + +export async function viewFavoriteItem(slugOrId: string): Promise { + const favorites = await listFavoriteItems() + const item = favorites.find((f) => f.slug === slugOrId || f.id === slugOrId) + if (!item) throw new Error(`Favorite item not found: ${slugOrId}`) + + const detail = await getRemoteItem(item.id) + return { + ...detail, + status: item.status, + localPath: item.localPath, + } +} + +export async function downloadFavoriteItem(slugOrId: string) { + const item = await viewFavoriteItem(slugOrId) + const localPath = await persistInstalledItem(item) + await mutateState((state) => { + const record = state.items[item.slug] + record.lifecycle = 'downloaded' + record.updatedAt = new Date().toISOString() + record.localPath = localPath + }) + return { ...item, status: 'Downloaded' as const, localPath } +} + +export async function loadFavoriteItem(slugOrId: string) { + const installed = await ensureInstalled(slugOrId) + const itemForConfig = await readItemForConfig(installed) + await addItemToConfig(itemForConfig, installed.localPath) + await mutateState((state) => { + const record = state.items[installed.slug] + record.lifecycle = 'active' + record.updatedAt = new Date().toISOString() + }) + return installed +} + +export async function unloadFavoriteItem(slugOrId: string) { + const installed = await ensureInstalled(slugOrId) + await removeItemFromConfig(installed.itemType, installed.slug, installed.localPath) + await mutateState((state) => { + const record = state.items[installed.slug] + record.lifecycle = 'unloaded' + record.updatedAt = new Date().toISOString() + }) + return installed +} + +export async function uninstallFavoriteItem(slugOrId: string) { + const installed = await ensureInstalled(slugOrId) + await removeItemFromConfig(installed.itemType, installed.slug, installed.localPath) + await rm(installed.localPath, { recursive: true, force: true }) + await mutateState((state) => { + delete state.items[installed.slug] + }) + return installed +} diff --git a/src/utils/config.ts b/src/utils/config.ts index 4167c70c5..99231ce0a 100644 --- a/src/utils/config.ts +++ b/src/utils/config.ts @@ -205,6 +205,10 @@ export type GlobalConfig = { // @deprecated - Migrated to ~/.claude/cache/changelog.md. Keep for migration support. cachedChangelog?: string mcpServers?: Record + // Favorite agents loaded from CoStrict cloud (config layer, runtime integration TBD) + agents?: Record & { prompt: string }> + // Favorite commands loaded from CoStrict cloud (config layer, runtime integration TBD) + commands?: Record & { template: string }> // claude.ai MCP connectors that have successfully connected at least once. // Used to gate "connector unavailable" / "needs auth" startup notifications: // a connector the user has actually used is worth flagging when it breaks,