diff --git a/src/__tests__/catalog.test.ts b/src/__tests__/catalog.test.ts new file mode 100644 index 0000000..af9863b --- /dev/null +++ b/src/__tests__/catalog.test.ts @@ -0,0 +1,373 @@ +/** + * Tests for facet catalog scanning and display. + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { mkdtempSync, writeFileSync, mkdirSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { + extractDescription, + parseFacetType, + scanFacets, + displayFacets, + showCatalog, + type FacetEntry, +} from '../features/catalog/catalogFacets.js'; + +// Mock external dependencies to isolate unit tests +vi.mock('../infra/config/global/globalConfig.js', () => ({ + getLanguage: () => 'en', + getBuiltinPiecesEnabled: () => true, +})); + +const mockLogError = vi.fn(); +const mockInfo = vi.fn(); +vi.mock('../shared/ui/index.js', () => ({ + error: (...args: unknown[]) => mockLogError(...args), + info: (...args: unknown[]) => mockInfo(...args), + section: (title: string) => console.log(title), +})); + +let mockBuiltinDir: string; +vi.mock('../infra/resources/index.js', () => ({ + getLanguageResourcesDir: () => mockBuiltinDir, +})); + +let mockGlobalDir: string; +vi.mock('../infra/config/paths.js', () => ({ + getGlobalConfigDir: () => mockGlobalDir, + getProjectConfigDir: (cwd: string) => join(cwd, '.takt'), +})); + +describe('parseFacetType', () => { + it('should return FacetType for valid inputs', () => { + expect(parseFacetType('personas')).toBe('personas'); + expect(parseFacetType('policies')).toBe('policies'); + expect(parseFacetType('knowledge')).toBe('knowledge'); + expect(parseFacetType('instructions')).toBe('instructions'); + expect(parseFacetType('output-contracts')).toBe('output-contracts'); + }); + + it('should return null for invalid inputs', () => { + expect(parseFacetType('unknown')).toBeNull(); + expect(parseFacetType('persona')).toBeNull(); + expect(parseFacetType('')).toBeNull(); + }); +}); + +describe('extractDescription', () => { + let tempDir: string; + + beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), 'takt-catalog-test-')); + }); + + afterEach(() => { + rmSync(tempDir, { recursive: true, force: true }); + }); + + it('should extract first heading from markdown file', () => { + const filePath = join(tempDir, 'test.md'); + writeFileSync(filePath, '# My Persona\n\nSome content here.'); + + expect(extractDescription(filePath)).toBe('My Persona'); + }); + + it('should return first non-empty line when no heading exists', () => { + const filePath = join(tempDir, 'test.md'); + writeFileSync(filePath, 'No heading in this file\nJust plain text.'); + + expect(extractDescription(filePath)).toBe('No heading in this file'); + }); + + it('should return empty string when file is empty', () => { + const filePath = join(tempDir, 'test.md'); + writeFileSync(filePath, ''); + + expect(extractDescription(filePath)).toBe(''); + }); + + it('should skip blank lines and return first non-empty line', () => { + const filePath = join(tempDir, 'test.md'); + writeFileSync(filePath, '\n\n \nActual content here\nMore text.'); + + expect(extractDescription(filePath)).toBe('Actual content here'); + }); + + it('should extract from first heading, ignoring later headings', () => { + const filePath = join(tempDir, 'test.md'); + writeFileSync(filePath, 'Preamble\n# First Heading\n# Second Heading'); + + expect(extractDescription(filePath)).toBe('First Heading'); + }); + + it('should trim whitespace from heading text', () => { + const filePath = join(tempDir, 'test.md'); + writeFileSync(filePath, '# Spaced Heading \n'); + + expect(extractDescription(filePath)).toBe('Spaced Heading'); + }); +}); + +describe('scanFacets', () => { + let tempDir: string; + let builtinDir: string; + let globalDir: string; + let projectDir: string; + + beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), 'takt-catalog-test-')); + builtinDir = join(tempDir, 'builtin-lang'); + globalDir = join(tempDir, 'global'); + projectDir = join(tempDir, 'project'); + + mockBuiltinDir = builtinDir; + mockGlobalDir = globalDir; + }); + + afterEach(() => { + rmSync(tempDir, { recursive: true, force: true }); + }); + + it('should collect facets from all three layers', () => { + // Given: facets in builtin, user, and project layers + const builtinPersonas = join(builtinDir, 'personas'); + const globalPersonas = join(globalDir, 'personas'); + const projectPersonas = join(projectDir, '.takt', 'personas'); + mkdirSync(builtinPersonas, { recursive: true }); + mkdirSync(globalPersonas, { recursive: true }); + mkdirSync(projectPersonas, { recursive: true }); + + writeFileSync(join(builtinPersonas, 'coder.md'), '# Coder Agent'); + writeFileSync(join(globalPersonas, 'my-reviewer.md'), '# My Reviewer'); + writeFileSync(join(projectPersonas, 'project-coder.md'), '# Project Coder'); + + // When: scanning personas + const entries = scanFacets('personas', projectDir); + + // Then: all three entries are collected + expect(entries).toHaveLength(3); + + const coder = entries.find((e) => e.name === 'coder'); + expect(coder).toBeDefined(); + expect(coder!.source).toBe('builtin'); + expect(coder!.description).toBe('Coder Agent'); + + const myReviewer = entries.find((e) => e.name === 'my-reviewer'); + expect(myReviewer).toBeDefined(); + expect(myReviewer!.source).toBe('user'); + + const projectCoder = entries.find((e) => e.name === 'project-coder'); + expect(projectCoder).toBeDefined(); + expect(projectCoder!.source).toBe('project'); + }); + + it('should detect override when higher layer has same name', () => { + // Given: same facet name in builtin and user layers + const builtinPersonas = join(builtinDir, 'personas'); + const globalPersonas = join(globalDir, 'personas'); + mkdirSync(builtinPersonas, { recursive: true }); + mkdirSync(globalPersonas, { recursive: true }); + + writeFileSync(join(builtinPersonas, 'coder.md'), '# Builtin Coder'); + writeFileSync(join(globalPersonas, 'coder.md'), '# Custom Coder'); + + // When: scanning personas + const entries = scanFacets('personas', tempDir); + + // Then: builtin entry is marked as overridden by user + const builtinCoder = entries.find((e) => e.name === 'coder' && e.source === 'builtin'); + expect(builtinCoder).toBeDefined(); + expect(builtinCoder!.overriddenBy).toBe('user'); + + const userCoder = entries.find((e) => e.name === 'coder' && e.source === 'user'); + expect(userCoder).toBeDefined(); + expect(userCoder!.overriddenBy).toBeUndefined(); + }); + + it('should detect override through project layer', () => { + // Given: same facet name in builtin and project layers + const builtinPolicies = join(builtinDir, 'policies'); + const projectPolicies = join(projectDir, '.takt', 'policies'); + mkdirSync(builtinPolicies, { recursive: true }); + mkdirSync(projectPolicies, { recursive: true }); + + writeFileSync(join(builtinPolicies, 'coding.md'), '# Builtin Coding'); + writeFileSync(join(projectPolicies, 'coding.md'), '# Project Coding'); + + // When: scanning policies + const entries = scanFacets('policies', projectDir); + + // Then: builtin entry is marked as overridden by project + const builtinCoding = entries.find((e) => e.name === 'coding' && e.source === 'builtin'); + expect(builtinCoding).toBeDefined(); + expect(builtinCoding!.overriddenBy).toBe('project'); + }); + + it('should handle non-existent directories gracefully', () => { + // Given: no directories exist + // When: scanning a facet type + const entries = scanFacets('knowledge', projectDir); + + // Then: returns empty array + expect(entries).toEqual([]); + }); + + it('should only include .md files', () => { + // Given: directory with mixed file types + const builtinKnowledge = join(builtinDir, 'knowledge'); + mkdirSync(builtinKnowledge, { recursive: true }); + + writeFileSync(join(builtinKnowledge, 'valid.md'), '# Valid'); + writeFileSync(join(builtinKnowledge, 'ignored.txt'), 'Not a markdown'); + writeFileSync(join(builtinKnowledge, 'also-ignored.yaml'), 'name: yaml'); + + // When: scanning knowledge + const entries = scanFacets('knowledge', tempDir); + + // Then: only .md file is included + expect(entries).toHaveLength(1); + expect(entries[0]!.name).toBe('valid'); + }); + + it('should work with all facet types', () => { + // Given: one facet in each type directory + const types = ['personas', 'policies', 'knowledge', 'instructions', 'output-contracts'] as const; + for (const type of types) { + const dir = join(builtinDir, type); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, 'test.md'), `# Test ${type}`); + } + + // When/Then: each type is scannable + for (const type of types) { + const entries = scanFacets(type, tempDir); + expect(entries).toHaveLength(1); + expect(entries[0]!.name).toBe('test'); + } + }); +}); + +describe('displayFacets', () => { + let consoleSpy: ReturnType; + + beforeEach(() => { + consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + }); + + afterEach(() => { + consoleSpy.mockRestore(); + }); + + it('should display entries with name, description, and source', () => { + // Given: a list of facet entries + const entries: FacetEntry[] = [ + { name: 'coder', description: 'Coder Agent', source: 'builtin' }, + { name: 'my-reviewer', description: 'My Reviewer', source: 'user' }, + ]; + + // When: displaying facets + displayFacets('personas', entries); + + // Then: output contains facet names + const output = consoleSpy.mock.calls.map((c) => c[0]).join('\n'); + expect(output).toContain('coder'); + expect(output).toContain('my-reviewer'); + expect(output).toContain('Personas'); + }); + + it('should display (none) when entries are empty', () => { + // Given: empty entries + const entries: FacetEntry[] = []; + + // When: displaying facets + displayFacets('policies', entries); + + // Then: output shows (none) + const output = consoleSpy.mock.calls.map((c) => c[0]).join('\n'); + expect(output).toContain('(none)'); + }); + + it('should display override information', () => { + // Given: an overridden entry + const entries: FacetEntry[] = [ + { name: 'coder', description: 'Builtin Coder', source: 'builtin', overriddenBy: 'user' }, + ]; + + // When: displaying facets + displayFacets('personas', entries); + + // Then: output contains override info + const output = consoleSpy.mock.calls.map((c) => c[0]).join('\n'); + expect(output).toContain('overridden by user'); + }); +}); + +describe('showCatalog', () => { + let tempDir: string; + let builtinDir: string; + let globalDir: string; + let consoleSpy: ReturnType; + + beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), 'takt-catalog-test-')); + builtinDir = join(tempDir, 'builtin-lang'); + globalDir = join(tempDir, 'global'); + + mockBuiltinDir = builtinDir; + mockGlobalDir = globalDir; + mockLogError.mockClear(); + mockInfo.mockClear(); + consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + }); + + afterEach(() => { + consoleSpy.mockRestore(); + rmSync(tempDir, { recursive: true, force: true }); + }); + + it('should display only the specified facet type when valid type is given', () => { + // Given: personas facet exists + const builtinPersonas = join(builtinDir, 'personas'); + mkdirSync(builtinPersonas, { recursive: true }); + writeFileSync(join(builtinPersonas, 'coder.md'), '# Coder Agent'); + + // When: showing catalog for personas only + showCatalog(tempDir, 'personas'); + + // Then: output contains the facet name and no error + const output = consoleSpy.mock.calls.map((c) => c[0]).join('\n'); + expect(output).toContain('coder'); + expect(mockLogError).not.toHaveBeenCalled(); + }); + + it('should show error when invalid facet type is given', () => { + // When: showing catalog for an invalid type + showCatalog(tempDir, 'invalid-type'); + + // Then: error is logged with the invalid type name + expect(mockLogError).toHaveBeenCalledWith( + expect.stringContaining('invalid-type'), + ); + // Then: available types are shown via info + expect(mockInfo).toHaveBeenCalledWith( + expect.stringContaining('personas'), + ); + }); + + it('should display all five facet types when no type is specified', () => { + // Given: no facets exist (empty directories) + + // When: showing catalog without specifying a type + showCatalog(tempDir); + + // Then: all 5 facet type headings are displayed + const output = consoleSpy.mock.calls.map((c) => c[0]).join('\n'); + expect(output).toContain('Personas'); + expect(output).toContain('Policies'); + expect(output).toContain('Knowledge'); + expect(output).toContain('Instructions'); + expect(output).toContain('Output-contracts'); + }); +}); diff --git a/src/app/cli/commands.ts b/src/app/cli/commands.ts index d176ac4..7566ffb 100644 --- a/src/app/cli/commands.ts +++ b/src/app/cli/commands.ts @@ -1,7 +1,7 @@ /** * CLI subcommand definitions * - * Registers all named subcommands (run, watch, add, list, switch, clear, eject, config, prompt). + * Registers all named subcommands (run, watch, add, list, switch, clear, eject, config, prompt, catalog). */ import { clearPersonaSessions, getCurrentPiece } from '../../infra/config/index.js'; @@ -9,6 +9,7 @@ import { success } from '../../shared/ui/index.js'; import { runAllTasks, addTask, watchTasks, listTasks } from '../../features/tasks/index.js'; import { switchPiece, switchConfig, ejectBuiltin, resetCategoriesToDefault, deploySkill } from '../../features/config/index.js'; import { previewPrompts } from '../../features/prompt/index.js'; +import { showCatalog } from '../../features/catalog/index.js'; import { program, resolvedCwd } from './program.js'; import { resolveAgentOverrides } from './helpers.js'; @@ -115,3 +116,11 @@ program .action(async () => { await deploySkill(); }); + +program + .command('catalog') + .description('List available facets (personas, policies, knowledge, instructions, output-contracts)') + .argument('[type]', 'Facet type to list') + .action((type?: string) => { + showCatalog(resolvedCwd, type); + }); diff --git a/src/features/catalog/catalogFacets.ts b/src/features/catalog/catalogFacets.ts new file mode 100644 index 0000000..5a37a43 --- /dev/null +++ b/src/features/catalog/catalogFacets.ts @@ -0,0 +1,178 @@ +/** + * Facet catalog — scan and display available facets across 3 layers. + * + * Scans builtin, user (~/.takt/), and project (.takt/) directories + * for facet files (.md) and displays them with layer provenance. + */ + +import { existsSync, readdirSync, readFileSync } from 'node:fs'; +import { join, basename } from 'node:path'; +import chalk from 'chalk'; +import type { PieceSource } from '../../infra/config/loaders/pieceResolver.js'; +import { getLanguageResourcesDir } from '../../infra/resources/index.js'; +import { getGlobalConfigDir, getProjectConfigDir } from '../../infra/config/paths.js'; +import { getLanguage, getBuiltinPiecesEnabled } from '../../infra/config/global/globalConfig.js'; +import { section, error as logError, info } from '../../shared/ui/index.js'; + +const FACET_TYPES = [ + 'personas', + 'policies', + 'knowledge', + 'instructions', + 'output-contracts', +] as const; + +export type FacetType = (typeof FACET_TYPES)[number]; + +export interface FacetEntry { + name: string; + description: string; + source: PieceSource; + overriddenBy?: PieceSource; +} + +/** Validate a string as a FacetType. Returns the type or null. */ +export function parseFacetType(input: string): FacetType | null { + if ((FACET_TYPES as readonly string[]).includes(input)) { + return input as FacetType; + } + return null; +} + +/** + * Extract description from a markdown file. + * Returns the first `# ` heading text, or falls back to the first non-empty line. + */ +export function extractDescription(filePath: string): string { + const content = readFileSync(filePath, 'utf-8'); + let firstNonEmpty = ''; + for (const line of content.split('\n')) { + if (line.startsWith('# ')) { + return line.slice(2).trim(); + } + if (!firstNonEmpty && line.trim()) { + firstNonEmpty = line.trim(); + } + } + return firstNonEmpty; +} + +/** Build the 3-layer directory list for a given facet type. */ +function getFacetDirs( + facetType: FacetType, + cwd: string, +): { dir: string; source: PieceSource }[] { + const dirs: { dir: string; source: PieceSource }[] = []; + + if (getBuiltinPiecesEnabled()) { + const lang = getLanguage(); + dirs.push({ dir: join(getLanguageResourcesDir(lang), facetType), source: 'builtin' }); + } + + dirs.push({ dir: join(getGlobalConfigDir(), facetType), source: 'user' }); + dirs.push({ dir: join(getProjectConfigDir(cwd), facetType), source: 'project' }); + + return dirs; +} + +/** Scan a single directory for .md facet files. */ +function scanDirectory(dir: string): string[] { + if (!existsSync(dir)) return []; + return readdirSync(dir).filter((f) => f.endsWith('.md')); +} + +/** + * Scan all layers for facets of a given type. + * + * Scans builtin → user → project in order. + * When a facet name appears in a higher-priority layer, the lower-priority + * entry gets `overriddenBy` set to the overriding layer. + */ +export function scanFacets(facetType: FacetType, cwd: string): FacetEntry[] { + const dirs = getFacetDirs(facetType, cwd); + const entriesByName = new Map(); + const allEntries: FacetEntry[] = []; + + for (const { dir, source } of dirs) { + const files = scanDirectory(dir); + for (const file of files) { + const name = basename(file, '.md'); + const description = extractDescription(join(dir, file)); + const entry: FacetEntry = { name, description, source }; + + const existing = entriesByName.get(name); + if (existing) { + existing.overriddenBy = source; + } + + entriesByName.set(name, entry); + allEntries.push(entry); + } + } + + return allEntries; +} + +/** Color a source tag for terminal display. */ +function colorSourceTag(source: PieceSource): string { + switch (source) { + case 'builtin': + return chalk.gray(`[${source}]`); + case 'user': + return chalk.yellow(`[${source}]`); + case 'project': + return chalk.green(`[${source}]`); + } +} + +/** Format and print a list of facet entries for one facet type. */ +export function displayFacets(facetType: FacetType, entries: FacetEntry[]): void { + section(`${capitalize(facetType)}:`); + + if (entries.length === 0) { + console.log(chalk.gray(' (none)')); + return; + } + + const maxNameLen = Math.max(...entries.map((e) => e.name.length)); + const maxDescLen = Math.max(...entries.map((e) => e.description.length)); + + for (const entry of entries) { + const name = entry.name.padEnd(maxNameLen + 2); + const desc = entry.description.padEnd(maxDescLen + 2); + const tag = colorSourceTag(entry.source); + const override = entry.overriddenBy + ? chalk.gray(` (overridden by ${entry.overriddenBy})`) + : ''; + console.log(` ${name}${chalk.dim(desc)}${tag}${override}`); + } +} + +function capitalize(s: string): string { + return s.charAt(0).toUpperCase() + s.slice(1); +} + +/** + * Main entry point: show facet catalog. + * + * If facetType is provided, shows only that type. + * Otherwise shows all facet types. + */ +export function showCatalog(cwd: string, facetType?: string): void { + if (facetType !== undefined) { + const parsed = parseFacetType(facetType); + if (!parsed) { + logError(`Unknown facet type: "${facetType}"`); + info(`Available types: ${FACET_TYPES.join(', ')}`); + return; + } + const entries = scanFacets(parsed, cwd); + displayFacets(parsed, entries); + return; + } + + for (const type of FACET_TYPES) { + const entries = scanFacets(type, cwd); + displayFacets(type, entries); + } +} diff --git a/src/features/catalog/index.ts b/src/features/catalog/index.ts new file mode 100644 index 0000000..eb094e6 --- /dev/null +++ b/src/features/catalog/index.ts @@ -0,0 +1,5 @@ +/** + * Catalog feature — list available facets across layers. + */ + +export { showCatalog } from './catalogFacets.js';