feat: add local playbook support for marketplace import and display (#216)

- Add merged manifest lookup for local playbook imports
- Read local manifest and merge with official during import
- Display "Local" badge for custom playbooks in marketplace UI
- Add source badge in playbook detail view sidebar
- Support filesystem paths (absolute and tilde) for local playbooks
- Add comprehensive tests for local playbook import scenarios
- Add tests for merged manifest lookup and source tagging
- Fix mock file reads to handle local manifest ENOENT cases
This commit is contained in:
Kayvan Sylvan
2026-01-22 08:32:03 -08:00
committed by GitHub
parent 30868daa0d
commit 5778a5b34b
3 changed files with 465 additions and 9 deletions

View File

@@ -651,9 +651,14 @@ describe('marketplace IPC handlers', () => {
manifest: sampleManifest,
};
// Mock file reads:
// 1. First read: official cache
// 2. Second read: local manifest (ENOENT = no local manifest)
// 3. Third read: existing playbooks for this session
vi.mocked(fs.readFile)
.mockResolvedValueOnce(JSON.stringify(validCache))
.mockResolvedValueOnce(JSON.stringify({ playbooks: existingPlaybooks }));
.mockResolvedValueOnce(JSON.stringify(validCache)) // Official cache
.mockRejectedValueOnce({ code: 'ENOENT' }) // No local manifest
.mockResolvedValueOnce(JSON.stringify({ playbooks: existingPlaybooks })); // Existing playbooks
vi.mocked(fs.mkdir).mockResolvedValue(undefined);
vi.mocked(fs.writeFile).mockResolvedValue(undefined);
@@ -678,7 +683,12 @@ describe('marketplace IPC handlers', () => {
manifest: sampleManifest,
};
vi.mocked(fs.readFile).mockResolvedValueOnce(JSON.stringify(validCache));
// Mock file reads:
// 1. First read: official cache
// 2. Second read: local manifest (ENOENT = no local manifest)
vi.mocked(fs.readFile)
.mockResolvedValueOnce(JSON.stringify(validCache)) // Official cache
.mockRejectedValueOnce({ code: 'ENOENT' }); // No local manifest
const handler = handlers.get('marketplace:importPlaybook');
const result = await handler!(
@@ -693,6 +703,240 @@ describe('marketplace IPC handlers', () => {
expect(result.error).toContain('Playbook not found');
});
it('should import a local playbook that only exists in the local manifest', async () => {
// Create a local-only playbook that doesn't exist in the official manifest
const localOnlyPlaybook = {
id: 'local-playbook-1',
title: 'Local Playbook',
description: 'A playbook from the local manifest',
category: 'Custom',
author: 'Local Author',
lastUpdated: '2024-01-20',
path: 'local-playbooks/local-playbook-1',
documents: [{ filename: 'local-phase-1', resetOnCompletion: false }],
loopEnabled: false,
maxLoops: null,
prompt: 'Local custom instructions',
};
const localManifest: MarketplaceManifest = {
lastUpdated: '2024-01-20',
playbooks: [localOnlyPlaybook],
};
// Setup: cache with official manifest (no local-playbook-1)
const validCache: MarketplaceCache = {
fetchedAt: Date.now(),
manifest: sampleManifest, // Official manifest without local playbook
};
// Mock file reads:
// 1. First read: official cache
// 2. Second read: local manifest (with the local-only playbook)
// 3. Third read: existing playbooks (ENOENT = none)
vi.mocked(fs.readFile)
.mockResolvedValueOnce(JSON.stringify(validCache)) // Cache with official manifest
.mockResolvedValueOnce(JSON.stringify(localManifest)) // Local manifest
.mockRejectedValueOnce({ code: 'ENOENT' }); // No existing playbooks
vi.mocked(fs.mkdir).mockResolvedValue(undefined);
vi.mocked(fs.writeFile).mockResolvedValue(undefined);
// Mock document fetch for the local playbook's document
mockFetch.mockResolvedValueOnce({
ok: true,
text: () => Promise.resolve('# Local Phase 1 Content'),
});
const handler = handlers.get('marketplace:importPlaybook');
const result = await handler!(
{} as any,
'local-playbook-1', // This ID only exists in the LOCAL manifest
'My Local Playbook',
'/autorun/folder',
'session-123'
);
// Verify the import succeeded
expect(result.success).toBe(true);
expect(result.playbook).toBeDefined();
expect(result.playbook.name).toBe('Local Playbook');
expect(result.importedDocs).toEqual(['local-phase-1']);
// Verify target folder was created
expect(fs.mkdir).toHaveBeenCalledWith('/autorun/folder/My Local Playbook', {
recursive: true,
});
// Verify document was written
expect(fs.writeFile).toHaveBeenCalledWith(
'/autorun/folder/My Local Playbook/local-phase-1.md',
'# Local Phase 1 Content',
'utf-8'
);
// Verify the custom prompt was preserved
expect(result.playbook.prompt).toBe('Local custom instructions');
});
it('should import a local playbook with filesystem path (reads from disk, not GitHub)', async () => {
// Create a local playbook with a LOCAL FILESYSTEM path (absolute path)
// This tests the isLocalPath() detection and fs.readFile document reading
const localFilesystemPlaybook = {
id: 'filesystem-playbook-1',
title: 'Filesystem Playbook',
description: 'A playbook stored on the local filesystem',
category: 'Custom',
author: 'Local Author',
lastUpdated: '2024-01-20',
path: '/Users/test/custom-playbooks/my-playbook', // ABSOLUTE PATH - triggers local file reading
documents: [
{ filename: 'phase-1', resetOnCompletion: false },
{ filename: 'phase-2', resetOnCompletion: true },
],
loopEnabled: false,
maxLoops: null,
prompt: 'Filesystem playbook instructions',
};
const localManifest: MarketplaceManifest = {
lastUpdated: '2024-01-20',
playbooks: [localFilesystemPlaybook],
};
// Setup: cache with official manifest (no filesystem-playbook-1)
const validCache: MarketplaceCache = {
fetchedAt: Date.now(),
manifest: sampleManifest,
};
// Mock file reads in order:
// 1. Official cache
// 2. Local manifest (with the filesystem playbook)
// 3. Document read: /Users/test/custom-playbooks/my-playbook/phase-1.md
// 4. Document read: /Users/test/custom-playbooks/my-playbook/phase-2.md
// 5. Existing playbooks file (ENOENT = none)
vi.mocked(fs.readFile)
.mockResolvedValueOnce(JSON.stringify(validCache)) // 1. Official cache
.mockResolvedValueOnce(JSON.stringify(localManifest)) // 2. Local manifest
.mockResolvedValueOnce('# Phase 1 from filesystem\n\n- [ ] Task 1') // 3. phase-1.md
.mockResolvedValueOnce('# Phase 2 from filesystem\n\n- [ ] Task 2') // 4. phase-2.md
.mockRejectedValueOnce({ code: 'ENOENT' }); // 5. No existing playbooks
vi.mocked(fs.mkdir).mockResolvedValue(undefined);
vi.mocked(fs.writeFile).mockResolvedValue(undefined);
const handler = handlers.get('marketplace:importPlaybook');
const result = await handler!(
{} as any,
'filesystem-playbook-1',
'Imported Filesystem Playbook',
'/autorun/folder',
'session-123'
);
// Verify the import succeeded
expect(result.success).toBe(true);
expect(result.playbook).toBeDefined();
expect(result.playbook.name).toBe('Filesystem Playbook');
expect(result.importedDocs).toEqual(['phase-1', 'phase-2']);
// Verify documents were READ FROM LOCAL FILESYSTEM (not fetched from GitHub)
// The fs.readFile mock should have been called for the document paths
expect(fs.readFile).toHaveBeenCalledWith(
'/Users/test/custom-playbooks/my-playbook/phase-1.md',
'utf-8'
);
expect(fs.readFile).toHaveBeenCalledWith(
'/Users/test/custom-playbooks/my-playbook/phase-2.md',
'utf-8'
);
// Verify NO fetch calls were made for documents (since they're local)
// Note: mockFetch should NOT have been called for document retrieval
expect(mockFetch).not.toHaveBeenCalled();
// Verify documents were written to the target folder
expect(fs.writeFile).toHaveBeenCalledWith(
'/autorun/folder/Imported Filesystem Playbook/phase-1.md',
'# Phase 1 from filesystem\n\n- [ ] Task 1',
'utf-8'
);
expect(fs.writeFile).toHaveBeenCalledWith(
'/autorun/folder/Imported Filesystem Playbook/phase-2.md',
'# Phase 2 from filesystem\n\n- [ ] Task 2',
'utf-8'
);
// Verify the prompt was preserved
expect(result.playbook.prompt).toBe('Filesystem playbook instructions');
});
it('should import a local playbook with tilde path (reads from disk, not GitHub)', async () => {
// Create a local playbook with a TILDE-PREFIXED path (home directory)
const tildePathPlaybook = {
id: 'tilde-playbook-1',
title: 'Tilde Path Playbook',
description: 'A playbook stored in home directory',
category: 'Custom',
author: 'Local Author',
lastUpdated: '2024-01-20',
path: '~/playbooks/my-tilde-playbook', // TILDE PATH - triggers local file reading
documents: [{ filename: 'setup', resetOnCompletion: false }],
loopEnabled: false,
maxLoops: null,
prompt: null,
};
const localManifest: MarketplaceManifest = {
lastUpdated: '2024-01-20',
playbooks: [tildePathPlaybook],
};
const validCache: MarketplaceCache = {
fetchedAt: Date.now(),
manifest: sampleManifest,
};
// Mock os.homedir() to return a predictable path
vi.mock('os', () => ({
homedir: vi.fn().mockReturnValue('/Users/testuser'),
}));
// The tilde path ~/playbooks/my-tilde-playbook will be resolved to:
// /Users/testuser/playbooks/my-tilde-playbook (or similar based on os.homedir)
// For this test, we just verify that fs.readFile is called (not fetch)
vi.mocked(fs.readFile)
.mockResolvedValueOnce(JSON.stringify(validCache))
.mockResolvedValueOnce(JSON.stringify(localManifest))
.mockResolvedValueOnce('# Setup from tilde path')
.mockRejectedValueOnce({ code: 'ENOENT' });
vi.mocked(fs.mkdir).mockResolvedValue(undefined);
vi.mocked(fs.writeFile).mockResolvedValue(undefined);
const handler = handlers.get('marketplace:importPlaybook');
const result = await handler!(
{} as any,
'tilde-playbook-1',
'Tilde Playbook',
'/autorun/folder',
'session-123'
);
// Verify the import succeeded
expect(result.success).toBe(true);
expect(result.playbook).toBeDefined();
expect(result.playbook.name).toBe('Tilde Path Playbook');
expect(result.importedDocs).toEqual(['setup']);
// Verify NO fetch calls were made (documents read from filesystem)
expect(mockFetch).not.toHaveBeenCalled();
// Verify null prompt is converted to empty string (Maestro default fallback)
expect(result.playbook.prompt).toBe('');
});
it('should continue importing when individual document fetch fails', async () => {
const validCache: MarketplaceCache = {
fetchedAt: Date.now(),
@@ -1196,4 +1440,169 @@ describe('marketplace IPC handlers', () => {
}
});
});
describe('merged manifest lookup', () => {
it('should find playbook ID that exists only in local manifest', async () => {
// Create a playbook that only exists in the local manifest
const localOnlyPlaybook = {
id: 'local-only-playbook',
title: 'Local Only Playbook',
description: 'This playbook only exists locally',
category: 'Custom',
author: 'Local Author',
lastUpdated: '2024-01-20',
path: 'custom/local-only-playbook',
documents: [{ filename: 'doc1', resetOnCompletion: false }],
loopEnabled: false,
maxLoops: null,
prompt: 'Local only prompt',
};
const localManifest: MarketplaceManifest = {
lastUpdated: '2024-01-20',
playbooks: [localOnlyPlaybook],
};
// Official manifest does NOT contain local-only-playbook
const validCache: MarketplaceCache = {
fetchedAt: Date.now(),
manifest: sampleManifest, // Only has test-playbook-1, test-playbook-2, test-playbook-with-assets
};
// Mock file reads:
// 1. Cache (official manifest)
// 2. Local manifest (with local-only-playbook)
vi.mocked(fs.readFile)
.mockResolvedValueOnce(JSON.stringify(validCache))
.mockResolvedValueOnce(JSON.stringify(localManifest));
const handler = handlers.get('marketplace:getManifest');
const result = await handler!({} as any);
// Verify the merged manifest contains the local-only playbook
const foundPlaybook = result.manifest.playbooks.find(
(p: any) => p.id === 'local-only-playbook'
);
expect(foundPlaybook).toBeDefined();
expect(foundPlaybook.title).toBe('Local Only Playbook');
expect(foundPlaybook.source).toBe('local');
// Verify it also contains the official playbooks
const officialPlaybook = result.manifest.playbooks.find(
(p: any) => p.id === 'test-playbook-1'
);
expect(officialPlaybook).toBeDefined();
expect(officialPlaybook.source).toBe('official');
});
it('should prefer local version when playbook ID exists in both manifests', async () => {
// Create a local playbook that has the SAME ID as an official one
const localOverridePlaybook = {
id: 'test-playbook-1', // SAME ID as official playbook
title: 'Local Override Version',
description: 'This local version overrides the official one',
category: 'Custom',
author: 'Local Author',
lastUpdated: '2024-01-25',
path: '/Users/local/custom-playbooks/test-playbook-1', // Local filesystem path
documents: [
{ filename: 'custom-phase-1', resetOnCompletion: false },
{ filename: 'custom-phase-2', resetOnCompletion: false },
],
loopEnabled: true,
maxLoops: 5,
prompt: 'Local override custom prompt',
};
const localManifest: MarketplaceManifest = {
lastUpdated: '2024-01-25',
playbooks: [localOverridePlaybook],
};
// Official manifest has test-playbook-1 with different properties
const validCache: MarketplaceCache = {
fetchedAt: Date.now(),
manifest: sampleManifest, // Contains test-playbook-1 with title "Test Playbook"
};
vi.mocked(fs.readFile)
.mockResolvedValueOnce(JSON.stringify(validCache))
.mockResolvedValueOnce(JSON.stringify(localManifest));
const handler = handlers.get('marketplace:getManifest');
const result = await handler!({} as any);
// Find the playbook with ID 'test-playbook-1'
const mergedPlaybook = result.manifest.playbooks.find((p: any) => p.id === 'test-playbook-1');
// Verify the LOCAL version took precedence
expect(mergedPlaybook).toBeDefined();
expect(mergedPlaybook.title).toBe('Local Override Version'); // NOT "Test Playbook"
expect(mergedPlaybook.source).toBe('local'); // Tagged as local
expect(mergedPlaybook.author).toBe('Local Author');
expect(mergedPlaybook.documents).toEqual([
{ filename: 'custom-phase-1', resetOnCompletion: false },
{ filename: 'custom-phase-2', resetOnCompletion: false },
]);
expect(mergedPlaybook.loopEnabled).toBe(true);
expect(mergedPlaybook.maxLoops).toBe(5);
expect(mergedPlaybook.prompt).toBe('Local override custom prompt');
// Verify there's only ONE playbook with ID 'test-playbook-1' (no duplicates)
const matchingPlaybooks = result.manifest.playbooks.filter(
(p: any) => p.id === 'test-playbook-1'
);
expect(matchingPlaybooks.length).toBe(1);
// Verify other official playbooks are still present
const otherOfficialPlaybook = result.manifest.playbooks.find(
(p: any) => p.id === 'test-playbook-2'
);
expect(otherOfficialPlaybook).toBeDefined();
expect(otherOfficialPlaybook.source).toBe('official');
});
it('should tag playbooks with correct source (official vs local)', async () => {
const localPlaybook = {
id: 'brand-new-local',
title: 'Brand New Local Playbook',
description: 'A completely new local playbook',
category: 'Custom',
author: 'Local Author',
lastUpdated: '2024-01-20',
path: '/local/playbooks/brand-new',
documents: [{ filename: 'doc', resetOnCompletion: false }],
loopEnabled: false,
maxLoops: null,
prompt: null,
};
const localManifest: MarketplaceManifest = {
lastUpdated: '2024-01-20',
playbooks: [localPlaybook],
};
const validCache: MarketplaceCache = {
fetchedAt: Date.now(),
manifest: sampleManifest,
};
vi.mocked(fs.readFile)
.mockResolvedValueOnce(JSON.stringify(validCache))
.mockResolvedValueOnce(JSON.stringify(localManifest));
const handler = handlers.get('marketplace:getManifest');
const result = await handler!({} as any);
// Verify all playbooks have the correct source tag
for (const playbook of result.manifest.playbooks) {
if (playbook.id === 'brand-new-local') {
expect(playbook.source).toBe('local');
} else {
// All sample manifest playbooks should be tagged as official
expect(playbook.source).toBe('official');
}
}
});
});
});

View File

@@ -735,18 +735,31 @@ export function registerMarketplaceHandlers(deps: MarketplaceHandlerDependencies
LOG_CONTEXT
);
// Get the manifest to find the playbook
// Get the manifest to find the playbook (including local playbooks)
// This mirrors the logic in marketplace:getManifest to ensure local playbooks are included
const cache = await readCache(app);
let manifest: MarketplaceManifest;
let officialManifest: MarketplaceManifest | null = null;
if (cache && isCacheValid(cache)) {
manifest = cache.manifest;
officialManifest = cache.manifest;
} else {
manifest = await fetchManifest();
await writeCache(app, manifest);
try {
officialManifest = await fetchManifest();
await writeCache(app, officialManifest);
} catch (error) {
logger.warn(
'Failed to fetch official manifest during import, continuing with local only',
LOG_CONTEXT,
{ error }
);
}
}
// Find the playbook
// Read local manifest and merge with official
const localManifest = await readLocalManifest(app);
const manifest = mergeManifests(officialManifest, localManifest);
// Find the playbook in the merged manifest
const marketplacePlaybook = manifest.playbooks.find((p) => p.id === playbookId);
if (!marketplacePlaybook) {
throw new MarketplaceImportError(`Playbook not found: ${playbookId}`);

View File

@@ -352,6 +352,18 @@ function PlaybookDetailView({
/ {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>
<h2 className="text-lg font-semibold truncate" style={{ color: theme.colors.textMain }}>
{playbook.title}
@@ -508,6 +520,28 @@ function PlaybookDetailView({
{playbook.lastUpdated}
</p>
</div>
{/* Source badge for local playbooks */}
{playbook.source === 'local' && (
<div className="mb-4">
<h4
className="text-xs font-semibold mb-1 uppercase tracking-wide"
style={{ color: theme.colors.textDim }}
>
Source
</h4>
<span
className="px-2 py-0.5 rounded text-xs font-medium inline-block"
style={{
backgroundColor: '#3b82f620',
color: '#3b82f6',
}}
title="Custom local playbook"
>
Local
</span>
</div>
)}
</div>
{/* Main content area with document dropdown and markdown preview */}