Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
645 changes: 645 additions & 0 deletions package-lock.json

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -45,9 +45,11 @@
],
"license": "MIT",
"dependencies": {
"@fastify/cors": "^11.2.0",
"commander": "^14.0.0",
"cron-parser": "^5.5.0",
"execa": "^9.5.2",
"fastify": "^5.7.4",
"nanoid": "^5.1.5",
"proper-lockfile": "^4.1.2",
"write-file-atomic": "^7.0.0",
Expand Down
77 changes: 8 additions & 69 deletions src/commands/list.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import fs from 'node:fs/promises';
import path from 'node:path';
import { getRepoRoot } from '../core/worktree.js';
import { listTemplatesWithSource } from '../core/template.js';
import { listPromptsWithSource, enrichEntryMetadata } from '../core/prompt.js';
import { listSwarmsWithSource, loadSwarm } from '../core/swarm.js';
import { templatesDir, promptsDir, globalTemplatesDir, globalPromptsDir } from '../lib/paths.js';
import { PpgError } from '../lib/errors.js';
Expand Down Expand Up @@ -34,18 +33,9 @@ async function listTemplatesCommand(options: ListOptions): Promise<void> {
}

const templates = await Promise.all(
entries.map(async ({ name, source }) => {
const dir = source === 'local' ? templatesDir(projectRoot) : globalTemplatesDir();
const filePath = path.join(dir, `${name}.md`);
const content = await fs.readFile(filePath, 'utf-8');
const firstLine = content.split('\n').find((l) => l.trim().length > 0) ?? '';
const description = firstLine.replace(/^#+\s*/, '').trim();

const vars = [...content.matchAll(/\{\{(\w+)\}\}/g)].map((m) => m[1]);
const uniqueVars = [...new Set(vars)];

return { name, description, variables: uniqueVars, source };
}),
entries.map(({ name, source }) =>
enrichEntryMetadata(name, source, templatesDir(projectRoot), globalTemplatesDir()),
),
);

if (options.json) {
Expand Down Expand Up @@ -111,52 +101,10 @@ async function listSwarmsCommand(options: ListOptions): Promise<void> {
console.log(formatTable(swarms, columns));
}

interface PromptEntry {
name: string;
source: 'local' | 'global';
}

async function listPromptEntries(projectRoot: string): Promise<PromptEntry[]> {
const localDir = promptsDir(projectRoot);
const globalDir = globalPromptsDir();

let localFiles: string[] = [];
try {
localFiles = (await fs.readdir(localDir)).filter((f) => f.endsWith('.md')).sort();
} catch {
// directory doesn't exist
}

let globalFiles: string[] = [];
try {
globalFiles = (await fs.readdir(globalDir)).filter((f) => f.endsWith('.md')).sort();
} catch {
// directory doesn't exist
}

const seen = new Set<string>();
const result: PromptEntry[] = [];

for (const file of localFiles) {
const name = file.replace(/\.md$/, '');
seen.add(name);
result.push({ name, source: 'local' });
}

for (const file of globalFiles) {
const name = file.replace(/\.md$/, '');
if (!seen.has(name)) {
result.push({ name, source: 'global' });
}
}

return result;
}

async function listPromptsCommand(options: ListOptions): Promise<void> {
const projectRoot = await getRepoRoot();

const entries = await listPromptEntries(projectRoot);
const entries = await listPromptsWithSource(projectRoot);

if (entries.length === 0) {
if (options.json) {
Expand All @@ -168,18 +116,9 @@ async function listPromptsCommand(options: ListOptions): Promise<void> {
}

const prompts = await Promise.all(
entries.map(async ({ name, source }) => {
const dir = source === 'local' ? promptsDir(projectRoot) : globalPromptsDir();
const filePath = path.join(dir, `${name}.md`);
const content = await fs.readFile(filePath, 'utf-8');
const firstLine = content.split('\n').find((l) => l.trim().length > 0) ?? '';
const description = firstLine.replace(/^#+\s*/, '').trim();

const vars = [...content.matchAll(/\{\{(\w+)\}\}/g)].map((m) => m[1]);
const uniqueVars = [...new Set(vars)];

return { name, description, variables: uniqueVars, source };
}),
entries.map(({ name, source }) =>
enrichEntryMetadata(name, source, promptsDir(projectRoot), globalPromptsDir()),
),
);

if (options.json) {
Expand Down
7 changes: 4 additions & 3 deletions src/commands/spawn.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { spawnAgent } from '../core/agent.js';
import { getRepoRoot } from '../core/worktree.js';
import { agentId, sessionId } from '../lib/id.js';
import * as tmux from '../core/tmux.js';
import type { Manifest } from '../types/manifest.js';

vi.mock('node:fs/promises', async () => {
const actual = await vi.importActual<typeof import('node:fs/promises')>('node:fs/promises');
Expand Down Expand Up @@ -79,7 +80,7 @@ const mockedEnsureSession = vi.mocked(tmux.ensureSession);
const mockedCreateWindow = vi.mocked(tmux.createWindow);
const mockedSplitPane = vi.mocked(tmux.splitPane);

function createManifest(tmuxWindow = '') {
function createManifest(tmuxWindow = ''): Manifest {
return {
version: 1 as const,
projectRoot: '/tmp/repo',
Expand All @@ -93,7 +94,7 @@ function createManifest(tmuxWindow = '') {
baseBranch: 'main',
status: 'active' as const,
tmuxWindow,
agents: {} as Record<string, any>,
agents: {} as Manifest['worktrees'][string]['agents'],
createdAt: '2026-02-27T00:00:00.000Z',
},
},
Expand All @@ -103,7 +104,7 @@ function createManifest(tmuxWindow = '') {
}

describe('spawnCommand', () => {
let manifestState = createManifest();
let manifestState: Manifest = createManifest();
let nextAgent = 1;
let nextSession = 1;

Expand Down
127 changes: 127 additions & 0 deletions src/core/prompt.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';

let tmpDir: string;
let globalDir: string;

vi.mock('../lib/paths.js', async () => {
const actual = await vi.importActual<typeof import('../lib/paths.js')>('../lib/paths.js');
return {
...actual,
globalPromptsDir: () => path.join(globalDir, 'prompts'),
};
});

// Dynamic import after mock setup
const { listPromptsWithSource, enrichEntryMetadata } = await import('./prompt.js');

beforeEach(async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'ppg-prompt-'));
globalDir = path.join(tmpDir, 'global');
await fs.mkdir(path.join(globalDir, 'prompts'), { recursive: true });
});

afterEach(async () => {
await fs.rm(tmpDir, { recursive: true, force: true });
});

describe('listPromptsWithSource', () => {
test('given no directories, should return empty array', async () => {
const entries = await listPromptsWithSource(tmpDir);
expect(entries).toEqual([]);
});

test('given local prompts, should return with local source', async () => {
const localDir = path.join(tmpDir, '.ppg', 'prompts');
await fs.mkdir(localDir, { recursive: true });
await fs.writeFile(path.join(localDir, 'review.md'), '# Review\n');
await fs.writeFile(path.join(localDir, 'fix.md'), '# Fix\n');

const entries = await listPromptsWithSource(tmpDir);
expect(entries).toEqual([
{ name: 'fix', source: 'local' },
{ name: 'review', source: 'local' },
]);
});

test('given global prompts, should return with global source', async () => {
await fs.writeFile(path.join(globalDir, 'prompts', 'shared.md'), '# Shared\n');

const entries = await listPromptsWithSource(tmpDir);
expect(entries).toEqual([{ name: 'shared', source: 'global' }]);
});

test('given same name in local and global, should prefer local', async () => {
const localDir = path.join(tmpDir, '.ppg', 'prompts');
await fs.mkdir(localDir, { recursive: true });
await fs.writeFile(path.join(localDir, 'shared.md'), '# Local\n');
await fs.writeFile(path.join(globalDir, 'prompts', 'shared.md'), '# Global\n');

const entries = await listPromptsWithSource(tmpDir);
expect(entries).toEqual([{ name: 'shared', source: 'local' }]);
});

test('given non-.md files, should ignore them', async () => {
const localDir = path.join(tmpDir, '.ppg', 'prompts');
await fs.mkdir(localDir, { recursive: true });
await fs.writeFile(path.join(localDir, 'valid.md'), '# Valid\n');
await fs.writeFile(path.join(localDir, 'readme.txt'), 'not a prompt');

const entries = await listPromptsWithSource(tmpDir);
expect(entries).toEqual([{ name: 'valid', source: 'local' }]);
});
});

describe('enrichEntryMetadata', () => {
test('given markdown file, should extract description from first line', async () => {
const dir = path.join(tmpDir, 'md');
await fs.mkdir(dir, { recursive: true });
await fs.writeFile(path.join(dir, 'task.md'), '# My Task\n\nBody here\n');

const result = await enrichEntryMetadata('task', 'local', dir, dir);
expect(result.description).toBe('My Task');
});

test('given template variables, should extract unique vars', async () => {
const dir = path.join(tmpDir, 'md');
await fs.mkdir(dir, { recursive: true });
await fs.writeFile(
path.join(dir, 'task.md'),
'{{NAME}} and {{NAME}} and {{OTHER}}\n',
);

const result = await enrichEntryMetadata('task', 'local', dir, dir);
expect(result.variables).toEqual(['NAME', 'OTHER']);
});

test('given no variables, should return empty array', async () => {
const dir = path.join(tmpDir, 'md');
await fs.mkdir(dir, { recursive: true });
await fs.writeFile(path.join(dir, 'plain.md'), '# Plain text\n');

const result = await enrichEntryMetadata('plain', 'local', dir, dir);
expect(result.variables).toEqual([]);
});

test('given global source, should read from global dir', async () => {
const localDir = path.join(tmpDir, 'local');
const gDir = path.join(tmpDir, 'gbl');
await fs.mkdir(gDir, { recursive: true });
await fs.writeFile(path.join(gDir, 'task.md'), '# Global Task\n');

const result = await enrichEntryMetadata('task', 'global', localDir, gDir);
expect(result.description).toBe('Global Task');
expect(result.source).toBe('global');
});

test('given empty first line, should skip to first non-empty line', async () => {
const dir = path.join(tmpDir, 'md');
await fs.mkdir(dir, { recursive: true });
await fs.writeFile(path.join(dir, 'task.md'), '\n\n# Actual Title\n');

const result = await enrichEntryMetadata('task', 'local', dir, dir);
expect(result.description).toBe('Actual Title');
});
});
63 changes: 63 additions & 0 deletions src/core/prompt.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import fs from 'node:fs/promises';
import path from 'node:path';
import { promptsDir, globalPromptsDir } from '../lib/paths.js';

export interface PromptEntry {
name: string;
source: 'local' | 'global';
}

export interface EnrichedEntry {
name: string;
description: string;
variables: string[];
source: 'local' | 'global';
[key: string]: unknown;
}

async function readMdNames(dir: string): Promise<string[]> {
try {
const files = await fs.readdir(dir);
return files.filter((f) => f.endsWith('.md')).map((f) => f.replace(/\.md$/, '')).sort();
} catch {
return [];
}
}

export async function listPromptsWithSource(projectRoot: string): Promise<PromptEntry[]> {
const localNames = await readMdNames(promptsDir(projectRoot));
const globalNames = await readMdNames(globalPromptsDir());

const seen = new Set<string>();
const result: PromptEntry[] = [];

for (const name of localNames) {
seen.add(name);
result.push({ name, source: 'local' });
}

for (const name of globalNames) {
if (!seen.has(name)) {
result.push({ name, source: 'global' });
}
}

return result;
}

export async function enrichEntryMetadata(
name: string,
source: 'local' | 'global',
localDir: string,
globalDir: string,
): Promise<EnrichedEntry> {
const dir = source === 'local' ? localDir : globalDir;
const filePath = path.join(dir, `${name}.md`);
const content = await fs.readFile(filePath, 'utf-8');
const firstLine = content.split('\n').find((l) => l.trim().length > 0) ?? '';
const description = firstLine.replace(/^#+\s*/, '').trim();
const vars = [...content.matchAll(/\{\{(\w+)\}\}/g)].map((m) => m[1]);
const uniqueVars = [...new Set(vars)];

return { name, description, variables: uniqueVars, source };
}
Loading