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
This commit is contained in:
Kayvan Sylvan
2026-01-18 08:10:51 -08:00
committed by GitHub
parent b87e21fe3f
commit 302cd59c5d
8 changed files with 723 additions and 24 deletions

View File

@@ -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"
}
]
}

257
docs/local-manifest.md Normal file
View File

@@ -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:** `<userData>/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: `<userData>/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

View File

@@ -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<MaestroSettings> | 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<MarketplaceManifest | null> {
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<string, MarketplacePlaybook>();
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<MarketplaceManifest> {
}
/**
* 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<string> {
// 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<st
}
/**
* Fetch an asset file from GitHub (from assets/ subfolder).
* Fetch an asset file from GitHub or local filesystem (from assets/ subfolder).
* Returns the raw content as a Buffer for binary-safe handling.
*/
async function fetchAsset(playbookPath: string, assetFilename: string): Promise<Buffer> {
// 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<string | null> {
// 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<string | null> {
}
}
/**
* 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,
};
})

View File

@@ -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)

View File

@@ -174,8 +174,8 @@ function PlaybookTile({ playbook, theme, isSelected, onSelect }: PlaybookTilePro
}),
}}
>
{/* Category badge */}
<div className="flex items-center gap-2 mb-2">
{/* Category and source badges */}
<div className="flex items-center gap-2 mb-2 flex-wrap">
<span
className="px-2 py-0.5 rounded text-xs"
style={{
@@ -190,6 +190,18 @@ function PlaybookTile({ playbook, theme, isSelected, onSelect }: PlaybookTilePro
/ {playbook.subcategory}
</span>
)}
{playbook.source === 'local' && (
<span
className="px-2 py-0.5 rounded text-xs font-medium"
style={{
backgroundColor: '#3b82f620',
color: '#3b82f6',
}}
title="Custom local playbook"
>
Local
</span>
)}
</div>
{/* Title - with tooltip for truncated text */}

View File

@@ -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: {

View File

@@ -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 ?? [];

View File

@@ -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;
}
/**