From 302cd59c5de0db2596c6fb4af52e32806d4238fc Mon Sep 17 00:00:00 2001 From: Kayvan Sylvan Date: Sun, 18 Jan 2026 08:10:51 -0800 Subject: [PATCH] feat: add local manifest feature for custom playbooks (#204) - Add comprehensive documentation for local manifest feature - Create example local-manifest.json with custom playbook structure - Implement local manifest reading and merging with official manifests - Add file watcher for hot reload on local manifest changes - Support local filesystem paths (absolute and tilde-prefixed) - Add "Local" badge to distinguish custom playbooks in UI - Expose manifest change event through preload API - Add source field to playbook type for origin tracking --- docs/examples/local-manifest.json | 54 +++ docs/local-manifest.md | 257 +++++++++++++ src/main/ipc/handlers/marketplace.ts | 384 ++++++++++++++++++- src/main/preload.ts | 5 + src/renderer/components/MarketplaceModal.tsx | 16 +- src/renderer/global.d.ts | 3 + src/renderer/hooks/batch/useMarketplace.ts | 19 + src/shared/marketplace-types.ts | 9 +- 8 files changed, 723 insertions(+), 24 deletions(-) create mode 100644 docs/examples/local-manifest.json create mode 100644 docs/local-manifest.md diff --git a/docs/examples/local-manifest.json b/docs/examples/local-manifest.json new file mode 100644 index 00000000..80f1bbac --- /dev/null +++ b/docs/examples/local-manifest.json @@ -0,0 +1,54 @@ +{ + "lastUpdated": "2026-01-17", + "playbooks": [ + { + "id": "my-custom-playbook", + "title": "My Custom Security Audit", + "description": "Internal security audit playbook for our organization", + "category": "Custom", + "author": "Internal Team", + "lastUpdated": "2026-01-17", + "path": "/Users/me/.maestro/custom-playbooks/security-audit", + "documents": [ + { + "filename": "1_SCAN", + "resetOnCompletion": false + }, + { + "filename": "2_ANALYZE", + "resetOnCompletion": true + }, + { + "filename": "3_REPORT", + "resetOnCompletion": false + } + ], + "loopEnabled": false, + "maxLoops": null, + "prompt": null, + "assets": ["config.yaml", "rules.json"] + }, + { + "id": "development-security", + "title": "Enhanced Development Security (Override)", + "description": "Custom version of the official playbook with our internal modifications", + "category": "Development", + "author": "Our Team", + "lastUpdated": "2026-01-17", + "path": "~/maestro-playbooks/dev-security", + "documents": [ + { + "filename": "SETUP", + "resetOnCompletion": false + }, + { + "filename": "SCAN", + "resetOnCompletion": true + } + ], + "loopEnabled": true, + "maxLoops": 3, + "prompt": "Custom prompt for our workflow" + } + ] +} diff --git a/docs/local-manifest.md b/docs/local-manifest.md new file mode 100644 index 00000000..c73184e7 --- /dev/null +++ b/docs/local-manifest.md @@ -0,0 +1,257 @@ +# Local Manifest for Custom Playbooks + +The local manifest feature allows you to extend the Playbook Exchange with custom or work-in-progress playbooks that are stored locally instead of in the public GitHub repository. + +## Overview + +- **File Location:** `/local-manifest.json` (same directory as `marketplace-cache.json`) +- **Format:** Same structure as the official `manifest.json` from GitHub +- **Optional:** If the file doesn't exist, Maestro works normally with official playbooks only +- **Hot Reload:** Changes to `local-manifest.json` automatically refresh the Playbook Exchange + +## Use Cases + +### 1. Bespoke Playbooks +Create organization-specific playbooks that aren't suitable for public sharing: +- Internal tools and workflows +- Proprietary processes +- Environment-specific automation +- Company-specific security policies + +### 2. Playbook Development +Iterate on new playbooks locally before submitting them to the [Maestro-Playbooks repository](https://github.com/pedramamini/Maestro-Playbooks): +- Test playbook structure and documents +- Refine prompts and loop behavior +- Validate asset bundling +- Preview in the UI before publishing + +## How It Works + +### Merge Semantics + +When both official and local manifests exist, they are merged by `id`: + +1. **Override:** Local playbooks with the same `id` as official ones **override** the official version +2. **Append:** Local playbooks with unique `id`s are **added** to the catalog +3. **Source Tagging:** All playbooks are tagged with `source: 'official' | 'local'` for UI distinction + +**Example:** +``` +Official playbooks: [A, B, C] +Local playbooks: [B_custom, D] +Merged result: [A, B_custom, C, D] + ↑ override ↑ append +``` + +### Path Resolution + +Local playbooks support local filesystem paths: + +- **Absolute paths:** `/Users/me/.maestro/custom-playbooks/security` +- **Tilde paths:** `~/maestro-playbooks/security` +- **Import behavior:** Files are copied from the local path instead of fetched from GitHub + +## Schema + +The local manifest uses the exact same structure as the official manifest. See [examples/local-manifest.json](./examples/local-manifest.json) for a complete example. + +### Required Fields + +Each playbook entry must include: + +- `id` - Unique identifier (use same ID as official to override) +- `title` - Display name +- `description` - Short description for search and tiles +- `category` - Top-level category +- `author` - Creator name +- `lastUpdated` - Date in YYYY-MM-DD format +- `path` - **Local filesystem path** or GitHub path +- `documents` - Array of document entries with `filename` and `resetOnCompletion` +- `loopEnabled` - Whether to loop through documents +- `prompt` - Custom prompt or `null` for Maestro default + +### Optional Fields + +- `subcategory` - Nested category +- `authorLink` - URL to author's website +- `tags` - Searchable keyword array +- `maxLoops` - Maximum loop iterations (null for unlimited) +- `assets` - Asset files from `assets/` subfolder + +## Creating a Local Playbook + +### Step 1: Create Playbook Files + +Organize your playbook in a local directory: + +``` +~/my-playbooks/security-audit/ +├── 1_SCAN.md +├── 2_ANALYZE.md +├── 3_REPORT.md +├── README.md (optional) +└── assets/ + ├── config.yaml + └── rules.json +``` + +### Step 2: Create local-manifest.json + +Location: `/local-manifest.json` + +On macOS: `~/Library/Application Support/Maestro/local-manifest.json` + +```json +{ + "lastUpdated": "2026-01-17", + "playbooks": [ + { + "id": "security-audit-internal", + "title": "Internal Security Audit", + "description": "Custom security audit for our infrastructure", + "category": "Security", + "author": "Security Team", + "lastUpdated": "2026-01-17", + "path": "~/my-playbooks/security-audit", + "documents": [ + { "filename": "1_SCAN", "resetOnCompletion": false }, + { "filename": "2_ANALYZE", "resetOnCompletion": true }, + { "filename": "3_REPORT", "resetOnCompletion": false } + ], + "loopEnabled": false, + "prompt": null, + "assets": ["config.yaml", "rules.json"] + } + ] +} +``` + +### Step 3: Open Playbook Exchange + +Your local playbook will appear with a **"Local"** badge, distinguishing it from official playbooks. + +### Step 4: Import and Use + +Import works the same as official playbooks - files are copied from your local path to the Auto Run folder. + +## Hot Reload + +Changes to `local-manifest.json` trigger an automatic refresh: + +1. Edit your local manifest +2. Save the file +3. The Playbook Exchange automatically reloads (500ms debounce) +4. No need to restart Maestro + +This enables rapid iteration during playbook development. + +## Error Handling + +### Invalid JSON +**Behavior:** Warning logged, empty array used, Maestro continues with official playbooks only + +**Fix:** Validate JSON syntax using a JSON validator + +### Missing Required Fields +**Behavior:** Invalid entries are skipped with warnings, valid entries are loaded + +**Fix:** Ensure all playbooks have `id`, `title`, `path`, and `documents` + +### Local Path Doesn't Exist +**Behavior:** Clear error message during import, playbook listing works normally + +**Fix:** Verify the `path` field points to an existing directory + +### File Watch Errors +**Behavior:** Warning logged, hot reload disabled, normal operation continues + +**Effect:** You'll need to restart Maestro to see manifest changes + +## UI Indicators + +Local playbooks are visually distinguished in the Playbook Exchange: + +- **Badge:** Blue "Local" badge next to category +- **Tooltip:** "Custom local playbook" on hover +- **Search:** Works across both official and local playbooks +- **Categories:** New categories from local playbooks appear in filters + +## Development Workflow + +### Testing Before Publishing + +1. Create your playbook files locally +2. Add to `local-manifest.json` +3. Test import and execution in Maestro +4. Refine documents and prompts +5. When ready, submit a PR to [Maestro-Playbooks](https://github.com/pedramamini/Maestro-Playbooks) +6. Remove from local manifest once published + +### Overriding Official Playbooks + +Use the same `id` as the official playbook to test modifications: + +```json +{ + "id": "development-security", // Same as official + "title": "Dev Security (Custom)", + "path": "~/my-version/dev-security", + ... +} +``` + +This allows you to: +- Add custom documents +- Modify prompts +- Change loop behavior +- Test improvements before contributing + +## Troubleshooting + +### Playbook Doesn't Appear + +1. Check JSON syntax in `local-manifest.json` +2. Verify all required fields are present +3. Look for warnings in console logs +4. Ensure `path` field is set correctly + +### Import Fails + +1. Verify the local `path` exists +2. Check file permissions on playbook directory +3. Ensure documents have `.md` extension +4. Verify assets exist in `assets/` subfolder + +### Hot Reload Not Working + +1. Check console for file watcher errors +2. Verify `local-manifest.json` path is correct +3. Try restarting Maestro +4. Check file system permissions + +## Related Files + +- **Manifest types:** `src/shared/marketplace-types.ts` +- **Marketplace handlers:** `src/main/ipc/handlers/marketplace.ts` +- **Import logic:** `marketplace:importPlaybook` handler +- **UI component:** `src/renderer/components/MarketplaceModal.tsx` +- **React hook:** `src/renderer/hooks/batch/useMarketplace.ts` + +## Security Considerations + +- Local manifest is **not synced** or shared +- Paths are validated before file operations +- Local-only playbooks remain private +- No network requests for local paths +- File watching is non-blocking and fails gracefully + +## Future Enhancements + +Potential improvements for consideration: + +- JSON schema validation +- Visual editor for local manifest +- UI controls to manage local playbooks +- Relative paths from Auto Run directory +- Local manifest templates +- Import/export for sharing local manifests diff --git a/src/main/ipc/handlers/marketplace.ts b/src/main/ipc/handlers/marketplace.ts index 3374935c..7f08d540 100644 --- a/src/main/ipc/handlers/marketplace.ts +++ b/src/main/ipc/handlers/marketplace.ts @@ -12,6 +12,7 @@ import { ipcMain, App } from 'electron'; import fs from 'fs/promises'; +import fsSync from 'fs'; import path from 'path'; import crypto from 'crypto'; import Store from 'electron-store'; @@ -20,6 +21,7 @@ import { createIpcHandler, CreateHandlerOptions } from '../../utils/ipcHandler'; import type { MarketplaceManifest, MarketplaceCache, + MarketplacePlaybook, } from '../../../shared/marketplace-types'; import { MarketplaceFetchError, @@ -51,6 +53,14 @@ export interface MarketplaceHandlerDependencies { // Module-level reference to settings store (set during registration) let marketplaceSettingsStore: Store | undefined; +// File watcher for local manifest +let localManifestWatcher: fsSync.FSWatcher | undefined; + +// Debounce timer for file changes +let watcherDebounceTimer: NodeJS.Timeout | undefined; + +const WATCHER_DEBOUNCE_MS = 500; + /** * Get SSH remote configuration by ID from the settings store. * Returns undefined if not found or store not provided. @@ -75,6 +85,160 @@ function getCacheFilePath(app: App): string { return path.join(app.getPath('userData'), 'marketplace-cache.json'); } +/** + * Get the path to the local manifest file. + * Local manifest allows users to define custom/private playbooks that extend + * or override the official marketplace catalog. + */ +function getLocalManifestPath(app: App): string { + return path.join(app.getPath('userData'), 'local-manifest.json'); +} + +/** + * Check if a path is a local filesystem path (absolute or tilde-prefixed). + * Returns true for paths like: + * - /absolute/path + * - ~/home/path + * - C:\Windows\path (on Windows) + * Returns false for GitHub repository paths. + */ +function isLocalPath(pathStr: string): boolean { + // Check for absolute paths + if (path.isAbsolute(pathStr)) { + return true; + } + // Check for tilde-prefixed paths + if (pathStr.startsWith('~/') || pathStr.startsWith('~\\')) { + return true; + } + return false; +} + +/** + * Read the local manifest from disk. + * Returns null if the file doesn't exist or is invalid. + * Logs warnings for invalid JSON but doesn't throw - graceful degradation. + */ +async function readLocalManifest(app: App): Promise { + const localManifestPath = getLocalManifestPath(app); + + try { + const content = await fs.readFile(localManifestPath, 'utf-8'); + const data = JSON.parse(content); + + // Validate local manifest structure + if (!data.playbooks || !Array.isArray(data.playbooks)) { + logger.warn('Invalid local manifest structure: missing playbooks array', LOG_CONTEXT); + return null; + } + + logger.info(`Loaded local manifest with ${data.playbooks.length} playbook(s)`, LOG_CONTEXT); + return data as MarketplaceManifest; + } catch (error) { + // File doesn't exist - this is normal, treat as empty + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + logger.debug('No local manifest found (this is normal)', LOG_CONTEXT); + return null; + } + + // Invalid JSON or other error - log warning but don't crash + logger.warn('Failed to read local manifest, ignoring', LOG_CONTEXT, { error }); + return null; + } +} + +/** + * Merge official and local manifests. + * + * Merge semantics: + * - Playbooks are merged by `id` field + * - Local playbooks with same `id` override official ones + * - Local-only `id`s are appended to the catalog + * - All playbooks are tagged with `source` field for UI distinction + * + * @param official Official manifest from GitHub (may be null) + * @param local Local manifest from filesystem (may be null) + * @returns Merged manifest with source tags + */ +function mergeManifests( + official: MarketplaceManifest | null, + local: MarketplaceManifest | null +): MarketplaceManifest { + // If no manifests at all, return empty + if (!official && !local) { + return { + lastUpdated: new Date().toISOString().split('T')[0], + playbooks: [], + }; + } + + // If only official exists, tag all as official + if (official && !local) { + return { + ...official, + playbooks: official.playbooks.map((p) => ({ ...p, source: 'official' as const })), + }; + } + + // If only local exists, tag all as local + if (!official && local) { + return { + ...local, + playbooks: local.playbooks.map((p) => ({ ...p, source: 'local' as const })), + }; + } + + // Both exist - merge by ID + const officialPlaybooks = official!.playbooks; + const localPlaybooks = local!.playbooks; + + // Create map of local playbooks by ID for fast lookup + const localMap = new Map(); + for (const playbook of localPlaybooks) { + if (!playbook.id) { + logger.warn('Local playbook missing required "id" field, skipping', LOG_CONTEXT, { + title: playbook.title, + }); + continue; + } + // Validate required fields + if (!playbook.title || !playbook.path || !playbook.documents) { + logger.warn(`Local playbook "${playbook.id}" missing required fields, skipping`, LOG_CONTEXT); + continue; + } + localMap.set(playbook.id, { ...playbook, source: 'local' }); + } + + // Override official playbooks with local matches, tag official ones + const mergedPlaybooks = officialPlaybooks.map((official) => { + const localOverride = localMap.get(official.id); + if (localOverride) { + logger.info(`Local playbook "${official.id}" overrides official version`, LOG_CONTEXT); + return localOverride; + } + return { ...official, source: 'official' as const }; + }); + + // Find local-only playbooks (not in official catalog) + const officialIds = new Set(officialPlaybooks.map((p) => p.id)); + const localOnlyPlaybooks = Array.from(localMap.values()).filter( + (local) => !officialIds.has(local.id) + ); + + // Append local-only playbooks + const finalPlaybooks = [...mergedPlaybooks, ...localOnlyPlaybooks]; + + logger.info( + `Merged manifest: ${officialPlaybooks.length} official, ${localPlaybooks.length} local, ${finalPlaybooks.length} total`, + LOG_CONTEXT + ); + + return { + lastUpdated: official?.lastUpdated || local?.lastUpdated || new Date().toISOString().split('T')[0], + playbooks: finalPlaybooks, + }; +} + /** * Read the marketplace cache from disk. * Returns null if cache doesn't exist or is invalid. @@ -170,11 +334,45 @@ async function fetchManifest(): Promise { } /** - * Fetch a document from GitHub. + * Resolve tilde (~) to user's home directory. + */ +function resolveTildePath(pathStr: string): string { + if (pathStr.startsWith('~/') || pathStr.startsWith('~\\')) { + const homedir = require('os').homedir(); + return path.join(homedir, pathStr.slice(2)); + } + return pathStr; +} + +/** + * Fetch a document from GitHub or local filesystem. + * If playbookPath is a local filesystem path, reads from disk. + * Otherwise, fetches from GitHub. */ async function fetchDocument(playbookPath: string, filename: string): Promise { + // Check if this is a local path + if (isLocalPath(playbookPath)) { + const resolvedPath = resolveTildePath(playbookPath); + const docPath = path.join(resolvedPath, `${filename}.md`); + logger.debug(`Reading local document: ${docPath}`, LOG_CONTEXT); + + try { + const content = await fs.readFile(docPath, 'utf-8'); + return content; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + throw new MarketplaceFetchError(`Local document not found: ${docPath}`); + } + throw new MarketplaceFetchError( + `Failed to read local document: ${error instanceof Error ? error.message : String(error)}`, + error + ); + } + } + + // GitHub path - fetch from remote const url = `${GITHUB_RAW_BASE}/${playbookPath}/${filename}.md`; - logger.debug(`Fetching document: ${url}`, LOG_CONTEXT); + logger.debug(`Fetching document from GitHub: ${url}`, LOG_CONTEXT); try { const response = await fetch(url); @@ -201,12 +399,33 @@ async function fetchDocument(playbookPath: string, filename: string): Promise { + // Check if this is a local path + if (isLocalPath(playbookPath)) { + const resolvedPath = resolveTildePath(playbookPath); + const assetPath = path.join(resolvedPath, 'assets', assetFilename); + logger.debug(`Reading local asset: ${assetPath}`, LOG_CONTEXT); + + try { + const content = await fs.readFile(assetPath); + return content; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + throw new MarketplaceFetchError(`Local asset not found: ${assetPath}`); + } + throw new MarketplaceFetchError( + `Failed to read local asset: ${error instanceof Error ? error.message : String(error)}`, + error + ); + } + } + + // GitHub path - fetch from remote const url = `${GITHUB_RAW_BASE}/${playbookPath}/assets/${assetFilename}`; - logger.debug(`Fetching asset: ${url}`, LOG_CONTEXT); + logger.debug(`Fetching asset from GitHub: ${url}`, LOG_CONTEXT); try { const response = await fetch(url); @@ -234,11 +453,31 @@ async function fetchAsset(playbookPath: string, assetFilename: string): Promise< } /** - * Fetch README from GitHub. + * Fetch README from GitHub or local filesystem. */ async function fetchReadme(playbookPath: string): Promise { + // Check if this is a local path + if (isLocalPath(playbookPath)) { + const resolvedPath = resolveTildePath(playbookPath); + const readmePath = path.join(resolvedPath, 'README.md'); + logger.debug(`Reading local README: ${readmePath}`, LOG_CONTEXT); + + try { + const content = await fs.readFile(readmePath, 'utf-8'); + return content; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return null; // README is optional + } + // Other errors are non-fatal for README + logger.debug(`Local README read failed (non-fatal): ${error}`, LOG_CONTEXT); + return null; + } + } + + // GitHub path - fetch from remote const url = `${GITHUB_RAW_BASE}/${playbookPath}/README.md`; - logger.debug(`Fetching README: ${url}`, LOG_CONTEXT); + logger.debug(`Fetching README from GitHub: ${url}`, LOG_CONTEXT); try { const response = await fetch(url); @@ -263,6 +502,76 @@ async function fetchReadme(playbookPath: string): Promise { } } +/** + * Setup file watcher for local manifest changes. + * Enables hot reload during development - changes to local-manifest.json + * trigger a manifest refresh event. + */ +function setupLocalManifestWatcher(app: App): void { + const localManifestPath = getLocalManifestPath(app); + + try { + // Clean up existing watcher if any + if (localManifestWatcher) { + localManifestWatcher.close(); + localManifestWatcher = undefined; + } + + // Create new watcher + localManifestWatcher = fsSync.watch( + localManifestPath, + (eventType: string) => { + logger.debug(`Local manifest file changed (${eventType}), debouncing refresh...`, LOG_CONTEXT); + + // Clear existing timer + if (watcherDebounceTimer) { + clearTimeout(watcherDebounceTimer); + } + + // Debounce file changes (wait for rapid saves to settle) + watcherDebounceTimer = setTimeout(async () => { + logger.info('Local manifest changed, broadcasting refresh event', LOG_CONTEXT); + + // Send IPC event to all renderer windows + const { BrowserWindow } = require('electron'); + const allWindows = BrowserWindow.getAllWindows(); + for (const win of allWindows) { + win.webContents.send('marketplace:manifestChanged'); + } + }, WATCHER_DEBOUNCE_MS); + } + ); + + logger.debug('Local manifest file watcher initialized', LOG_CONTEXT); + } catch (error) { + // File might not exist yet - this is normal + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + logger.warn('Failed to setup local manifest watcher (non-fatal)', LOG_CONTEXT, { error }); + } + // Don't throw - watcher failure shouldn't prevent normal operation + } +} + +/** + * Cleanup file watcher on app shutdown. + */ +function cleanupLocalManifestWatcher(): void { + if (watcherDebounceTimer) { + clearTimeout(watcherDebounceTimer); + watcherDebounceTimer = undefined; + } + + if (localManifestWatcher) { + try { + localManifestWatcher.close(); + logger.debug('Local manifest watcher cleaned up', LOG_CONTEXT); + } catch (error) { + logger.warn('Error closing local manifest watcher', LOG_CONTEXT, { error }); + } + localManifestWatcher = undefined; + } +} + /** * Helper to create handler options with consistent context. */ @@ -285,6 +594,14 @@ export function registerMarketplaceHandlers(deps: MarketplaceHandlerDependencies // Store settings reference for SSH remote lookups marketplaceSettingsStore = settingsStore; + // Setup hot reload watcher for local manifest + setupLocalManifestWatcher(app); + + // Cleanup watcher on app quit + app.on('will-quit', () => { + cleanupLocalManifestWatcher(); + }); + // ------------------------------------------------------------------------- // marketplace:getManifest - Get manifest (from cache if valid, else fetch) // ------------------------------------------------------------------------- @@ -293,24 +610,38 @@ export function registerMarketplaceHandlers(deps: MarketplaceHandlerDependencies createIpcHandler(handlerOpts('getManifest'), async () => { // Try to read from cache first const cache = await readCache(app); + let officialManifest: MarketplaceManifest | null = null; + let fromCache = false; + let cacheAge: number | undefined; if (cache && isCacheValid(cache)) { - const cacheAge = Date.now() - cache.fetchedAt; - logger.debug(`Serving manifest from cache (age: ${Math.round(cacheAge / 1000)}s)`, LOG_CONTEXT); - return { - manifest: cache.manifest, - fromCache: true, - cacheAge, - }; + cacheAge = Date.now() - cache.fetchedAt; + logger.debug(`Serving official manifest from cache (age: ${Math.round(cacheAge / 1000)}s)`, LOG_CONTEXT); + officialManifest = cache.manifest; + fromCache = true; + } else { + // Cache miss or expired - fetch fresh data + try { + officialManifest = await fetchManifest(); + await writeCache(app, officialManifest); + } catch (error) { + logger.warn('Failed to fetch official manifest, continuing with local only', LOG_CONTEXT, { error }); + // Continue - we might still have local playbooks + } } - // Cache miss or expired - fetch fresh data - const manifest = await fetchManifest(); - await writeCache(app, manifest); + // Read local manifest (always, not cached) + const localManifest = await readLocalManifest(app); + logger.info(`Local manifest loaded: ${localManifest ? localManifest.playbooks.length : 0} playbooks`, LOG_CONTEXT); + + // Merge manifests + const mergedManifest = mergeManifests(officialManifest, localManifest); + logger.info(`Merged manifest: ${mergedManifest.playbooks.length} total playbooks`, LOG_CONTEXT); return { - manifest, - fromCache: false, + manifest: mergedManifest, + fromCache, + cacheAge, }; }) ); @@ -323,11 +654,22 @@ export function registerMarketplaceHandlers(deps: MarketplaceHandlerDependencies createIpcHandler(handlerOpts('refreshManifest'), async () => { logger.info('Force refreshing manifest (bypass cache)', LOG_CONTEXT); - const manifest = await fetchManifest(); - await writeCache(app, manifest); + let officialManifest: MarketplaceManifest | null = null; + try { + officialManifest = await fetchManifest(); + await writeCache(app, officialManifest); + } catch (error) { + logger.warn('Failed to fetch official manifest during refresh, continuing with local only', LOG_CONTEXT, { error }); + } + + // Read local manifest (always fresh, not cached) + const localManifest = await readLocalManifest(app); + + // Merge manifests + const mergedManifest = mergeManifests(officialManifest, localManifest); return { - manifest, + manifest: mergedManifest, fromCache: false, }; }) diff --git a/src/main/preload.ts b/src/main/preload.ts index 54c4416f..4e842389 100644 --- a/src/main/preload.ts +++ b/src/main/preload.ts @@ -1325,6 +1325,11 @@ contextBridge.exposeInMainWorld('maestro', { ipcRenderer.invoke('marketplace:getReadme', playbookPath), importPlaybook: (playbookId: string, targetFolderName: string, autoRunFolderPath: string, sessionId: string, sshRemoteId?: string) => ipcRenderer.invoke('marketplace:importPlaybook', playbookId, targetFolderName, autoRunFolderPath, sessionId, sshRemoteId), + onManifestChanged: (handler: () => void) => { + const wrappedHandler = () => handler(); + ipcRenderer.on('marketplace:manifestChanged', wrappedHandler); + return () => ipcRenderer.removeListener('marketplace:manifestChanged', wrappedHandler); + }, }, // Debug Package API (generate support bundles for bug reporting) diff --git a/src/renderer/components/MarketplaceModal.tsx b/src/renderer/components/MarketplaceModal.tsx index 7fe56f60..8739da14 100644 --- a/src/renderer/components/MarketplaceModal.tsx +++ b/src/renderer/components/MarketplaceModal.tsx @@ -174,8 +174,8 @@ function PlaybookTile({ playbook, theme, isSelected, onSelect }: PlaybookTilePro }), }} > - {/* Category badge */} -
+ {/* Category and source badges */} +
)} + {playbook.source === 'local' && ( + + Local + + )}
{/* Title - with tooltip for truncated text */} diff --git a/src/renderer/global.d.ts b/src/renderer/global.d.ts index 0bcddf78..40b6f3a6 100644 --- a/src/renderer/global.d.ts +++ b/src/renderer/global.d.ts @@ -1008,6 +1008,7 @@ interface MaestroAPI { loopEnabled: boolean; maxLoops?: number | null; prompt: string | null; + source?: 'official' | 'local'; }>; }; fromCache?: boolean; @@ -1036,6 +1037,7 @@ interface MaestroAPI { loopEnabled: boolean; maxLoops?: number | null; prompt: string | null; + source?: 'official' | 'local'; }>; }; fromCache?: boolean; @@ -1066,6 +1068,7 @@ interface MaestroAPI { importedDocs?: string[]; error?: string; }>; + onManifestChanged: (handler: () => void) => () => void; }; // Updates API updates: { diff --git a/src/renderer/hooks/batch/useMarketplace.ts b/src/renderer/hooks/batch/useMarketplace.ts index a9a1d619..3acab92d 100644 --- a/src/renderer/hooks/batch/useMarketplace.ts +++ b/src/renderer/hooks/batch/useMarketplace.ts @@ -152,6 +152,25 @@ export function useMarketplace(): UseMarketplaceReturn { loadManifest(); }, []); + // Hot reload: listen for manifest changes (local-manifest.json edits) + useEffect(() => { + const cleanup = window.maestro.marketplace.onManifestChanged(async () => { + console.log('Local manifest changed, reloading...'); + try { + const result = await window.maestro.marketplace.getManifest(); + if (result.success && result.manifest) { + setManifest(result.manifest); + setFromCache(result.fromCache ?? false); + setCacheAge(result.cacheAge ?? null); + } + } catch (err) { + console.error('Failed to reload manifest after change:', err); + } + }); + + return cleanup; + }, []); + // Extract playbooks from manifest const playbooks = useMemo(() => { return manifest?.playbooks ?? []; diff --git a/src/shared/marketplace-types.ts b/src/shared/marketplace-types.ts index 7ac8e5f4..3fc2ec6e 100644 --- a/src/shared/marketplace-types.ts +++ b/src/shared/marketplace-types.ts @@ -21,6 +21,11 @@ export interface MarketplaceManifest { playbooks: MarketplacePlaybook[]; } +/** + * Playbook source type - distinguishes official GitHub playbooks from local ones. + */ +export type PlaybookSource = 'official' | 'local'; + /** * Individual playbook entry in the marketplace manifest. */ @@ -43,7 +48,7 @@ export interface MarketplacePlaybook { tags?: string[]; /** Last update date in YYYY-MM-DD format */ lastUpdated: string; - /** Folder path in repo for fetching documents */ + /** Folder path in repo for fetching documents (GitHub path or local filesystem path) */ path: string; /** Ordered list of documents in the playbook */ documents: MarketplaceDocument[]; @@ -59,6 +64,8 @@ export interface MarketplacePlaybook { * that are bundled with the playbook. */ assets?: string[]; + /** Source of the playbook - official (from GitHub) or local (from local-manifest.json) */ + source?: PlaybookSource; } /**